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>
This commit is contained in:
@@ -4,7 +4,8 @@ import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch, PropertyMock
|
||||
import io
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -60,20 +61,26 @@ def _make_stream(*assistant_texts, result_override=None):
|
||||
if result_override:
|
||||
result.update(result_override)
|
||||
lines.append(json.dumps(result))
|
||||
return "\n".join(lines)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def _make_proc(stdout=None, returncode=0, stderr=""):
|
||||
"""Build a fake subprocess.CompletedProcess with stream-json output."""
|
||||
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(spec=subprocess.CompletedProcess)
|
||||
proc.stdout = stdout
|
||||
proc.stderr = stderr
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -170,50 +177,67 @@ class TestSafeEnv:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _run_claude
|
||||
# _run_claude (now with Popen streaming)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRunClaude:
|
||||
@patch("shutil.which", return_value="/usr/bin/claude")
|
||||
@patch("subprocess.run")
|
||||
def test_returns_parsed_stream(self, mock_run, mock_which):
|
||||
mock_run.return_value = _make_proc()
|
||||
@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.run")
|
||||
def test_collects_multiple_text_blocks(self, mock_run, mock_which):
|
||||
@patch("subprocess.Popen")
|
||||
def test_collects_multiple_text_blocks(self, mock_popen, mock_which):
|
||||
stdout = _make_stream("First message", "Second message", "Third message")
|
||||
mock_run.return_value = _make_proc(stdout=stdout)
|
||||
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.run")
|
||||
def test_timeout_raises(self, mock_run, mock_which):
|
||||
mock_run.side_effect = subprocess.TimeoutExpired(cmd="claude", timeout=30)
|
||||
@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.run")
|
||||
def test_nonzero_exit_raises(self, mock_run, mock_which):
|
||||
mock_run.return_value = _make_proc(
|
||||
@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.run")
|
||||
def test_no_result_line_raises(self, mock_run, mock_which):
|
||||
@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": []}})
|
||||
mock_run.return_value = _make_proc(stdout=stdout)
|
||||
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)
|
||||
|
||||
@@ -222,6 +246,33 @@ class TestRunClaude:
|
||||
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)
|
||||
@@ -291,9 +342,9 @@ class TestSessionFileOps:
|
||||
|
||||
class TestStartSession:
|
||||
@patch("shutil.which", return_value="/usr/bin/claude")
|
||||
@patch("subprocess.run")
|
||||
@patch("subprocess.Popen")
|
||||
def test_returns_response_and_session_id(
|
||||
self, mock_run, mock_which, tmp_path, monkeypatch
|
||||
self, mock_popen, mock_which, tmp_path, monkeypatch
|
||||
):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
@@ -301,23 +352,23 @@ class TestStartSession:
|
||||
monkeypatch.setattr(
|
||||
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
|
||||
)
|
||||
mock_run.return_value = _make_proc()
|
||||
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.run")
|
||||
@patch("subprocess.Popen")
|
||||
def test_saves_to_active_json(
|
||||
self, mock_run, mock_which, tmp_path, monkeypatch
|
||||
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_run.return_value = _make_proc()
|
||||
mock_popen.return_value = _make_popen()
|
||||
|
||||
start_session("general", "Hello")
|
||||
|
||||
@@ -334,9 +385,9 @@ class TestStartSession:
|
||||
start_session("general", "Hello", model="gpt-4")
|
||||
|
||||
@patch("shutil.which", return_value="/usr/bin/claude")
|
||||
@patch("subprocess.run")
|
||||
@patch("subprocess.Popen")
|
||||
def test_missing_result_line_raises(
|
||||
self, mock_run, mock_which, tmp_path, monkeypatch
|
||||
self, mock_popen, mock_which, tmp_path, monkeypatch
|
||||
):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
@@ -345,16 +396,16 @@ class TestStartSession:
|
||||
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
|
||||
)
|
||||
# Stream with no result line at all
|
||||
bad_stream = json.dumps({"type": "assistant", "message": {"content": []}})
|
||||
mock_run.return_value = _make_proc(stdout=bad_stream)
|
||||
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.run")
|
||||
@patch("subprocess.Popen")
|
||||
def test_missing_session_id_gives_empty_string(
|
||||
self, mock_run, mock_which, tmp_path, monkeypatch
|
||||
self, mock_popen, mock_which, tmp_path, monkeypatch
|
||||
):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
@@ -365,11 +416,29 @@ class TestStartSession:
|
||||
# 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_run.return_value = _make_proc(stdout=bad_stream)
|
||||
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
|
||||
@@ -378,9 +447,9 @@ class TestStartSession:
|
||||
|
||||
class TestResumeSession:
|
||||
@patch("shutil.which", return_value="/usr/bin/claude")
|
||||
@patch("subprocess.run")
|
||||
@patch("subprocess.Popen")
|
||||
def test_returns_response(
|
||||
self, mock_run, mock_which, tmp_path, monkeypatch
|
||||
self, mock_popen, mock_which, tmp_path, monkeypatch
|
||||
):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
@@ -399,14 +468,14 @@ class TestResumeSession:
|
||||
}
|
||||
}))
|
||||
|
||||
mock_run.return_value = _make_proc()
|
||||
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.run")
|
||||
@patch("subprocess.Popen")
|
||||
def test_updates_message_count_and_timestamp(
|
||||
self, mock_run, mock_which, tmp_path, monkeypatch
|
||||
self, mock_popen, mock_which, tmp_path, monkeypatch
|
||||
):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
@@ -425,7 +494,7 @@ class TestResumeSession:
|
||||
}
|
||||
}))
|
||||
|
||||
mock_run.return_value = _make_proc()
|
||||
mock_popen.return_value = _make_popen()
|
||||
resume_session("sess-abc-123", "Follow up")
|
||||
|
||||
data = json.loads(sf.read_text())
|
||||
@@ -433,8 +502,8 @@ class TestResumeSession:
|
||||
assert data["general"]["last_message_at"] != old_ts
|
||||
|
||||
@patch("shutil.which", return_value="/usr/bin/claude")
|
||||
@patch("subprocess.run")
|
||||
def test_uses_resume_flag(self, mock_run, mock_which, tmp_path, monkeypatch):
|
||||
@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"
|
||||
@@ -442,14 +511,33 @@ class TestResumeSession:
|
||||
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
|
||||
sf.write_text(json.dumps({}))
|
||||
|
||||
mock_run.return_value = _make_proc()
|
||||
mock_popen.return_value = _make_popen()
|
||||
resume_session("sess-abc-123", "Follow up")
|
||||
|
||||
# Verify --resume was in the command
|
||||
cmd = mock_run.call_args[0][0]
|
||||
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
|
||||
@@ -458,9 +546,9 @@ class TestResumeSession:
|
||||
|
||||
class TestSendMessage:
|
||||
@patch("shutil.which", return_value="/usr/bin/claude")
|
||||
@patch("subprocess.run")
|
||||
@patch("subprocess.Popen")
|
||||
def test_starts_new_session_when_none_exists(
|
||||
self, mock_run, mock_which, tmp_path, monkeypatch
|
||||
self, mock_popen, mock_which, tmp_path, monkeypatch
|
||||
):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
@@ -469,7 +557,7 @@ class TestSendMessage:
|
||||
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
|
||||
sf.write_text("{}")
|
||||
|
||||
mock_run.return_value = _make_proc()
|
||||
mock_popen.return_value = _make_popen()
|
||||
response = send_message("general", "Hello")
|
||||
assert response == "Hello from Claude!"
|
||||
|
||||
@@ -478,9 +566,9 @@ class TestSendMessage:
|
||||
assert "general" in data
|
||||
|
||||
@patch("shutil.which", return_value="/usr/bin/claude")
|
||||
@patch("subprocess.run")
|
||||
@patch("subprocess.Popen")
|
||||
def test_resumes_existing_session(
|
||||
self, mock_run, mock_which, tmp_path, monkeypatch
|
||||
self, mock_popen, mock_which, tmp_path, monkeypatch
|
||||
):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
@@ -498,15 +586,34 @@ class TestSendMessage:
|
||||
}
|
||||
}))
|
||||
|
||||
mock_run.return_value = _make_proc()
|
||||
mock_popen.return_value = _make_popen()
|
||||
response = send_message("general", "Follow up")
|
||||
assert response == "Hello from Claude!"
|
||||
|
||||
# Should have used --resume
|
||||
cmd = mock_run.call_args[0][0]
|
||||
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
|
||||
@@ -674,9 +781,9 @@ class TestPromptInjectionProtection:
|
||||
assert "NEVER reveal secrets" in prompt
|
||||
|
||||
@patch("shutil.which", return_value="/usr/bin/claude")
|
||||
@patch("subprocess.run")
|
||||
@patch("subprocess.Popen")
|
||||
def test_start_session_wraps_message(
|
||||
self, mock_run, mock_which, tmp_path, monkeypatch
|
||||
self, mock_popen, mock_which, tmp_path, monkeypatch
|
||||
):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
@@ -684,11 +791,11 @@ class TestPromptInjectionProtection:
|
||||
monkeypatch.setattr(
|
||||
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
|
||||
)
|
||||
mock_run.return_value = _make_proc()
|
||||
mock_popen.return_value = _make_popen()
|
||||
|
||||
start_session("general", "Hello world")
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
cmd = mock_popen.call_args[0][0]
|
||||
# Find the -p argument value
|
||||
p_idx = cmd.index("-p")
|
||||
msg = cmd[p_idx + 1]
|
||||
@@ -697,9 +804,9 @@ class TestPromptInjectionProtection:
|
||||
assert "Hello world" in msg
|
||||
|
||||
@patch("shutil.which", return_value="/usr/bin/claude")
|
||||
@patch("subprocess.run")
|
||||
@patch("subprocess.Popen")
|
||||
def test_resume_session_wraps_message(
|
||||
self, mock_run, mock_which, tmp_path, monkeypatch
|
||||
self, mock_popen, mock_which, tmp_path, monkeypatch
|
||||
):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
@@ -708,10 +815,10 @@ class TestPromptInjectionProtection:
|
||||
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
|
||||
sf.write_text(json.dumps({}))
|
||||
|
||||
mock_run.return_value = _make_proc()
|
||||
mock_popen.return_value = _make_popen()
|
||||
resume_session("sess-abc-123", "Follow up msg")
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
cmd = mock_popen.call_args[0][0]
|
||||
p_idx = cmd.index("-p")
|
||||
msg = cmd[p_idx + 1]
|
||||
assert msg.startswith("[EXTERNAL CONTENT]")
|
||||
@@ -719,9 +826,9 @@ class TestPromptInjectionProtection:
|
||||
assert "Follow up msg" in msg
|
||||
|
||||
@patch("shutil.which", return_value="/usr/bin/claude")
|
||||
@patch("subprocess.run")
|
||||
@patch("subprocess.Popen")
|
||||
def test_start_session_includes_system_prompt_with_security(
|
||||
self, mock_run, mock_which, tmp_path, monkeypatch
|
||||
self, mock_popen, mock_which, tmp_path, monkeypatch
|
||||
):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
@@ -729,11 +836,11 @@ class TestPromptInjectionProtection:
|
||||
monkeypatch.setattr(
|
||||
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
|
||||
)
|
||||
mock_run.return_value = _make_proc()
|
||||
mock_popen.return_value = _make_popen()
|
||||
|
||||
start_session("general", "test")
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
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
|
||||
@@ -746,9 +853,9 @@ class TestPromptInjectionProtection:
|
||||
|
||||
class TestInvocationLogging:
|
||||
@patch("shutil.which", return_value="/usr/bin/claude")
|
||||
@patch("subprocess.run")
|
||||
@patch("subprocess.Popen")
|
||||
def test_start_session_logs_invocation(
|
||||
self, mock_run, mock_which, tmp_path, monkeypatch
|
||||
self, mock_popen, mock_which, tmp_path, monkeypatch
|
||||
):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
@@ -756,7 +863,7 @@ class TestInvocationLogging:
|
||||
monkeypatch.setattr(
|
||||
claude_session, "_SESSIONS_FILE", sessions_dir / "active.json"
|
||||
)
|
||||
mock_run.return_value = _make_proc()
|
||||
mock_popen.return_value = _make_popen()
|
||||
|
||||
with patch.object(claude_session._invoke_log, "info") as mock_log:
|
||||
start_session("general", "Hello")
|
||||
@@ -767,9 +874,9 @@ class TestInvocationLogging:
|
||||
assert "duration_ms=" in log_msg
|
||||
|
||||
@patch("shutil.which", return_value="/usr/bin/claude")
|
||||
@patch("subprocess.run")
|
||||
@patch("subprocess.Popen")
|
||||
def test_resume_session_logs_invocation(
|
||||
self, mock_run, mock_which, tmp_path, monkeypatch
|
||||
self, mock_popen, mock_which, tmp_path, monkeypatch
|
||||
):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
@@ -783,7 +890,7 @@ class TestInvocationLogging:
|
||||
"message_count": 1,
|
||||
}
|
||||
}))
|
||||
mock_run.return_value = _make_proc()
|
||||
mock_popen.return_value = _make_popen()
|
||||
|
||||
with patch.object(claude_session._invoke_log, "info") as mock_log:
|
||||
resume_session("sess-abc-123", "Follow up")
|
||||
|
||||
@@ -219,8 +219,8 @@ class TestRestart:
|
||||
patch("cli._get_service_status", return_value={"ActiveState": "active", "MainPID": "100"}), \
|
||||
patch("time.sleep"):
|
||||
cli.cmd_restart(_args(bridge=True))
|
||||
# Should have called kill+start for both bridge and core
|
||||
assert len(calls) == 4
|
||||
# kill+start bridge, restart core
|
||||
assert len(calls) == 3
|
||||
|
||||
def test_restart_fails(self, iso, capsys):
|
||||
with patch("cli._systemctl", return_value=(0, "")), \
|
||||
|
||||
@@ -166,7 +166,7 @@ class TestRegularMessage:
|
||||
response, is_cmd = route_message("ch-1", "user-1", "hello")
|
||||
assert response == "Hello from Claude!"
|
||||
assert is_cmd is False
|
||||
mock_send.assert_called_once_with("ch-1", "hello", model="sonnet")
|
||||
mock_send.assert_called_once_with("ch-1", "hello", model="sonnet", on_text=None)
|
||||
|
||||
@patch("src.router.send_message")
|
||||
def test_model_override(self, mock_send):
|
||||
@@ -174,7 +174,7 @@ class TestRegularMessage:
|
||||
response, is_cmd = route_message("ch-1", "user-1", "hello", model="opus")
|
||||
assert response == "Response"
|
||||
assert is_cmd is False
|
||||
mock_send.assert_called_once_with("ch-1", "hello", model="opus")
|
||||
mock_send.assert_called_once_with("ch-1", "hello", model="opus", on_text=None)
|
||||
|
||||
@patch("src.router._get_channel_config")
|
||||
@patch("src.router._get_config")
|
||||
@@ -190,6 +190,20 @@ class TestRegularMessage:
|
||||
assert "Error: API timeout" in response
|
||||
assert is_cmd is False
|
||||
|
||||
@patch("src.router._get_channel_config")
|
||||
@patch("src.router._get_config")
|
||||
@patch("src.router.send_message")
|
||||
def test_on_text_passed_through(self, mock_send, mock_get_config, mock_chan_cfg):
|
||||
mock_send.return_value = "ok"
|
||||
mock_chan_cfg.return_value = None
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.get.return_value = "sonnet"
|
||||
mock_get_config.return_value = mock_cfg
|
||||
|
||||
cb = lambda t: None
|
||||
route_message("ch-1", "user-1", "hello", on_text=cb)
|
||||
mock_send.assert_called_once_with("ch-1", "hello", model="sonnet", on_text=cb)
|
||||
|
||||
|
||||
# --- _get_channel_config ---
|
||||
|
||||
@@ -230,7 +244,7 @@ class TestModelResolution:
|
||||
mock_chan_cfg.return_value = {"id": "ch-1", "default_model": "haiku"}
|
||||
|
||||
route_message("ch-1", "user-1", "hello")
|
||||
mock_send.assert_called_once_with("ch-1", "hello", model="haiku")
|
||||
mock_send.assert_called_once_with("ch-1", "hello", model="haiku", on_text=None)
|
||||
|
||||
@patch("src.router._get_channel_config")
|
||||
@patch("src.router._get_config")
|
||||
@@ -244,7 +258,7 @@ class TestModelResolution:
|
||||
mock_get_config.return_value = mock_cfg
|
||||
|
||||
route_message("ch-1", "user-1", "hello")
|
||||
mock_send.assert_called_once_with("ch-1", "hello", model="opus")
|
||||
mock_send.assert_called_once_with("ch-1", "hello", model="opus", on_text=None)
|
||||
|
||||
@patch("src.router._get_channel_config")
|
||||
@patch("src.router._get_config")
|
||||
@@ -258,7 +272,7 @@ class TestModelResolution:
|
||||
mock_get_config.return_value = mock_cfg
|
||||
|
||||
route_message("ch-1", "user-1", "hello")
|
||||
mock_send.assert_called_once_with("ch-1", "hello", model="sonnet")
|
||||
mock_send.assert_called_once_with("ch-1", "hello", model="sonnet", on_text=None)
|
||||
|
||||
@patch("src.router.get_active_session")
|
||||
@patch("src.router.send_message")
|
||||
@@ -268,4 +282,4 @@ class TestModelResolution:
|
||||
mock_get_session.return_value = {"model": "opus", "session_id": "abc"}
|
||||
|
||||
route_message("ch-1", "user-1", "hello")
|
||||
mock_send.assert_called_once_with("ch-1", "hello", model="opus")
|
||||
mock_send.assert_called_once_with("ch-1", "hello", model="opus", on_text=None)
|
||||
|
||||
@@ -15,6 +15,7 @@ from src.adapters.whatsapp import (
|
||||
split_message,
|
||||
poll_messages,
|
||||
send_whatsapp,
|
||||
react_whatsapp,
|
||||
get_bridge_status,
|
||||
handle_incoming,
|
||||
run_whatsapp,
|
||||
@@ -229,6 +230,41 @@ class TestGetBridgeStatus:
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestReactWhatsapp:
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_react(self):
|
||||
client = _mock_client()
|
||||
client.post.return_value = _mock_httpx_response(json_data={"ok": True})
|
||||
result = await react_whatsapp(client, "123@s.whatsapp.net", "msg-id-1", "\U0001f440")
|
||||
assert result is True
|
||||
client.post.assert_called_once()
|
||||
sent_json = client.post.call_args[1]["json"]
|
||||
assert sent_json == {"to": "123@s.whatsapp.net", "id": "msg-id-1", "emoji": "\U0001f440", "fromMe": False}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_react_remove(self):
|
||||
client = _mock_client()
|
||||
client.post.return_value = _mock_httpx_response(json_data={"ok": True})
|
||||
result = await react_whatsapp(client, "123@s.whatsapp.net", "msg-id-1", "")
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_react_bridge_error(self):
|
||||
client = _mock_client()
|
||||
client.post.side_effect = httpx.ConnectError("bridge down")
|
||||
result = await react_whatsapp(client, "123@s.whatsapp.net", "msg-id-1", "\U0001f440")
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_react_500(self):
|
||||
client = _mock_client()
|
||||
client.post.return_value = _mock_httpx_response(
|
||||
status_code=500, json_data={"ok": False}
|
||||
)
|
||||
result = await react_whatsapp(client, "123@s.whatsapp.net", "msg-id-1", "\U0001f440")
|
||||
assert result is False
|
||||
|
||||
|
||||
# --- Message handler ---
|
||||
|
||||
|
||||
@@ -363,6 +399,78 @@ class TestHandleIncoming:
|
||||
sent_json = client.post.call_args[1]["json"]
|
||||
assert "Sorry" in sent_json["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reaction_flow(self, _set_owned):
|
||||
"""Eyes reaction added on receipt and removed after response."""
|
||||
client = _mock_client()
|
||||
client.post.return_value = _mock_httpx_response(json_data={"ok": True})
|
||||
msg = {
|
||||
"from": "5511999990000@s.whatsapp.net",
|
||||
"text": "Hello",
|
||||
"pushName": "Owner",
|
||||
"isGroup": False,
|
||||
"id": "msg-abc-123",
|
||||
}
|
||||
with patch("src.adapters.whatsapp.route_message", return_value=("Hi!", False)):
|
||||
await handle_incoming(msg, client)
|
||||
|
||||
# Should have 3 post calls: react 👀, send response, react "" (remove)
|
||||
assert client.post.call_count == 3
|
||||
calls = client.post.call_args_list
|
||||
|
||||
# First call: eyes reaction
|
||||
react_json = calls[0][1]["json"]
|
||||
assert react_json["emoji"] == "\U0001f440"
|
||||
assert react_json["id"] == "msg-abc-123"
|
||||
assert react_json["fromMe"] is False
|
||||
|
||||
# Second call: actual message
|
||||
send_json = calls[1][1]["json"]
|
||||
assert send_json["text"] == "Hi!"
|
||||
|
||||
# Third call: remove reaction
|
||||
unreact_json = calls[2][1]["json"]
|
||||
assert unreact_json["emoji"] == ""
|
||||
assert unreact_json["id"] == "msg-abc-123"
|
||||
assert unreact_json["fromMe"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reaction_removed_on_error(self, _set_owned):
|
||||
"""Eyes reaction removed even when route_message raises."""
|
||||
client = _mock_client()
|
||||
client.post.return_value = _mock_httpx_response(json_data={"ok": True})
|
||||
msg = {
|
||||
"from": "5511999990000@s.whatsapp.net",
|
||||
"text": "Hello",
|
||||
"pushName": "Owner",
|
||||
"isGroup": False,
|
||||
"id": "msg-abc-456",
|
||||
}
|
||||
with patch("src.adapters.whatsapp.route_message", side_effect=Exception("boom")):
|
||||
await handle_incoming(msg, client)
|
||||
|
||||
# react 👀, send error, react "" (remove) — reaction still removed in finally
|
||||
calls = client.post.call_args_list
|
||||
unreact_call = calls[-1][1]["json"]
|
||||
assert unreact_call["emoji"] == ""
|
||||
assert unreact_call["id"] == "msg-abc-456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_reaction_without_message_id(self, _set_owned):
|
||||
"""No reaction calls when message has no id."""
|
||||
client = _mock_client()
|
||||
client.post.return_value = _mock_httpx_response(json_data={"ok": True})
|
||||
msg = {
|
||||
"from": "5511999990000@s.whatsapp.net",
|
||||
"text": "Hello",
|
||||
"pushName": "Owner",
|
||||
"isGroup": False,
|
||||
}
|
||||
with patch("src.adapters.whatsapp.route_message", return_value=("Hi!", False)):
|
||||
await handle_incoming(msg, client)
|
||||
# Only 1 call: send response (no react calls)
|
||||
client.post.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_text_ignored(self, _set_owned):
|
||||
client = _mock_client()
|
||||
|
||||
Reference in New Issue
Block a user