Skip to content

Commit 7feb447

Browse files
committed
chore: add tests
1 parent 7e19534 commit 7feb447

24 files changed

+1229
-1
lines changed

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: dev console lint install start type-check
1+
.PHONY: dev console lint install start test type-check
22

33
install:
44
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:
1515
start:
1616
uv run chat
1717

18+
test:
19+
uv run pytest
20+
1821
type-check:
1922
uv run mypy src

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ Additional MCP servers are configured in `agent-chat-cli.config.yaml` and prompt
4242
- `make type-check`
4343
- Linting and formatting is via [Ruff](https://docs.astral.sh/ruff/)
4444
- `make lint`
45+
- Testing is via [pytest](https://docs.pytest.org/):
46+
- `make test`
47+
48+
See [docs/architecture.md](docs/architecture.md) for an overview of the codebase structure.
49+
50+
### Textual Dev Console
4551

4652
Textual has an integrated logging console that one can boot separately from the app to receive logs.
4753

docs/architecture.md

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# Architecture
2+
3+
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).
4+
5+
### Directory Structure
6+
7+
```
8+
src/agent_chat_cli/
9+
├── app.py # Main Textual application entry point
10+
├── core/
11+
│ ├── actions.py # User action handlers
12+
│ ├── agent_loop.py # Claude Agent SDK client wrapper
13+
│ ├── message_bus.py # Message routing from agent to UI
14+
│ ├── ui_state.py # Centralized UI state management
15+
│ └── styles.tcss # Textual CSS styles
16+
├── components/
17+
│ ├── balloon_spinner.py # Animated spinner widget
18+
│ ├── caret.py # Input caret indicator
19+
│ ├── chat_history.py # Chat message container
20+
│ ├── flex.py # Horizontal flex container
21+
│ ├── header.py # App header with MCP server status
22+
│ ├── messages.py # Message data models and widgets
23+
│ ├── spacer.py # Empty spacer widget
24+
│ ├── thinking_indicator.py # "Agent is thinking" indicator
25+
│ ├── tool_permission_prompt.py # Tool permission request UI
26+
│ └── user_input.py # User text input widget
27+
└── utils/
28+
├── config.py # YAML config loading
29+
├── enums.py # Shared enumerations
30+
├── format_tool_input.py # Tool input formatting
31+
├── logger.py # Logging setup
32+
├── mcp_server_status.py # MCP server connection state
33+
├── system_prompt.py # System prompt builder
34+
└── tool_info.py # Tool name parsing
35+
```
36+
37+
### Core Architecture
38+
39+
The application follows a loosely coupled architecture with four main orchestration objects:
40+
41+
```
42+
┌─────────────────────────────────────────────────────────────┐
43+
│ AgentChatCLIApp │
44+
│ ┌───────────┐ ┌───────────┐ ┌─────────┐ ┌───────────┐ │
45+
│ │ UIState │ │MessageBus │ │ Actions │ │ AgentLoop │ │
46+
│ └─────┬─────┘ └─────┬─────┘ └────┬────┘ └─────┬─────┘ │
47+
│ │ │ │ │ │
48+
│ └──────────────┴─────────────┴──────────────┘ │
49+
│ │ │
50+
│ ┌─────────────────────────┴─────────────────────────────┐ │
51+
│ │ Components │ │
52+
│ │ Header │ ChatHistory │ ThinkingIndicator │ UserInput │ │
53+
│ └────────────────────────────────────────────────────────┘ │
54+
└─────────────────────────────────────────────────────────────┘
55+
```
56+
57+
### Core Modules
58+
59+
**UIState** (`core/ui_state.py`)
60+
Centralized management of UI state behaviors. Handles:
61+
- Thinking indicator visibility and cursor blink state
62+
- Tool permission prompt display/hide
63+
- Interrupt state tracking
64+
65+
This class was introduced in PR #9 to consolidate scattered UI state logic from Actions and MessageBus into a single cohesive module.
66+
67+
**MessageBus** (`core/message_bus.py`)
68+
Routes messages from the AgentLoop to appropriate UI components:
69+
- `STREAM_EVENT`: Streaming text chunks to AgentMessage widgets
70+
- `ASSISTANT`: Complete assistant responses with tool use blocks
71+
- `SYSTEM` / `USER`: System and user messages
72+
- `TOOL_PERMISSION_REQUEST`: Triggers permission prompt UI
73+
- `RESULT`: Signals completion, resets state
74+
75+
**Actions** (`core/actions.py`)
76+
User-initiated action handlers:
77+
- `submit_user_message()`: Posts user message and queries agent
78+
- `interrupt()`: Cancels current agent operation
79+
- `new()`: Starts new conversation, clears history
80+
- `respond_to_tool_permission()`: Handles permission prompt responses
81+
82+
**AgentLoop** (`core/agent_loop.py`)
83+
Manages the Claude Agent SDK client lifecycle:
84+
- Initializes `ClaudeSDKClient` with config and MCP servers
85+
- Processes incoming messages via async generator
86+
- Handles tool permission flow via `_can_use_tool()` callback
87+
- Manages `query_queue` and `permission_response_queue` for async communication
88+
89+
### Message Flow
90+
91+
1. User types in `UserInput` and presses Enter
92+
2. `Actions.submit_user_message()` posts to UI and enqueues to `AgentLoop.query_queue`
93+
3. `AgentLoop` sends query to Claude Agent SDK and streams responses
94+
4. Responses flow through `MessageBus.handle_agent_message()` to update UI
95+
5. Tool use triggers permission prompt via `UIState.show_permission_prompt()`
96+
6. User response flows back through `Actions.respond_to_tool_permission()`
97+
98+
### Components
99+
100+
**UserInput** (`components/user_input.py`)
101+
Text input with:
102+
- Enter to submit
103+
- Ctrl+J for newlines (PR #10)
104+
- Control commands: `exit`, `clear`
105+
106+
**ToolPermissionPrompt** (`components/tool_permission_prompt.py`)
107+
Modal prompt for tool permission requests:
108+
- Shows tool name and MCP server
109+
- Enter to allow, ESC to deny, or type custom response
110+
- Manages focus to prevent input elsewhere while visible
111+
112+
**ChatHistory** (`components/chat_history.py`)
113+
Container for message widgets, handles `MessagePosted` events.
114+
115+
**ThinkingIndicator** (`components/thinking_indicator.py`)
116+
Animated indicator shown during agent processing.
117+
118+
**Header** (`components/header.py`)
119+
Displays available MCP servers with connection status via `MCPServerStatus` subscription.
120+
121+
### Configuration
122+
123+
Configuration is loaded from `agent-chat-cli.config.yaml`:
124+
125+
```yaml
126+
system_prompt: "prompt.md" # File path or literal string
127+
model: "claude-sonnet-4-20250514"
128+
permission_mode: "bypass_permissions"
129+
130+
mcp_servers:
131+
server_name:
132+
description: "Server description"
133+
command: "npx"
134+
args: ["-y", "@some/mcp-server"]
135+
env:
136+
API_KEY: "$API_KEY"
137+
enabled: true
138+
prompt: "server_prompt.md"
139+
140+
agents:
141+
agent_name:
142+
description: "Agent description"
143+
prompt: "agent_prompt.md"
144+
tools: ["tool1", "tool2"]
145+
```
146+
147+
### Key Patterns
148+
149+
**Reactive Properties**: Textual's `reactive` and `var` are used for automatic UI updates when state changes (e.g., `ThinkingIndicator.is_thinking`, `ToolPermissionPrompt.is_visible`).
150+
151+
**Async Queues**: Communication between UI and AgentLoop uses `asyncio.Queue` for decoupled async message passing.
152+
153+
**Observer Pattern**: `MCPServerStatus` uses callback subscriptions to notify components of connection state changes.
154+
155+
**TYPE_CHECKING Guards**: Circular import prevention via `if TYPE_CHECKING:` blocks for type hints.
156+
157+
### Testing
158+
159+
Tests use pytest with pytest-asyncio for async support and Textual's pilot testing framework for UI interactions.
160+
161+
```bash
162+
make test
163+
```

pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ dependencies = [
1919
dev = [
2020
"mypy>=1.19.0",
2121
"pre-commit>=4.3.0",
22+
"pytest>=8.0.0",
23+
"pytest-asyncio>=0.24.0",
24+
"pytest-textual-snapshot>=1.0.0",
2225
"ruff>=0.14.7",
2326
"textual-dev>=1.8.0",
2427
"types-pyyaml>=6.0.12.20250915",
@@ -30,3 +33,9 @@ dev = "textual_dev.cli:run"
3033

3134
[tool.uv]
3235
package = true
36+
37+
[tool.pytest.ini_options]
38+
asyncio_mode = "auto"
39+
asyncio_default_fixture_loop_scope = "function"
40+
testpaths = ["tests"]
41+
pythonpath = ["src"]

tests/__init__.py

Whitespace-only changes.

tests/components/__init__.py

Whitespace-only changes.

tests/components/test_messages.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from agent_chat_cli.components.messages import Message, MessageType
2+
3+
4+
class TestMessage:
5+
def test_system_creates_system_message(self):
6+
msg = Message.system("System alert")
7+
8+
assert msg.type == MessageType.SYSTEM
9+
assert msg.content == "System alert"
10+
assert msg.metadata is None
11+
12+
def test_user_creates_user_message(self):
13+
msg = Message.user("Hello there")
14+
15+
assert msg.type == MessageType.USER
16+
assert msg.content == "Hello there"
17+
assert msg.metadata is None
18+
19+
def test_agent_creates_agent_message(self):
20+
msg = Message.agent("I can help with that.")
21+
22+
assert msg.type == MessageType.AGENT
23+
assert msg.content == "I can help with that."
24+
assert msg.metadata is None
25+
26+
def test_tool_creates_tool_message_with_metadata(self):
27+
msg = Message.tool("read_file", '{"path": "/tmp/test.txt"}')
28+
29+
assert msg.type == MessageType.TOOL
30+
assert msg.content == '{"path": "/tmp/test.txt"}'
31+
assert msg.metadata == {"tool_name": "read_file"}
32+
33+
34+
class TestMessageType:
35+
def test_all_types_have_values(self):
36+
assert MessageType.SYSTEM.value == "system"
37+
assert MessageType.USER.value == "user"
38+
assert MessageType.AGENT.value == "agent"
39+
assert MessageType.TOOL.value == "tool"

tests/conftest.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import os
2+
import pytest
3+
from pathlib import Path
4+
from unittest.mock import AsyncMock, MagicMock, patch
5+
6+
7+
os.environ["ANTHROPIC_API_KEY"] = "test-key"
8+
9+
10+
FIXTURES_DIR = Path(__file__).parent / "fixtures"
11+
12+
13+
@pytest.fixture
14+
def test_config_path():
15+
return FIXTURES_DIR / "test_config.yaml"
16+
17+
18+
@pytest.fixture
19+
def mock_claude_sdk():
20+
with patch("agent_chat_cli.core.agent_loop.ClaudeSDKClient") as mock_client:
21+
instance = MagicMock()
22+
instance.connect = AsyncMock()
23+
instance.disconnect = AsyncMock()
24+
instance.query = AsyncMock()
25+
instance.interrupt = AsyncMock()
26+
instance.receive_response = AsyncMock(return_value=AsyncIteratorMock([]))
27+
mock_client.return_value = instance
28+
yield mock_client
29+
30+
31+
class AsyncIteratorMock:
32+
def __init__(self, items):
33+
self.items = items
34+
35+
def __aiter__(self):
36+
return self
37+
38+
async def __anext__(self):
39+
if not self.items:
40+
raise StopAsyncIteration
41+
return self.items.pop(0)

tests/core/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)