Files
echo-core/tests/test_claude_session.py
MoltBot Service 5928077646 cleanup: remove clawd/openclaw references, fix permissions, add architecture docs
- Replace all ~/clawd and ~/.clawdbot paths with ~/echo-core equivalents
  in tools (git_commit, ralph_prd_generator, backup_config, lead-gen)
- Update personality files: TOOLS.md repo/paths, AGENTS.md security audit cmd
- Migrate HANDOFF.md architectural decisions to docs/architecture.md
- Tighten credentials/ dir to 700, add to .gitignore
- Add .claude/ and *.pid to .gitignore
- Various adapter, router, and session improvements from prior work

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:44:13 +00:00

901 lines
33 KiB
Python

"""Comprehensive tests for src/claude_session.py."""
import json
import os
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch, PropertyMock
import io
import pytest
from src import claude_session
from src.claude_session import (
_load_sessions,
_run_claude,
_safe_env,
_save_sessions,
build_system_prompt,
clear_session,
get_active_session,
list_sessions,
resume_session,
send_message,
set_session_model,
start_session,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
FAKE_RESULT_LINE = {
"type": "result",
"subtype": "success",
"session_id": "sess-abc-123",
"result": "Hello from Claude!",
"cost_usd": 0.004,
"total_cost_usd": 0.004,
"duration_ms": 1500,
"num_turns": 1,
"usage": {"input_tokens": 100, "output_tokens": 50},
}
FAKE_ASSISTANT_LINE = {
"type": "assistant",
"message": {
"content": [{"type": "text", "text": "Hello from Claude!"}],
},
}
def _make_stream(*assistant_texts, result_override=None):
"""Build stream-json stdout with assistant + result lines."""
lines = []
for text in assistant_texts:
lines.append(json.dumps({
"type": "assistant",
"message": {"content": [{"type": "text", "text": text}]},
}))
result = dict(FAKE_RESULT_LINE)
if result_override:
result.update(result_override)
lines.append(json.dumps(result))
return "\n".join(lines) + "\n"
def _make_popen(stdout=None, returncode=0, stderr=""):
"""Build a fake subprocess.Popen that yields lines from stdout."""
if stdout is None:
stdout = _make_stream("Hello from Claude!")
proc = MagicMock()
proc.stdout = io.StringIO(stdout)
proc.stderr = io.StringIO(stderr)
proc.returncode = returncode
proc.wait.return_value = returncode
proc.kill = MagicMock()
return proc
# Keep old name for backward-compatible test helpers
_make_proc = _make_popen
# ---------------------------------------------------------------------------
# build_system_prompt
# ---------------------------------------------------------------------------
class TestBuildSystemPrompt:
def test_returns_non_empty_string(self):
prompt = build_system_prompt()
assert isinstance(prompt, str)
assert len(prompt) > 0
def test_contains_personality_content(self):
prompt = build_system_prompt()
# IDENTITY.md is first; should appear in the prompt
identity_path = claude_session.PERSONALITY_DIR / "IDENTITY.md"
identity_content = identity_path.read_text(encoding="utf-8")
assert identity_content in prompt
def test_correct_order(self):
prompt = build_system_prompt()
expected_order = [
"IDENTITY.md",
"SOUL.md",
"USER.md",
"AGENTS.md",
"TOOLS.md",
"HEARTBEAT.md",
]
# Read each file and find its position in the prompt
positions = []
for filename in expected_order:
filepath = claude_session.PERSONALITY_DIR / filename
if filepath.is_file():
content = filepath.read_text(encoding="utf-8")
pos = prompt.find(content)
assert pos >= 0, f"{filename} not found in prompt"
positions.append(pos)
# Positions must be strictly increasing
for i in range(1, len(positions)):
assert positions[i] > positions[i - 1], (
f"Order violation: {expected_order[i]} appears before "
f"{expected_order[i - 1]}"
)
def test_separator_between_files(self):
prompt = build_system_prompt()
assert "\n\n---\n\n" in prompt
def test_missing_personality_dir_raises(self, tmp_path, monkeypatch):
monkeypatch.setattr(
claude_session, "PERSONALITY_DIR", tmp_path / "nonexistent"
)
with pytest.raises(FileNotFoundError):
build_system_prompt()
# ---------------------------------------------------------------------------
# _safe_env
# ---------------------------------------------------------------------------
class TestSafeEnv:
def test_includes_path_and_home(self, monkeypatch):
monkeypatch.setenv("PATH", "/usr/bin")
monkeypatch.setenv("HOME", "/home/test")
env = _safe_env()
assert "PATH" in env
assert "HOME" in env
def test_excludes_discord_token(self, monkeypatch):
monkeypatch.setenv("DISCORD_TOKEN", "secret-token")
env = _safe_env()
assert "DISCORD_TOKEN" not in env
def test_excludes_claudecode(self, monkeypatch):
monkeypatch.setenv("CLAUDECODE", "1")
env = _safe_env()
assert "CLAUDECODE" not in env
def test_excludes_api_keys(self, monkeypatch):
monkeypatch.setenv("API_KEY", "sk-xxx")
monkeypatch.setenv("SECRET", "sk-ant-xxx")
env = _safe_env()
assert "API_KEY" not in env
assert "SECRET" not in env
def test_strips_all_blocked_keys(self, monkeypatch):
for key in claude_session._ENV_STRIP:
monkeypatch.setenv(key, "bad")
env = _safe_env()
for key in claude_session._ENV_STRIP:
assert key not in env
# ---------------------------------------------------------------------------
# _run_claude (now with Popen streaming)
# ---------------------------------------------------------------------------
class TestRunClaude:
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_returns_parsed_stream(self, mock_popen, mock_which):
mock_popen.return_value = _make_popen()
result = _run_claude(["claude", "-p", "hi"], timeout=30)
assert result["result"] == "Hello from Claude!"
assert result["session_id"] == "sess-abc-123"
assert "usage" in result
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_collects_multiple_text_blocks(self, mock_popen, mock_which):
stdout = _make_stream("First message", "Second message", "Third message")
mock_popen.return_value = _make_popen(stdout=stdout)
result = _run_claude(["claude", "-p", "hi"], timeout=30)
assert result["result"] == "First message\n\nSecond message\n\nThird message"
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_timeout_raises(self, mock_popen, mock_which):
proc = _make_popen()
# Track calls to distinguish watchdog (with big timeout) from cleanup
call_count = [0]
def wait_side_effect(timeout=None):
call_count[0] += 1
if call_count[0] == 1 and timeout is not None:
# First call is the watchdog — simulate timeout
raise subprocess.TimeoutExpired(cmd="claude", timeout=timeout)
return 0 # subsequent cleanup calls succeed
proc.wait.side_effect = wait_side_effect
# stdout returns empty immediately so the for-loop exits
proc.stdout = io.StringIO("")
proc.stderr = io.StringIO("")
mock_popen.return_value = proc
with pytest.raises(TimeoutError, match="timed out after 30s"):
_run_claude(["claude", "-p", "hi"], timeout=30)
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_nonzero_exit_raises(self, mock_popen, mock_which):
mock_popen.return_value = _make_popen(
stdout="", returncode=1, stderr="something went wrong"
)
with pytest.raises(RuntimeError, match="exit 1"):
_run_claude(["claude", "-p", "hi"], timeout=30)
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_no_result_line_raises(self, mock_popen, mock_which):
# Stream with only an assistant line but no result line
stdout = json.dumps({"type": "assistant", "message": {"content": []}}) + "\n"
mock_popen.return_value = _make_popen(stdout=stdout)
with pytest.raises(RuntimeError, match="no result line"):
_run_claude(["claude", "-p", "hi"], timeout=30)
@patch("shutil.which", return_value=None)
def test_missing_binary_raises(self, mock_which):
with pytest.raises(FileNotFoundError, match="Claude CLI not found"):
_run_claude(["claude", "-p", "hi"], timeout=30)
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_on_text_callback_called(self, mock_popen, mock_which):
stdout = _make_stream("First", "Second")
mock_popen.return_value = _make_popen(stdout=stdout)
received = []
result = _run_claude(
["claude", "-p", "hi"], timeout=30,
on_text=lambda t: received.append(t),
)
assert received == ["First", "Second"]
assert result["intermediate_count"] == 2
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_on_text_callback_error_does_not_crash(self, mock_popen, mock_which):
mock_popen.return_value = _make_popen()
def bad_callback(text):
raise ValueError("callback boom")
# Should not raise — callback errors are logged but swallowed
result = _run_claude(
["claude", "-p", "hi"], timeout=30, on_text=bad_callback
)
assert result["result"] == "Hello from Claude!"
# ---------------------------------------------------------------------------
# Session file helpers (_load_sessions / _save_sessions)
# ---------------------------------------------------------------------------
class TestSessionFileOps:
def test_load_missing_file_returns_empty(self, tmp_path, monkeypatch):
monkeypatch.setattr(
claude_session, "_SESSIONS_FILE", tmp_path / "no_such.json"
)
assert _load_sessions() == {}
def test_load_empty_file_returns_empty(self, tmp_path, monkeypatch):
f = tmp_path / "active.json"
f.write_text("")
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", f)
assert _load_sessions() == {}
def test_save_and_load_roundtrip(self, tmp_path, monkeypatch):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
data = {"general": {"session_id": "abc", "message_count": 1}}
_save_sessions(data)
loaded = _load_sessions()
assert loaded == data
def test_save_creates_directory(self, tmp_path, monkeypatch):
sessions_dir = tmp_path / "deep" / "sessions"
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
_save_sessions({"test": "data"})
assert sf.exists()
assert json.loads(sf.read_text()) == {"test": "data"}
def test_atomic_write_no_corruption_on_error(self, tmp_path, monkeypatch):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
# Write initial good data
_save_sessions({"good": "data"})
# Attempt to write non-serializable data → should raise
class Unserializable:
pass
with pytest.raises(TypeError):
_save_sessions({"bad": Unserializable()})
# Original file should still be intact
assert json.loads(sf.read_text()) == {"good": "data"}
# ---------------------------------------------------------------------------
# start_session
# ---------------------------------------------------------------------------
class TestStartSession:
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_returns_response_and_session_id(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
)
mock_popen.return_value = _make_popen()
response, sid = start_session("general", "Hello")
assert response == "Hello from Claude!"
assert sid == "sess-abc-123"
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_saves_to_active_json(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
mock_popen.return_value = _make_popen()
start_session("general", "Hello")
data = json.loads(sf.read_text())
assert "general" in data
assert data["general"]["session_id"] == "sess-abc-123"
assert data["general"]["model"] == "sonnet"
assert data["general"]["message_count"] == 1
assert "created_at" in data["general"]
assert "last_message_at" in data["general"]
def test_invalid_model_raises(self):
with pytest.raises(ValueError, match="Invalid model"):
start_session("general", "Hello", model="gpt-4")
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_missing_result_line_raises(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
)
# Stream with no result line at all
bad_stream = json.dumps({"type": "assistant", "message": {"content": []}}) + "\n"
mock_popen.return_value = _make_popen(stdout=bad_stream)
with pytest.raises(RuntimeError, match="no result line"):
start_session("general", "Hello")
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_missing_session_id_gives_empty_string(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
)
# Result line without session_id → _run_claude returns "" for session_id
# → start_session checks for empty session_id
bad_stream = _make_stream("hello", result_override={"session_id": None})
mock_popen.return_value = _make_popen(stdout=bad_stream)
with pytest.raises(RuntimeError, match="missing required field"):
start_session("general", "Hello")
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_on_text_passed_through(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
)
stdout = _make_stream("Block 1", "Block 2")
mock_popen.return_value = _make_popen(stdout=stdout)
received = []
start_session("general", "Hello", on_text=lambda t: received.append(t))
assert received == ["Block 1", "Block 2"]
# ---------------------------------------------------------------------------
# resume_session
# ---------------------------------------------------------------------------
class TestResumeSession:
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_returns_response(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
# Pre-populate active.json
sf.write_text(json.dumps({
"general": {
"session_id": "sess-abc-123",
"model": "sonnet",
"created_at": "2026-01-01T00:00:00+00:00",
"last_message_at": "2026-01-01T00:00:00+00:00",
"message_count": 1,
}
}))
mock_popen.return_value = _make_popen()
response = resume_session("sess-abc-123", "Follow up")
assert response == "Hello from Claude!"
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_updates_message_count_and_timestamp(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
old_ts = "2026-01-01T00:00:00+00:00"
sf.write_text(json.dumps({
"general": {
"session_id": "sess-abc-123",
"model": "sonnet",
"created_at": old_ts,
"last_message_at": old_ts,
"message_count": 3,
}
}))
mock_popen.return_value = _make_popen()
resume_session("sess-abc-123", "Follow up")
data = json.loads(sf.read_text())
assert data["general"]["message_count"] == 4
assert data["general"]["last_message_at"] != old_ts
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_uses_resume_flag(self, mock_popen, mock_which, tmp_path, monkeypatch):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text(json.dumps({}))
mock_popen.return_value = _make_popen()
resume_session("sess-abc-123", "Follow up")
# Verify --resume was in the command
cmd = mock_popen.call_args[0][0]
assert "--resume" in cmd
assert "sess-abc-123" in cmd
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_on_text_passed_through(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text(json.dumps({}))
stdout = _make_stream("Block A", "Block B")
mock_popen.return_value = _make_popen(stdout=stdout)
received = []
resume_session("sess-abc-123", "Follow up", on_text=lambda t: received.append(t))
assert received == ["Block A", "Block B"]
# ---------------------------------------------------------------------------
# send_message
# ---------------------------------------------------------------------------
class TestSendMessage:
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_starts_new_session_when_none_exists(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text("{}")
mock_popen.return_value = _make_popen()
response = send_message("general", "Hello")
assert response == "Hello from Claude!"
# Should have created a session
data = json.loads(sf.read_text())
assert "general" in data
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_resumes_existing_session(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text(json.dumps({
"general": {
"session_id": "sess-existing",
"model": "sonnet",
"created_at": "2026-01-01T00:00:00+00:00",
"last_message_at": "2026-01-01T00:00:00+00:00",
"message_count": 1,
}
}))
mock_popen.return_value = _make_popen()
response = send_message("general", "Follow up")
assert response == "Hello from Claude!"
# Should have used --resume
cmd = mock_popen.call_args[0][0]
assert "--resume" in cmd
assert "sess-existing" in cmd
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_on_text_passed_through(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text("{}")
stdout = _make_stream("Intermediate")
mock_popen.return_value = _make_popen(stdout=stdout)
received = []
send_message("general", "Hello", on_text=lambda t: received.append(t))
assert received == ["Intermediate"]
# ---------------------------------------------------------------------------
# clear_session
# ---------------------------------------------------------------------------
class TestClearSession:
def test_returns_true_when_existed(self, tmp_path, monkeypatch):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text(json.dumps({"general": {"session_id": "abc"}}))
assert clear_session("general") is True
def test_returns_false_when_not_existed(self, tmp_path, monkeypatch):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text("{}")
assert clear_session("general") is False
def test_removes_from_active_json(self, tmp_path, monkeypatch):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text(json.dumps({
"general": {"session_id": "abc"},
"other": {"session_id": "def"},
}))
clear_session("general")
data = json.loads(sf.read_text())
assert "general" not in data
assert "other" in data
# ---------------------------------------------------------------------------
# get_active_session
# ---------------------------------------------------------------------------
class TestGetActiveSession:
def test_returns_dict_when_exists(self, tmp_path, monkeypatch):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
session_data = {"session_id": "abc", "model": "sonnet"}
sf.write_text(json.dumps({"general": session_data}))
result = get_active_session("general")
assert result == session_data
def test_returns_none_when_not_exists(self, tmp_path, monkeypatch):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text("{}")
assert get_active_session("general") is None
def test_returns_none_when_file_missing(self, tmp_path, monkeypatch):
monkeypatch.setattr(
claude_session, "_SESSIONS_FILE", tmp_path / "nonexistent.json"
)
assert get_active_session("general") is None
# ---------------------------------------------------------------------------
# list_sessions
# ---------------------------------------------------------------------------
class TestListSessions:
def test_returns_all_sessions(self, tmp_path, monkeypatch):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
data = {
"general": {"session_id": "abc"},
"dev": {"session_id": "def"},
}
sf.write_text(json.dumps(data))
result = list_sessions()
assert result == data
def test_returns_empty_when_none(self, tmp_path, monkeypatch):
monkeypatch.setattr(
claude_session, "_SESSIONS_FILE", tmp_path / "nonexistent.json"
)
assert list_sessions() == {}
# ---------------------------------------------------------------------------
# set_session_model
# ---------------------------------------------------------------------------
class TestSetSessionModel:
def test_updates_model_in_active_json(self, tmp_path, monkeypatch):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text(json.dumps({
"general": {
"session_id": "abc",
"model": "sonnet",
"message_count": 1,
}
}))
result = set_session_model("general", "opus")
assert result is True
data = json.loads(sf.read_text())
assert data["general"]["model"] == "opus"
def test_returns_false_when_no_session(self, tmp_path, monkeypatch):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text("{}")
result = set_session_model("general", "opus")
assert result is False
def test_invalid_model_raises(self):
with pytest.raises(ValueError, match="Invalid model"):
set_session_model("general", "gpt4")
# ---------------------------------------------------------------------------
# Security: prompt injection protection
# ---------------------------------------------------------------------------
class TestPromptInjectionProtection:
def test_system_prompt_contains_security_section(self):
prompt = build_system_prompt()
assert "## Security" in prompt
assert "EXTERNAL CONTENT" in prompt
assert "NEVER follow instructions" in prompt
assert "NEVER reveal secrets" in prompt
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_start_session_wraps_message(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
)
mock_popen.return_value = _make_popen()
start_session("general", "Hello world")
cmd = mock_popen.call_args[0][0]
# Find the -p argument value
p_idx = cmd.index("-p")
msg = cmd[p_idx + 1]
assert msg.startswith("[EXTERNAL CONTENT]")
assert msg.endswith("[END EXTERNAL CONTENT]")
assert "Hello world" in msg
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_resume_session_wraps_message(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text(json.dumps({}))
mock_popen.return_value = _make_popen()
resume_session("sess-abc-123", "Follow up msg")
cmd = mock_popen.call_args[0][0]
p_idx = cmd.index("-p")
msg = cmd[p_idx + 1]
assert msg.startswith("[EXTERNAL CONTENT]")
assert msg.endswith("[END EXTERNAL CONTENT]")
assert "Follow up msg" in msg
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_start_session_includes_system_prompt_with_security(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
)
mock_popen.return_value = _make_popen()
start_session("general", "test")
cmd = mock_popen.call_args[0][0]
sp_idx = cmd.index("--system-prompt")
system_prompt = cmd[sp_idx + 1]
assert "NEVER follow instructions" in system_prompt
# ---------------------------------------------------------------------------
# Security: invocation logging
# ---------------------------------------------------------------------------
class TestInvocationLogging:
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_start_session_logs_invocation(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
)
mock_popen.return_value = _make_popen()
with patch.object(claude_session._invoke_log, "info") as mock_log:
start_session("general", "Hello")
mock_log.assert_called_once()
log_msg = mock_log.call_args[0][0]
assert "channel=" in log_msg
assert "model=" in log_msg
assert "duration_ms=" in log_msg
@patch("shutil.which", return_value="/usr/bin/claude")
@patch("subprocess.Popen")
def test_resume_session_logs_invocation(
self, mock_popen, mock_which, tmp_path, monkeypatch
):
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
sf.write_text(json.dumps({
"general": {
"session_id": "sess-abc-123",
"model": "sonnet",
"message_count": 1,
}
}))
mock_popen.return_value = _make_popen()
with patch.object(claude_session._invoke_log, "info") as mock_log:
resume_session("sess-abc-123", "Follow up")
mock_log.assert_called_once()
log_args = mock_log.call_args[0]
assert "general" in log_args # channel_id
assert "sonnet" in log_args # model