Compare commits

..

10 Commits

Author SHA1 Message Date
Xingyao Wang
d89595a9cf Merge commit '116ba199d1c0d35b87af59254d1249c4fdd1fde5' into improve-cli-colors 2025-08-10 11:38:58 -04:00
Xingyao Wang
116ba199d1 feat(agent): stop using short tool description for gpt-5 (#10184) 2025-08-09 17:56:52 -04:00
Boxuan Li
803bdced9c Fix Windows prompt refinement: ensure 'bash' is replaced with 'powershell' in all prompts (#10179)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-08 20:28:36 -07: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
18 changed files with 485 additions and 49 deletions

View File

@@ -40,7 +40,7 @@ repos:
hooks:
- id: mypy
additional_dependencies:
[types-requests, types-setuptools, types-pyyaml, types-toml, types-docker, 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

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",

View File

@@ -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,

View File

@@ -106,10 +106,15 @@ class CodeActAgent(Agent):
def _get_tools(self) -> list['ChatCompletionToolParam']:
# For these models, we use short tool descriptions ( < 1024 tokens)
# to avoid hitting the OpenAI token limit for tool descriptions.
SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-', 'o3', 'o1', 'o4']
SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-4', 'o3', 'o1', 'o4']
use_short_tool_desc = False
if self.llm is not None:
# For historical reasons, previously OpenAI enforces max function description length of 1k characters
# https://community.openai.com/t/function-call-description-max-length/529902
# But it no longer seems to be an issue recently
# https://community.openai.com/t/was-the-character-limit-for-schema-descriptions-upgraded/1225975
# Tested on GPT-5 and longer description still works. But we still keep the logic to be safe for older models.
use_short_tool_desc = any(
model_substr in self.llm.config.model
for model_substr in SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS

View File

@@ -1,3 +1,4 @@
import re
import sys
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
@@ -37,7 +38,16 @@ _SHORT_BASH_DESCRIPTION = """Execute a bash command in the terminal.
def refine_prompt(prompt: str):
if sys.platform == 'win32':
return prompt.replace('bash', 'powershell')
# Replace 'bash' with 'powershell' including tool names like 'execute_bash'
# First replace 'execute_bash' with 'execute_powershell' to handle tool names
result = re.sub(
r'\bexecute_bash\b', 'execute_powershell', prompt, flags=re.IGNORECASE
)
# Then replace standalone 'bash' with 'powershell'
result = re.sub(
r'(?<!execute_)(?<!_)\bbash\b', 'powershell', result, flags=re.IGNORECASE
)
return result
return prompt

View File

@@ -739,19 +739,3 @@ def run_cli_command(args):
except Exception as e:
print_formatted_text(f'Error during cleanup: {e}')
sys.exit(1)
def main():
"""Main entry point for OpenHands CLI."""
from openhands.core.config import get_cli_parser
parser = get_cli_parser()
args = parser.parse_args()
if hasattr(args, 'version') and args.version:
import openhands
print(f'OpenHands CLI version: {openhands.get_version()}')
sys.exit(0)
run_cli_command(args)

View File

@@ -6,6 +6,7 @@ import asyncio
import contextlib
import datetime
import json
import re
import sys
import threading
import time
@@ -36,6 +37,7 @@ from openhands.events import EventSource, EventStream
from openhands.events.action import (
Action,
ActionConfirmationStatus,
AgentFinishAction,
ChangeAgentStateAction,
CmdRunAction,
MCPAction,
@@ -65,10 +67,16 @@ MAX_RECENT_THOUGHTS = 5
# Color and styling constants
COLOR_GOLD = '#FFD700'
COLOR_GREY = '#808080'
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',
}
)
@@ -252,7 +260,10 @@ def display_thought_if_new(thought: str) -> 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_message(event.thought)
@@ -275,8 +286,8 @@ def display_event(event: Event, config: OpenHandsConfig) -> None:
if isinstance(event, MessageAction):
if event.source == EventSource.AGENT:
# Check if this message content is a duplicate thought
display_thought_if_new(event.content)
# Display agent messages with distinctive styling
display_agent_message(event.content)
elif isinstance(event, CmdOutputObservation):
display_command_output(event.content)
elif isinstance(event, FileEditObservation):
@@ -291,6 +302,29 @@ def display_event(event: Event, config: OpenHandsConfig) -> None:
display_error(event.content)
def process_markdown_for_terminal(text: str) -> str:
"""
Process markdown syntax for terminal display.
This function handles common markdown patterns like bold, italic, code blocks, etc.
"""
if not text:
return text
# Process bold text (**text**)
text = re.sub(r'\*\*(.*?)\*\*', r'\1', text)
# Process italic text (*text*)
text = re.sub(r'\*(.*?)\*', r'\1', text)
# Process inline code (`code`)
text = re.sub(r'`(.*?)`', r'\1', text)
# Process code blocks
text = re.sub(r'```(?:\w+)?\n(.*?)\n```', r'\1', text, flags=re.DOTALL)
return text
def display_message(message: str) -> None:
message = message.strip()
@@ -298,6 +332,76 @@ def display_message(message: str) -> None:
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:
error = error.strip()

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

View File

@@ -383,7 +383,7 @@ Do NOT assume the environment is the same as in the example above.
"""
example = example.lstrip()
return example
return refine_prompt(example)
IN_CONTEXT_LEARNING_EXAMPLE_PREFIX = get_example_for_tools

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()
)

View File

@@ -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(

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)

View File

@@ -4,6 +4,7 @@ from itertools import islice
from jinja2 import Template
from openhands.agenthub.codeact_agent.tools.bash import refine_prompt
from openhands.controller.state.state import State
from openhands.core.message import Message, TextContent
from openhands.events.observation.agent import MicroagentKnowledge
@@ -91,7 +92,8 @@ class PromptManager:
return Template(file.read())
def get_system_message(self) -> str:
return self.system_template.render().strip()
system_message = self.system_template.render().strip()
return refine_prompt(system_message)
def get_example_user_message(self) -> str:
"""This is an initial user message that can be provided to the agent

48
poetry.lock generated
View File

@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.3 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"
@@ -3770,6 +3770,22 @@ http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "httpx-aiohttp"
version = "0.1.8"
description = "Aiohttp transport for HTTPX"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "httpx_aiohttp-0.1.8-py3-none-any.whl", hash = "sha256:b7bd958d1331f3759a38a0ba22ad29832cb63ca69498c17735228055bf78fa7e"},
{file = "httpx_aiohttp-0.1.8.tar.gz", hash = "sha256:756c5e74cdb568c3248ba63fe82bfe8bbe64b928728720f7eaac64b3cf46f308"},
]
[package.dependencies]
aiohttp = ">=3.10.0,<4"
httpx = ">=0.27.0"
[[package]]
name = "httpx-sse"
version = "0.4.0"
@@ -5214,6 +5230,22 @@ files = [
[package.dependencies]
cobble = ">=0.1.3,<0.2"
[[package]]
name = "markdown"
version = "3.8.2"
description = "Python implementation of John Gruber's Markdown."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24"},
{file = "markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45"},
]
[package.extras]
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
testing = ["coverage", "pyyaml"]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
@@ -10433,6 +10465,18 @@ files = [
]
markers = {main = "extra == \"third-party-runtimes\""}
[[package]]
name = "types-markdown"
version = "3.8.0.20250708"
description = "Typing stubs for Markdown"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{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]]
name = "types-python-dateutil"
version = "2.9.0.20250516"
@@ -11753,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 = "4640c66849d6436eed73826154e2d8cf88b456a4d1b71efb9438531245845826"
content-hash = "0a2be134709df49a9e5132fdf0ec887f2a8cb99be0ed244349be638cbb48364b"

View File

@@ -20,6 +20,7 @@ packages = [
]
include = [
"openhands/integrations/vscode/openhands-vscode-0.0.1.vsix",
"microagents/**/*",
]
build = "build_vscode.py" # Build VSCode extension during Poetry build
@@ -41,6 +42,9 @@ numpy = "*"
json-repair = "*"
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
html2text = "*"
markdown = "*" # For markdown processing in CLI
types-Markdown = "*" # Type stubs for markdown
deprecated = "*"
pexpect = "*"
jinja2 = "^3.1.3"
python-multipart = "*"
@@ -97,6 +101,7 @@ e2b = { version = ">=1.0.5,<1.8.0", optional = true }
modal = { version = ">=0.66.26,<1.2.0", optional = true }
runloop-api-client = { version = "0.50.0", optional = true }
daytona = { version = "0.24.2", optional = true }
httpx-aiohttp = "^0.1.8"
[tool.poetry.extras]
third_party_runtimes = [ "e2b", "modal", "runloop-api-client", "daytona" ]
@@ -163,7 +168,7 @@ joblib = "*"
swebench = { git = "https://github.com/ryanhoangt/SWE-bench.git", rev = "fix-modal-patch-eval" }
[tool.poetry.scripts]
openhands = "openhands.cli.main:main"
openhands = "openhands.cli.entry:main"
[tool.poetry.group.testgeneval.dependencies]
fuzzywuzzy = "^0.18.0"

View File

@@ -145,8 +145,8 @@ class TestThoughtDisplayOrder:
# Verify that final thought is displayed
mock_display_message.assert_called_once_with('This is a final thought.')
@patch('openhands.cli.tui.display_message')
def test_message_action_from_agent(self, mock_display_message):
@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)
@@ -156,8 +156,8 @@ class TestThoughtDisplayOrder:
display_event(message_action, config)
# Verify that message is displayed
mock_display_message.assert_called_once_with('Hello from agent')
# Verify that agent message is displayed
mock_display_agent_message.assert_called_once_with('Hello from agent')
@patch('openhands.cli.tui.display_message')
def test_message_action_from_user_not_displayed(self, mock_display_message):

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,15 +110,15 @@ class TestDisplayFunctions:
assert 'What do you want to build?' in message_text
assert 'Type /help for help' in message_text
@patch('openhands.cli.tui.display_message')
def test_display_event_message_action(self, mock_display_message):
@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
display_event(message, config)
mock_display_message.assert_called_once_with('Test message')
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):
@@ -182,6 +185,15 @@ class TestDisplayFunctions:
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)
@@ -256,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')

View File

@@ -0,0 +1,179 @@
import sys
from unittest.mock import patch
import pytest
from openhands.agenthub.codeact_agent.codeact_agent import CodeActAgent
from openhands.core.config import AgentConfig
from openhands.llm.llm import LLM
# Skip all tests in this module if not running on Windows
pytestmark = pytest.mark.skipif(
sys.platform != 'win32', reason='Windows prompt refinement tests require Windows'
)
@pytest.fixture
def mock_llm():
"""Create a mock LLM for testing."""
llm = LLM(config={'model': 'gpt-4', 'api_key': 'test'})
return llm
@pytest.fixture
def agent_config():
"""Create a basic agent config for testing."""
return AgentConfig()
def test_codeact_agent_system_prompt_no_bash_on_windows(mock_llm, agent_config):
"""Test that CodeActAgent's system prompt doesn't contain 'bash' on Windows."""
# Create a CodeActAgent instance
agent = CodeActAgent(llm=mock_llm, config=agent_config)
# Get the system prompt
system_prompt = agent.prompt_manager.get_system_message()
# Assert that 'bash' doesn't exist in the system prompt (case-insensitive)
assert 'bash' not in system_prompt.lower(), (
f"System prompt contains 'bash' on Windows platform. "
f"It should be replaced with 'powershell'. "
f'System prompt: {system_prompt}'
)
# Verify that 'powershell' exists instead (case-insensitive)
assert 'powershell' in system_prompt.lower(), (
f"System prompt should contain 'powershell' on Windows platform. "
f'System prompt: {system_prompt}'
)
def test_codeact_agent_tool_descriptions_no_bash_on_windows(mock_llm, agent_config):
"""Test that CodeActAgent's tool descriptions don't contain 'bash' on Windows."""
# Create a CodeActAgent instance
agent = CodeActAgent(llm=mock_llm, config=agent_config)
# Get the tools
tools = agent.tools
# Check each tool's description and parameters
for tool in tools:
if tool['type'] == 'function':
function_info = tool['function']
# Check function description
description = function_info.get('description', '')
assert 'bash' not in description.lower(), (
f"Tool '{function_info['name']}' description contains 'bash' on Windows. "
f'Description: {description}'
)
# Check parameter descriptions
parameters = function_info.get('parameters', {})
properties = parameters.get('properties', {})
for param_name, param_info in properties.items():
param_description = param_info.get('description', '')
assert 'bash' not in param_description.lower(), (
f"Tool '{function_info['name']}' parameter '{param_name}' "
f"description contains 'bash' on Windows. "
f'Parameter description: {param_description}'
)
def test_in_context_learning_example_no_bash_on_windows():
"""Test that in-context learning examples don't contain 'bash' on Windows."""
from openhands.agenthub.codeact_agent.tools.bash import create_cmd_run_tool
from openhands.agenthub.codeact_agent.tools.finish import FinishTool
from openhands.agenthub.codeact_agent.tools.str_replace_editor import (
create_str_replace_editor_tool,
)
from openhands.llm.fn_call_converter import get_example_for_tools
# Create a sample set of tools
tools = [
create_cmd_run_tool(),
create_str_replace_editor_tool(),
FinishTool,
]
# Get the in-context learning example
example = get_example_for_tools(tools)
# Assert that 'bash' doesn't exist in the example (case-insensitive)
assert 'bash' not in example.lower(), (
f"In-context learning example contains 'bash' on Windows platform. "
f"It should be replaced with 'powershell'. "
f'Example: {example}'
)
# Verify that 'powershell' exists instead (case-insensitive)
if example: # Only check if example is not empty
assert 'powershell' in example.lower(), (
f"In-context learning example should contain 'powershell' on Windows platform. "
f'Example: {example}'
)
def test_refine_prompt_function_works():
"""Test that the refine_prompt function correctly replaces 'bash' with 'powershell'."""
from openhands.agenthub.codeact_agent.tools.bash import refine_prompt
# Test basic replacement
test_prompt = 'Execute a bash command to list files'
refined_prompt = refine_prompt(test_prompt)
assert 'bash' not in refined_prompt.lower()
assert 'powershell' in refined_prompt.lower()
assert refined_prompt == 'Execute a powershell command to list files'
# Test multiple occurrences
test_prompt = 'Use bash to run bash commands in the bash shell'
refined_prompt = refine_prompt(test_prompt)
assert 'bash' not in refined_prompt.lower()
assert (
refined_prompt
== 'Use powershell to run powershell commands in the powershell shell'
)
# Test case sensitivity
test_prompt = 'BASH and Bash and bash should all be replaced'
refined_prompt = refine_prompt(test_prompt)
assert 'bash' not in refined_prompt.lower()
assert (
refined_prompt
== 'powershell and powershell and powershell should all be replaced'
)
# Test execute_bash tool name replacement
test_prompt = 'Use the execute_bash tool to run commands'
refined_prompt = refine_prompt(test_prompt)
assert 'execute_bash' not in refined_prompt.lower()
assert 'execute_powershell' in refined_prompt.lower()
assert refined_prompt == 'Use the execute_powershell tool to run commands'
# Test that words containing 'bash' but not equal to 'bash' are preserved
test_prompt = 'The bashful person likes bash-like syntax'
refined_prompt = refine_prompt(test_prompt)
# 'bashful' should be preserved, 'bash-like' should become 'powershell-like'
assert 'bashful' in refined_prompt
assert 'powershell-like' in refined_prompt
assert refined_prompt == 'The bashful person likes powershell-like syntax'
def test_refine_prompt_function_on_non_windows():
"""Test that the refine_prompt function doesn't change anything on non-Windows platforms."""
from openhands.agenthub.codeact_agent.tools.bash import refine_prompt
# Mock sys.platform to simulate non-Windows
with patch('openhands.agenthub.codeact_agent.tools.bash.sys.platform', 'linux'):
test_prompt = 'Execute a bash command to list files'
refined_prompt = refine_prompt(test_prompt)
# On non-Windows, the prompt should remain unchanged
assert refined_prompt == test_prompt
assert 'bash' in refined_prompt.lower()