Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions libs/deepagents-cli/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ all: help
TEST_FILE ?= tests/

test:
uv run pytest --disable-socket --allow-unix-socket $(TEST_FILE) --timeout 10
uv run pytest --disable-socket --allow-unix-socket $(TEST_FILE) --timeout 10 --cov=deepagents_cli --cov-report=term-missing --cov-report=html

test_watch:
uv run ptw . -- $(TEST_FILE)
Expand Down Expand Up @@ -46,8 +46,8 @@ help:
@echo 'format - run code formatters'
@echo 'lint - run linters'
@echo '-- TESTS --'
@echo 'test - run unit tests'
@echo 'test TEST_FILE=<test_file> - run all tests in file'
@echo 'test - run unit tests with coverage'
@echo 'test TEST_FILE=<test_file> - run all tests in file with coverage'
@echo '-- DOCUMENTATION tasks are from the top-level Makefile --'


1 change: 1 addition & 0 deletions libs/deepagents-cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ ignore-var-parameters = true
"S311", # Allow pseudo-random generators in tests
"ANN201", # Missing return type annotation
"INP001", # Implicit namespace package
"PLR2004", # Allow magic values in tests
]

[tool.mypy]
Expand Down
15 changes: 15 additions & 0 deletions libs/deepagents-cli/tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Tests for CLI entry points."""

from io import StringIO
from unittest.mock import patch

from deepagents_cli.cli import cli_main


def test_cli_main_prints_message() -> None:
"""Test that cli_main prints the expected message."""
output = StringIO()
with patch("sys.stdout", output):
cli_main()

assert "I'm alive!" in output.getvalue()
157 changes: 157 additions & 0 deletions libs/deepagents-cli/tests/test_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""Tests for command handlers."""

import subprocess
from unittest.mock import Mock, patch

import pytest

from deepagents_cli.commands import execute_bash_command, handle_command


@pytest.fixture
def mock_agent() -> Mock:
"""Create a mock agent with checkpointer."""
agent = Mock()
agent.checkpointer = Mock()
return agent


@pytest.fixture
def mock_token_tracker() -> Mock:
"""Create a mock token tracker."""
tracker = Mock()
tracker.reset = Mock()
tracker.display_session = Mock()
return tracker


class TestHandleCommand:
"""Tests for handle_command function."""

@pytest.mark.parametrize("command", ["/quit", "/exit", "/q", "/QUIT", "/Exit", "/Q"])
def test_exit_commands(self, command: str, mock_agent: Mock, mock_token_tracker: Mock) -> None:
"""Test that all exit command variants return 'exit'."""
result = handle_command(command, mock_agent, mock_token_tracker)
assert result == "exit"

def test_clear_command(self, mock_agent: Mock, mock_token_tracker: Mock) -> None:
"""Test that /clear resets state and returns True."""
from langgraph.checkpoint.memory import InMemorySaver

result = handle_command("/clear", mock_agent, mock_token_tracker)

assert result is True
# Verify checkpointer was reset
assert isinstance(mock_agent.checkpointer, InMemorySaver)
# Verify token tracker was reset
mock_token_tracker.reset.assert_called_once()

def test_help_command(self, mock_agent: Mock, mock_token_tracker: Mock) -> None:
"""Test that /help returns True."""
with patch("deepagents_cli.commands.show_interactive_help"):
result = handle_command("/help", mock_agent, mock_token_tracker)
assert result is True

def test_tokens_command(self, mock_agent: Mock, mock_token_tracker: Mock) -> None:
"""Test that /tokens displays session and returns True."""
result = handle_command("/tokens", mock_agent, mock_token_tracker)

assert result is True
mock_token_tracker.display_session.assert_called_once()

def test_unknown_command(self, mock_agent: Mock, mock_token_tracker: Mock) -> None:
"""Test that unknown command returns True."""
result = handle_command("/unknown", mock_agent, mock_token_tracker)
assert result is True

@pytest.mark.parametrize(
"command",
["/quit", "quit", " /quit "],
ids=["with-slash", "without-slash", "with-whitespace"],
)
def test_command_formatting(
self, command: str, mock_agent: Mock, mock_token_tracker: Mock
) -> None:
"""Test commands work with/without leading slash and whitespace."""
result = handle_command(command, mock_agent, mock_token_tracker)
assert result == "exit"


class TestExecuteBashCommand:
"""Tests for execute_bash_command function."""

def test_execute_simple_command(self) -> None:
"""Test executing a simple bash command."""
with patch("subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(
args=["echo", "hello"], returncode=0, stdout="hello\n", stderr=""
)

result = execute_bash_command("!echo hello")
assert result is True
mock_run.assert_called_once()

def test_execute_empty_command(self) -> None:
"""Test that empty command is handled."""
result = execute_bash_command("!")
assert result is True

def test_execute_command_with_stderr(self) -> None:
"""Test command that produces stderr."""
with patch("subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(
args=["ls", "nonexistent"], returncode=1, stdout="", stderr="ls: cannot access"
)

result = execute_bash_command("!ls nonexistent")
assert result is True

def test_execute_command_timeout(self) -> None:
"""Test command timeout handling."""
with patch("subprocess.run") as mock_run:
mock_run.side_effect = subprocess.TimeoutExpired(cmd="sleep 100", timeout=30)

result = execute_bash_command("!sleep 100")
assert result is True

def test_execute_command_exception(self) -> None:
"""Test command execution exception handling."""
with patch("subprocess.run") as mock_run:
mock_run.side_effect = Exception("Test error")

result = execute_bash_command("!invalid")
assert result is True

def test_execute_command_strips_exclamation(self) -> None:
"""Test that leading ! is stripped from command."""
with patch("subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(
args=["pwd"], returncode=0, stdout="/home/user\n", stderr=""
)

execute_bash_command("!pwd")

# Check that the command was called with shell=True and the right command
call_args = mock_run.call_args
assert call_args[0][0] == "pwd" # Command without !
assert call_args[1]["shell"] is True

def test_execute_command_nonzero_exit_code(self) -> None:
"""Test command with non-zero exit code."""
with patch("subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(
args=["false"], returncode=1, stdout="", stderr=""
)

result = execute_bash_command("!false")
assert result is True

def test_execute_command_with_whitespace(self) -> None:
"""Test command with leading/trailing whitespace."""
with patch("subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess(
args=["echo", "test"], returncode=0, stdout="test\n", stderr=""
)

result = execute_bash_command(" !echo test ")
assert result is True
113 changes: 113 additions & 0 deletions libs/deepagents-cli/tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""Tests for configuration and utilities."""

import os
from pathlib import Path
from unittest.mock import patch

import pytest

from deepagents_cli.config import (
SessionState,
create_model,
get_default_coding_instructions,
)


def test_session_state_initialization() -> None:
"""Test SessionState initializes with correct defaults."""
state = SessionState()
assert state.auto_approve is False

state_with_approve = SessionState(auto_approve=True)
assert state_with_approve.auto_approve is True


def test_session_state_toggle_auto_approve() -> None:
"""Test SessionState toggle functionality."""
state = SessionState(auto_approve=False)

# Toggle from False to True
result = state.toggle_auto_approve()
assert result is True
assert state.auto_approve is True

# Toggle from True to False
result = state.toggle_auto_approve()
assert result is False
assert state.auto_approve is False


def test_get_default_coding_instructions_returns_content() -> None:
"""Test that get_default_coding_instructions reads and returns content."""
instructions = get_default_coding_instructions()

# Should return non-empty string
assert isinstance(instructions, str)
assert len(instructions) > 0


def test_get_default_coding_instructions_file_exists() -> None:
"""Test that the default agent prompt file exists."""
from deepagents_cli import config

prompt_path = Path(config.__file__).parent / "default_agent_prompt.md"
assert prompt_path.exists()
assert prompt_path.is_file()


def test_create_model_no_api_keys() -> None:
"""Test that create_model exits when no API keys are configured."""
with patch.dict(os.environ, {}, clear=True):
# Should exit with code 1
with pytest.raises(SystemExit) as exc_info:
create_model()
assert exc_info.value.code == 1


def test_create_model_with_openai_key() -> None:
"""Test that create_model returns OpenAI model when key is set."""
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}, clear=True):
model = create_model()
# Check that we got a ChatOpenAI instance
assert model.__class__.__name__ == "ChatOpenAI"


def test_create_model_with_anthropic_key() -> None:
"""Test that create_model returns Anthropic model when key is set."""
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}, clear=True):
model = create_model()
# Check that we got a ChatAnthropic instance
assert model.__class__.__name__ == "ChatAnthropic"


def test_create_model_prefers_openai() -> None:
"""Test that create_model prefers OpenAI when both keys are set."""
with patch.dict(
os.environ,
{"OPENAI_API_KEY": "openai-key", "ANTHROPIC_API_KEY": "anthropic-key"},
clear=True,
):
model = create_model()
# Should prefer OpenAI
assert model.__class__.__name__ == "ChatOpenAI"


def test_create_model_custom_model_name_openai() -> None:
"""Test that create_model respects custom OpenAI model name."""
with patch.dict(
os.environ, {"OPENAI_API_KEY": "test-key", "OPENAI_MODEL": "gpt-4o"}, clear=True
):
model = create_model()
assert model.model_name == "gpt-4o"


def test_create_model_custom_model_name_anthropic() -> None:
"""Test that create_model respects custom Anthropic model name."""
with patch.dict(
os.environ,
{"ANTHROPIC_API_KEY": "test-key", "ANTHROPIC_MODEL": "claude-3-opus-20240229"},
clear=True,
):
model = create_model()
# ChatAnthropic uses 'model' attribute, not 'model_name'
assert model.model == "claude-3-opus-20240229"
9 changes: 5 additions & 4 deletions libs/deepagents-cli/tests/test_file_ops.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import textwrap
from pathlib import Path

from langchain_core.messages import ToolMessage

from deepagents_cli.file_ops import FileOpTracker, build_approval_preview


def test_tracker_records_read_lines(tmp_path):
def test_tracker_records_read_lines(tmp_path: Path) -> None:
tracker = FileOpTracker(assistant_id=None)
path = tmp_path / "example.py"

Expand All @@ -28,7 +29,7 @@ def test_tracker_records_read_lines(tmp_path):
assert record.metrics.end_line == 2


def test_tracker_records_write_diff(tmp_path):
def test_tracker_records_write_diff(tmp_path: Path) -> None:
tracker = FileOpTracker(assistant_id=None)
file_path = tmp_path / "created.txt"

Expand All @@ -54,7 +55,7 @@ def test_tracker_records_write_diff(tmp_path):
assert "+hello world" in record.diff


def test_tracker_records_edit_diff(tmp_path):
def test_tracker_records_edit_diff(tmp_path: Path) -> None:
tracker = FileOpTracker(assistant_id=None)
file_path = tmp_path / "functions.py"
file_path.write_text(
Expand Down Expand Up @@ -99,7 +100,7 @@ def wave():
assert '+ return "hi"' in record.diff


def test_build_approval_preview_generates_diff(tmp_path):
def test_build_approval_preview_generates_diff(tmp_path: Path) -> None:
target = tmp_path / "notes.txt"
target.write_text("alpha\nbeta\n")

Expand Down
Loading