Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions openhands/events/observation/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class CmdOutputMetadata(BaseModel):
py_interpreter_path: str | None = None
prefix: str = '' # Prefix to add to command output
suffix: str = '' # Suffix to add to command output
awaiting_input: bool = False # True when the process is still running and waiting for input

@classmethod
def to_ps1_prompt(cls) -> str:
Expand Down Expand Up @@ -179,6 +180,8 @@ def exit_code(self) -> int:

@property
def error(self) -> bool:
if getattr(self.metadata, 'awaiting_input', False):
return False
return self.exit_code != 0

@property
Expand Down
14 changes: 12 additions & 2 deletions openhands/runtime/utils/bash.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ def _finalize_completed(
def _finalize_no_output(self, command: str, pane: str) -> CmdOutputObservation:
"""Run no-output handler and update state like the original loop."""
obs = self._handle_no_output(command, pane)
self.state.state = RunState.NO_OUTPUT_TIMEOUT
self.state.state = RunState.RUNNING
return obs

def _finalize_hard_timeout(self, command: str, pane: str,
Expand Down Expand Up @@ -532,7 +532,13 @@ def _handle_no_output(self, command: str, pane: str) -> CmdOutputObservation:
trimmed_content = output_parser.remove_command_prefix(active_pane_content, command)

meta = CmdOutputMetadata()
meta.suffix = f"[No output for timeout. {TIMEOUT_MESSAGE_TEMPLATE}]"
meta.awaiting_input = True
meta.suffix = (
"[The process is still running and awaiting input. "
"Send follow-up input with `is_input=true` (e.g., responses to prompts or "
"`C-c`/`C-d`/`C-z`). If the shell is stuck, run the reset command "
f"`{RESET_SESSION_COMMAND}`: {TIMEOUT_MESSAGE_TEMPLATE}]"
)
return CmdOutputObservation(content=trimmed_content, command=command, metadata=meta)

def _handle_hard_timeout(self, command: str, pane: str, timeout: float) -> CmdOutputObservation:
Expand All @@ -541,6 +547,7 @@ def _handle_hard_timeout(self, command: str, pane: str, timeout: float) -> CmdOu
trimmed_content = output_parser.remove_command_prefix(active_pane_content, command)

meta = CmdOutputMetadata()
meta.awaiting_input = False
meta.suffix = f"[Command timed out after {timeout} seconds. {TIMEOUT_MESSAGE_TEMPLATE}]"
return CmdOutputObservation(content=trimmed_content, command=command, metadata=meta)

Expand Down Expand Up @@ -568,6 +575,9 @@ def _finalize_successful_completion(

self.state.state = RunState.COMPLETED

# A command that completed should no longer be treated as awaiting input.
meta.awaiting_input = False

return CmdOutputObservation(
content=output.rstrip(),
command=command,
Expand Down
12 changes: 12 additions & 0 deletions tests/unit/events/test_command_success.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ def test_cmd_output_success():
assert obs.error is True


def test_cmd_output_awaiting_input_is_not_error():
obs = CmdOutputObservation(
command='git add -p',
content='',
metadata=CmdOutputMetadata(awaiting_input=True),
)

assert obs.error is False
assert obs.success is True
assert obs.exit_code == -1


def test_ipython_cell_success():
# IPython cells are always successful
obs = IPythonRunCellObservation(code='print("Hello")', content='Hello')
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/runtime/utils/test_bash_blocking_behavior.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def _session_with_mock_tmux(tmp_path):
session = BashSession(work_dir=str(tmp_path))
session.tmux = Mock()
session.tmux.capture.return_value = "pane"
session.tmux.clear = Mock()
return session


Expand All @@ -30,6 +31,29 @@ def test_blocking_after_timeout_states(tmp_path):
assert "NOT executed" in blocked.metadata.suffix


def test_no_output_flagged_as_awaiting_input(tmp_path):
session = _session_with_mock_tmux(tmp_path)
session.state.last_command = "git add -p"

obs = session._finalize_no_output("git add -p", "prompt: ...")

assert session.state.state == RunState.RUNNING
assert obs.metadata.awaiting_input is True
assert obs.error is False
assert "awaiting input" in obs.metadata.suffix


def test_completion_resets_awaiting_input_flag(tmp_path):
session = _session_with_mock_tmux(tmp_path)

meta = CmdOutputMetadata(awaiting_input=True, exit_code=0)
obs = session._finalize_successful_completion("echo hi", "output", meta)

assert session.state.state == RunState.COMPLETED
assert obs.metadata.awaiting_input is False
assert obs.metadata.exit_code == 0


def test_reset_request_resets_session(tmp_path, monkeypatch):
session = BashSession(work_dir=str(tmp_path))
session.tmux = Mock()
Expand Down
Loading