From 7feb447015a596ba5f4bf77a5d0e23a604ccc571 Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Sat, 13 Dec 2025 13:41:56 -0800 Subject: [PATCH 1/3] chore: add tests --- Makefile | 5 +- README.md | 6 + docs/architecture.md | 163 ++++++++++++++++++ pyproject.toml | 9 + tests/__init__.py | 0 tests/components/__init__.py | 0 tests/components/test_messages.py | 39 +++++ tests/conftest.py | 41 +++++ tests/core/__init__.py | 0 tests/core/test_actions.py | 110 ++++++++++++ tests/core/test_message_bus.py | 114 ++++++++++++ tests/core/test_ui_state.py | 141 +++++++++++++++ tests/fixtures/test_config.yaml | 10 ++ tests/fixtures/test_config_with_agents.yaml | 10 ++ tests/fixtures/test_config_with_disabled.yaml | 14 ++ tests/test_app.py | 152 ++++++++++++++++ tests/utils/__init__.py | 0 tests/utils/test_config.py | 87 ++++++++++ tests/utils/test_enums.py | 29 ++++ tests/utils/test_format_tool_input.py | 40 +++++ tests/utils/test_mcp_server_status.py | 88 ++++++++++ tests/utils/test_system_prompt.py | 44 +++++ tests/utils/test_tool_info.py | 39 +++++ uv.lock | 89 ++++++++++ 24 files changed, 1229 insertions(+), 1 deletion(-) create mode 100644 docs/architecture.md create mode 100644 tests/__init__.py create mode 100644 tests/components/__init__.py create mode 100644 tests/components/test_messages.py create mode 100644 tests/conftest.py create mode 100644 tests/core/__init__.py create mode 100644 tests/core/test_actions.py create mode 100644 tests/core/test_message_bus.py create mode 100644 tests/core/test_ui_state.py create mode 100644 tests/fixtures/test_config.yaml create mode 100644 tests/fixtures/test_config_with_agents.yaml create mode 100644 tests/fixtures/test_config_with_disabled.yaml create mode 100644 tests/test_app.py create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/test_config.py create mode 100644 tests/utils/test_enums.py create mode 100644 tests/utils/test_format_tool_input.py create mode 100644 tests/utils/test_mcp_server_status.py create mode 100644 tests/utils/test_system_prompt.py create mode 100644 tests/utils/test_tool_info.py diff --git a/Makefile b/Makefile index ef1c744..4d3da68 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev console lint install start type-check +.PHONY: dev console lint install start test type-check install: uv sync && uv run pre-commit install && cp .env.example .env && echo "Please edit the .env file with your API keys." @@ -15,5 +15,8 @@ lint: start: uv run chat +test: + uv run pytest + type-check: uv run mypy src diff --git a/README.md b/README.md index e6eb85b..7814012 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,12 @@ Additional MCP servers are configured in `agent-chat-cli.config.yaml` and prompt - `make type-check` - Linting and formatting is via [Ruff](https://docs.astral.sh/ruff/) - `make lint` +- Testing is via [pytest](https://docs.pytest.org/): + - `make test` + +See [docs/architecture.md](docs/architecture.md) for an overview of the codebase structure. + +### Textual Dev Console Textual has an integrated logging console that one can boot separately from the app to receive logs. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..a25007f --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,163 @@ +# Architecture + +Agent Chat CLI is a terminal-based chat interface for interacting with Claude agents, built with [Textual](https://textual.textualize.io/) and the [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk). + +### Directory Structure + +``` +src/agent_chat_cli/ +├── app.py # Main Textual application entry point +├── core/ +│ ├── actions.py # User action handlers +│ ├── agent_loop.py # Claude Agent SDK client wrapper +│ ├── message_bus.py # Message routing from agent to UI +│ ├── ui_state.py # Centralized UI state management +│ └── styles.tcss # Textual CSS styles +├── components/ +│ ├── balloon_spinner.py # Animated spinner widget +│ ├── caret.py # Input caret indicator +│ ├── chat_history.py # Chat message container +│ ├── flex.py # Horizontal flex container +│ ├── header.py # App header with MCP server status +│ ├── messages.py # Message data models and widgets +│ ├── spacer.py # Empty spacer widget +│ ├── thinking_indicator.py # "Agent is thinking" indicator +│ ├── tool_permission_prompt.py # Tool permission request UI +│ └── user_input.py # User text input widget +└── utils/ + ├── config.py # YAML config loading + ├── enums.py # Shared enumerations + ├── format_tool_input.py # Tool input formatting + ├── logger.py # Logging setup + ├── mcp_server_status.py # MCP server connection state + ├── system_prompt.py # System prompt builder + └── tool_info.py # Tool name parsing +``` + +### Core Architecture + +The application follows a loosely coupled architecture with four main orchestration objects: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ AgentChatCLIApp │ +│ ┌───────────┐ ┌───────────┐ ┌─────────┐ ┌───────────┐ │ +│ │ UIState │ │MessageBus │ │ Actions │ │ AgentLoop │ │ +│ └─────┬─────┘ └─────┬─────┘ └────┬────┘ └─────┬─────┘ │ +│ │ │ │ │ │ +│ └──────────────┴─────────────┴──────────────┘ │ +│ │ │ +│ ┌─────────────────────────┴─────────────────────────────┐ │ +│ │ Components │ │ +│ │ Header │ ChatHistory │ ThinkingIndicator │ UserInput │ │ +│ └────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Core Modules + +**UIState** (`core/ui_state.py`) +Centralized management of UI state behaviors. Handles: +- Thinking indicator visibility and cursor blink state +- Tool permission prompt display/hide +- Interrupt state tracking + +This class was introduced in PR #9 to consolidate scattered UI state logic from Actions and MessageBus into a single cohesive module. + +**MessageBus** (`core/message_bus.py`) +Routes messages from the AgentLoop to appropriate UI components: +- `STREAM_EVENT`: Streaming text chunks to AgentMessage widgets +- `ASSISTANT`: Complete assistant responses with tool use blocks +- `SYSTEM` / `USER`: System and user messages +- `TOOL_PERMISSION_REQUEST`: Triggers permission prompt UI +- `RESULT`: Signals completion, resets state + +**Actions** (`core/actions.py`) +User-initiated action handlers: +- `submit_user_message()`: Posts user message and queries agent +- `interrupt()`: Cancels current agent operation +- `new()`: Starts new conversation, clears history +- `respond_to_tool_permission()`: Handles permission prompt responses + +**AgentLoop** (`core/agent_loop.py`) +Manages the Claude Agent SDK client lifecycle: +- Initializes `ClaudeSDKClient` with config and MCP servers +- Processes incoming messages via async generator +- Handles tool permission flow via `_can_use_tool()` callback +- Manages `query_queue` and `permission_response_queue` for async communication + +### Message Flow + +1. User types in `UserInput` and presses Enter +2. `Actions.submit_user_message()` posts to UI and enqueues to `AgentLoop.query_queue` +3. `AgentLoop` sends query to Claude Agent SDK and streams responses +4. Responses flow through `MessageBus.handle_agent_message()` to update UI +5. Tool use triggers permission prompt via `UIState.show_permission_prompt()` +6. User response flows back through `Actions.respond_to_tool_permission()` + +### Components + +**UserInput** (`components/user_input.py`) +Text input with: +- Enter to submit +- Ctrl+J for newlines (PR #10) +- Control commands: `exit`, `clear` + +**ToolPermissionPrompt** (`components/tool_permission_prompt.py`) +Modal prompt for tool permission requests: +- Shows tool name and MCP server +- Enter to allow, ESC to deny, or type custom response +- Manages focus to prevent input elsewhere while visible + +**ChatHistory** (`components/chat_history.py`) +Container for message widgets, handles `MessagePosted` events. + +**ThinkingIndicator** (`components/thinking_indicator.py`) +Animated indicator shown during agent processing. + +**Header** (`components/header.py`) +Displays available MCP servers with connection status via `MCPServerStatus` subscription. + +### Configuration + +Configuration is loaded from `agent-chat-cli.config.yaml`: + +```yaml +system_prompt: "prompt.md" # File path or literal string +model: "claude-sonnet-4-20250514" +permission_mode: "bypass_permissions" + +mcp_servers: + server_name: + description: "Server description" + command: "npx" + args: ["-y", "@some/mcp-server"] + env: + API_KEY: "$API_KEY" + enabled: true + prompt: "server_prompt.md" + +agents: + agent_name: + description: "Agent description" + prompt: "agent_prompt.md" + tools: ["tool1", "tool2"] +``` + +### Key Patterns + +**Reactive Properties**: Textual's `reactive` and `var` are used for automatic UI updates when state changes (e.g., `ThinkingIndicator.is_thinking`, `ToolPermissionPrompt.is_visible`). + +**Async Queues**: Communication between UI and AgentLoop uses `asyncio.Queue` for decoupled async message passing. + +**Observer Pattern**: `MCPServerStatus` uses callback subscriptions to notify components of connection state changes. + +**TYPE_CHECKING Guards**: Circular import prevention via `if TYPE_CHECKING:` blocks for type hints. + +### Testing + +Tests use pytest with pytest-asyncio for async support and Textual's pilot testing framework for UI interactions. + +```bash +make test +``` diff --git a/pyproject.toml b/pyproject.toml index fd79485..9ecd64d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,9 @@ dependencies = [ dev = [ "mypy>=1.19.0", "pre-commit>=4.3.0", + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "pytest-textual-snapshot>=1.0.0", "ruff>=0.14.7", "textual-dev>=1.8.0", "types-pyyaml>=6.0.12.20250915", @@ -30,3 +33,9 @@ dev = "textual_dev.cli:run" [tool.uv] package = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/components/__init__.py b/tests/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/components/test_messages.py b/tests/components/test_messages.py new file mode 100644 index 0000000..79d78de --- /dev/null +++ b/tests/components/test_messages.py @@ -0,0 +1,39 @@ +from agent_chat_cli.components.messages import Message, MessageType + + +class TestMessage: + def test_system_creates_system_message(self): + msg = Message.system("System alert") + + assert msg.type == MessageType.SYSTEM + assert msg.content == "System alert" + assert msg.metadata is None + + def test_user_creates_user_message(self): + msg = Message.user("Hello there") + + assert msg.type == MessageType.USER + assert msg.content == "Hello there" + assert msg.metadata is None + + def test_agent_creates_agent_message(self): + msg = Message.agent("I can help with that.") + + assert msg.type == MessageType.AGENT + assert msg.content == "I can help with that." + assert msg.metadata is None + + def test_tool_creates_tool_message_with_metadata(self): + msg = Message.tool("read_file", '{"path": "/tmp/test.txt"}') + + assert msg.type == MessageType.TOOL + assert msg.content == '{"path": "/tmp/test.txt"}' + assert msg.metadata == {"tool_name": "read_file"} + + +class TestMessageType: + def test_all_types_have_values(self): + assert MessageType.SYSTEM.value == "system" + assert MessageType.USER.value == "user" + assert MessageType.AGENT.value == "agent" + assert MessageType.TOOL.value == "tool" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3226303 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,41 @@ +import os +import pytest +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + + +os.environ["ANTHROPIC_API_KEY"] = "test-key" + + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def test_config_path(): + return FIXTURES_DIR / "test_config.yaml" + + +@pytest.fixture +def mock_claude_sdk(): + with patch("agent_chat_cli.core.agent_loop.ClaudeSDKClient") as mock_client: + instance = MagicMock() + instance.connect = AsyncMock() + instance.disconnect = AsyncMock() + instance.query = AsyncMock() + instance.interrupt = AsyncMock() + instance.receive_response = AsyncMock(return_value=AsyncIteratorMock([])) + mock_client.return_value = instance + yield mock_client + + +class AsyncIteratorMock: + def __init__(self, items): + self.items = items + + def __aiter__(self): + return self + + async def __anext__(self): + if not self.items: + raise StopAsyncIteration + return self.items.pop(0) diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/test_actions.py b/tests/core/test_actions.py new file mode 100644 index 0000000..209859c --- /dev/null +++ b/tests/core/test_actions.py @@ -0,0 +1,110 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from agent_chat_cli.app import AgentChatCLIApp +from agent_chat_cli.components.chat_history import ChatHistory +from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt +from agent_chat_cli.utils.enums import ControlCommand + + +@pytest.fixture +def mock_agent_loop(): + with patch("agent_chat_cli.app.AgentLoop") as mock: + instance = MagicMock() + instance.start = AsyncMock() + instance.query_queue = MagicMock() + instance.query_queue.put = AsyncMock() + instance.query_queue.empty = MagicMock(return_value=True) + instance.permission_response_queue = MagicMock() + instance.permission_response_queue.put = AsyncMock() + instance.client = MagicMock() + instance.client.interrupt = AsyncMock() + mock.return_value = instance + yield instance + + +@pytest.fixture +def mock_config(): + with patch("agent_chat_cli.components.header.load_config") as mock: + mock.return_value = MagicMock(mcp_servers={}, agents={}) + yield mock + + +class TestActionsInterrupt: + async def test_sets_interrupting_flag(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + await app.actions.interrupt() + + assert app.ui_state.interrupting is True + + async def test_calls_client_interrupt(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + await app.actions.interrupt() + + mock_agent_loop.client.interrupt.assert_called_once() + + async def test_blocked_when_permission_prompt_visible( + self, mock_agent_loop, mock_config + ): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.show_permission_prompt(tool_name="test", tool_input={}) + + await app.actions.interrupt() + + assert app.ui_state.interrupting is False + mock_agent_loop.client.interrupt.assert_not_called() + + +class TestActionsNew: + async def test_queues_new_conversation_command(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + await app.actions.new() + + mock_agent_loop.query_queue.put.assert_called_with( + ControlCommand.NEW_CONVERSATION + ) + + async def test_clears_chat_history(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + initial_children = len(chat_history.children) + + await app.actions.new() + + assert len(chat_history.children) <= initial_children + + +class TestActionsRespondToToolPermission: + async def test_queues_response(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.show_permission_prompt(tool_name="test", tool_input={}) + + await app.actions.respond_to_tool_permission("yes") + + mock_agent_loop.permission_response_queue.put.assert_called_with("yes") + + async def test_hides_permission_prompt(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.show_permission_prompt(tool_name="test", tool_input={}) + + await app.actions.respond_to_tool_permission("yes") + + prompt = app.query_one(ToolPermissionPrompt) + assert prompt.is_visible is False + + async def test_deny_response_queries_agent(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.show_permission_prompt(tool_name="test", tool_input={}) + + await app.actions.respond_to_tool_permission("no") + + calls = mock_agent_loop.query_queue.put.call_args_list + assert any("denied" in str(call).lower() for call in calls) diff --git a/tests/core/test_message_bus.py b/tests/core/test_message_bus.py new file mode 100644 index 0000000..e8448d2 --- /dev/null +++ b/tests/core/test_message_bus.py @@ -0,0 +1,114 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from agent_chat_cli.app import AgentChatCLIApp +from agent_chat_cli.core.agent_loop import AgentMessage +from agent_chat_cli.utils.enums import AgentMessageType, ContentType + + +@pytest.fixture +def mock_agent_loop(): + with patch("agent_chat_cli.app.AgentLoop") as mock: + instance = MagicMock() + instance.start = AsyncMock() + instance.query_queue = MagicMock() + instance.query_queue.empty = MagicMock(return_value=True) + mock.return_value = instance + yield instance + + +@pytest.fixture +def mock_config(): + with patch("agent_chat_cli.components.header.load_config") as mock: + mock.return_value = MagicMock(mcp_servers={}, agents={}) + yield mock + + +class TestMessageBusHandleAgentMessage: + async def test_handles_stream_event(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + message = AgentMessage( + type=AgentMessageType.STREAM_EVENT, + data={"text": "Hello"}, + ) + + await app.message_bus.handle_agent_message(message) + + assert app.message_bus.current_response_text == "Hello" + + async def test_accumulates_stream_chunks(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + await app.message_bus.handle_agent_message( + AgentMessage( + type=AgentMessageType.STREAM_EVENT, data={"text": "Hello "} + ) + ) + await app.message_bus.handle_agent_message( + AgentMessage(type=AgentMessageType.STREAM_EVENT, data={"text": "world"}) + ) + + assert app.message_bus.current_response_text == "Hello world" + + async def test_handles_tool_permission_request(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + message = AgentMessage( + type=AgentMessageType.TOOL_PERMISSION_REQUEST, + data={"tool_name": "read_file", "tool_input": {"path": "/tmp"}}, + ) + + await app.message_bus.handle_agent_message(message) + + from agent_chat_cli.components.tool_permission_prompt import ( + ToolPermissionPrompt, + ) + + prompt = app.query_one(ToolPermissionPrompt) + assert prompt.is_visible is True + + async def test_handles_result_resets_state(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.start_thinking() + await app.message_bus.handle_agent_message( + AgentMessage(type=AgentMessageType.STREAM_EVENT, data={"text": "test"}) + ) + + await app.message_bus.handle_agent_message( + AgentMessage(type=AgentMessageType.RESULT, data=None) + ) + + assert app.message_bus.current_agent_message is None + assert app.message_bus.current_response_text == "" + + async def test_handles_assistant_with_tool_use(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + message = AgentMessage( + type=AgentMessageType.ASSISTANT, + data={ + "content": [ + { + "type": ContentType.TOOL_USE.value, + "name": "bash", + "input": {"command": "ls"}, + } + ] + }, + ) + + await app.message_bus.handle_agent_message(message) + + assert app.message_bus.current_agent_message is None + + async def test_ignores_empty_stream_chunks(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + await app.message_bus.handle_agent_message( + AgentMessage(type=AgentMessageType.STREAM_EVENT, data={"text": ""}) + ) + + assert app.message_bus.current_response_text == "" + assert app.message_bus.current_agent_message is None diff --git a/tests/core/test_ui_state.py b/tests/core/test_ui_state.py new file mode 100644 index 0000000..a83dd56 --- /dev/null +++ b/tests/core/test_ui_state.py @@ -0,0 +1,141 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from textual.widgets import TextArea + +from agent_chat_cli.app import AgentChatCLIApp +from agent_chat_cli.components.thinking_indicator import ThinkingIndicator +from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt +from agent_chat_cli.components.user_input import UserInput + + +@pytest.fixture +def mock_agent_loop(): + with patch("agent_chat_cli.app.AgentLoop") as mock: + instance = MagicMock() + instance.start = AsyncMock() + instance.query_queue = MagicMock() + mock.return_value = instance + yield instance + + +@pytest.fixture +def mock_config(): + with patch("agent_chat_cli.components.header.load_config") as mock: + mock.return_value = MagicMock(mcp_servers={}, agents={}) + yield mock + + +class TestUIStateInterrupting: + async def test_initially_false(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + assert app.ui_state.interrupting is False + + async def test_set_interrupting(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.set_interrupting(True) + assert app.ui_state.interrupting is True + + app.ui_state.set_interrupting(False) + assert app.ui_state.interrupting is False + + +class TestUIStateThinking: + async def test_start_thinking_shows_indicator(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.start_thinking() + + indicator = app.query_one(ThinkingIndicator) + assert indicator.is_thinking is True + + async def test_start_thinking_disables_cursor_blink( + self, mock_agent_loop, mock_config + ): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.start_thinking() + + text_area = app.query_one(TextArea) + assert text_area.cursor_blink is False + + async def test_stop_thinking_hides_indicator(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.start_thinking() + app.ui_state.stop_thinking() + + indicator = app.query_one(ThinkingIndicator) + assert indicator.is_thinking is False + + async def test_stop_thinking_restores_cursor_blink( + self, mock_agent_loop, mock_config + ): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.start_thinking() + app.ui_state.stop_thinking(show_cursor=True) + + text_area = app.query_one(TextArea) + assert text_area.cursor_blink is True + + async def test_stop_thinking_can_skip_cursor_restore( + self, mock_agent_loop, mock_config + ): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.start_thinking() + app.ui_state.stop_thinking(show_cursor=False) + + text_area = app.query_one(TextArea) + assert text_area.cursor_blink is False + + +class TestUIStatePermissionPrompt: + async def test_show_permission_prompt(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.show_permission_prompt( + tool_name="test_tool", + tool_input={"key": "value"}, + ) + + prompt = app.query_one(ToolPermissionPrompt) + assert prompt.is_visible is True + assert prompt.tool_name == "test_tool" + assert prompt.tool_input == {"key": "value"} + + async def test_show_permission_prompt_hides_user_input( + self, mock_agent_loop, mock_config + ): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.show_permission_prompt(tool_name="test", tool_input={}) + + user_input = app.query_one(UserInput) + assert user_input.display is False + + async def test_show_permission_prompt_stops_thinking( + self, mock_agent_loop, mock_config + ): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.start_thinking() + app.ui_state.show_permission_prompt(tool_name="test", tool_input={}) + + indicator = app.query_one(ThinkingIndicator) + assert indicator.is_thinking is False + + async def test_hide_permission_prompt(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.show_permission_prompt(tool_name="test", tool_input={}) + app.ui_state.hide_permission_prompt() + + prompt = app.query_one(ToolPermissionPrompt) + user_input = app.query_one(UserInput) + + assert prompt.is_visible is False + assert user_input.display is True diff --git a/tests/fixtures/test_config.yaml b/tests/fixtures/test_config.yaml new file mode 100644 index 0000000..2c8bc9a --- /dev/null +++ b/tests/fixtures/test_config.yaml @@ -0,0 +1,10 @@ +system_prompt: "You are a helpful assistant." +model: "claude-sonnet-4-20250514" +permission_mode: "bypass_permissions" + +mcp_servers: + test_server: + description: "Test MCP server" + command: "echo" + args: ["test"] + enabled: true diff --git a/tests/fixtures/test_config_with_agents.yaml b/tests/fixtures/test_config_with_agents.yaml new file mode 100644 index 0000000..76d7be7 --- /dev/null +++ b/tests/fixtures/test_config_with_agents.yaml @@ -0,0 +1,10 @@ +system_prompt: "You are a helpful assistant." +model: "claude-sonnet-4-20250514" + +agents: + researcher: + description: "Research agent" + prompt: "You are a research agent." + tools: + - web_search + - read_file diff --git a/tests/fixtures/test_config_with_disabled.yaml b/tests/fixtures/test_config_with_disabled.yaml new file mode 100644 index 0000000..112eaf8 --- /dev/null +++ b/tests/fixtures/test_config_with_disabled.yaml @@ -0,0 +1,14 @@ +system_prompt: "You are a helpful assistant." +model: "claude-sonnet-4-20250514" + +mcp_servers: + enabled_server: + description: "Enabled server" + command: "echo" + args: ["enabled"] + enabled: true + disabled_server: + description: "Disabled server" + command: "echo" + args: ["disabled"] + enabled: false diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..f44c2c1 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,152 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from pathlib import Path + +from textual.widgets import TextArea + +from agent_chat_cli.app import AgentChatCLIApp +from agent_chat_cli.components.thinking_indicator import ThinkingIndicator +from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt +from agent_chat_cli.components.user_input import UserInput + + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def mock_agent_loop(): + with patch("agent_chat_cli.app.AgentLoop") as mock: + instance = MagicMock() + instance.start = AsyncMock() + instance.query_queue = MagicMock() + instance.query_queue.put = AsyncMock() + instance.query_queue.empty = MagicMock(return_value=True) + instance.permission_response_queue = MagicMock() + instance.permission_response_queue.put = AsyncMock() + instance.client = MagicMock() + instance.client.interrupt = AsyncMock() + mock.return_value = instance + yield instance + + +@pytest.fixture +def mock_config(): + with patch("agent_chat_cli.components.header.load_config") as mock: + mock.return_value = MagicMock( + mcp_servers={}, + agents={}, + ) + yield mock + + +class TestUserInputBehavior: + async def test_submit_clears_input_and_starts_thinking( + self, mock_agent_loop, mock_config + ): + app = AgentChatCLIApp() + async with app.run_test() as pilot: + text_area = app.query_one(UserInput).query_one(TextArea) + text_area.insert("Hello agent") + + await pilot.press("enter") + + assert text_area.text == "" + + async def test_empty_submit_does_nothing(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test() as pilot: + text_area = app.query_one(UserInput).query_one(TextArea) + assert text_area.text == "" + + await pilot.press("enter") + + mock_agent_loop.query_queue.put.assert_not_called() + + async def test_ctrl_j_inserts_newline(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test() as pilot: + text_area = app.query_one(UserInput).query_one(TextArea) + text_area.insert("line1") + + await pilot.press("ctrl+j") + + assert "\n" in text_area.text + + +class TestToolPermissionBehavior: + async def test_permission_prompt_initially_hidden( + self, mock_agent_loop, mock_config + ): + app = AgentChatCLIApp() + async with app.run_test(): + prompt = app.query_one(ToolPermissionPrompt) + + assert prompt.is_visible is False + + async def test_show_permission_prompt_displays_tool( + self, mock_agent_loop, mock_config + ): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.show_permission_prompt( + tool_name="mcp__filesystem__read_file", + tool_input={"path": "/tmp/test.txt"}, + ) + + prompt = app.query_one(ToolPermissionPrompt) + assert prompt.is_visible is True + assert prompt.tool_name == "mcp__filesystem__read_file" + + async def test_hide_permission_prompt_restores_input( + self, mock_agent_loop, mock_config + ): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.show_permission_prompt(tool_name="test_tool", tool_input={}) + app.ui_state.hide_permission_prompt() + + prompt = app.query_one(ToolPermissionPrompt) + user_input = app.query_one(UserInput) + + assert prompt.is_visible is False + assert user_input.display is True + + +class TestThinkingIndicatorBehavior: + async def test_start_thinking_shows_indicator(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.start_thinking() + + indicator = app.query_one(ThinkingIndicator) + assert indicator.is_thinking is True + + async def test_stop_thinking_hides_indicator(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.start_thinking() + app.ui_state.stop_thinking() + + indicator = app.query_one(ThinkingIndicator) + assert indicator.is_thinking is False + + +class TestInterruptBehavior: + async def test_interrupt_sets_flag(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.start_thinking() + await app.actions.interrupt() + + assert app.ui_state.interrupting is True + + async def test_interrupt_blocked_during_permission_prompt( + self, mock_agent_loop, mock_config + ): + app = AgentChatCLIApp() + async with app.run_test(): + app.ui_state.show_permission_prompt(tool_name="test", tool_input={}) + + await app.actions.interrupt() + + assert app.ui_state.interrupting is False diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/test_config.py b/tests/utils/test_config.py new file mode 100644 index 0000000..ea6de17 --- /dev/null +++ b/tests/utils/test_config.py @@ -0,0 +1,87 @@ +import pytest +from pathlib import Path + +from agent_chat_cli.utils.config import ( + load_config, + get_available_servers, + get_sdk_config, + AgentChatConfig, +) + + +FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" + + +class TestLoadConfig: + def test_loads_basic_config(self): + config = load_config(FIXTURES_DIR / "test_config.yaml") + + assert config.model == "claude-sonnet-4-20250514" + assert "You are a helpful assistant." in config.system_prompt + assert config.permission_mode == "bypass_permissions" + + def test_loads_mcp_servers(self): + config = load_config(FIXTURES_DIR / "test_config.yaml") + + assert "test_server" in config.mcp_servers + server = config.mcp_servers["test_server"] + assert server.description == "Test MCP server" + assert server.command == "echo" + assert server.args == ["test"] + + def test_filters_disabled_servers(self): + config = load_config(FIXTURES_DIR / "test_config_with_disabled.yaml") + + assert "enabled_server" in config.mcp_servers + assert "disabled_server" not in config.mcp_servers + + def test_loads_agents(self): + config = load_config(FIXTURES_DIR / "test_config_with_agents.yaml") + + assert "researcher" in config.agents + agent = config.agents["researcher"] + assert agent.description == "Research agent" + assert agent.prompt == "You are a research agent." + assert agent.tools == ["web_search", "read_file"] + + def test_raises_for_missing_file(self): + with pytest.raises(FileNotFoundError): + load_config("nonexistent.yaml") + + +class TestGetAvailableServers: + def test_returns_enabled_servers(self): + servers = get_available_servers(FIXTURES_DIR / "test_config.yaml") + + assert "test_server" in servers + assert servers["test_server"].command == "echo" + + def test_excludes_disabled_servers(self): + servers = get_available_servers(FIXTURES_DIR / "test_config_with_disabled.yaml") + + assert "enabled_server" in servers + assert "disabled_server" not in servers + + +class TestGetSdkConfig: + def test_returns_dict_from_config(self): + config = load_config(FIXTURES_DIR / "test_config.yaml") + sdk_config = get_sdk_config(config) + + assert isinstance(sdk_config, dict) + assert sdk_config["model"] == "claude-sonnet-4-20250514" + assert "system_prompt" in sdk_config + + +class TestAgentChatConfig: + def test_default_values(self): + config = AgentChatConfig( + system_prompt="test", + model="claude-sonnet-4-20250514", + ) + + assert config.include_partial_messages is True + assert config.agents == {} + assert config.mcp_servers == {} + assert config.disallowed_tools == [] + assert config.permission_mode == "bypass_permissions" diff --git a/tests/utils/test_enums.py b/tests/utils/test_enums.py new file mode 100644 index 0000000..47412a4 --- /dev/null +++ b/tests/utils/test_enums.py @@ -0,0 +1,29 @@ +from agent_chat_cli.utils.enums import AgentMessageType, ContentType, ControlCommand + + +class TestAgentMessageType: + def test_all_message_types_have_values(self): + assert AgentMessageType.ASSISTANT.value == "assistant" + assert AgentMessageType.INIT.value == "init" + assert AgentMessageType.RESULT.value == "result" + assert AgentMessageType.STREAM_EVENT.value == "stream_event" + assert AgentMessageType.SYSTEM.value == "system" + assert ( + AgentMessageType.TOOL_PERMISSION_REQUEST.value == "tool_permission_request" + ) + assert AgentMessageType.USER.value == "user" + + +class TestContentType: + def test_all_content_types_have_values(self): + assert ContentType.TEXT.value == "text" + assert ContentType.TOOL_USE.value == "tool_use" + assert ContentType.CONTENT_BLOCK_DELTA.value == "content_block_delta" + assert ContentType.TEXT_DELTA.value == "text_delta" + + +class TestControlCommand: + def test_all_control_commands_have_values(self): + assert ControlCommand.NEW_CONVERSATION.value == "new_conversation" + assert ControlCommand.EXIT.value == "exit" + assert ControlCommand.CLEAR.value == "clear" diff --git a/tests/utils/test_format_tool_input.py b/tests/utils/test_format_tool_input.py new file mode 100644 index 0000000..9e22927 --- /dev/null +++ b/tests/utils/test_format_tool_input.py @@ -0,0 +1,40 @@ +from agent_chat_cli.utils.format_tool_input import format_tool_input + + +class TestFormatToolInput: + def test_query_string_extracted(self): + result = format_tool_input({"query": "SELECT * FROM users"}) + + assert result == "SELECT * FROM users" + + def test_query_with_newlines_unescaped(self): + result = format_tool_input({"query": "line1\\nline2"}) + + assert result == "line1\nline2" + + def test_query_with_tabs_converted_to_spaces(self): + result = format_tool_input({"query": "col1\\tcol2"}) + + assert result == "col1 col2" + + def test_non_query_dict_returns_json(self): + result = format_tool_input({"path": "/tmp/file.txt", "content": "hello"}) + + assert '"path": "/tmp/file.txt"' in result + assert '"content": "hello"' in result + + def test_empty_dict(self): + result = format_tool_input({}) + + assert result == "{}" + + def test_nested_dict_formatted_as_json(self): + result = format_tool_input({"options": {"verbose": True, "timeout": 30}}) + + assert '"options"' in result + assert '"verbose": true' in result + + def test_query_key_with_non_string_value_returns_json(self): + result = format_tool_input({"query": 123}) + + assert '"query": 123' in result diff --git a/tests/utils/test_mcp_server_status.py b/tests/utils/test_mcp_server_status.py new file mode 100644 index 0000000..98025ce --- /dev/null +++ b/tests/utils/test_mcp_server_status.py @@ -0,0 +1,88 @@ +import pytest + +from agent_chat_cli.utils.mcp_server_status import MCPServerStatus + + +class TestMCPServerStatus: + @pytest.fixture(autouse=True) + def reset_state(self): + MCPServerStatus._mcp_servers = [] + MCPServerStatus._callbacks = [] + yield + MCPServerStatus._mcp_servers = [] + MCPServerStatus._callbacks = [] + + def test_is_connected_returns_true_for_connected_server(self): + MCPServerStatus.update([{"name": "filesystem", "status": "connected"}]) + + assert MCPServerStatus.is_connected("filesystem") is True + + def test_is_connected_returns_false_for_disconnected_server(self): + MCPServerStatus.update([{"name": "filesystem", "status": "error"}]) + + assert MCPServerStatus.is_connected("filesystem") is False + + def test_is_connected_returns_false_for_unknown_server(self): + MCPServerStatus.update([{"name": "filesystem", "status": "connected"}]) + + assert MCPServerStatus.is_connected("unknown") is False + + def test_is_connected_returns_false_when_empty(self): + assert MCPServerStatus.is_connected("filesystem") is False + + def test_update_triggers_callbacks(self): + callback_called = [] + + def callback(): + callback_called.append(True) + + MCPServerStatus.subscribe(callback) + MCPServerStatus.update([{"name": "test", "status": "connected"}]) + + assert len(callback_called) == 1 + + def test_multiple_callbacks_triggered(self): + results = [] + + def callback1(): + results.append("cb1") + + def callback2(): + results.append("cb2") + + MCPServerStatus.subscribe(callback1) + MCPServerStatus.subscribe(callback2) + MCPServerStatus.update([]) + + assert results == ["cb1", "cb2"] + + def test_unsubscribe_removes_callback(self): + results = [] + + def callback(): + results.append(True) + + MCPServerStatus.subscribe(callback) + MCPServerStatus.unsubscribe(callback) + MCPServerStatus.update([]) + + assert results == [] + + def test_unsubscribe_nonexistent_callback_is_safe(self): + def callback(): + pass + + MCPServerStatus.unsubscribe(callback) + + def test_multiple_servers_tracked(self): + MCPServerStatus.update( + [ + {"name": "server1", "status": "connected"}, + {"name": "server2", "status": "error"}, + {"name": "server3", "status": "connected"}, + ] + ) + + assert MCPServerStatus.is_connected("server1") is True + assert MCPServerStatus.is_connected("server2") is False + assert MCPServerStatus.is_connected("server3") is True diff --git a/tests/utils/test_system_prompt.py b/tests/utils/test_system_prompt.py new file mode 100644 index 0000000..4eeda01 --- /dev/null +++ b/tests/utils/test_system_prompt.py @@ -0,0 +1,44 @@ +from agent_chat_cli.utils.system_prompt import build_system_prompt + + +class TestBuildSystemPrompt: + def test_base_prompt_only(self): + result = build_system_prompt("You are an assistant.", []) + + assert result == "You are an assistant." + + def test_base_prompt_with_single_mcp_prompt(self): + result = build_system_prompt( + "You are an assistant.", ["You have access to filesystem tools."] + ) + + assert "You are an assistant." in result + assert "You have access to filesystem tools." in result + assert "\n\n" in result + + def test_base_prompt_with_multiple_mcp_prompts(self): + result = build_system_prompt( + "Base prompt.", + ["MCP prompt 1.", "MCP prompt 2.", "MCP prompt 3."], + ) + + assert "Base prompt." in result + assert "MCP prompt 1." in result + assert "MCP prompt 2." in result + assert "MCP prompt 3." in result + assert result.count("\n\n") == 3 + + def test_empty_base_prompt_with_mcp_prompts(self): + result = build_system_prompt("", ["MCP prompt."]) + + assert "MCP prompt." in result + + def test_empty_base_prompt_and_no_mcp_prompts(self): + result = build_system_prompt("", []) + + assert result == "" + + def test_prompts_joined_with_double_newlines(self): + result = build_system_prompt("A", ["B", "C"]) + + assert result == "A\n\nB\n\nC" diff --git a/tests/utils/test_tool_info.py b/tests/utils/test_tool_info.py new file mode 100644 index 0000000..f18e6e9 --- /dev/null +++ b/tests/utils/test_tool_info.py @@ -0,0 +1,39 @@ +from agent_chat_cli.utils.tool_info import get_tool_info + + +class TestGetToolInfo: + def test_mcp_tool_with_server_name(self): + result = get_tool_info("mcp__filesystem__read_file") + + assert result["server_name"] == "filesystem" + assert result["tool_name"] == "read_file" + + def test_mcp_tool_with_underscores_in_tool_name(self): + result = get_tool_info("mcp__github__list_pull_requests") + + assert result["server_name"] == "github" + assert result["tool_name"] == "list_pull_requests" + + def test_non_mcp_tool(self): + result = get_tool_info("bash") + + assert result["server_name"] is None + assert result["tool_name"] == "bash" + + def test_tool_with_single_underscore(self): + result = get_tool_info("read_file") + + assert result["server_name"] is None + assert result["tool_name"] == "read_file" + + def test_malformed_mcp_prefix_without_server(self): + result = get_tool_info("mcp__") + + assert result["server_name"] is None + assert result["tool_name"] == "mcp__" + + def test_empty_string(self): + result = get_tool_info("") + + assert result["server_name"] is None + assert result["tool_name"] == "" diff --git a/uv.lock b/uv.lock index f139e20..37e1539 100644 --- a/uv.lock +++ b/uv.lock @@ -21,6 +21,9 @@ dependencies = [ dev = [ { name = "mypy" }, { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-textual-snapshot" }, { name = "ruff" }, { name = "textual-dev" }, { name = "types-pyyaml" }, @@ -42,6 +45,9 @@ requires-dist = [ dev = [ { name = "mypy", specifier = ">=1.19.0" }, { name = "pre-commit", specifier = ">=4.3.0" }, + { name = "pytest", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "pytest-textual-snapshot", specifier = ">=1.0.0" }, { name = "ruff", specifier = ">=0.14.7" }, { name = "textual-dev", specifier = ">=1.8.0" }, { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, @@ -437,6 +443,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -721,6 +736,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + [[package]] name = "pathspec" version = "0.12.1" @@ -739,6 +763,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pre-commit" version = "4.5.0" @@ -894,6 +927,50 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-textual-snapshot" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "pytest" }, + { name = "rich" }, + { name = "syrupy" }, + { name = "textual" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/75/2ef17ae52fa5bc848ff2d1d7bc317a702cbd6d7ad733ca991b9f899dbbae/pytest_textual_snapshot-1.0.0.tar.gz", hash = "sha256:065217055ed833b8a16f2320a0613f39a0154e8d9fee63535f29f32c6414b9d7", size = 11071, upload-time = "2024-07-22T15:17:44.629Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/2e/4bf16ed78b382b3d7c1e545475ec8cf04346870be662815540faf8f16e8c/pytest_textual_snapshot-1.0.0-py3-none-any.whl", hash = "sha256:dd3a421491a6b1987ee7b4336d7f65299524924d2b0a297e69733b73b01570e1", size = 11171, upload-time = "2024-07-22T15:17:43.167Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -1061,6 +1138,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, ] +[[package]] +name = "syrupy" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/90/1a442d21527009d4b40f37fe50b606ebb68a6407142c2b5cc508c34b696b/syrupy-5.0.0.tar.gz", hash = "sha256:3282fe963fa5d4d3e47231b16d1d4d0f4523705e8199eeb99a22a1bc9f5942f2", size = 48881, upload-time = "2025-09-28T21:15:12.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/9a/6c68aad2ccfce6e2eeebbf5bb709d0240592eb51ff142ec4c8fbf3c2460a/syrupy-5.0.0-py3-none-any.whl", hash = "sha256:c848e1a980ca52a28715cd2d2b4d434db424699c05653bd1158fb31cf56e9546", size = 49087, upload-time = "2025-09-28T21:15:11.639Z" }, +] + [[package]] name = "textual" version = "6.7.0" From 1f5944973639783d6eca4f831bd288038baed292 Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Sat, 13 Dec 2025 13:43:46 -0800 Subject: [PATCH 2/3] chore: add gh action for tests --- .github/workflows/{type-check.yml => ci.yml} | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) rename .github/workflows/{type-check.yml => ci.yml} (55%) diff --git a/.github/workflows/type-check.yml b/.github/workflows/ci.yml similarity index 55% rename from .github/workflows/type-check.yml rename to .github/workflows/ci.yml index 62329ab..f28e726 100644 --- a/.github/workflows/type-check.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Type Check +name: CI on: push: @@ -7,7 +7,7 @@ on: branches: [main] jobs: - type-check: + test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -20,5 +20,14 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v4 - - name: Run mypy - run: uv run mypy src/agent_chat_cli + - name: Install dependencies + run: uv sync --all-groups + + - name: Run linter + run: uv run ruff check src tests + + - name: Run type checker + run: uv run mypy src + + - name: Run tests + run: uv run pytest From 4f354f2064ea6e65ab4b0829d466d7f98653ddd7 Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Sat, 13 Dec 2025 14:03:54 -0800 Subject: [PATCH 3/3] chore: more test coverage --- tests/components/test_chat_history.py | 79 +++++++++++++ tests/components/test_header.py | 60 ++++++++++ tests/components/test_thinking_indicator.py | 35 ++++++ .../components/test_tool_permission_prompt.py | 108 ++++++++++++++++++ tests/components/test_user_input.py | 96 ++++++++++++++++ 5 files changed, 378 insertions(+) create mode 100644 tests/components/test_chat_history.py create mode 100644 tests/components/test_header.py create mode 100644 tests/components/test_thinking_indicator.py create mode 100644 tests/components/test_tool_permission_prompt.py create mode 100644 tests/components/test_user_input.py diff --git a/tests/components/test_chat_history.py b/tests/components/test_chat_history.py new file mode 100644 index 0000000..2df260d --- /dev/null +++ b/tests/components/test_chat_history.py @@ -0,0 +1,79 @@ +import pytest +from textual.app import App, ComposeResult + +from agent_chat_cli.components.chat_history import ChatHistory +from agent_chat_cli.components.messages import ( + Message, + SystemMessage, + UserMessage, + AgentMessage, + ToolMessage, +) + + +class ChatHistoryApp(App): + def compose(self) -> ComposeResult: + yield ChatHistory() + + +class TestChatHistoryAddMessage: + @pytest.fixture + def app(self): + return ChatHistoryApp() + + async def test_adds_system_message(self, app): + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + chat_history.add_message(Message.system("System alert")) + + widgets = chat_history.query(SystemMessage) + assert len(widgets) == 1 + assert widgets.first().message == "System alert" + + async def test_adds_user_message(self, app): + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + chat_history.add_message(Message.user("Hello")) + + widgets = chat_history.query(UserMessage) + assert len(widgets) == 1 + assert widgets.first().message == "Hello" + + async def test_adds_agent_message(self, app): + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + chat_history.add_message(Message.agent("I can help")) + + widgets = chat_history.query(AgentMessage) + assert len(widgets) == 1 + assert widgets.first().message == "I can help" + + async def test_adds_tool_message_with_json_content(self, app): + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + chat_history.add_message( + Message.tool("read_file", '{"path": "/tmp/test.txt"}') + ) + + widgets = chat_history.query(ToolMessage) + assert len(widgets) == 1 + assert widgets.first().tool_name == "read_file" + assert widgets.first().tool_input == {"path": "/tmp/test.txt"} + + async def test_tool_message_handles_invalid_json(self, app): + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + chat_history.add_message(Message.tool("bash", "not valid json")) + + widgets = chat_history.query(ToolMessage) + assert len(widgets) == 1 + assert widgets.first().tool_input == {"raw": "not valid json"} + + async def test_adds_multiple_messages(self, app): + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + chat_history.add_message(Message.user("First")) + chat_history.add_message(Message.agent("Second")) + chat_history.add_message(Message.user("Third")) + + assert len(chat_history.children) == 3 diff --git a/tests/components/test_header.py b/tests/components/test_header.py new file mode 100644 index 0000000..1033bda --- /dev/null +++ b/tests/components/test_header.py @@ -0,0 +1,60 @@ +import pytest +from unittest.mock import MagicMock, patch + +from textual.app import App, ComposeResult +from textual.widgets import Label + +from agent_chat_cli.components.header import Header +from agent_chat_cli.utils.mcp_server_status import MCPServerStatus + + +@pytest.fixture(autouse=True) +def reset_mcp_status(): + MCPServerStatus._mcp_servers = [] + MCPServerStatus._callbacks = [] + yield + MCPServerStatus._mcp_servers = [] + MCPServerStatus._callbacks = [] + + +@pytest.fixture +def mock_config(): + with patch("agent_chat_cli.components.header.load_config") as mock: + mock.return_value = MagicMock( + mcp_servers={"filesystem": MagicMock(), "github": MagicMock()}, + agents={"researcher": MagicMock()}, + ) + yield mock + + +class HeaderApp(App): + def compose(self) -> ComposeResult: + yield Header() + + +class TestHeaderMCPServerStatus: + async def test_subscribes_on_mount(self, mock_config): + app = HeaderApp() + async with app.run_test(): + assert len(MCPServerStatus._callbacks) == 1 + + async def test_updates_label_on_status_change(self, mock_config): + app = HeaderApp() + async with app.run_test(): + MCPServerStatus.update( + [ + {"name": "filesystem", "status": "connected"}, + {"name": "github", "status": "error"}, + ] + ) + + header = app.query_one(Header) + header._handle_mcp_server_status() + + label = app.query_one("#header-mcp-servers", Label) + # Label stores markup in _content or we can check via render + content = label.render() + rendered = str(content) + + assert "filesystem" in rendered + assert "github" in rendered diff --git a/tests/components/test_thinking_indicator.py b/tests/components/test_thinking_indicator.py new file mode 100644 index 0000000..87a494a --- /dev/null +++ b/tests/components/test_thinking_indicator.py @@ -0,0 +1,35 @@ +import pytest +from textual.app import App, ComposeResult + +from agent_chat_cli.components.thinking_indicator import ThinkingIndicator + + +class ThinkingIndicatorApp(App): + def compose(self) -> ComposeResult: + yield ThinkingIndicator() + + +class TestThinkingIndicator: + @pytest.fixture + def app(self): + return ThinkingIndicatorApp() + + async def test_hidden_by_default(self, app): + async with app.run_test(): + indicator = app.query_one(ThinkingIndicator) + assert indicator.display is False + + async def test_is_thinking_true_shows_indicator(self, app): + async with app.run_test(): + indicator = app.query_one(ThinkingIndicator) + indicator.is_thinking = True + + assert indicator.display is True + + async def test_is_thinking_false_hides_indicator(self, app): + async with app.run_test(): + indicator = app.query_one(ThinkingIndicator) + indicator.is_thinking = True + indicator.is_thinking = False + + assert indicator.display is False diff --git a/tests/components/test_tool_permission_prompt.py b/tests/components/test_tool_permission_prompt.py new file mode 100644 index 0000000..e277792 --- /dev/null +++ b/tests/components/test_tool_permission_prompt.py @@ -0,0 +1,108 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from textual.app import App, ComposeResult +from textual.widgets import TextArea, Label + +from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt + + +class ToolPermissionPromptApp(App): + def __init__(self): + super().__init__() + self.mock_actions = MagicMock() + self.mock_actions.respond_to_tool_permission = AsyncMock() + + def compose(self) -> ComposeResult: + yield ToolPermissionPrompt(actions=self.mock_actions) + + +class TestToolPermissionPromptVisibility: + @pytest.fixture + def app(self): + return ToolPermissionPromptApp() + + async def test_hidden_by_default(self, app): + async with app.run_test(): + prompt = app.query_one(ToolPermissionPrompt) + assert prompt.display is False + + async def test_is_visible_true_shows_prompt(self, app): + async with app.run_test(): + prompt = app.query_one(ToolPermissionPrompt) + prompt.is_visible = True + + assert prompt.display is True + + async def test_is_visible_clears_input_on_show(self, app): + async with app.run_test(): + prompt = app.query_one(ToolPermissionPrompt) + input_widget = prompt.query_one("#permission-input", TextArea) + input_widget.insert("leftover text") + + prompt.is_visible = True + + assert input_widget.text == "" + + +class TestToolPermissionPromptToolDisplay: + @pytest.fixture + def app(self): + return ToolPermissionPromptApp() + + async def test_displays_mcp_tool_with_server_name(self, app): + async with app.run_test(): + prompt = app.query_one(ToolPermissionPrompt) + prompt.tool_name = "mcp__filesystem__read_file" + + label = prompt.query_one("#tool-display", Label) + rendered = str(label.render()) + + assert "filesystem" in rendered + assert "read_file" in rendered + + async def test_displays_non_mcp_tool(self, app): + async with app.run_test(): + prompt = app.query_one(ToolPermissionPrompt) + prompt.tool_name = "bash" + + label = prompt.query_one("#tool-display", Label) + rendered = str(label.render()) + + assert "bash" in rendered + + +class TestToolPermissionPromptSubmit: + @pytest.fixture + def app(self): + return ToolPermissionPromptApp() + + async def test_empty_submit_defaults_to_yes(self, app): + async with app.run_test(): + prompt = app.query_one(ToolPermissionPrompt) + prompt.is_visible = True + + await prompt.action_submit() + + app.mock_actions.respond_to_tool_permission.assert_called_with("yes") + + async def test_submit_with_text(self, app): + async with app.run_test(): + prompt = app.query_one(ToolPermissionPrompt) + prompt.is_visible = True + + input_widget = prompt.query_one("#permission-input", TextArea) + input_widget.insert("no") + + await prompt.action_submit() + + app.mock_actions.respond_to_tool_permission.assert_called_with("no") + + async def test_escape_submits_no(self, app): + async with app.run_test() as pilot: + prompt = app.query_one(ToolPermissionPrompt) + prompt.is_visible = True + + await pilot.press("escape") + + app.mock_actions.respond_to_tool_permission.assert_called_with("no") diff --git a/tests/components/test_user_input.py b/tests/components/test_user_input.py new file mode 100644 index 0000000..102f6b7 --- /dev/null +++ b/tests/components/test_user_input.py @@ -0,0 +1,96 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from textual.app import App, ComposeResult +from textual.widgets import TextArea + +from agent_chat_cli.components.user_input import UserInput + + +class UserInputApp(App): + def __init__(self): + super().__init__() + self.mock_actions = MagicMock() + self.mock_actions.quit = MagicMock() + self.mock_actions.interrupt = AsyncMock() + self.mock_actions.new = AsyncMock() + self.mock_actions.submit_user_message = AsyncMock() + + def compose(self) -> ComposeResult: + yield UserInput(actions=self.mock_actions) + + +class TestUserInputSubmit: + @pytest.fixture + def app(self): + return UserInputApp() + + async def test_empty_submit_does_nothing(self, app): + async with app.run_test() as pilot: + await pilot.press("enter") + + app.mock_actions.submit_user_message.assert_not_called() + + async def test_submits_message(self, app): + async with app.run_test() as pilot: + user_input = app.query_one(UserInput) + text_area = user_input.query_one(TextArea) + text_area.insert("Hello agent") + + await pilot.press("enter") + + app.mock_actions.submit_user_message.assert_called_with("Hello agent") + + async def test_clears_input_after_submit(self, app): + async with app.run_test() as pilot: + user_input = app.query_one(UserInput) + text_area = user_input.query_one(TextArea) + text_area.insert("Hello agent") + + await pilot.press("enter") + + assert text_area.text == "" + + +class TestUserInputControlCommands: + @pytest.fixture + def app(self): + return UserInputApp() + + async def test_exit_command_quits(self, app): + async with app.run_test() as pilot: + user_input = app.query_one(UserInput) + text_area = user_input.query_one(TextArea) + text_area.insert("exit") + + await pilot.press("enter") + + app.mock_actions.quit.assert_called_once() + + async def test_clear_command_resets_conversation(self, app): + async with app.run_test() as pilot: + user_input = app.query_one(UserInput) + text_area = user_input.query_one(TextArea) + text_area.insert("clear") + + await pilot.press("enter") + + app.mock_actions.interrupt.assert_called_once() + app.mock_actions.new.assert_called_once() + + +class TestUserInputNewlines: + @pytest.fixture + def app(self): + return UserInputApp() + + async def test_ctrl_j_inserts_newline(self, app): + async with app.run_test() as pilot: + user_input = app.query_one(UserInput) + text_area = user_input.query_one(TextArea) + text_area.insert("line1") + + await pilot.press("ctrl+j") + text_area.insert("line2") + + assert "line1\nline2" in text_area.text