mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
239 lines
7.5 KiB
Python
239 lines
7.5 KiB
Python
"""Tests for the conversation visualizer and event visualization."""
|
|
|
|
import json
|
|
|
|
from rich.text import Text
|
|
|
|
from openhands_cli.tui.visualizer import (
|
|
CLIVisualizer,
|
|
)
|
|
from openhands.sdk.event import (
|
|
ActionEvent,
|
|
SystemPromptEvent,
|
|
UserRejectObservation,
|
|
)
|
|
from openhands.sdk.llm import (
|
|
MessageToolCall,
|
|
TextContent,
|
|
)
|
|
from openhands.sdk.tool import Action
|
|
|
|
|
|
class VisualizerMockAction(Action):
|
|
"""Mock action for testing."""
|
|
|
|
command: str = "test command"
|
|
working_dir: str = "/tmp"
|
|
|
|
|
|
class VisualizerCustomAction(Action):
|
|
"""Custom action with overridden visualize method."""
|
|
|
|
task_list: list[dict] = []
|
|
|
|
@property
|
|
def visualize(self) -> Text:
|
|
"""Custom visualization for task tracker."""
|
|
content = Text()
|
|
content.append("Task Tracker Action\n", style="bold")
|
|
content.append(f"Tasks: {len(self.task_list)}")
|
|
for i, task in enumerate(self.task_list):
|
|
content.append(f"\n {i + 1}. {task.get('title', 'Untitled')}")
|
|
return content
|
|
|
|
|
|
def create_tool_call(
|
|
call_id: str, function_name: str, arguments: dict
|
|
) -> MessageToolCall:
|
|
"""Helper to create a MessageToolCall."""
|
|
return MessageToolCall(
|
|
id=call_id,
|
|
name=function_name,
|
|
arguments=json.dumps(arguments),
|
|
origin="completion",
|
|
)
|
|
|
|
|
|
def test_conversation_visualizer_initialization():
|
|
"""Test DefaultConversationVisualizer can be initialized."""
|
|
visualizer = CLIVisualizer()
|
|
assert visualizer is not None
|
|
assert hasattr(visualizer, "on_event")
|
|
assert hasattr(visualizer, "_create_event_panel")
|
|
|
|
|
|
def test_visualizer_event_panel_creation():
|
|
"""Test that visualizer creates panels for different event types."""
|
|
conv_viz = CLIVisualizer()
|
|
|
|
# Test with a simple action event
|
|
action = VisualizerMockAction(command="test")
|
|
tool_call = create_tool_call("call_1", "test", {})
|
|
action_event = ActionEvent(
|
|
thought=[TextContent(text="Testing")],
|
|
action=action,
|
|
tool_name="test",
|
|
tool_call_id="call_1",
|
|
tool_call=tool_call,
|
|
llm_response_id="response_1",
|
|
)
|
|
panel = conv_viz._create_event_panel(action_event)
|
|
assert panel is not None
|
|
assert hasattr(panel, "renderable")
|
|
|
|
|
|
def test_visualizer_action_event_with_none_action_panel():
|
|
"""ActionEvent with action=None should render as 'Agent Action (Not Executed)'."""
|
|
visualizer = CLIVisualizer()
|
|
tc = create_tool_call("call_ne_1", "missing_fn", {})
|
|
action_event = ActionEvent(
|
|
thought=[TextContent(text="...")],
|
|
tool_call=tc,
|
|
tool_name=tc.name,
|
|
tool_call_id=tc.id,
|
|
llm_response_id="resp_viz_1",
|
|
action=None,
|
|
)
|
|
panel = visualizer._create_event_panel(action_event)
|
|
assert panel is not None
|
|
# Ensure it doesn't fall back to UNKNOWN
|
|
assert "UNKNOWN Event" not in str(panel.title)
|
|
# And uses the 'Agent Action (Not Executed)' title
|
|
assert "Agent Action (Not Executed)" in str(panel.title)
|
|
|
|
|
|
def test_visualizer_user_reject_observation_panel():
|
|
"""UserRejectObservation should render a dedicated panel."""
|
|
visualizer = CLIVisualizer()
|
|
event = UserRejectObservation(
|
|
tool_name="demo_tool",
|
|
tool_call_id="fc_call_1",
|
|
action_id="action_1",
|
|
rejection_reason="User rejected the proposed action.",
|
|
)
|
|
|
|
panel = visualizer._create_event_panel(event)
|
|
assert panel is not None
|
|
title = str(panel.title)
|
|
assert "UNKNOWN Event" not in title
|
|
assert "User Rejected Action" in title
|
|
# ensure the reason is part of the renderable text
|
|
renderable = panel.renderable
|
|
assert isinstance(renderable, Text)
|
|
assert "User rejected the proposed action." in renderable.plain
|
|
|
|
|
|
def test_metrics_formatting():
|
|
"""Test metrics subtitle formatting."""
|
|
from unittest.mock import MagicMock
|
|
|
|
from openhands.sdk.conversation.conversation_stats import ConversationStats
|
|
from openhands.sdk.llm.utils.metrics import Metrics
|
|
|
|
# Create conversation stats with metrics
|
|
conversation_stats = ConversationStats()
|
|
|
|
# Create metrics and add to conversation stats
|
|
metrics = Metrics(model_name="test-model")
|
|
metrics.add_cost(0.0234)
|
|
metrics.add_token_usage(
|
|
prompt_tokens=1500,
|
|
completion_tokens=500,
|
|
cache_read_tokens=300,
|
|
cache_write_tokens=0,
|
|
reasoning_tokens=200,
|
|
context_window=8000,
|
|
response_id="test_response",
|
|
)
|
|
|
|
# Add metrics to conversation stats
|
|
conversation_stats.usage_to_metrics["test_usage"] = metrics
|
|
|
|
# Create visualizer and initialize with mock state
|
|
visualizer = CLIVisualizer()
|
|
mock_state = MagicMock()
|
|
mock_state.stats = conversation_stats
|
|
visualizer.initialize(mock_state)
|
|
|
|
# Test the metrics subtitle formatting
|
|
subtitle = visualizer._format_metrics_subtitle()
|
|
assert subtitle is not None
|
|
assert "1.5K" in subtitle # Input tokens abbreviated (trailing zeros removed)
|
|
assert "500" in subtitle # Output tokens
|
|
assert "20.00%" in subtitle # Cache hit rate
|
|
assert "200" in subtitle # Reasoning tokens
|
|
assert "0.0234" in subtitle # Cost
|
|
|
|
|
|
def test_metrics_abbreviation_formatting():
|
|
"""Test number abbreviation with various edge cases."""
|
|
from unittest.mock import MagicMock
|
|
|
|
from openhands.sdk.conversation.conversation_stats import ConversationStats
|
|
from openhands.sdk.llm.utils.metrics import Metrics
|
|
|
|
test_cases = [
|
|
# (input_tokens, expected_abbr)
|
|
(999, "999"), # Below threshold
|
|
(1000, "1K"), # Exact K boundary, trailing zeros removed
|
|
(1500, "1.5K"), # K with one decimal, trailing zero removed
|
|
(89080, "89.08K"), # K with two decimals (regression test for bug)
|
|
(89000, "89K"), # K with trailing zeros removed
|
|
(1000000, "1M"), # Exact M boundary
|
|
(1234567, "1.23M"), # M with decimals
|
|
(1000000000, "1B"), # Exact B boundary
|
|
]
|
|
|
|
for tokens, expected in test_cases:
|
|
stats = ConversationStats()
|
|
metrics = Metrics(model_name="test-model")
|
|
metrics.add_token_usage(
|
|
prompt_tokens=tokens,
|
|
completion_tokens=100,
|
|
cache_read_tokens=0,
|
|
cache_write_tokens=0,
|
|
reasoning_tokens=0,
|
|
context_window=8000,
|
|
response_id="test",
|
|
)
|
|
stats.usage_to_metrics["test"] = metrics
|
|
|
|
visualizer = CLIVisualizer()
|
|
mock_state = MagicMock()
|
|
mock_state.stats = stats
|
|
visualizer.initialize(mock_state)
|
|
subtitle = visualizer._format_metrics_subtitle()
|
|
|
|
assert subtitle is not None, f"Failed for {tokens}"
|
|
assert expected in subtitle, (
|
|
f"Expected '{expected}' in subtitle for {tokens}, got: {subtitle}"
|
|
)
|
|
|
|
|
|
def test_event_base_fallback_visualize():
|
|
"""Test that Event provides fallback visualization."""
|
|
from openhands.sdk.event.base import Event
|
|
from openhands.sdk.event.types import SourceType
|
|
|
|
class UnknownEvent(Event):
|
|
source: SourceType = "agent"
|
|
|
|
event = UnknownEvent()
|
|
|
|
conv_viz = CLIVisualizer()
|
|
panel = conv_viz._create_event_panel(event)
|
|
|
|
assert "UNKNOWN Event" in str(panel.title)
|
|
|
|
|
|
def test_visualizer_does_not_render_system_prompt():
|
|
"""Test that Event provides fallback visualization."""
|
|
system_prompt_event = SystemPromptEvent(
|
|
source="agent",
|
|
system_prompt=TextContent(text="dummy"),
|
|
tools=[]
|
|
)
|
|
conv_viz = CLIVisualizer()
|
|
panel = conv_viz._create_event_panel(system_prompt_event)
|
|
assert panel is None
|