diff --git a/conftest.py b/conftest.py index 94d508381..4e03dd3d8 100644 --- a/conftest.py +++ b/conftest.py @@ -2,6 +2,9 @@ Test configuration for agent tests. """ +import sys +from pathlib import Path + import pytest # Add the agents path diff --git a/src/backend/callbacks/response_handlers.py b/src/backend/callbacks/response_handlers.py index 663c9b184..38151dec9 100644 --- a/src/backend/callbacks/response_handlers.py +++ b/src/backend/callbacks/response_handlers.py @@ -17,6 +17,45 @@ logger = logging.getLogger(__name__) +def format_agent_display_name(raw_name: str) -> str: + """Convert raw agent IDs (e.g. 'HRHelperAgent', 'hr_helper_agent') to + human-readable display names (e.g. 'HR Helper Agent'). + + Applies similar splitting/casing logic as the frontend's + ``cleanTextToSpaces`` + ``getAgentDisplayName`` pipeline, but does NOT + strip the "Agent" suffix (the frontend handles that separately). + """ + if not raw_name: + return "Assistant" + + name = raw_name + + # Replace underscores with spaces + name = name.replace("_", " ") + + # Insert space before each uppercase letter preceded by a lowercase letter + # e.g. "HelperAgent" → "Helper Agent" + name = re.sub(r'([a-z])([A-Z])', r'\1 \2', name) + + # Insert space between consecutive uppercase and an uppercase+lowercase pair + # e.g. "HRHelper" → "HR Helper" + name = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1 \2', name) + + # Collapse multiple spaces + name = re.sub(r'\s+', ' ', name).strip() + + # Title-case each word + name = name.title() + + # Fix common acronyms back to uppercase (word-boundary safe) + _ACRONYMS = {'Hr': 'HR', 'It': 'IT', 'Ai': 'AI', 'Api': 'API', + 'Ui': 'UI', 'Db': 'DB', 'Kb': 'KB'} + for title_form, upper_form in _ACRONYMS.items(): + name = re.sub(rf'\b{title_form}\b', upper_form, name) + + return name + + def clean_citations(text: str) -> str: """Remove citation markers from agent responses while preserving formatting.""" if not text: @@ -66,6 +105,7 @@ def agent_response_callback( Final (non-streaming) agent response callback using agent_framework Message. """ agent_name = getattr(message, "author_name", None) or agent_id or "Unknown Agent" + agent_name = format_agent_display_name(agent_name) role = getattr(message, "role", "assistant") # Message has a .text property that concatenates all TextContent items @@ -107,6 +147,8 @@ async def streaming_agent_response_callback( if not user_id: return + display_name = format_agent_display_name(agent_id) + try: chunk_text = getattr(update, "text", None) if not chunk_text: @@ -123,7 +165,7 @@ async def streaming_agent_response_callback( contents = getattr(update, "contents", []) or [] tool_calls = _extract_tool_calls_from_contents(contents) if tool_calls: - tool_message = AgentToolMessage(agent_name=agent_id) + tool_message = AgentToolMessage(agent_name=display_name) tool_message.tool_calls.extend(tool_calls) await connection_config.send_status_update_async( tool_message, @@ -134,7 +176,7 @@ async def streaming_agent_response_callback( if cleaned: streaming_payload = AgentMessageStreaming( - agent_name=agent_id, + agent_name=display_name, content=cleaned, is_final=is_final, ) diff --git a/src/backend/orchestration/orchestration_manager.py b/src/backend/orchestration/orchestration_manager.py index d66ba9adb..25984fce0 100644 --- a/src/backend/orchestration/orchestration_manager.py +++ b/src/backend/orchestration/orchestration_manager.py @@ -16,6 +16,7 @@ MagenticPlanReviewRequest) from agents.agent_factory import AgentFactory from callbacks.response_handlers import (agent_response_callback, + format_agent_display_name, streaming_agent_response_callback) from common.config.app_config import config from common.database.database_base import DatabaseBase @@ -782,12 +783,12 @@ async def _process_event_stream( and executor != current_streaming_agent_ref[0] ): current_streaming_agent_ref[0] = executor - display_name = executor.replace("_", " ") + display_name = format_agent_display_name(executor) header_text = f"\n\n---\n### {display_name}\n\n" try: await connection_config.send_status_update_async( AgentMessageStreaming( - agent_name=executor, + agent_name=display_name, content=header_text, is_final=False, ), diff --git a/src/tests/backend/callbacks/test_response_handlers.py b/src/tests/backend/callbacks/test_response_handlers.py index 97b52f8bb..d44a712f5 100644 --- a/src/tests/backend/callbacks/test_response_handlers.py +++ b/src/tests/backend/callbacks/test_response_handlers.py @@ -134,6 +134,7 @@ def __init__(self, text="", role="assistant", author_name=""): from backend.callbacks.response_handlers import ( _extract_tool_calls_from_contents, _is_function_call_item, agent_response_callback, clean_citations, + format_agent_display_name, streaming_agent_response_callback) # Access mocked modules that we'll use in tests @@ -216,7 +217,45 @@ def test_clean_citations_preserves_formatting(self): assert clean_citations(text) == expected -class TestIsFunctionCallItem: +class TestFormatAgentDisplayName: + """Tests for the format_agent_display_name function.""" + + def test_empty_string_returns_assistant(self): + assert format_agent_display_name("") == "Assistant" + + def test_none_returns_assistant(self): + assert format_agent_display_name(None) == "Assistant" + + def test_pascal_case(self): + assert format_agent_display_name("ContentAgent") == "Content Agent" + + def test_snake_case(self): + assert format_agent_display_name("content_agent") == "Content Agent" + + def test_acronym_prefix(self): + assert format_agent_display_name("HRHelperAgent") == "HR Helper Agent" + + def test_acronym_word_boundary_no_false_positive(self): + """Ensure 'It' inside 'Habit' is NOT replaced with 'IT'.""" + assert format_agent_display_name("HabitAgent") == "Habit Agent" + + def test_multiple_acronyms(self): + assert format_agent_display_name("AIApiAgent") == "AI API Agent" + + def test_already_spaced(self): + assert format_agent_display_name("Content Agent") == "Content Agent" + + def test_single_word(self): + assert format_agent_display_name("Triage") == "Triage" + + def test_mixed_case_id(self): + assert format_agent_display_name("agent_123") == "Agent 123" + + def test_consecutive_uppercase(self): + assert format_agent_display_name("DBAdmin") == "DB Admin" + + + """Tests for the _is_function_call_item function.""" def test_is_function_call_item_none(self): @@ -415,7 +454,7 @@ def test_agent_response_callback_with_chat_message(self, mock_time, mock_create_ # Verify AgentMessage was created with cleaned text mock_agent_message.assert_called_once_with( - agent_name="TestAgent", + agent_name="Test Agent", timestamp=1234567890.0, content="Test message with citations " ) @@ -445,7 +484,7 @@ def test_agent_response_callback_fallback_message(self, mock_time, mock_create_t # Verify AgentMessage was created with agent_id as agent_name mock_agent_message.assert_called_once_with( - agent_name="agent_123", + agent_name="Agent 123", timestamp=1234567890.0, content="Fallback message text" ) @@ -469,7 +508,7 @@ def test_agent_response_callback_no_text_attribute(self, mock_time, mock_create_ # Verify AgentMessage was created with empty content mock_agent_message.assert_called_once_with( - agent_name="TestAgent", + agent_name="Test Agent", timestamp=1234567890.0, content="" ) @@ -515,7 +554,7 @@ def test_agent_response_callback_successful_logging(self, mock_time, mock_create call_args = mock_logger.info.call_args[0] assert call_args[0] == "%s message (agent=%s): %s" assert call_args[1] == "Assistant" - assert call_args[2] == "TestAgent" + assert call_args[2] == "Test Agent" assert len(call_args[3]) == 193 # Message should be the actual length (not truncated in this case) @@ -547,7 +586,7 @@ async def test_streaming_callback_with_text(self): # Verify AgentMessageStreaming was created with cleaned text mock_streaming.assert_called_once_with( - agent_name="agent_123", + agent_name="Agent 123", content="Test streaming text ", is_final=True ) @@ -587,7 +626,7 @@ async def test_streaming_callback_no_text_with_contents(self): # Verify AgentMessageStreaming was created with concatenated content text mock_streaming.assert_called_once_with( - agent_name="agent_123", + agent_name="Agent 123", content="Content from content attribute", is_final=False ) @@ -640,7 +679,7 @@ async def test_streaming_callback_with_tool_calls(self): await streaming_agent_response_callback("agent_123", mock_update, False, user_id="user_456") # Verify tool message was created and sent - mock_tool_message.assert_called_once_with(agent_name="agent_123") + mock_tool_message.assert_called_once_with(agent_name="Agent 123") # Verify tool_calls.extend was called with our mock tool call assert mock_tool_call in mock_tool_msg.tool_calls or mock_tool_msg.tool_calls.extend.called # type: ignore[union-attr] # noqa: E1101 # pylint: disable=no-member @@ -666,7 +705,7 @@ async def test_streaming_callback_no_contents_attribute(self): # Should still process the text mock_streaming.assert_called_once_with( - agent_name="agent_123", + agent_name="Agent 123", content="Test text", is_final=True ) @@ -733,7 +772,7 @@ async def test_streaming_callback_tool_calls_functionality(self): await streaming_agent_response_callback("agent_123", mock_update, False, user_id="user_456") # Verify tool message was created and tool calls were processed - mock_tool_message.assert_called_once_with(agent_name="agent_123") + mock_tool_message.assert_called_once_with(agent_name="Agent 123") assert connection_config.send_status_update_async.called @pytest.mark.asyncio @@ -751,7 +790,7 @@ async def test_streaming_callback_chunk_processing(self): # Verify streaming message was created with correct parameters mock_streaming.assert_called_once_with( - agent_name="agent_123", + agent_name="Agent 123", content="Test streaming text for processing", is_final=True )