Skip to content

Commit 51db292

Browse files
authored
Merge pull request #11 from damassi/feat/tests
chore: Add tests
2 parents 7e19534 + 4f354f2 commit 51db292

30 files changed

+1620
-5
lines changed
Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Type Check
1+
name: CI
22

33
on:
44
push:
@@ -7,7 +7,7 @@ on:
77
branches: [main]
88

99
jobs:
10-
type-check:
10+
test:
1111
runs-on: ubuntu-latest
1212
steps:
1313
- uses: actions/checkout@v4
@@ -20,5 +20,14 @@ jobs:
2020
- name: Install uv
2121
uses: astral-sh/setup-uv@v4
2222

23-
- name: Run mypy
24-
run: uv run mypy src/agent_chat_cli
23+
- name: Install dependencies
24+
run: uv sync --all-groups
25+
26+
- name: Run linter
27+
run: uv run ruff check src tests
28+
29+
- name: Run type checker
30+
run: uv run mypy src
31+
32+
- name: Run tests
33+
run: uv run pytest

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.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import pytest
2+
from textual.app import App, ComposeResult
3+
4+
from agent_chat_cli.components.chat_history import ChatHistory
5+
from agent_chat_cli.components.messages import (
6+
Message,
7+
SystemMessage,
8+
UserMessage,
9+
AgentMessage,
10+
ToolMessage,
11+
)
12+
13+
14+
class ChatHistoryApp(App):
15+
def compose(self) -> ComposeResult:
16+
yield ChatHistory()
17+
18+
19+
class TestChatHistoryAddMessage:
20+
@pytest.fixture
21+
def app(self):
22+
return ChatHistoryApp()
23+
24+
async def test_adds_system_message(self, app):
25+
async with app.run_test():
26+
chat_history = app.query_one(ChatHistory)
27+
chat_history.add_message(Message.system("System alert"))
28+
29+
widgets = chat_history.query(SystemMessage)
30+
assert len(widgets) == 1
31+
assert widgets.first().message == "System alert"
32+
33+
async def test_adds_user_message(self, app):
34+
async with app.run_test():
35+
chat_history = app.query_one(ChatHistory)
36+
chat_history.add_message(Message.user("Hello"))
37+
38+
widgets = chat_history.query(UserMessage)
39+
assert len(widgets) == 1
40+
assert widgets.first().message == "Hello"
41+
42+
async def test_adds_agent_message(self, app):
43+
async with app.run_test():
44+
chat_history = app.query_one(ChatHistory)
45+
chat_history.add_message(Message.agent("I can help"))
46+
47+
widgets = chat_history.query(AgentMessage)
48+
assert len(widgets) == 1
49+
assert widgets.first().message == "I can help"
50+
51+
async def test_adds_tool_message_with_json_content(self, app):
52+
async with app.run_test():
53+
chat_history = app.query_one(ChatHistory)
54+
chat_history.add_message(
55+
Message.tool("read_file", '{"path": "/tmp/test.txt"}')
56+
)
57+
58+
widgets = chat_history.query(ToolMessage)
59+
assert len(widgets) == 1
60+
assert widgets.first().tool_name == "read_file"
61+
assert widgets.first().tool_input == {"path": "/tmp/test.txt"}
62+
63+
async def test_tool_message_handles_invalid_json(self, app):
64+
async with app.run_test():
65+
chat_history = app.query_one(ChatHistory)
66+
chat_history.add_message(Message.tool("bash", "not valid json"))
67+
68+
widgets = chat_history.query(ToolMessage)
69+
assert len(widgets) == 1
70+
assert widgets.first().tool_input == {"raw": "not valid json"}
71+
72+
async def test_adds_multiple_messages(self, app):
73+
async with app.run_test():
74+
chat_history = app.query_one(ChatHistory)
75+
chat_history.add_message(Message.user("First"))
76+
chat_history.add_message(Message.agent("Second"))
77+
chat_history.add_message(Message.user("Third"))
78+
79+
assert len(chat_history.children) == 3

tests/components/test_header.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import pytest
2+
from unittest.mock import MagicMock, patch
3+
4+
from textual.app import App, ComposeResult
5+
from textual.widgets import Label
6+
7+
from agent_chat_cli.components.header import Header
8+
from agent_chat_cli.utils.mcp_server_status import MCPServerStatus
9+
10+
11+
@pytest.fixture(autouse=True)
12+
def reset_mcp_status():
13+
MCPServerStatus._mcp_servers = []
14+
MCPServerStatus._callbacks = []
15+
yield
16+
MCPServerStatus._mcp_servers = []
17+
MCPServerStatus._callbacks = []
18+
19+
20+
@pytest.fixture
21+
def mock_config():
22+
with patch("agent_chat_cli.components.header.load_config") as mock:
23+
mock.return_value = MagicMock(
24+
mcp_servers={"filesystem": MagicMock(), "github": MagicMock()},
25+
agents={"researcher": MagicMock()},
26+
)
27+
yield mock
28+
29+
30+
class HeaderApp(App):
31+
def compose(self) -> ComposeResult:
32+
yield Header()
33+
34+
35+
class TestHeaderMCPServerStatus:
36+
async def test_subscribes_on_mount(self, mock_config):
37+
app = HeaderApp()
38+
async with app.run_test():
39+
assert len(MCPServerStatus._callbacks) == 1
40+
41+
async def test_updates_label_on_status_change(self, mock_config):
42+
app = HeaderApp()
43+
async with app.run_test():
44+
MCPServerStatus.update(
45+
[
46+
{"name": "filesystem", "status": "connected"},
47+
{"name": "github", "status": "error"},
48+
]
49+
)
50+
51+
header = app.query_one(Header)
52+
header._handle_mcp_server_status()
53+
54+
label = app.query_one("#header-mcp-servers", Label)
55+
# Label stores markup in _content or we can check via render
56+
content = label.render()
57+
rendered = str(content)
58+
59+
assert "filesystem" in rendered
60+
assert "github" in rendered

0 commit comments

Comments
 (0)