Compare commits

..

8 Commits

Author SHA1 Message Date
Xingyao Wang d89595a9cf Merge commit '116ba199d1c0d35b87af59254d1249c4fdd1fde5' into improve-cli-colors 2025-08-10 11:38:58 -04:00
openhands 53872a4d55 Fix test_message_action_from_agent to use display_agent_message instead of display_message 2025-07-30 18:08:12 +00:00
openhands f56314bda6 Fix poetry.lock and linting issues 2025-07-30 16:43:32 +00:00
openhands 166d7a4d1a Fix TypeScript errors and mypy errors in CLI colors PR 2025-07-30 16:36:15 +00:00
openhands db478cbc7e Fix markdown rendering in CLI and frontend linting issues 2025-07-30 16:12:34 +00:00
openhands a86a0e7792 Merge main into improve-cli-colors branch 2025-07-30 15:33:18 +00:00
openhands 9dfc85f4e3 Fix tests for new CLI colors feature 2025-07-19 16:06:57 +00:00
openhands e9c844087c Improve CLI colors for agent finish and message actions
- Add distinctive colors for AgentFinishAction with success/partial/failed status indicators
- Add soft blue styling for agent MessageAction to distinguish from regular output
- Import AgentFinishAction and create dedicated display functions
- Use bright green for finish actions and soft blue for agent messages
- Add visual status indicators (, ⚠️, ) and emoji titles for better UX
- Maintain backward compatibility with existing CLI functionality
2025-07-17 19:16:12 +00:00
12 changed files with 279 additions and 163 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ repos:
hooks:
- id: mypy
additional_dependencies:
[types-requests, types-setuptools, types-pyyaml, types-toml, types-docker, types-Markdown, pydantic, lxml]
[types-requests, types-setuptools, types-pyyaml, types-toml, types-docker, pydantic, lxml, types-Markdown]
# To see gaps add `--html-report mypy-report/`
entry: mypy --config-file dev_config/python/mypy.ini openhands/
always_run: true
+54
View File
@@ -6072,6 +6072,60 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.4.3",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.4.3",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.0.2",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.11",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.9.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.0",
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",
@@ -14,6 +14,7 @@ import {
isStatusUpdate,
} from "#/types/core/guards";
import { AgentState } from "#/types/agent-state";
import EventLogger from "#/utils/event-logger";
import {
renderConversationErroredToast,
renderConversationCreatedToast,
+109 -93
View File
@@ -6,13 +6,12 @@ import asyncio
import contextlib
import datetime
import json
import shutil
import re
import sys
import threading
import time
from typing import Generator
import markdown # type: ignore
from prompt_toolkit import PromptSession, print_formatted_text
from prompt_toolkit.application import Application
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
@@ -38,6 +37,7 @@ from openhands.events import EventSource, EventStream
from openhands.events.action import (
Action,
ActionConfirmationStatus,
AgentFinishAction,
ChangeAgentStateAction,
CmdRunAction,
MCPAction,
@@ -67,11 +67,16 @@ MAX_RECENT_THOUGHTS = 5
# Color and styling constants
COLOR_GOLD = '#FFD700'
COLOR_GREY = '#808080'
COLOR_AGENT_BLUE = '#4682B4' # Steel blue - less saturated, works well on both light and dark backgrounds
COLOR_SUCCESS_GREEN = '#00D787' # Bright green for finish actions
COLOR_AGENT_BLUE = '#5FAFFF' # Soft blue for agent messages
COLOR_FINISH_FRAME = '#00AF87' # Darker green for finish action frames
DEFAULT_STYLE = Style.from_dict(
{
'gold': COLOR_GOLD,
'grey': COLOR_GREY,
'success-green': COLOR_SUCCESS_GREEN,
'agent-blue': COLOR_AGENT_BLUE,
'finish-frame': COLOR_FINISH_FRAME,
'prompt': f'{COLOR_GOLD} bold',
}
)
@@ -239,19 +244,13 @@ def display_mcp_errors() -> None:
# Prompt output display functions
def display_thought_if_new(thought: str, is_agent_message: bool = False) -> None:
"""
Display a thought only if it hasn't been displayed recently.
Args:
thought: The thought to display
is_agent_message: If True, apply agent styling and markdown rendering
"""
def display_thought_if_new(thought: str) -> None:
"""Display a thought only if it hasn't been displayed recently."""
global recent_thoughts
if thought and thought.strip():
# Check if this thought was recently displayed
if thought not in recent_thoughts:
display_message(thought, is_agent_message=is_agent_message)
display_message(thought)
recent_thoughts.append(thought)
# Keep only the most recent thoughts
if len(recent_thoughts) > MAX_RECENT_THOUGHTS:
@@ -261,10 +260,13 @@ def display_thought_if_new(thought: str, is_agent_message: bool = False) -> None
def display_event(event: Event, config: OpenHandsConfig) -> None:
global streaming_output_text_area
with print_lock:
if isinstance(event, CmdRunAction):
if isinstance(event, AgentFinishAction):
# Handle agent finish actions with special styling
display_agent_finish(event)
elif isinstance(event, CmdRunAction):
# For CmdRunAction, display thought first, then command
if hasattr(event, 'thought') and event.thought:
display_thought_if_new(event.thought)
display_message(event.thought)
# Only display the command if it's not already confirmed
# Commands are always shown when AWAITING_CONFIRMATION, so we don't need to show them again when CONFIRMED
@@ -278,15 +280,14 @@ def display_event(event: Event, config: OpenHandsConfig) -> None:
elif isinstance(event, Action):
# For other actions, display thoughts normally
if hasattr(event, 'thought') and event.thought:
display_thought_if_new(event.thought)
display_message(event.thought)
if hasattr(event, 'final_thought') and event.final_thought:
# Display final thoughts with agent styling
display_message(event.final_thought, is_agent_message=True)
display_message(event.final_thought)
if isinstance(event, MessageAction):
if event.source == EventSource.AGENT:
# Display agent messages with styling and markdown rendering
display_thought_if_new(event.content, is_agent_message=True)
# Display agent messages with distinctive styling
display_agent_message(event.content)
elif isinstance(event, CmdOutputObservation):
display_command_output(event.content)
elif isinstance(event, FileEditObservation):
@@ -301,89 +302,104 @@ def display_event(event: Event, config: OpenHandsConfig) -> None:
display_error(event.content)
def display_message(message: str, is_agent_message: bool = False) -> None:
def process_markdown_for_terminal(text: str) -> str:
"""
Display a message in the terminal with markdown rendering.
Args:
message: The message to display
is_agent_message: If True, apply agent styling (blue color)
"""
message = message.strip()
if message:
# Add spacing before the message
print_formatted_text('')
try:
# Convert markdown to HTML for all messages
html_content = convert_markdown_to_html(message)
if is_agent_message:
# Use prompt_toolkit's HTML renderer with the agent color
print_formatted_text(
HTML(f'<style fg="{COLOR_AGENT_BLUE}">{html_content}</style>')
)
else:
# Regular message display with HTML rendering but default color
print_formatted_text(HTML(html_content))
except Exception as e:
# If HTML rendering fails, fall back to plain text
print(f'Warning: HTML rendering failed: {str(e)}', file=sys.stderr)
if is_agent_message:
print_formatted_text(
FormattedText([('fg:' + COLOR_AGENT_BLUE, message)])
)
else:
print_formatted_text(message)
# Add spacing after the message
print_formatted_text('')
def display_agent_message(message: str) -> None:
"""
Display an agent message in the terminal with markdown rendering and agent styling.
Args:
message: The message to display
"""
display_message(message, is_agent_message=True)
def convert_markdown_to_html(text: str) -> str:
"""
Convert markdown to HTML for prompt_toolkit's HTML renderer using the markdown library.
Args:
text: Markdown text to convert
Returns:
HTML formatted text with custom styling for headers and bullet points
Process markdown syntax for terminal display.
This function handles common markdown patterns like bold, italic, code blocks, etc.
"""
if not text:
return text
# Use the markdown library to convert markdown to HTML
# Enable the 'extra' extension for tables, fenced code, etc.
html = markdown.markdown(text, extensions=['extra'])
# Process bold text (**text**)
text = re.sub(r'\*\*(.*?)\*\*', r'\1', text)
# Customize headers
for i in range(1, 7):
# Get the appropriate number of # characters for this heading level
prefix = '#' * i + ' '
# Process italic text (*text*)
text = re.sub(r'\*(.*?)\*', r'\1', text)
# Replace <h1> with the prefix and bold text
html = html.replace(f'<h{i}>', f'<b>{prefix}')
html = html.replace(f'</h{i}>', '</b>\n')
# Process inline code (`code`)
text = re.sub(r'`(.*?)`', r'\1', text)
# Customize bullet points to use dashes instead of dots with compact spacing
html = html.replace('<ul>', '')
html = html.replace('</ul>', '')
html = html.replace('<li>', '- ')
html = html.replace('</li>', '')
# Process code blocks
text = re.sub(r'```(?:\w+)?\n(.*?)\n```', r'\1', text, flags=re.DOTALL)
return html
return text
def display_message(message: str) -> None:
message = message.strip()
if message:
print_formatted_text(f'\n{message}')
def display_agent_message(message: str) -> None:
"""Display a message from the agent with distinctive styling and markdown rendering."""
message = message.strip()
if message:
# Process markdown in the message
try:
# Process markdown for terminal display
processed_message = process_markdown_for_terminal(message)
except Exception:
# If markdown processing fails, use the original message
processed_message = message
container = Frame(
TextArea(
text=processed_message,
read_only=True,
style=COLOR_AGENT_BLUE,
wrap_lines=True,
),
title='Agent Message',
style=f'fg:{COLOR_AGENT_BLUE}',
)
print_formatted_text('')
print_container(container)
def display_agent_finish(event: AgentFinishAction) -> None:
"""Display an agent finish action with distinctive styling and markdown rendering."""
# Determine the message to display
if event.final_thought:
message = event.final_thought
elif event.thought:
message = event.thought
else:
message = "All done! What's next on the agenda?"
# Add task completion status if available
if event.task_completed:
status_map = {
'true': '✅ Task completed successfully',
'partial': '⚠️ Task partially completed',
'false': '❌ Task could not be completed',
}
status_text = status_map.get(event.task_completed.value, '')
if status_text:
message = f'{status_text}\n\n{message}'
# Process markdown in the message
try:
# Process markdown for terminal display
processed_message = process_markdown_for_terminal(message)
except Exception:
# If markdown processing fails, use the original message
processed_message = message
container = Frame(
TextArea(
text=processed_message,
read_only=True,
style=COLOR_SUCCESS_GREEN,
wrap_lines=True,
),
title='🎯 Agent Finished',
style=f'fg:{COLOR_FINISH_FRAME}',
)
print_formatted_text('')
print_container(container)
def display_error(error: str) -> None:
+1 -1
View File
@@ -234,7 +234,7 @@ async def run_controller(
file_path = config.save_trajectory_path
os.makedirs(os.path.dirname(file_path), exist_ok=True)
histories = controller.get_trajectory(config.save_screenshots_in_trajectory)
with open(file_path, 'w') as f: # noqa: ASYNC101
with open(file_path, 'w') as f: # noqa
json.dump(histories, f, indent=4)
return state
+8 -8
View File
@@ -571,7 +571,7 @@ class IssueResolver:
# checkout the repo
repo_dir = os.path.join(self.output_dir, 'repo')
if not os.path.exists(repo_dir):
checkout_output = subprocess.check_output( # noqa: ASYNC101
checkout_output = subprocess.check_output( # noqa
[
'git',
'clone',
@@ -584,7 +584,7 @@ class IssueResolver:
# get the commit id of current repo for reproducibility
base_commit = (
subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir) # noqa: ASYNC101
subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir) # noqa
.decode('utf-8')
.strip()
)
@@ -596,7 +596,7 @@ class IssueResolver:
repo_dir, '.openhands_instructions'
)
if os.path.exists(openhands_instructions_path):
with open(openhands_instructions_path, 'r') as f: # noqa: ASYNC101
with open(openhands_instructions_path, 'r') as f: # noqa
self.repo_instruction = f.read()
# OUTPUT FILE
@@ -605,7 +605,7 @@ class IssueResolver:
# Check if this issue was already processed
if os.path.exists(output_file):
with open(output_file, 'r') as f: # noqa: ASYNC101
with open(output_file, 'r') as f: # noqa
for line in f:
data = ResolverOutput.model_validate_json(line)
if data.issue.number == self.issue_number:
@@ -614,7 +614,7 @@ class IssueResolver:
)
return
output_fp = open(output_file, 'a') # noqa: ASYNC101
output_fp = open(output_file, 'a') # noqa
logger.info(
f'Resolving issue {self.issue_number} with Agent {AGENT_CLASS}, model {model_name}, max iterations {self.max_iterations}.'
@@ -633,20 +633,20 @@ class IssueResolver:
# Fetch the branch first to ensure it exists locally
fetch_cmd = ['git', 'fetch', 'origin', branch_to_use]
subprocess.check_output( # noqa: ASYNC101
subprocess.check_output( # noqa
fetch_cmd,
cwd=repo_dir,
)
# Checkout the branch
checkout_cmd = ['git', 'checkout', branch_to_use]
subprocess.check_output( # noqa: ASYNC101
subprocess.check_output( # noqa
checkout_cmd,
cwd=repo_dir,
)
base_commit = (
subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir) # noqa: ASYNC101
subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir) # noqa
.decode('utf-8')
.strip()
)
@@ -69,7 +69,7 @@ class JupyterPlugin(Plugin):
# Using synchronous subprocess.Popen for Windows as asyncio.create_subprocess_shell
# has limitations on Windows platforms
self.gateway_process = subprocess.Popen( # type: ignore[ASYNC101] # noqa: ASYNC101
self.gateway_process = subprocess.Popen( # type: ignore[ASYNC101] # noqa
jupyter_launch_command,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
@@ -82,19 +82,19 @@ class JupyterPlugin(Plugin):
output = ''
while should_continue():
if self.gateway_process.stdout is None:
time.sleep(1) # type: ignore[ASYNC101] # noqa: ASYNC101
time.sleep(1) # type: ignore[ASYNC101] # noqa
continue
line = self.gateway_process.stdout.readline()
if not line:
time.sleep(1) # type: ignore[ASYNC101] # noqa: ASYNC101
time.sleep(1) # type: ignore[ASYNC101] # noqa
continue
output += line
if 'at' in line:
break
time.sleep(1) # type: ignore[ASYNC101] # noqa: ASYNC101
time.sleep(1) # type: ignore[ASYNC101] # noqa
logger.debug('Waiting for jupyter kernel gateway to start...')
logger.debug(
+2 -2
View File
@@ -86,7 +86,7 @@ async def read_file(
)
try:
with open(whole_path, 'r', encoding='utf-8') as file: # noqa: ASYNC101
with open(whole_path, 'r', encoding='utf-8') as file: # noqa
lines = read_lines(file.readlines(), start, end)
except FileNotFoundError:
return ErrorObservation(f'File not found: {path}')
@@ -127,7 +127,7 @@ async def write_file(
os.makedirs(os.path.dirname(whole_path))
mode = 'w' if not os.path.exists(whole_path) else 'r+'
try:
with open(whole_path, mode, encoding='utf-8') as file: # noqa: ASYNC101
with open(whole_path, mode, encoding='utf-8') as file: # noqa
if mode != 'w':
all_lines = file.readlines()
new_file = insert_lines(insert, all_lines, start, end)
Generated
+6 -6
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
[[package]]
name = "aiofiles"
@@ -10467,14 +10467,14 @@ markers = {main = "extra == \"third-party-runtimes\""}
[[package]]
name = "types-markdown"
version = "3.8.0.20250809"
version = "3.8.0.20250708"
description = "Typing stubs for Markdown"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
groups = ["main"]
files = [
{file = "types_markdown-3.8.0.20250809-py3-none-any.whl", hash = "sha256:3f34a38c2259a3158e90ab0cb058cd8f4fdd3d75e2a0b335cb57f25dc2bc77d3"},
{file = "types_markdown-3.8.0.20250809.tar.gz", hash = "sha256:fa619e735878a244332a4bbe16bcfc44e49ff6264c2696056278f0642cdfa223"},
{file = "types_markdown-3.8.0.20250708-py3-none-any.whl", hash = "sha256:d1f634931b463adf7603c012724b7e9e5eff976eb517dc700ebece2d6189b1ce"},
{file = "types_markdown-3.8.0.20250708.tar.gz", hash = "sha256:28690251fe90757f5a99cd671c79502bc2de07aef2d35fe54117c3b1c799804a"},
]
[[package]]
@@ -11797,4 +11797,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "9fd177a2dfa1eebb9212e515db93c58f82d6126cc2d131de5321d68772bc2a59"
content-hash = "0a2be134709df49a9e5132fdf0ec887f2a8cb99be0ed244349be638cbb48364b"
+2 -2
View File
@@ -42,7 +42,8 @@ numpy = "*"
json-repair = "*"
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
html2text = "*"
markdown = "*" # For markdown to HTML conversion
markdown = "*" # For markdown processing in CLI
types-Markdown = "*" # Type stubs for markdown
deprecated = "*"
pexpect = "*"
jinja2 = "^3.1.3"
@@ -115,7 +116,6 @@ pre-commit = "4.2.0"
build = "*"
types-setuptools = "*"
pytest = "^8.4.0"
types-markdown = "^3.8.0.20250809"
[tool.poetry.group.test]
optional = true
+35 -42
View File
@@ -15,10 +15,10 @@ from openhands.events.action.message import MessageAction
class TestThoughtDisplayOrder:
"""Test that thoughts are displayed in the correct order relative to commands."""
@patch('openhands.cli.tui.display_thought_if_new')
@patch('openhands.cli.tui.display_message')
@patch('openhands.cli.tui.display_command')
def test_cmd_run_action_thought_before_command(
self, mock_display_command, mock_display_thought_if_new
self, mock_display_command, mock_display_message
):
"""Test that for CmdRunAction, thought is displayed before command."""
config = MagicMock(spec=OpenHandsConfig)
@@ -32,8 +32,8 @@ class TestThoughtDisplayOrder:
display_event(cmd_action, config)
# Verify that display_thought_if_new (for thought) was called before display_command
mock_display_thought_if_new.assert_called_once_with(
# Verify that display_message (for thought) was called before display_command
mock_display_message.assert_called_once_with(
'I need to install the dependencies first before running the tests.'
)
mock_display_command.assert_called_once_with(cmd_action)
@@ -41,24 +41,21 @@ class TestThoughtDisplayOrder:
# Check the call order by examining the mock call history
all_calls = []
all_calls.extend(
[
('display_thought_if_new', call)
for call in mock_display_thought_if_new.call_args_list
]
[('display_message', call) for call in mock_display_message.call_args_list]
)
all_calls.extend(
[('display_command', call) for call in mock_display_command.call_args_list]
)
# Sort by the order they were called (this is a simplified check)
# In practice, we know display_thought_if_new should be called first based on our code
assert mock_display_thought_if_new.called
# In practice, we know display_message should be called first based on our code
assert mock_display_message.called
assert mock_display_command.called
@patch('openhands.cli.tui.display_thought_if_new')
@patch('openhands.cli.tui.display_message')
@patch('openhands.cli.tui.display_command')
def test_cmd_run_action_no_thought(
self, mock_display_command, mock_display_thought_if_new
self, mock_display_command, mock_display_message
):
"""Test that CmdRunAction without thought only displays command."""
config = MagicMock(spec=OpenHandsConfig)
@@ -69,14 +66,14 @@ class TestThoughtDisplayOrder:
display_event(cmd_action, config)
# Verify that display_thought_if_new was not called (no thought)
mock_display_thought_if_new.assert_not_called()
# Verify that display_message was not called (no thought)
mock_display_message.assert_not_called()
mock_display_command.assert_called_once_with(cmd_action)
@patch('openhands.cli.tui.display_thought_if_new')
@patch('openhands.cli.tui.display_message')
@patch('openhands.cli.tui.display_command')
def test_cmd_run_action_empty_thought(
self, mock_display_command, mock_display_thought_if_new
self, mock_display_command, mock_display_message
):
"""Test that CmdRunAction with empty thought only displays command."""
config = MagicMock(spec=OpenHandsConfig)
@@ -87,15 +84,15 @@ class TestThoughtDisplayOrder:
display_event(cmd_action, config)
# Verify that display_thought_if_new was not called (empty thought)
mock_display_thought_if_new.assert_not_called()
# Verify that display_message was not called (empty thought)
mock_display_message.assert_not_called()
mock_display_command.assert_called_once_with(cmd_action)
@patch('openhands.cli.tui.display_thought_if_new')
@patch('openhands.cli.tui.display_message')
@patch('openhands.cli.tui.display_command')
@patch('openhands.cli.tui.initialize_streaming_output')
def test_cmd_run_action_confirmed_no_display(
self, mock_init_streaming, mock_display_command, mock_display_thought_if_new
self, mock_init_streaming, mock_display_command, mock_display_message
):
"""Test that confirmed CmdRunAction doesn't display command again but initializes streaming."""
config = MagicMock(spec=OpenHandsConfig)
@@ -110,7 +107,7 @@ class TestThoughtDisplayOrder:
display_event(cmd_action, config)
# Verify that thought is still displayed
mock_display_thought_if_new.assert_called_once_with(
mock_display_message.assert_called_once_with(
'I need to install the dependencies first before running the tests.'
)
# But command should not be displayed again (already shown when awaiting confirmation)
@@ -118,8 +115,8 @@ class TestThoughtDisplayOrder:
# Streaming should be initialized
mock_init_streaming.assert_called_once()
@patch('openhands.cli.tui.display_thought_if_new')
def test_other_action_thought_display(self, mock_display_thought_if_new):
@patch('openhands.cli.tui.display_message')
def test_other_action_thought_display(self, mock_display_message):
"""Test that other Action types still display thoughts normally."""
config = MagicMock(spec=OpenHandsConfig)
@@ -130,13 +127,13 @@ class TestThoughtDisplayOrder:
display_event(action, config)
# Verify that thought is displayed
mock_display_thought_if_new.assert_called_once_with(
mock_display_message.assert_called_once_with(
'This is a thought for a generic action.'
)
@patch('openhands.cli.tui.display_message')
def test_other_action_final_thought_display(self, mock_display_message):
"""Test that other Action types display final thoughts as agent messages."""
"""Test that other Action types display final thoughts."""
config = MagicMock(spec=OpenHandsConfig)
# Create a generic Action with final thought
@@ -145,13 +142,11 @@ class TestThoughtDisplayOrder:
display_event(action, config)
# Verify that final thought is displayed as an agent message
mock_display_message.assert_called_once_with(
'This is a final thought.', is_agent_message=True
)
# Verify that final thought is displayed
mock_display_message.assert_called_once_with('This is a final thought.')
@patch('openhands.cli.tui.display_thought_if_new')
def test_message_action_from_agent(self, mock_display_thought_if_new):
@patch('openhands.cli.tui.display_agent_message')
def test_message_action_from_agent(self, mock_display_agent_message):
"""Test that MessageAction from agent is displayed."""
config = MagicMock(spec=OpenHandsConfig)
@@ -161,13 +156,11 @@ class TestThoughtDisplayOrder:
display_event(message_action, config)
# Verify that agent message is displayed with agent styling
mock_display_thought_if_new.assert_called_once_with(
'Hello from agent', is_agent_message=True
)
# Verify that agent message is displayed
mock_display_agent_message.assert_called_once_with('Hello from agent')
@patch('openhands.cli.tui.display_thought_if_new')
def test_message_action_from_user_not_displayed(self, mock_display_thought_if_new):
@patch('openhands.cli.tui.display_message')
def test_message_action_from_user_not_displayed(self, mock_display_message):
"""Test that MessageAction from user is not displayed."""
config = MagicMock(spec=OpenHandsConfig)
@@ -178,12 +171,12 @@ class TestThoughtDisplayOrder:
display_event(message_action, config)
# Verify that message is not displayed (only agent messages are shown)
mock_display_thought_if_new.assert_not_called()
mock_display_message.assert_not_called()
@patch('openhands.cli.tui.display_thought_if_new')
@patch('openhands.cli.tui.display_message')
@patch('openhands.cli.tui.display_command')
def test_cmd_run_action_with_both_thoughts(
self, mock_display_command, mock_display_thought_if_new
self, mock_display_command, mock_display_message
):
"""Test CmdRunAction with both thought and final_thought."""
config = MagicMock(spec=OpenHandsConfig)
@@ -197,7 +190,7 @@ class TestThoughtDisplayOrder:
# For CmdRunAction, only the regular thought should be displayed
# (final_thought is handled by the general Action case, but CmdRunAction is handled first)
mock_display_thought_if_new.assert_called_once_with('Initial thought')
mock_display_message.assert_called_once_with('Initial thought')
mock_display_command.assert_called_once_with(cmd_action)
@@ -211,7 +204,7 @@ class TestThoughtDisplayIntegration:
# Track the order of calls
call_order = []
def track_display_message(message, is_agent_message=False):
def track_display_message(message):
call_order.append(f'THOUGHT: {message}')
def track_display_command(event):
+56 -4
View File
@@ -6,6 +6,8 @@ from openhands.cli.tui import (
CustomDiffLexer,
UsageMetrics,
UserCancelledError,
display_agent_finish,
display_agent_message,
display_banner,
display_command,
display_event,
@@ -26,6 +28,7 @@ from openhands.events import EventSource
from openhands.events.action import (
Action,
ActionConfirmationStatus,
AgentFinishAction,
CmdRunAction,
MCPAction,
MessageAction,
@@ -107,14 +110,16 @@ class TestDisplayFunctions:
assert 'What do you want to build?' in message_text
assert 'Type /help for help' in message_text
def test_display_event_message_action(self):
@patch('openhands.cli.tui.display_agent_message')
def test_display_event_message_action(self, mock_display_agent_message):
config = MagicMock(spec=OpenHandsConfig)
message = MessageAction(content='Test message')
message._source = EventSource.AGENT
# Directly test the function without mocking
display_event(message, config)
mock_display_agent_message.assert_called_once_with('Test message')
@patch('openhands.cli.tui.display_command')
def test_display_event_cmd_action(self, mock_display_command):
config = MagicMock(spec=OpenHandsConfig)
@@ -170,14 +175,25 @@ class TestDisplayFunctions:
mock_display_file_read.assert_called_once_with(file_read)
def test_display_event_thought(self):
@patch('openhands.cli.tui.display_message')
def test_display_event_thought(self, mock_display_message):
config = MagicMock(spec=OpenHandsConfig)
action = Action()
action.thought = 'Thinking about this...'
# Directly test the function without mocking
display_event(action, config)
mock_display_message.assert_called_once_with('Thinking about this...')
@patch('openhands.cli.tui.display_agent_finish')
def test_display_event_agent_finish(self, mock_display_agent_finish):
config = MagicMock(spec=OpenHandsConfig)
finish_action = AgentFinishAction(final_thought='Task completed')
display_event(finish_action, config)
mock_display_agent_finish.assert_called_once_with(finish_action)
@patch('openhands.cli.tui.display_mcp_action')
def test_display_event_mcp_action(self, mock_display_mcp_action):
config = MagicMock(spec=OpenHandsConfig)
@@ -252,6 +268,42 @@ class TestDisplayFunctions:
args, kwargs = mock_print.call_args
assert message in str(args[0])
@patch('openhands.cli.tui.print_container')
@patch('openhands.cli.tui.print_formatted_text')
def test_display_agent_message(self, mock_print_formatted, mock_print_container):
message = 'Agent message'
display_agent_message(message)
mock_print_formatted.assert_called_once()
mock_print_container.assert_called_once()
@patch('openhands.cli.tui.print_container')
@patch('openhands.cli.tui.print_formatted_text')
def test_display_agent_finish_with_thought(
self, mock_print_formatted, mock_print_container
):
finish_action = AgentFinishAction(thought='Final thought')
display_agent_finish(finish_action)
mock_print_formatted.assert_called_once()
mock_print_container.assert_called_once()
@patch('openhands.cli.tui.print_container')
@patch('openhands.cli.tui.print_formatted_text')
def test_display_agent_finish_with_task_completed(
self, mock_print_formatted, mock_print_container
):
from openhands.events.action.agent import AgentFinishTaskCompleted
finish_action = AgentFinishAction()
finish_action.task_completed = AgentFinishTaskCompleted.TRUE
display_agent_finish(finish_action)
mock_print_formatted.assert_called_once()
mock_print_container.assert_called_once()
@patch('openhands.cli.tui.print_container')
def test_display_command_awaiting_confirmation(self, mock_print_container):
cmd_action = CmdRunAction(command='echo test')