mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-04-29 03:50:06 +00:00
Phase 7: Directory restructuring (messaging/ and tests/)
- Create messaging/platforms/ (base, discord, telegram, factory) - Create messaging/rendering/ (discord_markdown, telegram_markdown) - Create messaging/trees/ (data, repository, processor, queue_manager) - Organize tests/ into api/, providers/, messaging/, cli/, config/ - Add backward-compatible re-exports at old locations - Update handler.py and test_messaging_factory.py imports - Fix Telegram type hints for TELEGRAM_AVAILABLE=False case - Fix Python 3 except syntax in discord_markdown Co-authored-by: Ali Khokhar <alishahryar2@gmail.com>
This commit is contained in:
parent
38a7980546
commit
4b4f87515d
76 changed files with 3294 additions and 3124 deletions
607
tests/cli/test_cli.py
Normal file
607
tests/cli/test_cli.py
Normal file
|
|
@ -0,0 +1,607 @@
|
|||
"""Tests for cli/ module."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from messaging.event_parser import parse_cli_event
|
||||
|
||||
# --- Existing Parser Tests ---
|
||||
|
||||
|
||||
class TestCLIParser:
|
||||
"""Test CLI event parsing."""
|
||||
|
||||
def test_parse_text_content(self):
|
||||
"""Test parsing text content from assistant message."""
|
||||
event = {
|
||||
"type": "assistant",
|
||||
"message": {"content": [{"type": "text", "text": "Hello, world!"}]},
|
||||
}
|
||||
result = parse_cli_event(event)
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "text_chunk"
|
||||
assert result[0]["text"] == "Hello, world!"
|
||||
|
||||
def test_parse_thinking_content(self):
|
||||
"""Test parsing thinking content."""
|
||||
event = {
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [{"type": "thinking", "thinking": "Let me think..."}]
|
||||
},
|
||||
}
|
||||
result = parse_cli_event(event)
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "thinking_chunk"
|
||||
assert (
|
||||
result[0]["text"] == "Let me think...\n"
|
||||
or result[0]["text"] == "Let me think..."
|
||||
)
|
||||
|
||||
def test_parse_multiple_content(self):
|
||||
"""Test parsing mixed content (thinking + tools)."""
|
||||
event = {
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Thinking..."},
|
||||
{"type": "tool_use", "name": "ls", "input": {}},
|
||||
]
|
||||
},
|
||||
}
|
||||
result = parse_cli_event(event)
|
||||
assert len(result) == 2
|
||||
assert result[0]["type"] == "thinking_chunk"
|
||||
assert result[0]["text"] == "Thinking..."
|
||||
assert result[1]["type"] == "tool_use"
|
||||
|
||||
def test_parse_tool_use(self):
|
||||
"""Test parsing tool use content."""
|
||||
event = {
|
||||
"type": "assistant",
|
||||
"message": {
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"name": "read_file",
|
||||
"input": {"path": "/test"},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
result = parse_cli_event(event)
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "tool_use"
|
||||
assert result[0]["name"] == "read_file"
|
||||
assert result[0]["input"] == {"path": "/test"}
|
||||
|
||||
def test_parse_text_delta(self):
|
||||
"""Test parsing streaming text delta."""
|
||||
event = {
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"delta": {"type": "text_delta", "text": "streaming text"},
|
||||
}
|
||||
result = parse_cli_event(event)
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "text_delta"
|
||||
assert result[0]["text"] == "streaming text"
|
||||
|
||||
def test_parse_thinking_delta(self):
|
||||
"""Test parsing streaming thinking delta."""
|
||||
event = {
|
||||
"type": "content_block_delta",
|
||||
"index": 1,
|
||||
"delta": {"type": "thinking_delta", "thinking": "thinking..."},
|
||||
}
|
||||
result = parse_cli_event(event)
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "thinking_delta"
|
||||
assert result[0]["text"] == "thinking..."
|
||||
|
||||
def test_parse_error(self):
|
||||
"""Test parsing error event."""
|
||||
event = {"type": "error", "error": {"message": "Something went wrong"}}
|
||||
result = parse_cli_event(event)
|
||||
assert result[0]["type"] == "error"
|
||||
assert result[0]["message"] == "Something went wrong"
|
||||
|
||||
def test_parse_exit_success(self):
|
||||
"""Test parsing exit event with success."""
|
||||
event = {"type": "exit", "code": 0}
|
||||
result = parse_cli_event(event)
|
||||
assert result[0]["type"] == "complete"
|
||||
assert result[0]["status"] == "success"
|
||||
|
||||
def test_parse_exit_failure(self):
|
||||
"""Test parsing exit event with failure returns error then complete."""
|
||||
event = {"type": "exit", "code": 1}
|
||||
result = parse_cli_event(event)
|
||||
# Non-zero exit now returns error first, then complete
|
||||
assert len(result) == 2
|
||||
assert result[0]["type"] == "error"
|
||||
assert (
|
||||
"exit" in result[0]["message"].lower()
|
||||
or "code" in result[0]["message"].lower()
|
||||
)
|
||||
assert result[1]["type"] == "complete"
|
||||
assert result[1]["status"] == "failed"
|
||||
|
||||
def test_parse_invalid_event(self):
|
||||
"""Test parsing returns empty list for unrecognized event."""
|
||||
result = parse_cli_event({"type": "unknown"})
|
||||
assert result == []
|
||||
|
||||
def test_parse_non_dict(self):
|
||||
"""Test parsing returns empty list for non-dict input."""
|
||||
result = parse_cli_event("not a dict")
|
||||
assert result == []
|
||||
|
||||
|
||||
# --- CLI Session Tests ---
|
||||
|
||||
|
||||
class TestCLISession:
|
||||
"""Test CLISession."""
|
||||
|
||||
def test_session_init(self):
|
||||
"""Test CLISession initialization."""
|
||||
from cli.session import CLISession
|
||||
|
||||
session = CLISession(
|
||||
workspace_path="/tmp/test",
|
||||
api_url="http://localhost:8082/v1",
|
||||
allowed_dirs=["/home/user/projects"],
|
||||
)
|
||||
assert session.workspace == os.path.normpath(os.path.abspath("/tmp/test"))
|
||||
assert session.api_url == "http://localhost:8082/v1"
|
||||
assert not session.is_busy
|
||||
|
||||
def test_session_extract_session_id(self):
|
||||
"""Test session ID extraction from various event formats."""
|
||||
from cli.session import CLISession
|
||||
|
||||
session = CLISession("/tmp", "http://localhost:8082/v1")
|
||||
|
||||
# Direct session_id field
|
||||
assert session._extract_session_id({"session_id": "abc123"}) == "abc123"
|
||||
assert session._extract_session_id({"sessionId": "abc123"}) == "abc123"
|
||||
|
||||
# Nested in init
|
||||
assert (
|
||||
session._extract_session_id({"init": {"session_id": "nested123"}})
|
||||
== "nested123"
|
||||
)
|
||||
|
||||
# Nested in result
|
||||
assert (
|
||||
session._extract_session_id({"result": {"session_id": "res123"}})
|
||||
== "res123"
|
||||
)
|
||||
|
||||
# Conversation id
|
||||
assert (
|
||||
session._extract_session_id({"conversation": {"id": "conv123"}})
|
||||
== "conv123"
|
||||
)
|
||||
|
||||
# No session ID
|
||||
assert session._extract_session_id({"type": "message"}) is None
|
||||
assert session._extract_session_id("not a dict") is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_task_basic_flow(self):
|
||||
"""Test start_task running a basic command flow."""
|
||||
from cli.session import CLISession
|
||||
|
||||
session = CLISession("/tmp", "http://localhost:8082/v1")
|
||||
|
||||
# Mock subprocess
|
||||
mock_process = AsyncMock()
|
||||
mock_process.stdout.read.side_effect = [
|
||||
b'{"type": "message", "content": "Hello"}\n',
|
||||
b'{"session_id": "sess_1"}\n',
|
||||
b"", # EOF
|
||||
]
|
||||
mock_process.stderr.read.return_value = b"" # No error
|
||||
mock_process.wait.return_value = 0
|
||||
mock_process.returncode = 0
|
||||
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", new_callable=AsyncMock
|
||||
) as mock_exec:
|
||||
mock_exec.return_value = mock_process
|
||||
|
||||
events = []
|
||||
async for event in session.start_task("Hello"):
|
||||
events.append(event)
|
||||
|
||||
# Verify command construction
|
||||
# Arg 1 is subprocess command
|
||||
args = mock_exec.call_args[0]
|
||||
assert args[0] == "claude"
|
||||
assert "-p" in args
|
||||
assert "Hello" in args
|
||||
|
||||
# Verify events
|
||||
assert (
|
||||
len(events) == 4
|
||||
) # message, session_id, session_info (synthesized), exit
|
||||
assert events[0] == {"type": "message", "content": "Hello"}
|
||||
assert events[1] == {"type": "session_info", "session_id": "sess_1"}
|
||||
# The session_info event is yielded by _handle_line_gen right after extracting ID
|
||||
assert events[2] == {"session_id": "sess_1"} # The original event
|
||||
assert events[3] == {"type": "exit", "code": 0, "stderr": None}
|
||||
|
||||
assert session.current_session_id == "sess_1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_task_with_session_resume(self):
|
||||
"""Test resuming an existing session."""
|
||||
from cli.session import CLISession
|
||||
|
||||
session = CLISession("/tmp", "http://localhost:8082/v1")
|
||||
|
||||
mock_process = AsyncMock()
|
||||
mock_process.stdout.read.side_effect = [
|
||||
b"",
|
||||
] # Immediate EOF
|
||||
mock_process.stderr.read.return_value = b""
|
||||
mock_process.wait.return_value = 0
|
||||
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", new_callable=AsyncMock
|
||||
) as mock_exec:
|
||||
mock_exec.return_value = mock_process
|
||||
|
||||
async for _ in session.start_task("Hello", session_id="sess_abc"):
|
||||
pass
|
||||
|
||||
args = mock_exec.call_args[0]
|
||||
assert "--resume" in args
|
||||
assert "sess_abc" in args
|
||||
assert "--fork-session" not in args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_task_with_session_resume_and_fork(self):
|
||||
"""Test resuming an existing session and forking."""
|
||||
from cli.session import CLISession
|
||||
|
||||
session = CLISession("/tmp", "http://localhost:8082/v1")
|
||||
|
||||
mock_process = AsyncMock()
|
||||
mock_process.stdout.read.side_effect = [b""] # Immediate EOF
|
||||
mock_process.stderr.read.return_value = b""
|
||||
mock_process.wait.return_value = 0
|
||||
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", new_callable=AsyncMock
|
||||
) as mock_exec:
|
||||
mock_exec.return_value = mock_process
|
||||
|
||||
async for _ in session.start_task(
|
||||
"Hello", session_id="sess_abc", fork_session=True
|
||||
):
|
||||
pass
|
||||
|
||||
args = mock_exec.call_args[0]
|
||||
assert "--resume" in args
|
||||
assert "sess_abc" in args
|
||||
assert "--fork-session" in args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_task_process_failure_with_stderr(self):
|
||||
"""Test process exit with error code and stderr output."""
|
||||
from cli.session import CLISession
|
||||
|
||||
session = CLISession("/tmp", "http://localhost:8082/v1")
|
||||
|
||||
mock_process = AsyncMock()
|
||||
mock_process.stdout.read.side_effect = [b""] # No stdout
|
||||
mock_process.stderr.read.return_value = b"Fatal error"
|
||||
mock_process.wait.return_value = 1
|
||||
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", new_callable=AsyncMock
|
||||
) as mock_exec:
|
||||
mock_exec.return_value = mock_process
|
||||
|
||||
events = []
|
||||
async for event in session.start_task("Hello"):
|
||||
events.append(event)
|
||||
|
||||
# Should have error event from stderr, then exit event
|
||||
assert len(events) == 2
|
||||
assert events[0]["type"] == "error"
|
||||
assert events[0]["error"]["message"] == "Fatal error"
|
||||
|
||||
assert events[1]["type"] == "exit"
|
||||
assert events[1]["code"] == 1
|
||||
assert events[1]["stderr"] == "Fatal error"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_session(self):
|
||||
"""Test stopping the session process."""
|
||||
from cli.session import CLISession
|
||||
|
||||
session = CLISession("/tmp", "http://localhost:8082/v1")
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_process.returncode = None # Running
|
||||
# Mock wait to simulate async finish
|
||||
mock_process.wait = AsyncMock(return_value=0)
|
||||
|
||||
session.process = mock_process
|
||||
|
||||
stopped = await session.stop()
|
||||
|
||||
assert stopped is True
|
||||
mock_process.terminate.assert_called_once()
|
||||
mock_process.wait.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_session_timeout_force_kill(self):
|
||||
"""Test force kill if terminate times out."""
|
||||
from cli.session import CLISession
|
||||
|
||||
session = CLISession("/tmp", "http://localhost:8082/v1")
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_process.returncode = None
|
||||
|
||||
# First wait times out
|
||||
async def wait_side_effect():
|
||||
if not mock_process.kill.called:
|
||||
await asyncio.sleep(6) # Should be > 5.0 timeout
|
||||
return 0
|
||||
|
||||
# We can simulate timeout by raising TimeoutError directly on first call
|
||||
mock_process.wait = AsyncMock(side_effect=[asyncio.TimeoutError, 0])
|
||||
|
||||
session.process = mock_process
|
||||
|
||||
stopped = await session.stop()
|
||||
|
||||
assert stopped is True
|
||||
mock_process.terminate.assert_called()
|
||||
mock_process.kill.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_task_split_buffer(self):
|
||||
"""Test handling of JSON split across chunks."""
|
||||
from cli.session import CLISession
|
||||
|
||||
session = CLISession("/tmp", "http://localhost:8082/v1")
|
||||
|
||||
mock_process = AsyncMock()
|
||||
# Split json: {"type": "mess... age"}
|
||||
mock_process.stdout.read.side_effect = [
|
||||
b'{"type": "mess',
|
||||
b'age", "content": "Split"}\n',
|
||||
b"",
|
||||
]
|
||||
mock_process.stderr.read.return_value = b""
|
||||
mock_process.wait.return_value = 0
|
||||
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", new_callable=AsyncMock
|
||||
) as mock_exec:
|
||||
mock_exec.return_value = mock_process
|
||||
|
||||
events = []
|
||||
async for event in session.start_task("test"):
|
||||
if event["type"] == "message":
|
||||
events.append(event)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0]["content"] == "Split"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_task_remnant_buffer(self):
|
||||
"""Test handling of buffer remnant at EOF (no newline at end)."""
|
||||
from cli.session import CLISession
|
||||
|
||||
session = CLISession("/tmp", "http://localhost:8082/v1")
|
||||
|
||||
mock_process = AsyncMock()
|
||||
mock_process.stdout.read.side_effect = [
|
||||
b'{"type": "message", "content": "Remnant"}', # No newline
|
||||
b"",
|
||||
]
|
||||
mock_process.stderr.read.return_value = b""
|
||||
mock_process.wait.return_value = 0
|
||||
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", new_callable=AsyncMock
|
||||
) as mock_exec:
|
||||
mock_exec.return_value = mock_process
|
||||
|
||||
events = []
|
||||
async for event in session.start_task("test"):
|
||||
if event["type"] == "message":
|
||||
events.append(event)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0]["content"] == "Remnant"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_task_non_v1_url(self):
|
||||
"""Test start_task with a non-v1 URL."""
|
||||
from cli.session import CLISession
|
||||
|
||||
# URL not ending in /v1
|
||||
session = CLISession("/tmp", "http://localhost:8082")
|
||||
|
||||
mock_process = AsyncMock()
|
||||
mock_process.stdout.read.side_effect = [b""]
|
||||
mock_process.stderr.read.return_value = b""
|
||||
mock_process.wait.return_value = 0
|
||||
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", new_callable=AsyncMock
|
||||
) as mock_exec:
|
||||
mock_exec.return_value = mock_process
|
||||
async for _ in session.start_task("test"):
|
||||
pass
|
||||
|
||||
# Check env var
|
||||
kwargs = mock_exec.call_args[1]
|
||||
env = kwargs["env"]
|
||||
assert env["ANTHROPIC_BASE_URL"] == "http://localhost:8082"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_task_allowed_dirs(self):
|
||||
"""Test start_task includes allowed dirs in command."""
|
||||
from cli.session import CLISession
|
||||
|
||||
session = CLISession(
|
||||
"/tmp", "http://localhost:8082/v1", allowed_dirs=["/dir1", "/dir2"]
|
||||
)
|
||||
|
||||
mock_process = AsyncMock()
|
||||
mock_process.stdout.read.side_effect = [b""]
|
||||
mock_process.stderr.read.return_value = b""
|
||||
mock_process.wait.return_value = 0
|
||||
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", new_callable=AsyncMock
|
||||
) as mock_exec:
|
||||
mock_exec.return_value = mock_process
|
||||
async for _ in session.start_task("test"):
|
||||
pass
|
||||
|
||||
cmd = mock_exec.call_args[0]
|
||||
assert "--add-dir" in cmd
|
||||
assert os.path.normpath("/dir1") in cmd
|
||||
assert os.path.normpath("/dir2") in cmd
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_task_plans_directory(self):
|
||||
"""Test start_task includes --settings plansDirectory when plans_directory set."""
|
||||
from cli.session import CLISession
|
||||
|
||||
session = CLISession(
|
||||
"/tmp",
|
||||
"http://localhost:8082/v1",
|
||||
plans_directory="./agent_workspace/plans",
|
||||
)
|
||||
|
||||
mock_process = AsyncMock()
|
||||
mock_process.stdout.read.side_effect = [b""]
|
||||
mock_process.stderr.read.return_value = b""
|
||||
mock_process.wait.return_value = 0
|
||||
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", new_callable=AsyncMock
|
||||
) as mock_exec:
|
||||
mock_exec.return_value = mock_process
|
||||
async for _ in session.start_task("test"):
|
||||
pass
|
||||
|
||||
cmd = mock_exec.call_args[0]
|
||||
assert "--settings" in cmd
|
||||
settings_idx = cmd.index("--settings")
|
||||
assert settings_idx + 1 < len(cmd)
|
||||
settings = json.loads(cmd[settings_idx + 1])
|
||||
assert settings["plansDirectory"] == "./agent_workspace/plans"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_task_json_error(self):
|
||||
"""Test handling of non-JSON output from CLI."""
|
||||
from cli.session import CLISession
|
||||
|
||||
session = CLISession("/tmp", "http://localhost:8082/v1")
|
||||
|
||||
mock_process = AsyncMock()
|
||||
mock_process.stdout.read.side_effect = [b"Not valid json\n", b""]
|
||||
mock_process.stderr.read.return_value = b""
|
||||
mock_process.wait.return_value = 0
|
||||
|
||||
with patch(
|
||||
"asyncio.create_subprocess_exec", new_callable=AsyncMock
|
||||
) as mock_exec:
|
||||
mock_exec.return_value = mock_process
|
||||
|
||||
events = []
|
||||
async for event in session.start_task("test"):
|
||||
if event["type"] == "raw":
|
||||
events.append(event)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0]["content"] == "Not valid json"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_exception(self):
|
||||
"""Test exception handling during stop."""
|
||||
from cli.session import CLISession
|
||||
|
||||
session = CLISession("/tmp", "http://localhost:8082/v1")
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_process.returncode = None
|
||||
# Raise exception on terminate
|
||||
mock_process.terminate.side_effect = RuntimeError("Permission denied")
|
||||
|
||||
session.process = mock_process
|
||||
|
||||
stopped = await session.stop()
|
||||
assert stopped is False
|
||||
|
||||
|
||||
class TestCLISessionManager:
|
||||
"""Test CLISessionManager."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_create_session(self):
|
||||
"""Test creating a new session."""
|
||||
from cli.manager import CLISessionManager
|
||||
|
||||
manager = CLISessionManager(
|
||||
workspace_path="/tmp/test",
|
||||
api_url="http://localhost:8082/v1",
|
||||
max_sessions=5,
|
||||
)
|
||||
|
||||
session, sid, is_new = await manager.get_or_create_session()
|
||||
assert session is not None
|
||||
assert sid.startswith("pending_")
|
||||
assert is_new is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_reuse_session(self):
|
||||
"""Test reusing an existing session."""
|
||||
from cli.manager import CLISessionManager
|
||||
|
||||
manager = CLISessionManager(
|
||||
workspace_path="/tmp/test",
|
||||
api_url="http://localhost:8082/v1",
|
||||
)
|
||||
|
||||
# Create first session
|
||||
s1, sid1, is_new1 = await manager.get_or_create_session()
|
||||
|
||||
# Request same session
|
||||
s2, sid2, is_new2 = await manager.get_or_create_session(session_id=sid1)
|
||||
|
||||
assert s1 is s2
|
||||
assert is_new2 is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manager_stats(self):
|
||||
"""Test manager stats."""
|
||||
from cli.manager import CLISessionManager
|
||||
|
||||
manager = CLISessionManager(
|
||||
workspace_path="/tmp/test",
|
||||
api_url="http://localhost:8082/v1",
|
||||
max_sessions=10,
|
||||
)
|
||||
|
||||
stats = manager.get_stats()
|
||||
assert stats["max_sessions"] == 10
|
||||
assert stats["active_sessions"] == 0
|
||||
assert stats["pending_sessions"] == 0
|
||||
Loading…
Add table
Add a link
Reference in a new issue