From 6789d81d592a660bf1a8748672dac81f0c4bc638 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 4 Nov 2025 14:13:38 +0530 Subject: [PATCH 01/10] Add Windows terminal backend support Introduces WindowsTerminal for PowerShell-based command execution on Windows systems. Updates factory and init logic to conditionally import and use platform-specific terminal backends, ensuring compatibility across Windows and Unix-like platforms. --- .../tools/execute_bash/terminal/__init__.py | 37 +- .../tools/execute_bash/terminal/factory.py | 8 +- .../terminal/subprocess_terminal.py | 12 +- .../execute_bash/terminal/terminal_session.py | 1 + .../execute_bash/terminal/windows_terminal.py | 409 ++++++++++++++++++ .../execute_bash/test_windows_terminal.py | 360 +++++++++++++++ 6 files changed, 812 insertions(+), 15 deletions(-) create mode 100644 openhands-tools/openhands/tools/execute_bash/terminal/windows_terminal.py create mode 100644 tests/tools/execute_bash/test_windows_terminal.py diff --git a/openhands-tools/openhands/tools/execute_bash/terminal/__init__.py b/openhands-tools/openhands/tools/execute_bash/terminal/__init__.py index a18020f2cc..51a1773d07 100644 --- a/openhands-tools/openhands/tools/execute_bash/terminal/__init__.py +++ b/openhands-tools/openhands/tools/execute_bash/terminal/__init__.py @@ -1,22 +1,35 @@ +import platform + from openhands.tools.execute_bash.terminal.factory import create_terminal_session from openhands.tools.execute_bash.terminal.interface import ( TerminalInterface, TerminalSessionBase, ) -from openhands.tools.execute_bash.terminal.subprocess_terminal import SubprocessTerminal from openhands.tools.execute_bash.terminal.terminal_session import ( TerminalCommandStatus, TerminalSession, ) -from openhands.tools.execute_bash.terminal.tmux_terminal import TmuxTerminal - -__all__ = [ - "TerminalInterface", - "TerminalSessionBase", - "TmuxTerminal", - "SubprocessTerminal", - "TerminalSession", - "TerminalCommandStatus", - "create_terminal_session", -] +# Conditionally import platform-specific terminals +if platform.system() == "Windows": + from openhands.tools.execute_bash.terminal.windows_terminal import WindowsTerminal + __all__ = [ + "TerminalInterface", + "TerminalSessionBase", + "WindowsTerminal", + "TerminalSession", + "TerminalCommandStatus", + "create_terminal_session", + ] +else: + from openhands.tools.execute_bash.terminal.subprocess_terminal import SubprocessTerminal + from openhands.tools.execute_bash.terminal.tmux_terminal import TmuxTerminal + __all__ = [ + "TerminalInterface", + "TerminalSessionBase", + "TmuxTerminal", + "SubprocessTerminal", + "TerminalSession", + "TerminalCommandStatus", + "create_terminal_session", + ] diff --git a/openhands-tools/openhands/tools/execute_bash/terminal/factory.py b/openhands-tools/openhands/tools/execute_bash/terminal/factory.py index eda41bcda6..16bfddb073 100644 --- a/openhands-tools/openhands/tools/execute_bash/terminal/factory.py +++ b/openhands-tools/openhands/tools/execute_bash/terminal/factory.py @@ -94,7 +94,13 @@ def create_terminal_session( system = platform.system() if system == "Windows": - raise NotImplementedError("Windows is not supported yet for OpenHands V1.") + from openhands.tools.execute_bash.terminal.windows_terminal import ( + WindowsTerminal, + ) + + logger.info("Auto-detected: Using WindowsTerminal (Windows system)") + terminal = WindowsTerminal(work_dir, username) + return TerminalSession(terminal, no_change_timeout_seconds) else: # On Unix-like systems, prefer tmux if available, otherwise use subprocess if _is_tmux_available(): diff --git a/openhands-tools/openhands/tools/execute_bash/terminal/subprocess_terminal.py b/openhands-tools/openhands/tools/execute_bash/terminal/subprocess_terminal.py index 620f41ec6d..9019e890b3 100644 --- a/openhands-tools/openhands/tools/execute_bash/terminal/subprocess_terminal.py +++ b/openhands-tools/openhands/tools/execute_bash/terminal/subprocess_terminal.py @@ -1,8 +1,7 @@ """PTY-based terminal backend implementation (replaces pipe-based subprocess).""" -import fcntl import os -import pty +import platform import re import select import signal @@ -12,6 +11,15 @@ import uuid from collections import deque +# Unix-specific imports +if platform.system() != "Windows": + import fcntl + import pty +else: + # Provide dummy values for Windows (this module shouldn't be used on Windows) + fcntl = None + pty = None + from openhands.sdk.logger import get_logger from openhands.tools.execute_bash.constants import ( CMD_OUTPUT_PS1_BEGIN, diff --git a/openhands-tools/openhands/tools/execute_bash/terminal/terminal_session.py b/openhands-tools/openhands/tools/execute_bash/terminal/terminal_session.py index 0d46638650..52b024d35a 100644 --- a/openhands-tools/openhands/tools/execute_bash/terminal/terminal_session.py +++ b/openhands-tools/openhands/tools/execute_bash/terminal/terminal_session.py @@ -189,6 +189,7 @@ def _handle_completed_command( return ExecuteBashObservation( output=command_output, command=command, + exit_code=metadata.exit_code if metadata.exit_code != -1 else None, metadata=metadata, ) diff --git a/openhands-tools/openhands/tools/execute_bash/terminal/windows_terminal.py b/openhands-tools/openhands/tools/execute_bash/terminal/windows_terminal.py new file mode 100644 index 0000000000..bd48e2e414 --- /dev/null +++ b/openhands-tools/openhands/tools/execute_bash/terminal/windows_terminal.py @@ -0,0 +1,409 @@ +"""Windows-compatible terminal backend implementation.""" + +import codecs +import json +import os +import re +import subprocess +import threading +import time +from collections import deque + +from openhands.sdk.logger import get_logger +from openhands.tools.execute_bash.constants import ( + CMD_OUTPUT_PS1_BEGIN, + CMD_OUTPUT_PS1_END, + HISTORY_LIMIT, +) +from openhands.tools.execute_bash.metadata import CmdOutputMetadata +from openhands.tools.execute_bash.terminal import TerminalInterface + + +logger = get_logger(__name__) + +# Constants +CTRL_C = "\x03" +SCREEN_CLEAR_DELAY = 0.2 +SETUP_DELAY = 0.5 +SETUP_POLL_INTERVAL = 0.05 +MAX_SETUP_WAIT = 2.0 +READ_CHUNK_SIZE = 1024 +POWERSHELL_CMD = ["powershell.exe", "-NoLogo", "-NoProfile", "-Command", "-"] +READER_THREAD_TIMEOUT = 1.0 +SPECIAL_KEYS = {CTRL_C, "C-c", "C-C"} + + +class WindowsTerminal(TerminalInterface): + """Windows-compatible terminal backend. + + Uses subprocess with PIPE communication for Windows systems. + """ + + process: subprocess.Popen[bytes] | None + output_buffer: deque[str] + output_lock: threading.Lock + reader_thread: threading.Thread | None + _command_running_event: threading.Event + _stop_reader: bool + _decoder: codecs.IncrementalDecoder + + def __init__(self, work_dir: str, username: str | None = None): + """Initialize Windows terminal. + + Args: + work_dir: Working directory for commands + username: Optional username (unused on Windows) + """ + super().__init__(work_dir, username) + self.process = None + self.output_buffer = deque(maxlen=HISTORY_LIMIT) + self.output_lock = threading.Lock() + self.reader_thread = None + self._command_running_event = threading.Event() + self._stop_reader = False + self._decoder = codecs.getincrementaldecoder('utf-8')(errors='replace') + + def initialize(self) -> None: + """Initialize the Windows terminal session.""" + if self._initialized: + return + + self._start_session() + self._initialized = True + + def _start_session(self) -> None: + """Start PowerShell session.""" + # Use PowerShell for better Windows compatibility + startupinfo = subprocess.STARTUPINFO() + # Hide the console window (prevents popup on Windows) + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + + self.process = subprocess.Popen( + POWERSHELL_CMD, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=self.work_dir, + text=False, + bufsize=0, + startupinfo=startupinfo, + ) + + # Start reader thread + self._stop_reader = False + self.reader_thread = threading.Thread(target=self._read_output, daemon=True) + self.reader_thread.start() + + # Set up PowerShell prompt + self._setup_prompt() + + def _setup_prompt(self) -> None: + """Configure PowerShell prompt.""" + # For PowerShell, we'll append the PS1 marker to each command instead of + # using a custom prompt function, since prompt output isn't reliably captured + # Wait for PowerShell initialization (copyright, welcome messages) to complete + start_time = time.time() + while time.time() - start_time < MAX_SETUP_WAIT: + time.sleep(SETUP_POLL_INTERVAL) + # Check if we have any output yet (indicates PowerShell is ready) + with self.output_lock: + if len(self.output_buffer) > 0: + break + + # Additional small delay for stability + time.sleep(SETUP_DELAY) + + with self.output_lock: + self.output_buffer.clear() + + def _write_to_stdin(self, data: str) -> None: + """Write data to stdin.""" + if self.process and self.process.stdin: + try: + self.process.stdin.write(data.encode('utf-8')) + self.process.stdin.flush() + except (BrokenPipeError, OSError) as e: + logger.error(f"Failed to write to stdin: {e}") + + def _read_output(self) -> None: + """Read output from process in background thread.""" + if not self.process or not self.process.stdout: + return + + # Cache stdout reference to prevent race condition during close() + stdout = self.process.stdout + + while not self._stop_reader: + try: + # Read in chunks + chunk = stdout.read(READ_CHUNK_SIZE) + if not chunk: + break + + # Use incremental decoder to handle UTF-8 boundary splits correctly + decoded = self._decoder.decode(chunk, False) + if decoded: # Only append non-empty strings + with self.output_lock: + self.output_buffer.append(decoded) + + except (ValueError, OSError) as e: + # Expected when stdout is closed + logger.debug(f"Output reading stopped: {e}") + break + except Exception as e: + logger.error(f"Error reading output: {e}") + break + + # Flush any remaining bytes when stopping + try: + final = self._decoder.decode(b'', True) + if final: + with self.output_lock: + self.output_buffer.append(final) + except Exception as e: + logger.error(f"Error flushing decoder: {e}") + + def _get_buffered_output(self, clear: bool = True) -> str: + """Get all buffered output. + + Args: + clear: Whether to clear the buffer after reading + """ + with self.output_lock: + # Create list copy to avoid race conditions during join + buffer_copy = list(self.output_buffer) + if clear: + self.output_buffer.clear() + return ''.join(buffer_copy) + + def _is_special_key(self, text: str) -> bool: + """Check if text is a special key sequence. + + Args: + text: Text to check + + Returns: + True if special key + """ + return text in SPECIAL_KEYS + + def _escape_powershell_string(self, s: str) -> str: + """Escape a string for safe use in PowerShell single quotes. + + In PowerShell single-quoted strings, only the single quote character + needs escaping (by doubling it). + + Args: + s: String to escape + + Returns: + Escaped string with single quotes doubled + """ + # In PowerShell single quotes, only single quote needs escaping + return s.replace("'", "''") + + def _parse_metadata(self, output: str) -> CmdOutputMetadata | None: + """Extract metadata from command output. + + Args: + output: Command output containing metadata markers + + Returns: + Parsed metadata or None if not found/invalid + """ + pattern = f"{re.escape(CMD_OUTPUT_PS1_BEGIN)}(.+?){re.escape(CMD_OUTPUT_PS1_END)}" + match = re.search(pattern, output, re.DOTALL) + if match: + try: + meta_json = json.loads(match.group(1).strip()) + return CmdOutputMetadata(**meta_json) + except (json.JSONDecodeError, TypeError, ValueError) as e: + logger.error(f"Failed to parse metadata: {e}") + return None + + def send_keys(self, text: str, enter: bool = True, _internal: bool = False) -> None: + """Send text to the terminal. + + Args: + text: Text to send + enter: Whether to add newline + _internal: Internal flag for system commands (don't track as user command) + + Raises: + RuntimeError: If terminal process is not running + """ + # Validate process state + if not self.process or self.process.poll() is not None: + error_msg = "Cannot send keys: terminal process is not running" + logger.error(error_msg) + raise RuntimeError(error_msg) + + # Check if this is a special key (like C-c or Ctrl+C) + is_special_key = self._is_special_key(text) + + # Clear old output buffer when sending a new command (not for special keys) + if not is_special_key and not _internal: + self._get_buffered_output(clear=True) + + # For regular commands (not special keys or internal), append PS1 marker with metadata + if not is_special_key and text.strip() and not _internal: + # Set command running flag + self._command_running_event.set() + + # Build PowerShell metadata output command with proper escaping + ps1_begin = self._escape_powershell_string(CMD_OUTPUT_PS1_BEGIN.strip()) + ps1_end = self._escape_powershell_string(CMD_OUTPUT_PS1_END.strip()) + metadata_cmd = ( + f"; Write-Host '{ps1_begin}'; " + # Use $? to check success (True/False), convert to 0/1 + "$exit_code = if ($?) { if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } else { 0 } } else { 1 }; " + "$py_path = (Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source); " + "$meta = @{pid=$PID; exit_code=$exit_code; username=$env:USERNAME; " + "hostname=$env:COMPUTERNAME; working_dir=(Get-Location).Path.Replace('\\', '/'); " + "py_interpreter_path=if ($py_path) { $py_path } else { $null }}; " + "Write-Host (ConvertTo-Json $meta -Compress); " + f"Write-Host '{ps1_end}'" + ) + text = text.rstrip() + metadata_cmd + + if enter and not text.endswith('\n'): + text = text + '\n' + self._write_to_stdin(text) + + def read_screen(self) -> str: + """Read current terminal output without clearing buffer. + + This allows TerminalSession to poll the output multiple times + until it detects the PS1 prompt marker. + + Returns: + Current buffered output + """ + return self._get_buffered_output(clear=False) + + def clear_screen(self) -> None: + """Clear the terminal screen.""" + self.send_keys("Clear-Host", enter=True, _internal=True) + time.sleep(SCREEN_CLEAR_DELAY) + self._get_buffered_output() # Clear buffer + # Reset command running flag since screen is cleared after command completion + self._command_running_event.clear() + + def interrupt(self) -> bool: + """Send interrupt signal to the terminal. + + Returns: + True if successful + """ + if self.process and self.process.poll() is None: + try: + # Send Ctrl+C to PowerShell + self.send_keys(CTRL_C, enter=False) + self._command_running_event.clear() + return True + except Exception as e: + logger.error(f"Failed to send interrupt: {e}") + return False + return False + + def is_running(self) -> bool: + """Check if a command is currently running. + + Returns: + True if command is running + """ + if not self._initialized or not self.process: + return False + + # Check if process is still alive + if self.process.poll() is not None: + self._command_running_event.clear() + return False + + try: + content = self.read_screen() + # Check for completion marker (PS1_END) + if CMD_OUTPUT_PS1_END.rstrip() in content: + self._command_running_event.clear() + return False + # Return current state - empty buffer doesn't mean command isn't running + # (command might be executing without output yet) + return self._command_running_event.is_set() + except (OSError, IOError) as e: + logger.warning(f"Error reading screen in is_running: {e}") + return self._command_running_event.is_set() + except Exception as e: + logger.error(f"Unexpected error in is_running: {e}") + return self._command_running_event.is_set() + + def is_powershell(self) -> bool: + """Check if this is a PowerShell terminal. + + Returns: + True (this is always PowerShell on Windows) + """ + return True + + def close(self) -> None: + """Close the terminal session.""" + if self._closed: + return + + self._stop_reader = True + + # Close pipes to unblock reader thread + if self.process: + try: + if self.process.stdin: + self.process.stdin.close() + except (OSError, ValueError) as e: + logger.debug(f"Error closing stdin: {e}") + except Exception as e: + logger.error(f"Unexpected error closing stdin: {e}") + + try: + if self.process.stdout: + self.process.stdout.close() + except (OSError, ValueError) as e: + logger.debug(f"Error closing stdout: {e}") + except Exception as e: + logger.error(f"Unexpected error closing stdout: {e}") + + # Now join the reader thread + if self.reader_thread and self.reader_thread.is_alive(): + self.reader_thread.join(timeout=READER_THREAD_TIMEOUT) + if self.reader_thread.is_alive(): + logger.warning("Reader thread did not terminate within timeout") + + if self.process: + try: + self.process.terminate() + self.process.wait(timeout=5.0) + except subprocess.TimeoutExpired: + logger.warning("Process did not terminate, forcing kill") + self.process.kill() + except Exception as e: + logger.error(f"Error terminating process: {e}") + finally: + self.process = None + + self._closed = True + + def __enter__(self): + """Context manager entry.""" + self.initialize() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.close() + return False + + def __del__(self): + """Cleanup on deletion.""" + try: + self.close() + except Exception: + # Suppress errors during interpreter shutdown + pass + diff --git a/tests/tools/execute_bash/test_windows_terminal.py b/tests/tools/execute_bash/test_windows_terminal.py new file mode 100644 index 0000000000..923dfb5d0a --- /dev/null +++ b/tests/tools/execute_bash/test_windows_terminal.py @@ -0,0 +1,360 @@ +""" +Tests for Windows terminal implementation. + +This test suite specifically tests the WindowsTerminal backend functionality +on Windows systems. Tests are skipped on non-Windows platforms. +""" + +import os +import platform +import tempfile +import time + +import pytest + +from openhands.tools.execute_bash.definition import ExecuteBashAction +from openhands.tools.execute_bash.terminal import create_terminal_session + +# Skip all tests in this file if not on Windows +pytestmark = pytest.mark.skipif( + platform.system() != "Windows", + reason="Windows terminal tests only run on Windows", +) + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for testing.""" + with tempfile.TemporaryDirectory() as tmp: + yield tmp + + +@pytest.fixture +def windows_session(temp_dir): + """Create a WindowsTerminal session for testing.""" + session = create_terminal_session(work_dir=temp_dir) + session.initialize() + yield session + session.close() + + +def test_windows_terminal_initialization(temp_dir): + """Test that WindowsTerminal initializes correctly.""" + session = create_terminal_session(work_dir=temp_dir) + assert session is not None + assert not session.terminal.initialized + + session.initialize() + assert session.terminal.initialized + assert not session.terminal.closed + + session.close() + assert session.terminal.closed + + +def test_windows_terminal_basic_command(windows_session): + """Test executing a basic command.""" + obs = windows_session.execute(ExecuteBashAction(command="echo Hello")) + + assert obs.output is not None + assert "Hello" in obs.output + assert obs.exit_code == 0 + + +def test_windows_terminal_pwd(windows_session, temp_dir): + """Test that Get-Location returns correct working directory.""" + obs = windows_session.execute(ExecuteBashAction(command="(Get-Location).Path")) + + # PowerShell may show the path in different format + # Verify the command executed and returned the working directory + assert obs.output is not None + assert obs.exit_code == 0 + assert temp_dir.lower().replace("\\", "/") in obs.output.lower().replace("\\", "/") + + +def test_windows_terminal_cd_command(windows_session, temp_dir): + """Test changing directory.""" + # Create a subdirectory + test_dir = os.path.join(temp_dir, "testdir") + os.makedirs(test_dir, exist_ok=True) + + # Change to the new directory + obs = windows_session.execute(ExecuteBashAction(command=f"cd {test_dir}")) + assert obs.exit_code == 0 + + # Verify we're in the new directory + # PowerShell uses Get-Location, not pwd + obs = windows_session.execute(ExecuteBashAction(command="(Get-Location).Path")) + # PowerShell may return path with different separators + normalized_output = obs.output.replace("\\", "/").lower() + normalized_test_dir = test_dir.replace("\\", "/").lower() + assert normalized_test_dir in normalized_output + + +def test_windows_terminal_multiline_output(windows_session): + """Test command with multiline output.""" + obs = windows_session.execute( + ExecuteBashAction(command='echo "Line1"; echo "Line2"; echo "Line3"') + ) + + assert obs.output is not None + assert "Line1" in obs.output + assert "Line2" in obs.output + assert "Line3" in obs.output + + +def test_windows_terminal_file_operations(windows_session, temp_dir): + """Test file creation and reading.""" + test_file = os.path.join(temp_dir, "test.txt") + + # Create a file + obs = windows_session.execute( + ExecuteBashAction(command=f'echo "Test content" > "{test_file}"') + ) + assert obs.exit_code == 0 + + # Verify file was created + assert os.path.exists(test_file) + + # Read the file + obs = windows_session.execute( + ExecuteBashAction(command=f'Get-Content "{test_file}"') + ) + assert "Test content" in obs.output + + +def test_windows_terminal_error_handling(windows_session): + """Test handling of commands that fail.""" + # Try to access a non-existent file + obs = windows_session.execute( + ExecuteBashAction(command='Get-Content "nonexistent_file.txt"') + ) + + # Command should fail (non-zero exit code or error in output) + assert obs.exit_code != 0 or "cannot find" in obs.output.lower() + + +def test_windows_terminal_environment_variables(windows_session): + """Test setting and reading environment variables.""" + # Set an environment variable + obs = windows_session.execute( + ExecuteBashAction(command='$env:TEST_VAR = "test_value"') + ) + assert obs.exit_code == 0 + + # Read the environment variable + obs = windows_session.execute( + ExecuteBashAction(command='echo $env:TEST_VAR') + ) + assert "test_value" in obs.output + + +def test_windows_terminal_long_running_command(windows_session): + """Test a command that takes some time to execute.""" + # Sleep for 2 seconds + obs = windows_session.execute( + ExecuteBashAction(command="Start-Sleep -Seconds 2; echo Done") + ) + + assert "Done" in obs.output + assert obs.exit_code == 0 + + +def test_windows_terminal_special_characters(windows_session): + """Test handling of special characters in output.""" + obs = windows_session.execute( + ExecuteBashAction(command='echo "Test@#$%^&*()_+-=[]{}|;:,.<>?"') + ) + + assert obs.output is not None + assert obs.exit_code == 0 + + +def test_windows_terminal_multiple_commands(windows_session): + """Test executing multiple commands in sequence.""" + commands = [ + "echo First", + "echo Second", + "echo Third", + ] + + for cmd in commands: + obs = windows_session.execute(ExecuteBashAction(command=cmd)) + assert obs.exit_code == 0 + + +def test_windows_terminal_send_keys(temp_dir): + """Test send_keys method.""" + session = create_terminal_session(work_dir=temp_dir) + session.initialize() + + # Send a command using send_keys + session.terminal.send_keys("echo TestSendKeys", enter=True) + time.sleep(0.5) + + # Read the output + output = session.terminal.read_screen() + assert output is not None + + session.close() + + +def test_windows_terminal_clear_screen(windows_session): + """Test clear_screen method.""" + # Execute some commands + windows_session.execute(ExecuteBashAction(command="echo Test1")) + windows_session.execute(ExecuteBashAction(command="echo Test2")) + + # Clear the screen + windows_session.terminal.clear_screen() + + # Execute another command + obs = windows_session.execute(ExecuteBashAction(command="echo Test3")) + assert "Test3" in obs.output + + +def test_windows_terminal_is_running(windows_session): + """Test is_running method.""" + # Terminal should not be running a command initially + assert not windows_session.terminal.is_running() + + # After executing a quick command, it should complete + windows_session.execute(ExecuteBashAction(command="echo Quick")) + assert not windows_session.terminal.is_running() + + +def test_windows_terminal_is_powershell(windows_session): + """Test that is_powershell returns True for Windows terminal.""" + assert windows_session.terminal.is_powershell() + + +def test_windows_terminal_close_and_reopen(temp_dir): + """Test closing and reopening a terminal session.""" + # Create and initialize first session + session1 = create_terminal_session(work_dir=temp_dir) + session1.initialize() + + obs = session1.execute(ExecuteBashAction(command="echo Session1")) + assert "Session1" in obs.output + + # Close first session + session1.close() + assert session1.terminal.closed + + # Create and initialize second session + session2 = create_terminal_session(work_dir=temp_dir) + session2.initialize() + + obs = session2.execute(ExecuteBashAction(command="echo Session2")) + assert "Session2" in obs.output + + session2.close() + + +def test_windows_terminal_timeout_handling(windows_session): + """Test that very long commands respect timeout settings.""" + # This test might take a while, so we use a shorter timeout + # Note: The actual timeout behavior depends on implementation + obs = windows_session.execute( + ExecuteBashAction(command="Start-Sleep -Seconds 1; echo Done") + ) + + # Should complete within reasonable time + assert obs.output is not None + + +def test_windows_terminal_consecutive_commands(windows_session, temp_dir): + """Test executing consecutive commands that depend on each other.""" + test_file = os.path.join(temp_dir, "counter.txt") + + # Create file with initial value + obs1 = windows_session.execute( + ExecuteBashAction(command=f'echo "1" > "{test_file}"') + ) + assert obs1.exit_code == 0 + + # Read and verify + obs2 = windows_session.execute( + ExecuteBashAction(command=f'Get-Content "{test_file}"') + ) + assert "1" in obs2.output + + # Update the file + obs3 = windows_session.execute( + ExecuteBashAction(command=f'echo "2" > "{test_file}"') + ) + assert obs3.exit_code == 0 + + # Read and verify update + obs4 = windows_session.execute( + ExecuteBashAction(command=f'Get-Content "{test_file}"') + ) + assert "2" in obs4.output + + +def test_windows_terminal_unicode_handling(windows_session): + """Test handling of Unicode characters.""" + obs = windows_session.execute( + ExecuteBashAction(command='echo "Hello δΈ–η•Œ 🌍"') + ) + + # Just verify the command executes without crashing + assert obs.output is not None + + +def test_windows_terminal_path_with_spaces(windows_session, temp_dir): + """Test handling paths with spaces.""" + # Create directory with spaces in name + dir_with_spaces = os.path.join(temp_dir, "test dir with spaces") + os.makedirs(dir_with_spaces, exist_ok=True) + + # Create a file in that directory + test_file = os.path.join(dir_with_spaces, "test.txt") + obs = windows_session.execute( + ExecuteBashAction(command=f'echo "Content" > "{test_file}"') + ) + assert obs.exit_code == 0 + + # Verify file exists + assert os.path.exists(test_file) + + +def test_windows_terminal_command_with_quotes(windows_session): + """Test command with various quote types.""" + obs = windows_session.execute( + ExecuteBashAction(command='echo "Double quotes" ; echo \'Single quotes\'') + ) + + assert obs.output is not None + assert obs.exit_code == 0 + + +def test_windows_terminal_empty_command(windows_session): + """Test executing an empty command.""" + obs = windows_session.execute(ExecuteBashAction(command="")) + + # Empty command should execute without error + assert obs.output is not None + + +def test_windows_terminal_working_directory_persistence(windows_session, temp_dir): + """Test that working directory persists across commands.""" + # Create subdirectories + dir1 = os.path.join(temp_dir, "dir1") + dir2 = os.path.join(temp_dir, "dir2") + os.makedirs(dir1, exist_ok=True) + os.makedirs(dir2, exist_ok=True) + + # Change to dir1 + obs = windows_session.execute(ExecuteBashAction(command=f"cd '{dir1}'")) + assert obs.exit_code == 0 + + # Create file in current directory (should be dir1) + obs = windows_session.execute( + ExecuteBashAction(command='echo "In dir1" > file1.txt') + ) + assert obs.exit_code == 0 + + # Verify file was created in dir1 + assert os.path.exists(os.path.join(dir1, "file1.txt")) + From a1f63190aa42f3a65eac54a851dbe8ae23175464 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 5 Nov 2025 12:17:36 +0530 Subject: [PATCH 02/10] ruff --- .../execute_bash/terminal/windows_terminal.py | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/openhands-tools/openhands/tools/execute_bash/terminal/windows_terminal.py b/openhands-tools/openhands/tools/execute_bash/terminal/windows_terminal.py index bd48e2e414..aa52e01ebf 100644 --- a/openhands-tools/openhands/tools/execute_bash/terminal/windows_terminal.py +++ b/openhands-tools/openhands/tools/execute_bash/terminal/windows_terminal.py @@ -2,7 +2,6 @@ import codecs import json -import os import re import subprocess import threading @@ -211,7 +210,10 @@ def _parse_metadata(self, output: str) -> CmdOutputMetadata | None: Returns: Parsed metadata or None if not found/invalid """ - pattern = f"{re.escape(CMD_OUTPUT_PS1_BEGIN)}(.+?){re.escape(CMD_OUTPUT_PS1_END)}" + pattern = ( + f"{re.escape(CMD_OUTPUT_PS1_BEGIN)}" + f"(.+?){re.escape(CMD_OUTPUT_PS1_END)}" + ) match = re.search(pattern, output, re.DOTALL) if match: try: @@ -245,7 +247,8 @@ def send_keys(self, text: str, enter: bool = True, _internal: bool = False) -> N if not is_special_key and not _internal: self._get_buffered_output(clear=True) - # For regular commands (not special keys or internal), append PS1 marker with metadata + # For regular commands (not special keys or internal), + # append PS1 marker with metadata if not is_special_key and text.strip() and not _internal: # Set command running flag self._command_running_event.set() @@ -256,11 +259,17 @@ def send_keys(self, text: str, enter: bool = True, _internal: bool = False) -> N metadata_cmd = ( f"; Write-Host '{ps1_begin}'; " # Use $? to check success (True/False), convert to 0/1 - "$exit_code = if ($?) { if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } else { 0 } } else { 1 }; " - "$py_path = (Get-Command python -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source); " - "$meta = @{pid=$PID; exit_code=$exit_code; username=$env:USERNAME; " - "hostname=$env:COMPUTERNAME; working_dir=(Get-Location).Path.Replace('\\', '/'); " - "py_interpreter_path=if ($py_path) { $py_path } else { $null }}; " + "$exit_code = if ($?) { " + "if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } " + "else { 0 } } else { 1 }; " + "$py_path = (Get-Command python -ErrorAction " + "SilentlyContinue | Select-Object -ExpandProperty Source); " + "$meta = @{pid=$PID; exit_code=$exit_code; " + "username=$env:USERNAME; " + "hostname=$env:COMPUTERNAME; " + "working_dir=(Get-Location).Path.Replace('\\', '/'); " + "py_interpreter_path=if ($py_path) { $py_path } " + "else { $null }}; " "Write-Host (ConvertTo-Json $meta -Compress); " f"Write-Host '{ps1_end}'" ) @@ -329,7 +338,7 @@ def is_running(self) -> bool: # Return current state - empty buffer doesn't mean command isn't running # (command might be executing without output yet) return self._command_running_event.is_set() - except (OSError, IOError) as e: + except OSError as e: logger.warning(f"Error reading screen in is_running: {e}") return self._command_running_event.is_set() except Exception as e: From 4e064b023083e180cbdbf416a0683f6fbb0a4725 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 5 Nov 2025 12:26:11 +0530 Subject: [PATCH 03/10] Remove platform check and always import fcntl and pty for pyright Simplifies the code by unconditionally importing fcntl and pty, removing the platform-specific logic and dummy assignments for Windows. This module is intended for Unix-like systems only. --- .../execute_bash/terminal/subprocess_terminal.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/openhands-tools/openhands/tools/execute_bash/terminal/subprocess_terminal.py b/openhands-tools/openhands/tools/execute_bash/terminal/subprocess_terminal.py index 9019e890b3..620f41ec6d 100644 --- a/openhands-tools/openhands/tools/execute_bash/terminal/subprocess_terminal.py +++ b/openhands-tools/openhands/tools/execute_bash/terminal/subprocess_terminal.py @@ -1,7 +1,8 @@ """PTY-based terminal backend implementation (replaces pipe-based subprocess).""" +import fcntl import os -import platform +import pty import re import select import signal @@ -11,15 +12,6 @@ import uuid from collections import deque -# Unix-specific imports -if platform.system() != "Windows": - import fcntl - import pty -else: - # Provide dummy values for Windows (this module shouldn't be used on Windows) - fcntl = None - pty = None - from openhands.sdk.logger import get_logger from openhands.tools.execute_bash.constants import ( CMD_OUTPUT_PS1_BEGIN, From baa383e033932b59ffe1062a45035db7938870ee Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 5 Nov 2025 12:49:46 +0530 Subject: [PATCH 04/10] pyright --- .../tools/execute_bash/terminal/windows_terminal.py | 4 ++-- scripts/demo.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 scripts/demo.py diff --git a/openhands-tools/openhands/tools/execute_bash/terminal/windows_terminal.py b/openhands-tools/openhands/tools/execute_bash/terminal/windows_terminal.py index aa52e01ebf..a64bf0bcc8 100644 --- a/openhands-tools/openhands/tools/execute_bash/terminal/windows_terminal.py +++ b/openhands-tools/openhands/tools/execute_bash/terminal/windows_terminal.py @@ -73,9 +73,9 @@ def initialize(self) -> None: def _start_session(self) -> None: """Start PowerShell session.""" # Use PowerShell for better Windows compatibility - startupinfo = subprocess.STARTUPINFO() + startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined] # Hide the console window (prevents popup on Windows) - startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW + startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore[attr-defined] self.process = subprocess.Popen( POWERSHELL_CMD, diff --git a/scripts/demo.py b/scripts/demo.py new file mode 100644 index 0000000000..473eac4cfb --- /dev/null +++ b/scripts/demo.py @@ -0,0 +1,11 @@ +from openhands.sdk import LLM, Conversation +from openhands.tools.preset.default import get_default_agent + +# Configure LLM and create agent +llm = LLM(model="gemini/gemini-2.5-flash",) +agent = get_default_agent(llm=llm, cli_mode=True) + +# Start a conversation +conversation = Conversation(agent=agent, workspace=".") +conversation.send_message("run ls") +conversation.run() From 7939e98cf62d5642f15e80d7b84564df8f4c64d7 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 5 Nov 2025 13:26:18 +0530 Subject: [PATCH 05/10] ruff --- .../tools/execute_bash/terminal/__init__.py | 7 +- .../execute_bash/terminal/windows_terminal.py | 80 ++++++++-------- .../execute_bash/test_windows_terminal.py | 92 +++++++++---------- 3 files changed, 89 insertions(+), 90 deletions(-) diff --git a/openhands-tools/openhands/tools/execute_bash/terminal/__init__.py b/openhands-tools/openhands/tools/execute_bash/terminal/__init__.py index 51a1773d07..f10ac4cf76 100644 --- a/openhands-tools/openhands/tools/execute_bash/terminal/__init__.py +++ b/openhands-tools/openhands/tools/execute_bash/terminal/__init__.py @@ -10,9 +10,11 @@ TerminalSession, ) + # Conditionally import platform-specific terminals if platform.system() == "Windows": from openhands.tools.execute_bash.terminal.windows_terminal import WindowsTerminal + __all__ = [ "TerminalInterface", "TerminalSessionBase", @@ -22,8 +24,11 @@ "create_terminal_session", ] else: - from openhands.tools.execute_bash.terminal.subprocess_terminal import SubprocessTerminal + from openhands.tools.execute_bash.terminal.subprocess_terminal import ( + SubprocessTerminal, + ) from openhands.tools.execute_bash.terminal.tmux_terminal import TmuxTerminal + __all__ = [ "TerminalInterface", "TerminalSessionBase", diff --git a/openhands-tools/openhands/tools/execute_bash/terminal/windows_terminal.py b/openhands-tools/openhands/tools/execute_bash/terminal/windows_terminal.py index a64bf0bcc8..e2be3aeb22 100644 --- a/openhands-tools/openhands/tools/execute_bash/terminal/windows_terminal.py +++ b/openhands-tools/openhands/tools/execute_bash/terminal/windows_terminal.py @@ -60,13 +60,13 @@ def __init__(self, work_dir: str, username: str | None = None): self.reader_thread = None self._command_running_event = threading.Event() self._stop_reader = False - self._decoder = codecs.getincrementaldecoder('utf-8')(errors='replace') + self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") def initialize(self) -> None: """Initialize the Windows terminal session.""" if self._initialized: return - + self._start_session() self._initialized = True @@ -76,7 +76,7 @@ def _start_session(self) -> None: startupinfo = subprocess.STARTUPINFO() # type: ignore[attr-defined] # Hide the console window (prevents popup on Windows) startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore[attr-defined] - + self.process = subprocess.Popen( POWERSHELL_CMD, stdin=subprocess.PIPE, @@ -98,7 +98,7 @@ def _start_session(self) -> None: def _setup_prompt(self) -> None: """Configure PowerShell prompt.""" - # For PowerShell, we'll append the PS1 marker to each command instead of + # For PowerShell, we'll append the PS1 marker to each command instead of # using a custom prompt function, since prompt output isn't reliably captured # Wait for PowerShell initialization (copyright, welcome messages) to complete start_time = time.time() @@ -108,10 +108,10 @@ def _setup_prompt(self) -> None: with self.output_lock: if len(self.output_buffer) > 0: break - + # Additional small delay for stability time.sleep(SETUP_DELAY) - + with self.output_lock: self.output_buffer.clear() @@ -119,7 +119,7 @@ def _write_to_stdin(self, data: str) -> None: """Write data to stdin.""" if self.process and self.process.stdin: try: - self.process.stdin.write(data.encode('utf-8')) + self.process.stdin.write(data.encode("utf-8")) self.process.stdin.flush() except (BrokenPipeError, OSError) as e: logger.error(f"Failed to write to stdin: {e}") @@ -131,20 +131,20 @@ def _read_output(self) -> None: # Cache stdout reference to prevent race condition during close() stdout = self.process.stdout - + while not self._stop_reader: try: # Read in chunks chunk = stdout.read(READ_CHUNK_SIZE) if not chunk: break - + # Use incremental decoder to handle UTF-8 boundary splits correctly decoded = self._decoder.decode(chunk, False) if decoded: # Only append non-empty strings with self.output_lock: self.output_buffer.append(decoded) - + except (ValueError, OSError) as e: # Expected when stdout is closed logger.debug(f"Output reading stopped: {e}") @@ -152,10 +152,10 @@ def _read_output(self) -> None: except Exception as e: logger.error(f"Error reading output: {e}") break - + # Flush any remaining bytes when stopping try: - final = self._decoder.decode(b'', True) + final = self._decoder.decode(b"", True) if final: with self.output_lock: self.output_buffer.append(final) @@ -164,7 +164,7 @@ def _read_output(self) -> None: def _get_buffered_output(self, clear: bool = True) -> str: """Get all buffered output. - + Args: clear: Whether to clear the buffer after reading """ @@ -173,14 +173,14 @@ def _get_buffered_output(self, clear: bool = True) -> str: buffer_copy = list(self.output_buffer) if clear: self.output_buffer.clear() - return ''.join(buffer_copy) + return "".join(buffer_copy) def _is_special_key(self, text: str) -> bool: """Check if text is a special key sequence. - + Args: text: Text to check - + Returns: True if special key """ @@ -188,31 +188,30 @@ def _is_special_key(self, text: str) -> bool: def _escape_powershell_string(self, s: str) -> str: """Escape a string for safe use in PowerShell single quotes. - + In PowerShell single-quoted strings, only the single quote character needs escaping (by doubling it). - + Args: s: String to escape - + Returns: Escaped string with single quotes doubled """ # In PowerShell single quotes, only single quote needs escaping return s.replace("'", "''") - + def _parse_metadata(self, output: str) -> CmdOutputMetadata | None: """Extract metadata from command output. - + Args: output: Command output containing metadata markers - + Returns: Parsed metadata or None if not found/invalid """ pattern = ( - f"{re.escape(CMD_OUTPUT_PS1_BEGIN)}" - f"(.+?){re.escape(CMD_OUTPUT_PS1_END)}" + f"{re.escape(CMD_OUTPUT_PS1_BEGIN)}(.+?){re.escape(CMD_OUTPUT_PS1_END)}" ) match = re.search(pattern, output, re.DOTALL) if match: @@ -230,7 +229,7 @@ def send_keys(self, text: str, enter: bool = True, _internal: bool = False) -> N text: Text to send enter: Whether to add newline _internal: Internal flag for system commands (don't track as user command) - + Raises: RuntimeError: If terminal process is not running """ @@ -239,20 +238,20 @@ def send_keys(self, text: str, enter: bool = True, _internal: bool = False) -> N error_msg = "Cannot send keys: terminal process is not running" logger.error(error_msg) raise RuntimeError(error_msg) - + # Check if this is a special key (like C-c or Ctrl+C) is_special_key = self._is_special_key(text) - + # Clear old output buffer when sending a new command (not for special keys) if not is_special_key and not _internal: self._get_buffered_output(clear=True) - + # For regular commands (not special keys or internal), # append PS1 marker with metadata if not is_special_key and text.strip() and not _internal: # Set command running flag self._command_running_event.set() - + # Build PowerShell metadata output command with proper escaping ps1_begin = self._escape_powershell_string(CMD_OUTPUT_PS1_BEGIN.strip()) ps1_end = self._escape_powershell_string(CMD_OUTPUT_PS1_END.strip()) @@ -274,14 +273,14 @@ def send_keys(self, text: str, enter: bool = True, _internal: bool = False) -> N f"Write-Host '{ps1_end}'" ) text = text.rstrip() + metadata_cmd - - if enter and not text.endswith('\n'): - text = text + '\n' + + if enter and not text.endswith("\n"): + text = text + "\n" self._write_to_stdin(text) def read_screen(self) -> str: """Read current terminal output without clearing buffer. - + This allows TerminalSession to poll the output multiple times until it detects the PS1 prompt marker. @@ -357,9 +356,9 @@ def close(self) -> None: """Close the terminal session.""" if self._closed: return - + self._stop_reader = True - + # Close pipes to unblock reader thread if self.process: try: @@ -369,7 +368,7 @@ def close(self) -> None: logger.debug(f"Error closing stdin: {e}") except Exception as e: logger.error(f"Unexpected error closing stdin: {e}") - + try: if self.process.stdout: self.process.stdout.close() @@ -377,13 +376,13 @@ def close(self) -> None: logger.debug(f"Error closing stdout: {e}") except Exception as e: logger.error(f"Unexpected error closing stdout: {e}") - + # Now join the reader thread if self.reader_thread and self.reader_thread.is_alive(): self.reader_thread.join(timeout=READER_THREAD_TIMEOUT) if self.reader_thread.is_alive(): logger.warning("Reader thread did not terminate within timeout") - + if self.process: try: self.process.terminate() @@ -395,14 +394,14 @@ def close(self) -> None: logger.error(f"Error terminating process: {e}") finally: self.process = None - + self._closed = True def __enter__(self): """Context manager entry.""" self.initialize() return self - + def __exit__(self, exc_type, exc_val, exc_tb): """Context manager exit.""" self.close() @@ -415,4 +414,3 @@ def __del__(self): except Exception: # Suppress errors during interpreter shutdown pass - diff --git a/tests/tools/execute_bash/test_windows_terminal.py b/tests/tools/execute_bash/test_windows_terminal.py index 923dfb5d0a..bbf5b81fc2 100644 --- a/tests/tools/execute_bash/test_windows_terminal.py +++ b/tests/tools/execute_bash/test_windows_terminal.py @@ -15,6 +15,7 @@ from openhands.tools.execute_bash.definition import ExecuteBashAction from openhands.tools.execute_bash.terminal import create_terminal_session + # Skip all tests in this file if not on Windows pytestmark = pytest.mark.skipif( platform.system() != "Windows", @@ -43,11 +44,11 @@ def test_windows_terminal_initialization(temp_dir): session = create_terminal_session(work_dir=temp_dir) assert session is not None assert not session.terminal.initialized - + session.initialize() assert session.terminal.initialized assert not session.terminal.closed - + session.close() assert session.terminal.closed @@ -55,7 +56,7 @@ def test_windows_terminal_initialization(temp_dir): def test_windows_terminal_basic_command(windows_session): """Test executing a basic command.""" obs = windows_session.execute(ExecuteBashAction(command="echo Hello")) - + assert obs.output is not None assert "Hello" in obs.output assert obs.exit_code == 0 @@ -64,7 +65,7 @@ def test_windows_terminal_basic_command(windows_session): def test_windows_terminal_pwd(windows_session, temp_dir): """Test that Get-Location returns correct working directory.""" obs = windows_session.execute(ExecuteBashAction(command="(Get-Location).Path")) - + # PowerShell may show the path in different format # Verify the command executed and returned the working directory assert obs.output is not None @@ -77,12 +78,12 @@ def test_windows_terminal_cd_command(windows_session, temp_dir): # Create a subdirectory test_dir = os.path.join(temp_dir, "testdir") os.makedirs(test_dir, exist_ok=True) - + # Change to the new directory obs = windows_session.execute(ExecuteBashAction(command=f"cd {test_dir}")) assert obs.exit_code == 0 - - # Verify we're in the new directory + + # Verify we're in the new directory # PowerShell uses Get-Location, not pwd obs = windows_session.execute(ExecuteBashAction(command="(Get-Location).Path")) # PowerShell may return path with different separators @@ -96,7 +97,7 @@ def test_windows_terminal_multiline_output(windows_session): obs = windows_session.execute( ExecuteBashAction(command='echo "Line1"; echo "Line2"; echo "Line3"') ) - + assert obs.output is not None assert "Line1" in obs.output assert "Line2" in obs.output @@ -106,16 +107,16 @@ def test_windows_terminal_multiline_output(windows_session): def test_windows_terminal_file_operations(windows_session, temp_dir): """Test file creation and reading.""" test_file = os.path.join(temp_dir, "test.txt") - + # Create a file obs = windows_session.execute( ExecuteBashAction(command=f'echo "Test content" > "{test_file}"') ) assert obs.exit_code == 0 - + # Verify file was created assert os.path.exists(test_file) - + # Read the file obs = windows_session.execute( ExecuteBashAction(command=f'Get-Content "{test_file}"') @@ -129,7 +130,7 @@ def test_windows_terminal_error_handling(windows_session): obs = windows_session.execute( ExecuteBashAction(command='Get-Content "nonexistent_file.txt"') ) - + # Command should fail (non-zero exit code or error in output) assert obs.exit_code != 0 or "cannot find" in obs.output.lower() @@ -141,11 +142,9 @@ def test_windows_terminal_environment_variables(windows_session): ExecuteBashAction(command='$env:TEST_VAR = "test_value"') ) assert obs.exit_code == 0 - + # Read the environment variable - obs = windows_session.execute( - ExecuteBashAction(command='echo $env:TEST_VAR') - ) + obs = windows_session.execute(ExecuteBashAction(command="echo $env:TEST_VAR")) assert "test_value" in obs.output @@ -155,7 +154,7 @@ def test_windows_terminal_long_running_command(windows_session): obs = windows_session.execute( ExecuteBashAction(command="Start-Sleep -Seconds 2; echo Done") ) - + assert "Done" in obs.output assert obs.exit_code == 0 @@ -165,7 +164,7 @@ def test_windows_terminal_special_characters(windows_session): obs = windows_session.execute( ExecuteBashAction(command='echo "Test@#$%^&*()_+-=[]{}|;:,.<>?"') ) - + assert obs.output is not None assert obs.exit_code == 0 @@ -177,7 +176,7 @@ def test_windows_terminal_multiple_commands(windows_session): "echo Second", "echo Third", ] - + for cmd in commands: obs = windows_session.execute(ExecuteBashAction(command=cmd)) assert obs.exit_code == 0 @@ -187,15 +186,15 @@ def test_windows_terminal_send_keys(temp_dir): """Test send_keys method.""" session = create_terminal_session(work_dir=temp_dir) session.initialize() - + # Send a command using send_keys session.terminal.send_keys("echo TestSendKeys", enter=True) time.sleep(0.5) - + # Read the output output = session.terminal.read_screen() assert output is not None - + session.close() @@ -204,10 +203,10 @@ def test_windows_terminal_clear_screen(windows_session): # Execute some commands windows_session.execute(ExecuteBashAction(command="echo Test1")) windows_session.execute(ExecuteBashAction(command="echo Test2")) - + # Clear the screen windows_session.terminal.clear_screen() - + # Execute another command obs = windows_session.execute(ExecuteBashAction(command="echo Test3")) assert "Test3" in obs.output @@ -217,7 +216,7 @@ def test_windows_terminal_is_running(windows_session): """Test is_running method.""" # Terminal should not be running a command initially assert not windows_session.terminal.is_running() - + # After executing a quick command, it should complete windows_session.execute(ExecuteBashAction(command="echo Quick")) assert not windows_session.terminal.is_running() @@ -233,21 +232,21 @@ def test_windows_terminal_close_and_reopen(temp_dir): # Create and initialize first session session1 = create_terminal_session(work_dir=temp_dir) session1.initialize() - + obs = session1.execute(ExecuteBashAction(command="echo Session1")) assert "Session1" in obs.output - + # Close first session session1.close() assert session1.terminal.closed - + # Create and initialize second session session2 = create_terminal_session(work_dir=temp_dir) session2.initialize() - + obs = session2.execute(ExecuteBashAction(command="echo Session2")) assert "Session2" in obs.output - + session2.close() @@ -258,7 +257,7 @@ def test_windows_terminal_timeout_handling(windows_session): obs = windows_session.execute( ExecuteBashAction(command="Start-Sleep -Seconds 1; echo Done") ) - + # Should complete within reasonable time assert obs.output is not None @@ -266,25 +265,25 @@ def test_windows_terminal_timeout_handling(windows_session): def test_windows_terminal_consecutive_commands(windows_session, temp_dir): """Test executing consecutive commands that depend on each other.""" test_file = os.path.join(temp_dir, "counter.txt") - + # Create file with initial value obs1 = windows_session.execute( ExecuteBashAction(command=f'echo "1" > "{test_file}"') ) assert obs1.exit_code == 0 - + # Read and verify obs2 = windows_session.execute( ExecuteBashAction(command=f'Get-Content "{test_file}"') ) assert "1" in obs2.output - + # Update the file obs3 = windows_session.execute( ExecuteBashAction(command=f'echo "2" > "{test_file}"') ) assert obs3.exit_code == 0 - + # Read and verify update obs4 = windows_session.execute( ExecuteBashAction(command=f'Get-Content "{test_file}"') @@ -294,10 +293,8 @@ def test_windows_terminal_consecutive_commands(windows_session, temp_dir): def test_windows_terminal_unicode_handling(windows_session): """Test handling of Unicode characters.""" - obs = windows_session.execute( - ExecuteBashAction(command='echo "Hello δΈ–η•Œ 🌍"') - ) - + obs = windows_session.execute(ExecuteBashAction(command='echo "Hello δΈ–η•Œ 🌍"')) + # Just verify the command executes without crashing assert obs.output is not None @@ -307,14 +304,14 @@ def test_windows_terminal_path_with_spaces(windows_session, temp_dir): # Create directory with spaces in name dir_with_spaces = os.path.join(temp_dir, "test dir with spaces") os.makedirs(dir_with_spaces, exist_ok=True) - + # Create a file in that directory test_file = os.path.join(dir_with_spaces, "test.txt") obs = windows_session.execute( ExecuteBashAction(command=f'echo "Content" > "{test_file}"') ) assert obs.exit_code == 0 - + # Verify file exists assert os.path.exists(test_file) @@ -322,9 +319,9 @@ def test_windows_terminal_path_with_spaces(windows_session, temp_dir): def test_windows_terminal_command_with_quotes(windows_session): """Test command with various quote types.""" obs = windows_session.execute( - ExecuteBashAction(command='echo "Double quotes" ; echo \'Single quotes\'') + ExecuteBashAction(command="echo \"Double quotes\" ; echo 'Single quotes'") ) - + assert obs.output is not None assert obs.exit_code == 0 @@ -332,7 +329,7 @@ def test_windows_terminal_command_with_quotes(windows_session): def test_windows_terminal_empty_command(windows_session): """Test executing an empty command.""" obs = windows_session.execute(ExecuteBashAction(command="")) - + # Empty command should execute without error assert obs.output is not None @@ -344,17 +341,16 @@ def test_windows_terminal_working_directory_persistence(windows_session, temp_di dir2 = os.path.join(temp_dir, "dir2") os.makedirs(dir1, exist_ok=True) os.makedirs(dir2, exist_ok=True) - + # Change to dir1 obs = windows_session.execute(ExecuteBashAction(command=f"cd '{dir1}'")) assert obs.exit_code == 0 - + # Create file in current directory (should be dir1) obs = windows_session.execute( ExecuteBashAction(command='echo "In dir1" > file1.txt') ) assert obs.exit_code == 0 - + # Verify file was created in dir1 assert os.path.exists(os.path.join(dir1, "file1.txt")) - From a7b35dda4ec49dbd0b0796ff63686433ac18f2b3 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 6 Nov 2025 08:05:10 +0530 Subject: [PATCH 06/10] Remove demo script Deleted scripts/demo.py as it is no longer needed. The demo script previously demonstrated basic usage of the OpenHands SDK and agent setup. --- scripts/demo.py | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 scripts/demo.py diff --git a/scripts/demo.py b/scripts/demo.py deleted file mode 100644 index 473eac4cfb..0000000000 --- a/scripts/demo.py +++ /dev/null @@ -1,11 +0,0 @@ -from openhands.sdk import LLM, Conversation -from openhands.tools.preset.default import get_default_agent - -# Configure LLM and create agent -llm = LLM(model="gemini/gemini-2.5-flash",) -agent = get_default_agent(llm=llm, cli_mode=True) - -# Start a conversation -conversation = Conversation(agent=agent, workspace=".") -conversation.send_message("run ls") -conversation.run() From 4fefd4053d8aa084dff4b8d3280ccba947c470ac Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 6 Nov 2025 09:03:22 +0530 Subject: [PATCH 07/10] Remove stray character from import statement Eliminated an erroneous 'n' character from the import block in terminal_session.py to clean up the code and prevent potential syntax errors. --- .../openhands/tools/execute_bash/terminal/terminal_session.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openhands-tools/openhands/tools/execute_bash/terminal/terminal_session.py b/openhands-tools/openhands/tools/execute_bash/terminal/terminal_session.py index f4aa700086..a5ed721e6f 100644 --- a/openhands-tools/openhands/tools/execute_bash/terminal/terminal_session.py +++ b/openhands-tools/openhands/tools/execute_bash/terminal/terminal_session.py @@ -21,7 +21,6 @@ TerminalSessionBase, ) from openhands.tools.execute_bash.utils.command import ( -n escape_bash_special_chars, split_bash_commands, ) From d252b385b97f809439fa363efb09b347fd0d13c7 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 6 Nov 2025 10:20:35 +0530 Subject: [PATCH 08/10] Update assertions to use obs.text in Windows Terminal tests Changed assertions in test_windows_terminal_close_and_reopen to check 'obs.text' instead of 'obs.output' for command results. This aligns the test with the updated observation object structure. --- tests/tools/execute_bash/test_windows_terminal.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/tools/execute_bash/test_windows_terminal.py b/tests/tools/execute_bash/test_windows_terminal.py index bbf5b81fc2..1772ea7ef6 100644 --- a/tests/tools/execute_bash/test_windows_terminal.py +++ b/tests/tools/execute_bash/test_windows_terminal.py @@ -234,7 +234,7 @@ def test_windows_terminal_close_and_reopen(temp_dir): session1.initialize() obs = session1.execute(ExecuteBashAction(command="echo Session1")) - assert "Session1" in obs.output + assert "Session1" in obs.text # Close first session session1.close() @@ -245,7 +245,7 @@ def test_windows_terminal_close_and_reopen(temp_dir): session2.initialize() obs = session2.execute(ExecuteBashAction(command="echo Session2")) - assert "Session2" in obs.output + assert "Session2" in obs.text session2.close() From 83f31c8dbf4f4164bcfcca1c2ddac81d370370d5 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 7 Nov 2025 03:19:56 +0530 Subject: [PATCH 09/10] Add platform-specific terminal imports to __init__.py Update the terminal module's __init__.py to conditionally import WindowsTerminal on Windows and SubprocessTerminal/TmuxTerminal on other platforms. This change ensures only relevant terminal classes are exposed based on the operating system. --- .../tools/terminal/terminal/__init__.py | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/openhands-tools/openhands/tools/terminal/terminal/__init__.py b/openhands-tools/openhands/tools/terminal/terminal/__init__.py index 81d269b729..9fa4cc4832 100644 --- a/openhands-tools/openhands/tools/terminal/terminal/__init__.py +++ b/openhands-tools/openhands/tools/terminal/terminal/__init__.py @@ -1,24 +1,40 @@ +import platform + from openhands.tools.terminal.terminal.factory import create_terminal_session from openhands.tools.terminal.terminal.interface import ( TerminalInterface, TerminalSessionBase, ) -from openhands.tools.terminal.terminal.subprocess_terminal import ( - SubprocessTerminal, -) from openhands.tools.terminal.terminal.terminal_session import ( TerminalCommandStatus, TerminalSession, ) -from openhands.tools.terminal.terminal.tmux_terminal import TmuxTerminal -__all__ = [ - "TerminalInterface", - "TerminalSessionBase", - "TmuxTerminal", - "SubprocessTerminal", - "TerminalSession", - "TerminalCommandStatus", - "create_terminal_session", -] +# Conditionally import platform-specific terminals +if platform.system() == "Windows": + from openhands.tools.terminal.terminal.windows_terminal import WindowsTerminal + + __all__ = [ + "TerminalInterface", + "TerminalSessionBase", + "WindowsTerminal", + "TerminalSession", + "TerminalCommandStatus", + "create_terminal_session", + ] +else: + from openhands.tools.terminal.terminal.subprocess_terminal import ( + SubprocessTerminal, + ) + from openhands.tools.terminal.terminal.tmux_terminal import TmuxTerminal + + __all__ = [ + "TerminalInterface", + "TerminalSessionBase", + "TmuxTerminal", + "SubprocessTerminal", + "TerminalSession", + "TerminalCommandStatus", + "create_terminal_session", + ] From ff46c49daad23b376c23d65490361c9fcca8e071 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 7 Nov 2025 03:34:39 +0530 Subject: [PATCH 10/10] Refactor terminal modules and update imports Removed the deprecated execute_bash.terminal package and updated all relevant imports to use the new openhands.tools.terminal.terminal structure. This change consolidates terminal-related code under a single module for improved maintainability and clarity. --- .../tools/execute_bash/terminal/__init__.py | 40 ------------------- .../tools/terminal/terminal/factory.py | 2 +- .../terminal/terminal/windows_terminal.py | 6 +-- tests/tools/terminal/test_windows_terminal.py | 4 +- 4 files changed, 6 insertions(+), 46 deletions(-) delete mode 100644 openhands-tools/openhands/tools/execute_bash/terminal/__init__.py diff --git a/openhands-tools/openhands/tools/execute_bash/terminal/__init__.py b/openhands-tools/openhands/tools/execute_bash/terminal/__init__.py deleted file mode 100644 index f10ac4cf76..0000000000 --- a/openhands-tools/openhands/tools/execute_bash/terminal/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -import platform - -from openhands.tools.execute_bash.terminal.factory import create_terminal_session -from openhands.tools.execute_bash.terminal.interface import ( - TerminalInterface, - TerminalSessionBase, -) -from openhands.tools.execute_bash.terminal.terminal_session import ( - TerminalCommandStatus, - TerminalSession, -) - - -# Conditionally import platform-specific terminals -if platform.system() == "Windows": - from openhands.tools.execute_bash.terminal.windows_terminal import WindowsTerminal - - __all__ = [ - "TerminalInterface", - "TerminalSessionBase", - "WindowsTerminal", - "TerminalSession", - "TerminalCommandStatus", - "create_terminal_session", - ] -else: - from openhands.tools.execute_bash.terminal.subprocess_terminal import ( - SubprocessTerminal, - ) - from openhands.tools.execute_bash.terminal.tmux_terminal import TmuxTerminal - - __all__ = [ - "TerminalInterface", - "TerminalSessionBase", - "TmuxTerminal", - "SubprocessTerminal", - "TerminalSession", - "TerminalCommandStatus", - "create_terminal_session", - ] diff --git a/openhands-tools/openhands/tools/terminal/terminal/factory.py b/openhands-tools/openhands/tools/terminal/terminal/factory.py index 8552f08a02..8912d4ffe0 100644 --- a/openhands-tools/openhands/tools/terminal/terminal/factory.py +++ b/openhands-tools/openhands/tools/terminal/terminal/factory.py @@ -98,7 +98,7 @@ def create_terminal_session( system = platform.system() if system == "Windows": - from openhands.tools.execute_bash.terminal.windows_terminal import ( + from openhands.tools.terminal.terminal.windows_terminal import ( WindowsTerminal, ) diff --git a/openhands-tools/openhands/tools/terminal/terminal/windows_terminal.py b/openhands-tools/openhands/tools/terminal/terminal/windows_terminal.py index e2be3aeb22..628c82400f 100644 --- a/openhands-tools/openhands/tools/terminal/terminal/windows_terminal.py +++ b/openhands-tools/openhands/tools/terminal/terminal/windows_terminal.py @@ -9,13 +9,13 @@ from collections import deque from openhands.sdk.logger import get_logger -from openhands.tools.execute_bash.constants import ( +from openhands.tools.terminal.constants import ( CMD_OUTPUT_PS1_BEGIN, CMD_OUTPUT_PS1_END, HISTORY_LIMIT, ) -from openhands.tools.execute_bash.metadata import CmdOutputMetadata -from openhands.tools.execute_bash.terminal import TerminalInterface +from openhands.tools.terminal.metadata import CmdOutputMetadata +from openhands.tools.terminal.terminal import TerminalInterface logger = get_logger(__name__) diff --git a/tests/tools/terminal/test_windows_terminal.py b/tests/tools/terminal/test_windows_terminal.py index 1772ea7ef6..b02906bae4 100644 --- a/tests/tools/terminal/test_windows_terminal.py +++ b/tests/tools/terminal/test_windows_terminal.py @@ -12,8 +12,8 @@ import pytest -from openhands.tools.execute_bash.definition import ExecuteBashAction -from openhands.tools.execute_bash.terminal import create_terminal_session +from openhands.tools.terminal.definition import ExecuteBashAction +from openhands.tools.terminal.terminal import create_terminal_session # Skip all tests in this file if not on Windows