mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d89595a9cf | |||
| 53872a4d55 | |||
| f56314bda6 | |||
| 166d7a4d1a | |||
| db478cbc7e | |||
| a86a0e7792 | |||
| 9dfc85f4e3 | |||
| e9c844087c |
@@ -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
|
||||
|
||||
Generated
+54
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user