diff --git a/docs/architecture.md b/docs/architecture.md index 31975b8..f5351f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,7 +10,7 @@ src/agent_chat_cli/ ├── core/ │ ├── actions.py # User action handlers │ ├── agent_loop.py # Claude Agent SDK client wrapper -│ ├── message_bus.py # Message routing from agent to UI +│ ├── renderer.py # Message routing from agent to UI │ ├── ui_state.py # Centralized UI state management │ └── styles.tcss # Textual CSS styles ├── components/ @@ -43,7 +43,7 @@ The application follows a loosely coupled architecture with four main orchestrat ┌─────────────────────────────────────────────────────────────┐ │ AgentChatCLIApp │ │ ┌───────────┐ ┌───────────┐ ┌─────────┐ ┌───────────┐ │ -│ │ UIState │ │MessageBus │ │ Actions │ │ AgentLoop │ │ +│ │ UIState │ │ Renderer │ │ Actions │ │ AgentLoop │ │ │ └─────┬─────┘ └─────┬─────┘ └────┬────┘ └─────┬─────┘ │ │ │ │ │ │ │ │ └──────────────┴─────────────┴──────────────┘ │ @@ -63,9 +63,9 @@ Centralized management of UI state behaviors. Handles: - 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. +This class was introduced in PR #9 to consolidate scattered UI state logic from Actions and Renderer into a single cohesive module. -**MessageBus** (`core/message_bus.py`) +**Renderer** (`core/renderer.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 @@ -92,7 +92,7 @@ Manages the Claude Agent SDK client lifecycle: 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 +4. Responses flow through `Actions.render_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()` @@ -118,7 +118,7 @@ Modal prompt for tool permission requests: - Manages focus to prevent input elsewhere while visible **ChatHistory** (`components/chat_history.py`) -Container for message widgets, handles `MessagePosted` events. +Container for message widgets. **ThinkingIndicator** (`components/thinking_indicator.py`) Animated indicator shown during agent processing. diff --git a/src/agent_chat_cli/app.py b/src/agent_chat_cli/app.py index d2fddde..f05b7e2 100644 --- a/src/agent_chat_cli/app.py +++ b/src/agent_chat_cli/app.py @@ -5,12 +5,12 @@ from textual.binding import Binding from agent_chat_cli.components.header import Header -from agent_chat_cli.components.chat_history import ChatHistory, MessagePosted +from agent_chat_cli.components.chat_history import ChatHistory 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 from agent_chat_cli.core.agent_loop import AgentLoop -from agent_chat_cli.core.message_bus import MessageBus +from agent_chat_cli.core.renderer import Renderer from agent_chat_cli.core.actions import Actions from agent_chat_cli.core.ui_state import UIState from agent_chat_cli.utils.logger import setup_logging @@ -34,7 +34,7 @@ def __init__(self) -> None: super().__init__() self.ui_state = UIState(app=self) - self.message_bus = MessageBus(app=self) + self.renderer = Renderer(app=self) self.actions = Actions(app=self) self.agent_loop = AgentLoop(app=self) @@ -49,9 +49,6 @@ def compose(self) -> ComposeResult: async def on_mount(self) -> None: asyncio.create_task(self.agent_loop.start()) - async def on_message_posted(self, event: MessagePosted) -> None: - await self.message_bus.on_message_posted(event) - async def action_interrupt(self) -> None: await self.actions.interrupt() diff --git a/src/agent_chat_cli/components/chat_history.py b/src/agent_chat_cli/components/chat_history.py index 357e739..0f0885c 100644 --- a/src/agent_chat_cli/components/chat_history.py +++ b/src/agent_chat_cli/components/chat_history.py @@ -1,6 +1,5 @@ import json from textual.containers import Container -from textual.message import Message as TextualMessage from agent_chat_cli.components.messages import ( AgentMessage, @@ -48,9 +47,3 @@ def _create_message_widget( tool_widget.tool_input = {"raw": message.content} return tool_widget - - -class MessagePosted(TextualMessage): - def __init__(self, message: Message) -> None: - self.message = message - super().__init__() diff --git a/src/agent_chat_cli/core/actions.py b/src/agent_chat_cli/core/actions.py index 27d361c..1fa0e6a 100644 --- a/src/agent_chat_cli/core/actions.py +++ b/src/agent_chat_cli/core/actions.py @@ -1,8 +1,8 @@ from typing import TYPE_CHECKING from agent_chat_cli.utils.enums import ControlCommand -from agent_chat_cli.components.chat_history import ChatHistory, MessagePosted -from agent_chat_cli.components.messages import Message +from agent_chat_cli.components.chat_history import ChatHistory +from agent_chat_cli.components.messages import Message, MessageType from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt from agent_chat_cli.utils.logger import log_json @@ -17,16 +17,32 @@ def __init__(self, app: "AgentChatCLIApp") -> None: def quit(self) -> None: self.app.exit() + async def add_message_to_chat(self, type: MessageType, content: str) -> None: + match type: + case MessageType.USER: + message = Message.user(content) + case MessageType.SYSTEM: + message = Message.system(content) + case MessageType.AGENT: + message = Message.agent(content) + case _: + raise ValueError(f"Unsupported message type: {type}") + + chat_history = self.app.query_one(ChatHistory) + chat_history.add_message(message) + async def submit_user_message(self, message: str) -> None: - self.app.post_message(MessagePosted(Message.user(message))) + chat_history = self.app.query_one(ChatHistory) + chat_history.add_message(Message.user(message)) self.app.ui_state.start_thinking() + await self.app.ui_state.scroll_to_bottom() await self._query(message) - def post_system_message(self, message: str) -> None: - self.app.post_message(MessagePosted(Message.system(message))) + async def post_system_message(self, message: str) -> None: + await self.add_message_to_chat(MessageType.SYSTEM, message) - async def handle_agent_message(self, message) -> None: - await self.app.message_bus.handle_agent_message(message) + async def render_message(self, message) -> None: + await self.app.renderer.render_message(message) async def interrupt(self) -> None: permission_prompt = self.app.query_one(ToolPermissionPrompt) diff --git a/src/agent_chat_cli/core/agent_loop.py b/src/agent_chat_cli/core/agent_loop.py index fe8cab0..c3945fb 100644 --- a/src/agent_chat_cli/core/agent_loop.py +++ b/src/agent_chat_cli/core/agent_loop.py @@ -92,7 +92,7 @@ async def start(self) -> None: await self._handle_message(message) - await self.app.actions.handle_agent_message( + await self.app.actions.render_message( AgentMessage(type=AgentMessageType.RESULT, data=None) ) @@ -135,7 +135,7 @@ async def _handle_message(self, message: Message) -> None: text_chunk = delta.get("text", "") if text_chunk: - await self.app.actions.handle_agent_message( + await self.app.actions.render_message( AgentMessage( type=AgentMessageType.STREAM_EVENT, data={"text": text_chunk}, @@ -163,7 +163,7 @@ async def _handle_message(self, message: Message) -> None: ) # Finally, post the agent assistant response - await self.app.actions.handle_agent_message( + await self.app.actions.render_message( AgentMessage( type=AgentMessageType.ASSISTANT, data={"content": content}, @@ -180,7 +180,7 @@ async def _can_use_tool( # Handle permission request queue sequentially async with self.permission_lock: - await self.app.actions.handle_agent_message( + await self.app.actions.render_message( AgentMessage( type=AgentMessageType.TOOL_PERMISSION_REQUEST, data={ @@ -213,7 +213,7 @@ async def _can_use_tool( ) if rejected_tool: - self.app.actions.post_system_message( + await self.app.actions.post_system_message( f"Permission denied for {tool_name}" ) diff --git a/src/agent_chat_cli/core/message_bus.py b/src/agent_chat_cli/core/message_bus.py deleted file mode 100644 index 4a6cdec..0000000 --- a/src/agent_chat_cli/core/message_bus.py +++ /dev/null @@ -1,149 +0,0 @@ -import asyncio -from typing import TYPE_CHECKING - -from textual.widgets import Markdown -from textual.containers import VerticalScroll - -from agent_chat_cli.components.chat_history import ChatHistory, MessagePosted -from agent_chat_cli.components.messages import ( - AgentMessage as AgentMessageWidget, - Message, - ToolMessage, -) -from agent_chat_cli.core.agent_loop import AgentMessage -from agent_chat_cli.utils.enums import AgentMessageType, ContentType -from agent_chat_cli.utils.logger import log_json - -if TYPE_CHECKING: - from agent_chat_cli.app import AgentChatCLIApp - - -class MessageBus: - def __init__(self, app: "AgentChatCLIApp") -> None: - self.app = app - self.current_agent_message: AgentMessageWidget | None = None - self.current_response_text = "" - - async def handle_agent_message(self, message: AgentMessage) -> None: - match message.type: - case AgentMessageType.STREAM_EVENT: - await self._handle_stream_event(message) - - case AgentMessageType.ASSISTANT: - await self._handle_assistant(message) - - case AgentMessageType.SYSTEM: - await self._handle_system(message) - - case AgentMessageType.USER: - await self._handle_user(message) - - case AgentMessageType.TOOL_PERMISSION_REQUEST: - await self._handle_tool_permission_request(message) - - case AgentMessageType.RESULT: - await self._handle_result() - - async def on_message_posted(self, event: MessagePosted) -> None: - chat_history = self.app.query_one(ChatHistory) - chat_history.add_message(event.message) - - await self._scroll_to_bottom() - - async def _handle_stream_event(self, message: AgentMessage) -> None: - text_chunk = message.data.get("text", "") - - if not text_chunk: - return - - chat_history = self.app.query_one(ChatHistory) - - if self.current_agent_message is None: - self.current_response_text = text_chunk - - agent_msg = AgentMessageWidget() - agent_msg.message = text_chunk - - # Append to chat history - chat_history.mount(agent_msg) - self.current_agent_message = agent_msg - else: - self.current_response_text += text_chunk - - markdown = self.current_agent_message.query_one(Markdown) - markdown.update(self.current_response_text) - - await self._scroll_to_bottom() - - async def _handle_assistant(self, message: AgentMessage) -> None: - content_blocks = message.data.get("content", []) - chat_history = self.app.query_one(ChatHistory) - - for block in content_blocks: - block_type = block.get("type") - - if block_type == ContentType.TOOL_USE.value: - if self.current_agent_message is not None: - self.current_agent_message = None - self.current_response_text = "" - - tool_name = block.get("name", "unknown") - tool_input = block.get("input", {}) - - tool_msg = ToolMessage() - tool_msg.tool_name = tool_name - tool_msg.tool_input = tool_input - - # Append to chat history - chat_history.mount(tool_msg) - - await self._scroll_to_bottom() - - async def _handle_system(self, message: AgentMessage) -> None: - system_content = ( - message.data if isinstance(message.data, str) else str(message.data) - ) - - # Dispatch message - self.app.post_message(MessagePosted(Message.system(system_content))) - - await self._scroll_to_bottom() - - async def _handle_user(self, message: AgentMessage) -> None: - user_content = ( - message.data if isinstance(message.data, str) else str(message.data) - ) - - self.app.post_message(MessagePosted(Message.user(user_content))) - - await self._scroll_to_bottom() - - async def _handle_tool_permission_request(self, message: AgentMessage) -> None: - log_json( - { - "event": "showing_permission_prompt", - "tool_name": message.data.get("tool_name", ""), - } - ) - - self.app.ui_state.show_permission_prompt( - tool_name=message.data.get("tool_name", ""), - tool_input=message.data.get("tool_input", {}), - ) - - await self._scroll_to_bottom() - - async def _handle_result(self) -> None: - if not self.app.agent_loop.query_queue.empty(): - return - - self.app.ui_state.stop_thinking() - - self.current_agent_message = None - self.current_response_text = "" - - async def _scroll_to_bottom(self) -> None: - await asyncio.sleep(0.1) - - container = self.app.query_one(VerticalScroll) - container.scroll_end(animate=False, immediate=True) diff --git a/src/agent_chat_cli/core/renderer.py b/src/agent_chat_cli/core/renderer.py new file mode 100644 index 0000000..3ac65e3 --- /dev/null +++ b/src/agent_chat_cli/core/renderer.py @@ -0,0 +1,132 @@ +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from textual.widgets import Markdown + +from agent_chat_cli.components.chat_history import ChatHistory +from agent_chat_cli.components.messages import ( + AgentMessage as AgentMessageWidget, + MessageType, + ToolMessage, +) +from agent_chat_cli.core.agent_loop import AgentMessage +from agent_chat_cli.utils.enums import AgentMessageType, ContentType +from agent_chat_cli.utils.logger import log_json + +if TYPE_CHECKING: + from agent_chat_cli.app import AgentChatCLIApp + + +@dataclass +class StreamBuffer: + widget: AgentMessageWidget | None = None + text: str = "" + + def reset(self) -> None: + self.widget = None + self.text = "" + + +class Renderer: + def __init__(self, app: "AgentChatCLIApp") -> None: + self.app = app + self._stream = StreamBuffer() + + async def render_message(self, message: AgentMessage) -> None: + match message.type: + case AgentMessageType.STREAM_EVENT: + await self._render_stream_event(message) + + case AgentMessageType.ASSISTANT: + await self._render_assistant_message(message) + + case AgentMessageType.SYSTEM: + await self._render_system_message(message) + + case AgentMessageType.USER: + await self._render_user_message(message) + + case AgentMessageType.TOOL_PERMISSION_REQUEST: + await self._render_tool_permission_request(message) + + case AgentMessageType.RESULT: + await self._on_complete() + + if message.type is not AgentMessageType.RESULT: + await self.app.ui_state.scroll_to_bottom() + + async def _render_stream_event(self, message: AgentMessage) -> None: + text_chunk = message.data.get("text", "") + + if not text_chunk: + return + + chat_history = self.app.query_one(ChatHistory) + + if self._stream.widget is None: + self._stream.text = text_chunk + + agent_msg = AgentMessageWidget() + agent_msg.message = text_chunk + + await chat_history.mount(agent_msg) + self._stream.widget = agent_msg + else: + self._stream.text += text_chunk + + markdown = self._stream.widget.query_one(Markdown) + markdown.update(self._stream.text) + + async def _render_assistant_message(self, message: AgentMessage) -> None: + content_blocks = message.data.get("content", []) + chat_history = self.app.query_one(ChatHistory) + + for block in content_blocks: + block_type = block.get("type") + + if block_type == ContentType.TOOL_USE.value: + if self._stream.widget is not None: + self._stream.reset() + + tool_name = block.get("name", "unknown") + tool_input = block.get("input", {}) + + tool_msg = ToolMessage() + tool_msg.tool_name = tool_name + tool_msg.tool_input = tool_input + + await chat_history.mount(tool_msg) + + async def _render_system_message(self, message: AgentMessage) -> None: + system_content = ( + message.data if isinstance(message.data, str) else str(message.data) + ) + + await self.app.actions.add_message_to_chat(MessageType.SYSTEM, system_content) + + async def _render_user_message(self, message: AgentMessage) -> None: + user_content = ( + message.data if isinstance(message.data, str) else str(message.data) + ) + + await self.app.actions.add_message_to_chat(MessageType.USER, user_content) + + async def _render_tool_permission_request(self, message: AgentMessage) -> None: + log_json( + { + "event": "showing_permission_prompt", + "tool_name": message.data.get("tool_name", ""), + } + ) + + self.app.ui_state.show_permission_prompt( + tool_name=message.data.get("tool_name", ""), + tool_input=message.data.get("tool_input", {}), + ) + + async def _on_complete(self) -> None: + if not self.app.agent_loop.query_queue.empty(): + return + + self.app.ui_state.stop_thinking() + self._stream.reset() diff --git a/src/agent_chat_cli/core/ui_state.py b/src/agent_chat_cli/core/ui_state.py index 2679836..61b9a33 100644 --- a/src/agent_chat_cli/core/ui_state.py +++ b/src/agent_chat_cli/core/ui_state.py @@ -1,5 +1,7 @@ +import asyncio from typing import TYPE_CHECKING, Any +from textual.containers import VerticalScroll from textual.widgets import TextArea from agent_chat_cli.components.thinking_indicator import ThinkingIndicator @@ -69,3 +71,8 @@ def clear_input(self) -> None: user_input = self.app.query_one(UserInput) input_widget = user_input.query_one(TextArea) input_widget.clear() + + async def scroll_to_bottom(self) -> None: + await asyncio.sleep(0.1) + container = self.app.query_one(VerticalScroll) + container.scroll_end(animate=False, immediate=True) diff --git a/src/agent_chat_cli/docs/architecture.md b/src/agent_chat_cli/docs/architecture.md index 268bfb1..52702fb 100644 --- a/src/agent_chat_cli/docs/architecture.md +++ b/src/agent_chat_cli/docs/architecture.md @@ -30,7 +30,7 @@ Manages the conversation loop with Claude SDK: - Uses `permission_lock` (asyncio.Lock) to serialize parallel permission requests - Manages `permission_response_queue` for user responses to tool permission prompts -#### Message Bus (`system/message_bus.py`) +#### Renderer (`core/renderer.py`) Routes agent messages to appropriate UI components: - Handles streaming text updates - Mounts tool use messages @@ -74,7 +74,7 @@ User Input ↓ UserInput.on_input_submitted ↓ -MessagePosted event → ChatHistory (immediate UI update) +Actions.add_message_to_chat → ChatHistory (immediate UI update) ↓ Actions.query(user_input) → AgentLoop.query_queue.put() ↓ @@ -82,7 +82,7 @@ Claude SDK (all enabled servers pre-connected at startup) ↓ AgentLoop._handle_message ↓ -AgentMessage (typed message) → MessageBus.handle_agent_message +AgentMessage (typed message) → Actions.render_message ↓ Match on AgentMessageType: - STREAM_EVENT → Update streaming message widget @@ -182,7 +182,7 @@ AgentLoop._can_use_tool (callback with permission_lock acquired) ↓ Emit SYSTEM AgentMessage with tool_permission_request data ↓ -MessageBus._handle_system detects permission request +Renderer._handle_tool_permission_request shows permission prompt ↓ Show ToolPermissionPrompt, hide UserInput ↓ @@ -299,22 +299,20 @@ SDK reconnects to previous session with full history ### User Message Flow 1. User submits text → UserInput -2. MessagePosted event → App -3. App → MessageBus.on_message_posted -4. MessageBus → ChatHistory.add_message -5. MessageBus → Scroll to bottom +2. Actions.add_message_to_chat → ChatHistory.add_message +3. Scroll to bottom ### Agent Response Flow 1. AgentLoop receives SDK message 2. Parse into AgentMessage with AgentMessageType -3. MessageBus.handle_agent_message (match/case on type) +3. Actions.render_message → Renderer (match/case on type) 4. Update UI components based on type 5. Scroll to bottom ## Notes - Two distinct MessageType enums exist for different purposes (UI vs Agent events) -- Message bus manages stateful streaming (tracks current_agent_message) +- Renderer manages stateful streaming via StreamBuffer - Config loading combines multiple prompts into final system_prompt - Tool names follow format: `mcp__servername__toolname` - Actions class provides single interface for all user-initiated operations diff --git a/tests/core/test_actions.py b/tests/core/test_actions.py index 209859c..0cb8eb3 100644 --- a/tests/core/test_actions.py +++ b/tests/core/test_actions.py @@ -3,6 +3,12 @@ from agent_chat_cli.app import AgentChatCLIApp from agent_chat_cli.components.chat_history import ChatHistory +from agent_chat_cli.components.messages import ( + MessageType, + SystemMessage, + UserMessage, + AgentMessage, +) from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt from agent_chat_cli.utils.enums import ControlCommand @@ -30,6 +36,90 @@ def mock_config(): yield mock +class TestActionsAddMessageToChat: + async def test_adds_user_message(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + + await app.actions.add_message_to_chat(MessageType.USER, "Hello") + + widgets = chat_history.query(UserMessage) + assert len(widgets) == 1 + assert widgets.first().message == "Hello" + + async def test_adds_system_message(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + + await app.actions.add_message_to_chat(MessageType.SYSTEM, "System alert") + + widgets = chat_history.query(SystemMessage) + assert len(widgets) == 1 + assert widgets.first().message == "System alert" + + async def test_adds_agent_message(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + + await app.actions.add_message_to_chat(MessageType.AGENT, "I can help") + + widgets = chat_history.query(AgentMessage) + assert len(widgets) == 1 + assert widgets.first().message == "I can help" + + async def test_raises_for_unsupported_type(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + with pytest.raises(ValueError, match="Unsupported message type"): + await app.actions.add_message_to_chat(MessageType.TOOL, "tool content") + + +class TestActionsPostSystemMessage: + async def test_adds_system_message_to_chat(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + + await app.actions.post_system_message("Connection established") + + widgets = chat_history.query(SystemMessage) + assert len(widgets) == 1 + assert widgets.first().message == "Connection established" + + +class TestActionsSubmitUserMessage: + async def test_adds_user_message_to_chat(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + + await app.actions.submit_user_message("Hello agent") + + widgets = chat_history.query(UserMessage) + assert len(widgets) == 1 + assert widgets.first().message == "Hello agent" + + async def test_starts_thinking_indicator(self, mock_agent_loop, mock_config): + from agent_chat_cli.components.thinking_indicator import ThinkingIndicator + + app = AgentChatCLIApp() + async with app.run_test(): + await app.actions.submit_user_message("Hello agent") + + thinking_indicator = app.query_one(ThinkingIndicator) + assert thinking_indicator.is_thinking is True + + async def test_queues_message_to_agent_loop(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + await app.actions.submit_user_message("Hello agent") + + mock_agent_loop.query_queue.put.assert_called_with("Hello agent") + + class TestActionsInterrupt: async def test_sets_interrupting_flag(self, mock_agent_loop, mock_config): app = AgentChatCLIApp() diff --git a/tests/core/test_agent_loop.py b/tests/core/test_agent_loop.py index a4d6bb8..e061e32 100644 --- a/tests/core/test_agent_loop.py +++ b/tests/core/test_agent_loop.py @@ -20,8 +20,8 @@ def mock_app(): app = MagicMock() app.ui_state = MagicMock() app.actions = MagicMock() - app.actions.handle_agent_message = AsyncMock() - app.actions.post_system_message = MagicMock() + app.actions.render_message = AsyncMock() + app.actions.post_system_message = AsyncMock() return app @@ -152,8 +152,8 @@ async def test_handles_text_delta_stream_event(self, mock_app, mock_config): await agent_loop._handle_message(message) - mock_app.actions.handle_agent_message.assert_called_once() - call_arg = mock_app.actions.handle_agent_message.call_args[0][0] + mock_app.actions.render_message.assert_called_once() + call_arg = mock_app.actions.render_message.call_args[0][0] assert call_arg.type == AgentMessageType.STREAM_EVENT assert call_arg.data == {"text": "Hello world"} @@ -171,7 +171,7 @@ async def test_ignores_empty_text_delta(self, mock_app, mock_config): await agent_loop._handle_message(message) - mock_app.actions.handle_agent_message.assert_not_called() + mock_app.actions.render_message.assert_not_called() class TestHandleMessageAssistantMessage: @@ -186,8 +186,8 @@ async def test_handles_text_block(self, mock_app, mock_config): await agent_loop._handle_message(message) - mock_app.actions.handle_agent_message.assert_called_once() - call_arg = mock_app.actions.handle_agent_message.call_args[0][0] + mock_app.actions.render_message.assert_called_once() + call_arg = mock_app.actions.render_message.call_args[0][0] assert call_arg.type == AgentMessageType.ASSISTANT assert call_arg.data["content"][0]["type"] == ContentType.TEXT.value assert call_arg.data["content"][0]["text"] == "Assistant response" @@ -205,8 +205,8 @@ async def test_handles_tool_use_block(self, mock_app, mock_config): await agent_loop._handle_message(message) - mock_app.actions.handle_agent_message.assert_called_once() - call_arg = mock_app.actions.handle_agent_message.call_args[0][0] + mock_app.actions.render_message.assert_called_once() + call_arg = mock_app.actions.render_message.call_args[0][0] assert call_arg.type == AgentMessageType.ASSISTANT assert call_arg.data["content"][0]["type"] == ContentType.TOOL_USE.value assert call_arg.data["content"][0]["name"] == "read_file" @@ -278,7 +278,7 @@ async def test_posts_permission_request_message(self, mock_app, mock_config): _context=MagicMock(), ) - mock_app.actions.handle_agent_message.assert_called_once() - call_arg = mock_app.actions.handle_agent_message.call_args[0][0] + mock_app.actions.render_message.assert_called_once() + call_arg = mock_app.actions.render_message.call_args[0][0] assert call_arg.type == AgentMessageType.TOOL_PERMISSION_REQUEST assert call_arg.data["tool_name"] == "write_file" diff --git a/tests/core/test_message_bus.py b/tests/core/test_renderer.py similarity index 76% rename from tests/core/test_message_bus.py rename to tests/core/test_renderer.py index e8448d2..71556bd 100644 --- a/tests/core/test_message_bus.py +++ b/tests/core/test_renderer.py @@ -24,7 +24,7 @@ def mock_config(): yield mock -class TestMessageBusHandleAgentMessage: +class TestRendererRenderMessage: async def test_handles_stream_event(self, mock_agent_loop, mock_config): app = AgentChatCLIApp() async with app.run_test(): @@ -33,23 +33,23 @@ async def test_handles_stream_event(self, mock_agent_loop, mock_config): data={"text": "Hello"}, ) - await app.message_bus.handle_agent_message(message) + await app.renderer.render_message(message) - assert app.message_bus.current_response_text == "Hello" + assert app.renderer._stream.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( + await app.renderer.render_message( AgentMessage( type=AgentMessageType.STREAM_EVENT, data={"text": "Hello "} ) ) - await app.message_bus.handle_agent_message( + await app.renderer.render_message( AgentMessage(type=AgentMessageType.STREAM_EVENT, data={"text": "world"}) ) - assert app.message_bus.current_response_text == "Hello world" + assert app.renderer._stream.text == "Hello world" async def test_handles_tool_permission_request(self, mock_agent_loop, mock_config): app = AgentChatCLIApp() @@ -59,7 +59,7 @@ async def test_handles_tool_permission_request(self, mock_agent_loop, mock_confi data={"tool_name": "read_file", "tool_input": {"path": "/tmp"}}, ) - await app.message_bus.handle_agent_message(message) + await app.renderer.render_message(message) from agent_chat_cli.components.tool_permission_prompt import ( ToolPermissionPrompt, @@ -72,16 +72,16 @@ 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( + await app.renderer.render_message( AgentMessage(type=AgentMessageType.STREAM_EVENT, data={"text": "test"}) ) - await app.message_bus.handle_agent_message( + await app.renderer.render_message( AgentMessage(type=AgentMessageType.RESULT, data=None) ) - assert app.message_bus.current_agent_message is None - assert app.message_bus.current_response_text == "" + assert app.renderer._stream.widget is None + assert app.renderer._stream.text == "" async def test_handles_assistant_with_tool_use(self, mock_agent_loop, mock_config): app = AgentChatCLIApp() @@ -99,16 +99,16 @@ async def test_handles_assistant_with_tool_use(self, mock_agent_loop, mock_confi }, ) - await app.message_bus.handle_agent_message(message) + await app.renderer.render_message(message) - assert app.message_bus.current_agent_message is None + assert app.renderer._stream.widget 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( + await app.renderer.render_message( AgentMessage(type=AgentMessageType.STREAM_EVENT, data={"text": ""}) ) - assert app.message_bus.current_response_text == "" - assert app.message_bus.current_agent_message is None + assert app.renderer._stream.text == "" + assert app.renderer._stream.widget is None