From a72bae7dfa1ea08de40000b18b5a3656fd3d4333 Mon Sep 17 00:00:00 2001 From: openhands Date: Wed, 11 Feb 2026 09:32:59 +0000 Subject: [PATCH 01/11] feat: use rejection_source field for hook rejection detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the CLI to use the new rejection_source field from the SDK to distinguish hook rejections from user rejections, instead of pattern matching on rejection_reason strings. This provides a cleaner, more reliable way to detect hook rejections. TUI changes (richlog_visualizer.py): - Add _is_hook_rejection() helper using getattr() for SDK compatibility - Add _get_rejection_title() and _get_rejection_icon() helpers - Show 'Hook Blocked Action' title and '⚡ ✗' icon for hook rejections - Falls back to 'User Rejected Action' and '✗' for user rejections ACP changes (shared_event_handler.py): - Add _is_hook_rejection() helper using getattr() for SDK compatibility - Prepend '**⚡ Hook Blocked Action**' header for hook rejections Tests: - Add 8 new tests for TUI hook rejection detection - Add 5 new tests for ACP hook rejection detection - Tests use model_copy() for SDK version compatibility Depends on: OpenHands/software-agent-sdk#1995 Co-authored-by: openhands --- .../acp_impl/events/shared_event_handler.py | 18 ++- .../tui/widgets/richlog_visualizer.py | 36 ++++- tests/acp/events/test_shared_event_handler.py | 64 +++++++++ tests/tui/widgets/test_richlog_visualizer.py | 130 ++++++++++++++++++ 4 files changed, 244 insertions(+), 4 deletions(-) create mode 100644 tests/acp/events/test_shared_event_handler.py diff --git a/openhands_cli/acp_impl/events/shared_event_handler.py b/openhands_cli/acp_impl/events/shared_event_handler.py index 678cc7e05..cca9d6e27 100644 --- a/openhands_cli/acp_impl/events/shared_event_handler.py +++ b/openhands_cli/acp_impl/events/shared_event_handler.py @@ -48,12 +48,24 @@ # Formatting constants for consistent headers across streaming and non-streaming modes REASONING_HEADER = "**Reasoning**:\n" THOUGHT_HEADER = "\n**Thought**:\n" +HOOK_BLOCKED_HEADER = "**⚡ Hook Blocked Action**:\n" def _event_visualize_to_plain(event: Event) -> str: return str(event.visualize.plain) +def _is_hook_rejection(event: UserRejectObservation | AgentErrorEvent) -> bool: + """Check if a rejection event originated from a hook. + + Uses the rejection_source field when available (SDK >= X.Y.Z), + otherwise returns False for backwards compatibility. + """ + if isinstance(event, UserRejectObservation): + return getattr(event, "rejection_source", "user") == "hook" + return False + + class _ACPContext(Protocol): session_id: str conn: Client @@ -117,11 +129,15 @@ async def handle_condensation_request( async def handle_user_reject_or_agent_error( self, ctx: _ACPContext, event: UserRejectObservation | AgentErrorEvent ) -> None: + text = _event_visualize_to_plain(event) + # Prepend hook blocked header for hook rejections + if _is_hook_rejection(event): + text = f"{HOOK_BLOCKED_HEADER}{text}" await self.send_tool_progress( ctx, tool_call_id=event.tool_call_id, status="failed", - text=_event_visualize_to_plain(event), + text=text, raw_output=event.model_dump(), ) diff --git a/openhands_cli/tui/widgets/richlog_visualizer.py b/openhands_cli/tui/widgets/richlog_visualizer.py index 974dfe3ac..2b60db12d 100644 --- a/openhands_cli/tui/widgets/richlog_visualizer.py +++ b/openhands_cli/tui/widgets/richlog_visualizer.py @@ -40,6 +40,7 @@ # Icons for different event types SUCCESS_ICON = "✓" ERROR_ICON = "✗" +HOOK_ICON = "⚡" # Icon for hook-blocked actions AGENT_MESSAGE_PADDING = (1, 0, 1, 1) # top, right, bottom, left # Maximum line length for truncating titles/commands in collapsed view @@ -47,6 +48,31 @@ ELLIPSIS = "..." +def _is_hook_rejection(event: UserRejectObservation | AgentErrorEvent) -> bool: + """Check if a rejection event originated from a hook. + + Uses the rejection_source field when available (SDK >= X.Y.Z), + otherwise returns False for backwards compatibility. + """ + if isinstance(event, UserRejectObservation): + return getattr(event, "rejection_source", "user") == "hook" + return False + + +def _get_rejection_title(event: UserRejectObservation | AgentErrorEvent) -> str: + """Get the appropriate title for a rejection event.""" + if _is_hook_rejection(event): + return "Hook Blocked Action" + return "User Rejected Action" + + +def _get_rejection_icon(event: UserRejectObservation | AgentErrorEvent) -> str: + """Get the appropriate icon for a rejection event.""" + if _is_hook_rejection(event): + return f"{HOOK_ICON} {ERROR_ICON}" + return ERROR_ICON + + if TYPE_CHECKING: from textual.containers import VerticalScroll from textual.widget import Widget @@ -349,9 +375,12 @@ def _handle_observation_event( action_event, collapsible = self._pending_actions.pop(tool_call_id) - # Determine success/error status + # Determine success/error status and icon is_error = isinstance(event, UserRejectObservation | AgentErrorEvent) - status_icon = ERROR_ICON if is_error else SUCCESS_ICON + if is_error: + status_icon = _get_rejection_icon(event) + else: + status_icon = SUCCESS_ICON # Build the new title with status icon new_title = self._build_action_title(action_event) @@ -739,7 +768,8 @@ def _create_event_collapsible(self, event: Event) -> Collapsible | None: self._escape_rich_markup(str(content)), f"{agent_prefix}{title}", event ) elif isinstance(event, UserRejectObservation): - title = self._extract_meaningful_title(event, "User Rejected Action") + default_title = _get_rejection_title(event) + title = self._extract_meaningful_title(event, default_title) return self._make_collapsible( self._escape_rich_markup(str(content)), f"{agent_prefix}{title}", event ) diff --git a/tests/acp/events/test_shared_event_handler.py b/tests/acp/events/test_shared_event_handler.py new file mode 100644 index 000000000..983ce8b64 --- /dev/null +++ b/tests/acp/events/test_shared_event_handler.py @@ -0,0 +1,64 @@ +"""Tests for ACP shared event handler hook rejection detection.""" + +from openhands.sdk.event import AgentErrorEvent, UserRejectObservation + +from openhands_cli.acp_impl.events.shared_event_handler import ( + HOOK_BLOCKED_HEADER, + _is_hook_rejection, +) + + +class TestACPHookRejectionDetection: + """Tests for hook rejection detection in ACP shared event handler. + + Note: These tests use model_copy() to add rejection_source field since the + SDK version with this field may not be installed yet. The _is_hook_rejection + function uses getattr() for backwards compatibility. + """ + + def test_is_hook_rejection_with_hook_source(self): + """Test _is_hook_rejection returns True for hook rejections.""" + base_event = UserRejectObservation( + action_id="test_action_id", + tool_name="terminal", + tool_call_id="call_1", + rejection_reason="Blocked by security hook", + ) + event = base_event.model_copy(update={"rejection_source": "hook"}) + assert _is_hook_rejection(event) is True + + def test_is_hook_rejection_with_user_source(self): + """Test _is_hook_rejection returns False for user rejections.""" + base_event = UserRejectObservation( + action_id="test_action_id", + tool_name="terminal", + tool_call_id="call_1", + rejection_reason="User rejected the action", + ) + event = base_event.model_copy(update={"rejection_source": "user"}) + assert _is_hook_rejection(event) is False + + def test_is_hook_rejection_with_no_source_field(self): + """Test _is_hook_rejection returns False when rejection_source not present.""" + event = UserRejectObservation( + action_id="test_action_id", + tool_name="terminal", + tool_call_id="call_1", + rejection_reason="User rejected the action", + ) + assert _is_hook_rejection(event) is False + + def test_is_hook_rejection_with_agent_error_event(self): + """Test _is_hook_rejection returns False for AgentErrorEvent.""" + event = AgentErrorEvent( + error="Something went wrong", + tool_name="terminal", + tool_call_id="call_1", + ) + assert _is_hook_rejection(event) is False + + def test_hook_blocked_header_format(self): + """Test HOOK_BLOCKED_HEADER has expected format.""" + assert "⚡" in HOOK_BLOCKED_HEADER + assert "Hook" in HOOK_BLOCKED_HEADER + assert HOOK_BLOCKED_HEADER.endswith("\n") diff --git a/tests/tui/widgets/test_richlog_visualizer.py b/tests/tui/widgets/test_richlog_visualizer.py index 85559eda3..cfa0796e0 100644 --- a/tests/tui/widgets/test_richlog_visualizer.py +++ b/tests/tui/widgets/test_richlog_visualizer.py @@ -1121,3 +1121,133 @@ def capture_add(func, *args): assert isinstance(widget, Static) # Check the widget has user-message class (format verification) assert "user-message" in widget.classes + + +class TestHookRejectionDetection: + """Tests for hook rejection detection using rejection_source field. + + Note: These tests use model_copy() to add rejection_source field since the + SDK version with this field may not be installed yet. The _is_hook_rejection + function uses getattr() for backwards compatibility. + """ + + def test_is_hook_rejection_with_hook_source(self): + """Test _is_hook_rejection returns True for hook rejections.""" + from openhands.sdk.event import UserRejectObservation + from openhands_cli.tui.widgets.richlog_visualizer import _is_hook_rejection + + # Create base event and add rejection_source via model_copy + base_event = UserRejectObservation( + action_id="test_action_id", + tool_name="terminal", + tool_call_id="call_1", + rejection_reason="Blocked by security hook", + ) + # Simulate SDK with rejection_source field + event = base_event.model_copy(update={"rejection_source": "hook"}) + assert _is_hook_rejection(event) is True + + def test_is_hook_rejection_with_user_source(self): + """Test _is_hook_rejection returns False for user rejections.""" + from openhands.sdk.event import UserRejectObservation + from openhands_cli.tui.widgets.richlog_visualizer import _is_hook_rejection + + base_event = UserRejectObservation( + action_id="test_action_id", + tool_name="terminal", + tool_call_id="call_1", + rejection_reason="User rejected the action", + ) + event = base_event.model_copy(update={"rejection_source": "user"}) + assert _is_hook_rejection(event) is False + + def test_is_hook_rejection_with_no_source_field(self): + """Test _is_hook_rejection returns False when rejection_source not present.""" + from openhands.sdk.event import UserRejectObservation + from openhands_cli.tui.widgets.richlog_visualizer import _is_hook_rejection + + # Event without rejection_source field (older SDK version) + event = UserRejectObservation( + action_id="test_action_id", + tool_name="terminal", + tool_call_id="call_1", + rejection_reason="User rejected the action", + ) + # getattr with default should return "user", so not a hook rejection + assert _is_hook_rejection(event) is False + + def test_is_hook_rejection_with_agent_error_event(self): + """Test _is_hook_rejection returns False for AgentErrorEvent.""" + from openhands.sdk.event import AgentErrorEvent + from openhands_cli.tui.widgets.richlog_visualizer import _is_hook_rejection + + event = AgentErrorEvent( + error="Something went wrong", + tool_name="terminal", + tool_call_id="call_1", + ) + assert _is_hook_rejection(event) is False + + def test_get_rejection_title_for_hook(self): + """Test _get_rejection_title returns correct title for hook rejection.""" + from openhands.sdk.event import UserRejectObservation + from openhands_cli.tui.widgets.richlog_visualizer import _get_rejection_title + + base_event = UserRejectObservation( + action_id="test_action_id", + tool_name="terminal", + tool_call_id="call_1", + rejection_reason="Blocked by hook", + ) + event = base_event.model_copy(update={"rejection_source": "hook"}) + assert _get_rejection_title(event) == "Hook Blocked Action" + + def test_get_rejection_title_for_user(self): + """Test _get_rejection_title returns correct title for user rejection.""" + from openhands.sdk.event import UserRejectObservation + from openhands_cli.tui.widgets.richlog_visualizer import _get_rejection_title + + base_event = UserRejectObservation( + action_id="test_action_id", + tool_name="terminal", + tool_call_id="call_1", + rejection_reason="User rejected", + ) + event = base_event.model_copy(update={"rejection_source": "user"}) + assert _get_rejection_title(event) == "User Rejected Action" + + def test_get_rejection_icon_for_hook(self): + """Test _get_rejection_icon returns hook icon for hook rejection.""" + from openhands.sdk.event import UserRejectObservation + from openhands_cli.tui.widgets.richlog_visualizer import ( + ERROR_ICON, + HOOK_ICON, + _get_rejection_icon, + ) + + base_event = UserRejectObservation( + action_id="test_action_id", + tool_name="terminal", + tool_call_id="call_1", + rejection_reason="Blocked by hook", + ) + event = base_event.model_copy(update={"rejection_source": "hook"}) + expected_icon = f"{HOOK_ICON} {ERROR_ICON}" + assert _get_rejection_icon(event) == expected_icon + + def test_get_rejection_icon_for_user(self): + """Test _get_rejection_icon returns error icon for user rejection.""" + from openhands.sdk.event import UserRejectObservation + from openhands_cli.tui.widgets.richlog_visualizer import ( + ERROR_ICON, + _get_rejection_icon, + ) + + base_event = UserRejectObservation( + action_id="test_action_id", + tool_name="terminal", + tool_call_id="call_1", + rejection_reason="User rejected", + ) + event = base_event.model_copy(update={"rejection_source": "user"}) + assert _get_rejection_icon(event) == ERROR_ICON From 610201f1a3a67c2b183db527b7b650d9e6046db7 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 15 Feb 2026 11:11:38 +0000 Subject: [PATCH 02/11] fix: remove extra blank line in imports to pass ruff lint Co-authored-by: openhands --- tests/acp/events/test_shared_event_handler.py | 1 - uv.lock | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/acp/events/test_shared_event_handler.py b/tests/acp/events/test_shared_event_handler.py index 983ce8b64..f084b2581 100644 --- a/tests/acp/events/test_shared_event_handler.py +++ b/tests/acp/events/test_shared_event_handler.py @@ -1,7 +1,6 @@ """Tests for ACP shared event handler hook rejection detection.""" from openhands.sdk.event import AgentErrorEvent, UserRejectObservation - from openhands_cli.acp_impl.events.shared_event_handler import ( HOOK_BLOCKED_HEADER, _is_hook_rejection, diff --git a/uv.lock b/uv.lock index 6e7ecfd29..f64c4d215 100644 --- a/uv.lock +++ b/uv.lock @@ -1601,6 +1601,7 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-forked" }, { name = "pytest-httpserver" }, + { name = "pytest-rerunfailures" }, { name = "pytest-textual-snapshot" }, { name = "pytest-xdist" }, { name = "ruff" }, @@ -1638,6 +1639,7 @@ dev = [ { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "pytest-forked", specifier = ">=1.6.0" }, { name = "pytest-httpserver", specifier = ">=1.1.3" }, + { name = "pytest-rerunfailures", specifier = ">=14.0" }, { name = "pytest-textual-snapshot", specifier = ">=1.1.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "ruff", specifier = ">=0.12.10" }, @@ -4688,6 +4690,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/d2/dfc2f25f3905921c2743c300a48d9494d29032f1389fc142e718d6978fb2/pytest_httpserver-1.1.3-py3-none-any.whl", hash = "sha256:5f84757810233e19e2bb5287f3826a71c97a3740abe3a363af9155c0f82fdbb9", size = 21000, upload-time = "2025-04-10T08:17:13.906Z" }, ] +[[package]] +name = "pytest-rerunfailures" +version = "16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/04/71e9520551fc8fe2cf5c1a1842e4e600265b0815f2016b7c27ec85688682/pytest_rerunfailures-16.1.tar.gz", hash = "sha256:c38b266db8a808953ebd71ac25c381cb1981a78ff9340a14bcb9f1b9bff1899e", size = 30889, upload-time = "2025-10-10T07:06:01.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/54/60eabb34445e3db3d3d874dc1dfa72751bfec3265bd611cb13c8b290adea/pytest_rerunfailures-16.1-py3-none-any.whl", hash = "sha256:5d11b12c0ca9a1665b5054052fcc1084f8deadd9328962745ef6b04e26382e86", size = 14093, upload-time = "2025-10-10T07:06:00.019Z" }, +] + [[package]] name = "pytest-textual-snapshot" version = "1.1.0" From 7d21acd6e18f0efcbc20713fee1f8dc570d51654 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 15 Feb 2026 11:15:21 +0000 Subject: [PATCH 03/11] test: add snapshot tests for rejection event feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add visual snapshot tests to verify the display of user and hook rejection events: - test_user_rejection_display: verifies 'User Rejected Action' title - test_hook_rejection_display: verifies 'Hook Blocked Action' title with ⚡ icon - test_user_and_hook_rejections_comparison: shows both types side by side These tests ensure the visual appearance of rejection events is correct and provides regression protection for the rejection_source field feature. --- ...tSnapshots.test_hook_rejection_display.svg | 104 +++++++++++++++ ...st_user_and_hook_rejections_comparison.svg | 120 ++++++++++++++++++ ...tSnapshots.test_user_rejection_display.svg | 104 +++++++++++++++ tests/snapshots/test_visualizer_snapshots.py | 56 +++++++- 4 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 tests/snapshots/__snapshots__/test_visualizer_snapshots/TestRejectionEventSnapshots.test_hook_rejection_display.svg create mode 100644 tests/snapshots/__snapshots__/test_visualizer_snapshots/TestRejectionEventSnapshots.test_user_and_hook_rejections_comparison.svg create mode 100644 tests/snapshots/__snapshots__/test_visualizer_snapshots/TestRejectionEventSnapshots.test_user_rejection_display.svg diff --git a/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestRejectionEventSnapshots.test_hook_rejection_display.svg b/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestRejectionEventSnapshots.test_hook_rejection_display.svg new file mode 100644 index 000000000..a5285f04f --- /dev/null +++ b/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestRejectionEventSnapshots.test_hook_rejection_display.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + VisualizerTestApp + + + + + + + + + + + +> hi how are you + + Hook Blocked Action: Tool: terminal  Rejection Reason: Blocked by security +hook: dangero... + + + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestRejectionEventSnapshots.test_user_and_hook_rejections_comparison.svg b/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestRejectionEventSnapshots.test_user_and_hook_rejections_comparison.svg new file mode 100644 index 000000000..6771bc375 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestRejectionEventSnapshots.test_user_and_hook_rejections_comparison.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + VisualizerTestApp + + + + + + + + + + + +> hi how are you + + User Rejected Action: Tool: terminal  Rejection Reason: User chose not to +proceed + + Hook Blocked Action: Tool: terminal  Rejection Reason: Hook blocked: rm +-rf detected + + + + + + + + + + diff --git a/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestRejectionEventSnapshots.test_user_rejection_display.svg b/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestRejectionEventSnapshots.test_user_rejection_display.svg new file mode 100644 index 000000000..373f86a1d --- /dev/null +++ b/tests/snapshots/__snapshots__/test_visualizer_snapshots/TestRejectionEventSnapshots.test_user_rejection_display.svg @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + VisualizerTestApp + + + + + + + + + + + +> hi how are you + + User Rejected Action: Tool: terminal  Rejection Reason: I don't want to +run this command + + + + + + + + + diff --git a/tests/snapshots/test_visualizer_snapshots.py b/tests/snapshots/test_visualizer_snapshots.py index 6f469267a..67b3351d7 100644 --- a/tests/snapshots/test_visualizer_snapshots.py +++ b/tests/snapshots/test_visualizer_snapshots.py @@ -4,14 +4,14 @@ proper padding alignment with user messages. """ -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Literal, cast from unittest.mock import MagicMock from textual.app import App, ComposeResult from textual.containers import VerticalScroll from textual.widgets import Static -from openhands.sdk.event import ActionEvent +from openhands.sdk.event import ActionEvent, UserRejectObservation from openhands.sdk.llm import MessageToolCall from openhands.sdk.tool.builtins.finish import FinishAction from openhands.sdk.tool.builtins.think import ThinkAction @@ -121,6 +121,20 @@ def _create_think_action_event(thought: str) -> ActionEvent: ) +def _create_user_reject_observation( + rejection_reason: str, + rejection_source: Literal["user", "hook"] = "user", +) -> UserRejectObservation: + """Create a UserRejectObservation event for testing.""" + return UserRejectObservation( + action_id="test_action_id", + tool_name="terminal", + tool_call_id="test_tool_call_id", + rejection_reason=rejection_reason, + rejection_source=rejection_source, + ) + + class TestVisualizerSnapshots: """Snapshot tests for the ConversationVisualizer.""" @@ -141,3 +155,41 @@ def test_multiple_actions_alignment(self, snap_compare): _create_finish_action_event("Done! Here's the result."), ] assert snap_compare(VisualizerTestApp(events), terminal_size=(80, 16)) + + +class TestRejectionEventSnapshots: + """Snapshot tests for rejection events (user and hook rejections).""" + + def test_user_rejection_display(self, snap_compare): + """Verify user rejection shows 'User Rejected Action' title.""" + events = [ + _create_user_reject_observation( + rejection_reason="I don't want to run this command", + rejection_source="user", + ) + ] + assert snap_compare(VisualizerTestApp(events), terminal_size=(80, 12)) + + def test_hook_rejection_display(self, snap_compare): + """Verify hook rejection shows 'Hook Blocked Action' title with ⚡ icon.""" + events = [ + _create_user_reject_observation( + rejection_reason="Blocked by security hook: dangerous command detected", + rejection_source="hook", + ) + ] + assert snap_compare(VisualizerTestApp(events), terminal_size=(80, 12)) + + def test_user_and_hook_rejections_comparison(self, snap_compare): + """Verify visual difference between user and hook rejections.""" + events = [ + _create_user_reject_observation( + rejection_reason="User chose not to proceed", + rejection_source="user", + ), + _create_user_reject_observation( + rejection_reason="Hook blocked: rm -rf detected", + rejection_source="hook", + ), + ] + assert snap_compare(VisualizerTestApp(events), terminal_size=(80, 16)) From 85ff6bf28f4d58747ed0d25c1926332958474d9f Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 31 Mar 2026 11:05:11 +0000 Subject: [PATCH 04/11] fix: lint issues - shorten docstrings, apply ruff formatting Co-authored-by: openhands --- openhands_cli/tui/widgets/richlog_visualizer.py | 4 +--- tests/acp/events/test_shared_event_handler.py | 2 +- tests/tui/widgets/test_richlog_visualizer.py | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/openhands_cli/tui/widgets/richlog_visualizer.py b/openhands_cli/tui/widgets/richlog_visualizer.py index cc3ce30d6..0bd4077c3 100644 --- a/openhands_cli/tui/widgets/richlog_visualizer.py +++ b/openhands_cli/tui/widgets/richlog_visualizer.py @@ -842,9 +842,7 @@ def _create_event_collapsible(self, event: Event) -> Collapsible | None: # UserRejectObservation needs dynamic title based on rejection_source if isinstance(event, UserRejectObservation): - return self._create_titled_collapsible( - event, _get_rejection_title(event) - ) + return self._create_titled_collapsible(event, _get_rejection_title(event)) fallback_titles: list[tuple[type[Event], str]] = [ (ObservationEvent, "Observation"), diff --git a/tests/acp/events/test_shared_event_handler.py b/tests/acp/events/test_shared_event_handler.py index 05727fa09..c0d62e966 100644 --- a/tests/acp/events/test_shared_event_handler.py +++ b/tests/acp/events/test_shared_event_handler.py @@ -33,7 +33,7 @@ def test_is_hook_rejection_with_user_source(self): assert _is_hook_rejection(event) is False def test_is_hook_rejection_with_default_source(self): - """Test _is_hook_rejection returns False when rejection_source defaults to 'user'.""" + """Test _is_hook_rejection returns False with default source.""" event = UserRejectObservation( action_id="test_action_id", tool_name="terminal", diff --git a/tests/tui/widgets/test_richlog_visualizer.py b/tests/tui/widgets/test_richlog_visualizer.py index 9308e19ef..2cd8a36cb 100644 --- a/tests/tui/widgets/test_richlog_visualizer.py +++ b/tests/tui/widgets/test_richlog_visualizer.py @@ -1279,7 +1279,7 @@ def test_is_hook_rejection_with_user_source(self): assert _is_hook_rejection(event) is False def test_is_hook_rejection_with_default_source(self): - """Test _is_hook_rejection returns False when rejection_source defaults to 'user'.""" + """Test _is_hook_rejection returns False with default source.""" from openhands.sdk.event import UserRejectObservation from openhands_cli.tui.widgets.richlog_visualizer import _is_hook_rejection From 6554317e6d97a3c18db78e147d6b6304ecdeaa69 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 2 Apr 2026 13:03:41 +0000 Subject: [PATCH 05/11] refactor: remove special hook icon for simplicity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove HOOK_ICON constant and _get_rejection_icon() function. All rejection events now use the standard ERROR_ICON (✗). Remove ⚡ from ACP HOOK_BLOCKED_HEADER. Title distinction (Hook Blocked Action vs User Rejected Action) is preserved. Co-authored-by: openhands --- .../acp_impl/events/shared_event_handler.py | 2 +- .../tui/widgets/richlog_visualizer.py | 18 ++------- tests/acp/events/test_shared_event_handler.py | 1 - tests/snapshots/test_visualizer_snapshots.py | 2 +- tests/tui/widgets/test_richlog_visualizer.py | 37 ------------------- 5 files changed, 5 insertions(+), 55 deletions(-) diff --git a/openhands_cli/acp_impl/events/shared_event_handler.py b/openhands_cli/acp_impl/events/shared_event_handler.py index 640e57d98..92e427927 100644 --- a/openhands_cli/acp_impl/events/shared_event_handler.py +++ b/openhands_cli/acp_impl/events/shared_event_handler.py @@ -48,7 +48,7 @@ # Formatting constants for consistent headers across streaming and non-streaming modes REASONING_HEADER = "**Reasoning**:\n" THOUGHT_HEADER = "\n**Thought**:\n" -HOOK_BLOCKED_HEADER = "**⚡ Hook Blocked Action**:\n" +HOOK_BLOCKED_HEADER = "**Hook Blocked Action**:\n" def _event_visualize_to_plain(event: Event) -> str: diff --git a/openhands_cli/tui/widgets/richlog_visualizer.py b/openhands_cli/tui/widgets/richlog_visualizer.py index 0bd4077c3..acc4a292e 100644 --- a/openhands_cli/tui/widgets/richlog_visualizer.py +++ b/openhands_cli/tui/widgets/richlog_visualizer.py @@ -42,7 +42,6 @@ SUCCESS_COLOR = "#6bff6b" ERROR_ICON = "✗" ERROR_COLOR = "#ff6b6b" -HOOK_ICON = "⚡" # Icon for hook-blocked actions AGENT_MESSAGE_PADDING = (1, 0, 1, 1) # top, right, bottom, left # Maximum line length for truncating titles/commands in collapsed view @@ -67,13 +66,6 @@ def _get_rejection_title(event: UserRejectObservation | AgentErrorEvent) -> str: return "User Rejected Action" -def _get_rejection_icon(event: UserRejectObservation | AgentErrorEvent) -> str: - """Get the appropriate icon for a rejection event.""" - if _is_hook_rejection(event): - return f"{HOOK_ICON} {ERROR_ICON}" - return ERROR_ICON - - if TYPE_CHECKING: from textual.containers import VerticalScroll from textual.widget import Widget @@ -450,14 +442,10 @@ def _handle_observation_event( action_event, collapsible = self._pending_actions.pop(tool_call_id) - # Determine success/error status and icon + # Determine success/error status is_error = isinstance(event, UserRejectObservation | AgentErrorEvent) - if is_error: - status_icon = _get_rejection_icon(event) - status_color = ERROR_COLOR - else: - status_icon = SUCCESS_ICON - status_color = SUCCESS_COLOR + status_icon = ERROR_ICON if is_error else SUCCESS_ICON + status_color = ERROR_COLOR if is_error else SUCCESS_COLOR # Build the new title with colored status icon title_text = Text.from_markup(self._build_action_title(action_event)) diff --git a/tests/acp/events/test_shared_event_handler.py b/tests/acp/events/test_shared_event_handler.py index c0d62e966..5c9e908c2 100644 --- a/tests/acp/events/test_shared_event_handler.py +++ b/tests/acp/events/test_shared_event_handler.py @@ -53,6 +53,5 @@ def test_is_hook_rejection_with_agent_error_event(self): def test_hook_blocked_header_format(self): """Test HOOK_BLOCKED_HEADER has expected format.""" - assert "⚡" in HOOK_BLOCKED_HEADER assert "Hook" in HOOK_BLOCKED_HEADER assert HOOK_BLOCKED_HEADER.endswith("\n") diff --git a/tests/snapshots/test_visualizer_snapshots.py b/tests/snapshots/test_visualizer_snapshots.py index 67b3351d7..319a08010 100644 --- a/tests/snapshots/test_visualizer_snapshots.py +++ b/tests/snapshots/test_visualizer_snapshots.py @@ -171,7 +171,7 @@ def test_user_rejection_display(self, snap_compare): assert snap_compare(VisualizerTestApp(events), terminal_size=(80, 12)) def test_hook_rejection_display(self, snap_compare): - """Verify hook rejection shows 'Hook Blocked Action' title with ⚡ icon.""" + """Verify hook rejection shows 'Hook Blocked Action' title.""" events = [ _create_user_reject_observation( rejection_reason="Blocked by security hook: dangerous command detected", diff --git a/tests/tui/widgets/test_richlog_visualizer.py b/tests/tui/widgets/test_richlog_visualizer.py index 2cd8a36cb..ac7f8aafc 100644 --- a/tests/tui/widgets/test_richlog_visualizer.py +++ b/tests/tui/widgets/test_richlog_visualizer.py @@ -1331,43 +1331,6 @@ def test_get_rejection_title_for_user(self): ) assert _get_rejection_title(event) == "User Rejected Action" - def test_get_rejection_icon_for_hook(self): - """Test _get_rejection_icon returns hook icon for hook rejection.""" - from openhands.sdk.event import UserRejectObservation - from openhands_cli.tui.widgets.richlog_visualizer import ( - ERROR_ICON, - HOOK_ICON, - _get_rejection_icon, - ) - - event = UserRejectObservation( - action_id="test_action_id", - tool_name="terminal", - tool_call_id="call_1", - rejection_reason="Blocked by hook", - rejection_source="hook", - ) - expected_icon = f"{HOOK_ICON} {ERROR_ICON}" - assert _get_rejection_icon(event) == expected_icon - - def test_get_rejection_icon_for_user(self): - """Test _get_rejection_icon returns error icon for user rejection.""" - from openhands.sdk.event import UserRejectObservation - from openhands_cli.tui.widgets.richlog_visualizer import ( - ERROR_ICON, - _get_rejection_icon, - ) - - event = UserRejectObservation( - action_id="test_action_id", - tool_name="terminal", - tool_call_id="call_1", - rejection_reason="User rejected", - rejection_source="user", - ) - assert _get_rejection_icon(event) == ERROR_ICON - - class TestDefaultAgentPrefixBehavior: """Tests for hiding agent prefix for the default OpenHands Agent. From f4ec511dc825d04192f1e1a23848a4c02a75ba0e Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 2 Apr 2026 13:37:23 +0000 Subject: [PATCH 06/11] style: fix ruff-format missing blank line Co-authored-by: openhands --- tests/tui/widgets/test_richlog_visualizer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/tui/widgets/test_richlog_visualizer.py b/tests/tui/widgets/test_richlog_visualizer.py index ac7f8aafc..b46af57ec 100644 --- a/tests/tui/widgets/test_richlog_visualizer.py +++ b/tests/tui/widgets/test_richlog_visualizer.py @@ -1331,6 +1331,7 @@ def test_get_rejection_title_for_user(self): ) assert _get_rejection_title(event) == "User Rejected Action" + class TestDefaultAgentPrefixBehavior: """Tests for hiding agent prefix for the default OpenHands Agent. From ff40cea58025b50a3cbeafc008c857fc1e5d76f8 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 2 Apr 2026 14:12:27 +0000 Subject: [PATCH 07/11] feat: handle HookExecutionEvent for stop hook rejections The stop hook path uses HookExecutionEvent (not UserRejectObservation), so 'Hook Blocked Action' was never shown for stop hook denials. TUI: Import HookExecutionEvent, show 'Hook Blocked Action' collapsible for blocked HookExecutionEvents, hide successful ones. ACP: Add handle_hook_execution() to SharedEventHandler, dispatch in event.py and token_streamer.py. Tests: Add HookExecutionEvent tests for both TUI and ACP helpers. Co-authored-by: openhands --- openhands_cli/acp_impl/events/event.py | 3 ++ .../acp_impl/events/shared_event_handler.py | 16 ++++++- .../acp_impl/events/token_streamer.py | 3 ++ .../tui/widgets/richlog_visualizer.py | 23 +++++++++- tests/acp/events/test_shared_event_handler.py | 29 +++++++++++- tests/tui/widgets/test_richlog_visualizer.py | 44 +++++++++++++++++++ 6 files changed, 114 insertions(+), 4 deletions(-) diff --git a/openhands_cli/acp_impl/events/event.py b/openhands_cli/acp_impl/events/event.py index aafe2d690..81d72f285 100644 --- a/openhands_cli/acp_impl/events/event.py +++ b/openhands_cli/acp_impl/events/event.py @@ -14,6 +14,7 @@ CondensationRequest, ConversationStateUpdateEvent, Event, + HookExecutionEvent, MessageEvent, ObservationEvent, PauseEvent, @@ -87,6 +88,8 @@ async def __call__(self, event: Event) -> None: await self.shared_events_handler.handle_system_prompt(self, event) elif isinstance(event, PauseEvent): await self.shared_events_handler.handle_pause(self, event) + elif isinstance(event, HookExecutionEvent): + await self.shared_events_handler.handle_hook_execution(self, event) elif isinstance(event, Condensation): await self.shared_events_handler.handle_condensation(self, event) elif isinstance(event, CondensationRequest): diff --git a/openhands_cli/acp_impl/events/shared_event_handler.py b/openhands_cli/acp_impl/events/shared_event_handler.py index 92e427927..d58a3755f 100644 --- a/openhands_cli/acp_impl/events/shared_event_handler.py +++ b/openhands_cli/acp_impl/events/shared_event_handler.py @@ -23,6 +23,7 @@ Condensation, CondensationRequest, Event, + HookExecutionEvent, ObservationEvent, PauseEvent, SystemPromptEvent, @@ -55,8 +56,12 @@ def _event_visualize_to_plain(event: Event) -> str: return str(event.visualize.plain) -def _is_hook_rejection(event: UserRejectObservation | AgentErrorEvent) -> bool: +def _is_hook_rejection( + event: UserRejectObservation | AgentErrorEvent | HookExecutionEvent, +) -> bool: """Check if a rejection event originated from a hook.""" + if isinstance(event, HookExecutionEvent): + return event.blocked if isinstance(event, UserRejectObservation): return event.rejection_source == "hook" return False @@ -114,6 +119,15 @@ async def handle_system_prompt( ) -> None: await self.send_thought(ctx, str(event.visualize.plain)) + async def handle_hook_execution( + self, ctx: _ACPContext, event: HookExecutionEvent + ) -> None: + if not event.blocked: + return + text = _event_visualize_to_plain(event) + text = f"{HOOK_BLOCKED_HEADER}{text}" + await self.send_thought(ctx, text) + async def handle_condensation(self, ctx: _ACPContext, event: Condensation) -> None: await self.send_thought(ctx, _event_visualize_to_plain(event)) diff --git a/openhands_cli/acp_impl/events/token_streamer.py b/openhands_cli/acp_impl/events/token_streamer.py index 0063d0574..bd4acbf0c 100644 --- a/openhands_cli/acp_impl/events/token_streamer.py +++ b/openhands_cli/acp_impl/events/token_streamer.py @@ -34,6 +34,7 @@ Condensation, CondensationRequest, ConversationStateUpdateEvent, + HookExecutionEvent, ObservationEvent, PauseEvent, SystemPromptEvent, @@ -172,6 +173,8 @@ async def unstreamed_event_handler(self, event: Event) -> None: await self.shared_events_handler.handle_system_prompt(self, event) elif isinstance(event, PauseEvent): await self.shared_events_handler.handle_pause(self, event) + elif isinstance(event, HookExecutionEvent): + await self.shared_events_handler.handle_hook_execution(self, event) elif isinstance(event, Condensation): await self.shared_events_handler.handle_condensation(self, event) elif isinstance(event, CondensationRequest): diff --git a/openhands_cli/tui/widgets/richlog_visualizer.py b/openhands_cli/tui/widgets/richlog_visualizer.py index acc4a292e..fbd9a3268 100644 --- a/openhands_cli/tui/widgets/richlog_visualizer.py +++ b/openhands_cli/tui/widgets/richlog_visualizer.py @@ -14,6 +14,7 @@ from openhands.sdk.event import ( ActionEvent, AgentErrorEvent, + HookExecutionEvent, MessageEvent, ObservationEvent, PauseEvent, @@ -52,14 +53,20 @@ DEFAULT_AGENT_NAME = "OpenHands Agent" -def _is_hook_rejection(event: UserRejectObservation | AgentErrorEvent) -> bool: +def _is_hook_rejection( + event: UserRejectObservation | AgentErrorEvent | HookExecutionEvent, +) -> bool: """Check if a rejection event originated from a hook.""" + if isinstance(event, HookExecutionEvent): + return event.blocked if isinstance(event, UserRejectObservation): return event.rejection_source == "hook" return False -def _get_rejection_title(event: UserRejectObservation | AgentErrorEvent) -> str: +def _get_rejection_title( + event: UserRejectObservation | AgentErrorEvent | HookExecutionEvent, +) -> str: """Get the appropriate title for a rejection event.""" if _is_hook_rejection(event): return "Hook Blocked Action" @@ -89,6 +96,10 @@ def _get_event_symbol_color(event: Event) -> str: return OPENHANDS_THEME.primary else: return OPENHANDS_THEME.accent or DEFAULT_COLOR + elif isinstance(event, HookExecutionEvent): + if event.blocked: + return OPENHANDS_THEME.error or DEFAULT_COLOR + return DEFAULT_COLOR elif isinstance(event, AgentErrorEvent): return OPENHANDS_THEME.error or DEFAULT_COLOR elif isinstance(event, ConversationErrorEvent): @@ -832,6 +843,14 @@ def _create_event_collapsible(self, event: Event) -> Collapsible | None: if isinstance(event, UserRejectObservation): return self._create_titled_collapsible(event, _get_rejection_title(event)) + # HookExecutionEvent: show blocked hooks, hide successful ones + if isinstance(event, HookExecutionEvent): + if event.blocked: + return self._create_titled_collapsible( + event, _get_rejection_title(event) + ) + return None + fallback_titles: list[tuple[type[Event], str]] = [ (ObservationEvent, "Observation"), (AgentErrorEvent, "Agent Error"), diff --git a/tests/acp/events/test_shared_event_handler.py b/tests/acp/events/test_shared_event_handler.py index 5c9e908c2..127af94b0 100644 --- a/tests/acp/events/test_shared_event_handler.py +++ b/tests/acp/events/test_shared_event_handler.py @@ -1,6 +1,10 @@ """Tests for ACP shared event handler hook rejection detection.""" -from openhands.sdk.event import AgentErrorEvent, UserRejectObservation +from openhands.sdk.event import ( + AgentErrorEvent, + HookExecutionEvent, + UserRejectObservation, +) from openhands_cli.acp_impl.events.shared_event_handler import ( HOOK_BLOCKED_HEADER, _is_hook_rejection, @@ -51,6 +55,29 @@ def test_is_hook_rejection_with_agent_error_event(self): ) assert _is_hook_rejection(event) is False + def test_is_hook_rejection_with_blocked_hook_execution_event(self): + """Test _is_hook_rejection returns True for blocked HookExecutionEvent.""" + event = HookExecutionEvent( + hook_event_type="Stop", + hook_command=".openhands/hooks/on_stop.sh", + success=False, + blocked=True, + exit_code=2, + reason="Checks failed", + ) + assert _is_hook_rejection(event) is True + + def test_is_hook_rejection_with_successful_hook_execution_event(self): + """Test _is_hook_rejection returns False for successful HookExecutionEvent.""" + event = HookExecutionEvent( + hook_event_type="Stop", + hook_command=".openhands/hooks/on_stop.sh", + success=True, + blocked=False, + exit_code=0, + ) + assert _is_hook_rejection(event) is False + def test_hook_blocked_header_format(self): """Test HOOK_BLOCKED_HEADER has expected format.""" assert "Hook" in HOOK_BLOCKED_HEADER diff --git a/tests/tui/widgets/test_richlog_visualizer.py b/tests/tui/widgets/test_richlog_visualizer.py index b46af57ec..b51f9baa1 100644 --- a/tests/tui/widgets/test_richlog_visualizer.py +++ b/tests/tui/widgets/test_richlog_visualizer.py @@ -1331,6 +1331,50 @@ def test_get_rejection_title_for_user(self): ) assert _get_rejection_title(event) == "User Rejected Action" + def test_is_hook_rejection_with_blocked_hook_execution_event(self): + """Test _is_hook_rejection returns True for blocked HookExecutionEvent.""" + from openhands.sdk.event import HookExecutionEvent + from openhands_cli.tui.widgets.richlog_visualizer import _is_hook_rejection + + event = HookExecutionEvent( + hook_event_type="Stop", + hook_command=".openhands/hooks/on_stop.sh", + success=False, + blocked=True, + exit_code=2, + reason="Checks failed", + ) + assert _is_hook_rejection(event) is True + + def test_is_hook_rejection_with_successful_hook_execution_event(self): + """Test _is_hook_rejection returns False for successful HookExecutionEvent.""" + from openhands.sdk.event import HookExecutionEvent + from openhands_cli.tui.widgets.richlog_visualizer import _is_hook_rejection + + event = HookExecutionEvent( + hook_event_type="Stop", + hook_command=".openhands/hooks/on_stop.sh", + success=True, + blocked=False, + exit_code=0, + ) + assert _is_hook_rejection(event) is False + + def test_get_rejection_title_for_blocked_hook_execution(self): + """Test _get_rejection_title returns hook title for blocked HookExecutionEvent.""" + from openhands.sdk.event import HookExecutionEvent + from openhands_cli.tui.widgets.richlog_visualizer import _get_rejection_title + + event = HookExecutionEvent( + hook_event_type="Stop", + hook_command=".openhands/hooks/on_stop.sh", + success=False, + blocked=True, + exit_code=2, + reason="CI checks failed", + ) + assert _get_rejection_title(event) == "Hook Blocked Action" + class TestDefaultAgentPrefixBehavior: """Tests for hiding agent prefix for the default OpenHands Agent. From f8e38cfe0d04c94fdfaed9c8ed8a6c233e3771b5 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 2 Apr 2026 15:40:00 +0000 Subject: [PATCH 08/11] style: shorten docstring to fix E501 line too long Co-authored-by: openhands --- tests/tui/widgets/test_richlog_visualizer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tui/widgets/test_richlog_visualizer.py b/tests/tui/widgets/test_richlog_visualizer.py index b51f9baa1..02187c2f2 100644 --- a/tests/tui/widgets/test_richlog_visualizer.py +++ b/tests/tui/widgets/test_richlog_visualizer.py @@ -1361,7 +1361,7 @@ def test_is_hook_rejection_with_successful_hook_execution_event(self): assert _is_hook_rejection(event) is False def test_get_rejection_title_for_blocked_hook_execution(self): - """Test _get_rejection_title returns hook title for blocked HookExecutionEvent.""" + """Test _get_rejection_title for blocked HookExecutionEvent.""" from openhands.sdk.event import HookExecutionEvent from openhands_cli.tui.widgets.richlog_visualizer import _get_rejection_title From dd83b2a3a9eaa2df401383118aeb8153c4bd1362 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 2 Apr 2026 16:10:02 +0000 Subject: [PATCH 09/11] docs: add headless E2E testing and SDK hook event flow to AGENTS.md Co-authored-by: openhands --- AGENTS.md | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d8d4c7112..5786712ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,6 +209,97 @@ To view the generated SVG snapshots in a browser: - Do not embed API keys or endpoints in code; rely on runtime configuration/env vars when integrating new services. - When packaging, verify no sensitive files are included in `dist/`; adjust `openhands-cli.spec` if new assets are added. +## Headless/JSON Mode for E2E Testing + +The CLI supports a headless JSON output mode useful for automated E2E testing without the TUI. This is particularly helpful for testing event flows (e.g., hook rejections) programmatically. + +### Running Headless Mode + +```bash +# Basic headless run with JSON event output +LLM_API_KEY=$LLM_API_KEY LLM_BASE_URL=https://llm-proxy.eval.all-hands.dev \ + uv run openhands --headless --json --override-with-envs \ + -t "Your task prompt here" > output.log 2>stderr.log & +``` + +- `--headless`: Runs without the TUI (no interactive UI) +- `--json`: Outputs each event as a JSON object separated by `--JSON Event--` markers +- `--override-with-envs`: Uses `LLM_API_KEY`/`LLM_BASE_URL` env vars instead of stored settings +- `-t "..."`: The task/prompt to send to the agent +- Run in background (`&`) so you can monitor logs with `grep`/`tail` + +### Analyzing JSON Output + +```bash +# Count events +grep -c "JSON Event" output.log + +# Search for specific event types +grep '"kind": "HookExecutionEvent"' output.log +grep '"blocked": true' output.log + +# View a specific event with context +grep -B2 -A20 '"kind": "HookExecutionEvent"' output.log +``` + +### Using tmux for Interactive TUI Testing + +When you need to observe the actual TUI (not headless), use tmux: + +```bash +# Start a tmux session +tmux new-session -d -s test-cli -x 120 -y 40 + +# Send the CLI command to tmux +tmux send-keys -t test-cli 'cd /path/to/workspace && uv run openhands' Enter + +# Wait for startup, then send a task +tmux send-keys -t test-cli 'your task prompt' Enter + +# Capture the screen to check output +tmux capture-pane -t test-cli -p + +# Detach: Ctrl+b d (or programmatically) +tmux detach -s test-cli + +# Kill when done +tmux kill-session -t test-cli +``` + +### Testing Against the SDK Repo (Hook Testing) + +The `OpenHands/software-agent-sdk` repo has pre-commit hooks configured, making it a good workspace for testing hook behavior: + +```bash +# Clone the SDK repo +git clone https://github.com/OpenHands/software-agent-sdk.git /path/to/sdk + +# Run CLI in that workspace to trigger hooks +cd /path/to/sdk +LLM_API_KEY=$LLM_API_KEY LLM_BASE_URL=https://llm-proxy.eval.all-hands.dev \ + /path/to/cli/.venv/bin/openhands --headless --json --override-with-envs \ + -t "break the pre-commit and return finish" > /tmp/test.log 2>&1 & +``` + +### LLM Configuration for E2E Tests + +Agent settings are stored in `~/.openhands/agent_settings.json`. For E2E testing: +- Use `--override-with-envs` flag with `LLM_API_KEY` and `LLM_BASE_URL` env vars +- Or modify `~/.openhands/agent_settings.json` directly (remember to restore after) + +## SDK Event Flow: Hooks + +Understanding how the SDK emits events for different hook types is critical for CLI event handling: + +- **Stop hooks** (on finish): Emit `HookExecutionEvent` (with `blocked=true/false`) + `MessageEvent` with feedback. They do **not** produce `UserRejectObservation`. +- **PreToolUse hooks** (before tool calls): Emit `HookExecutionEvent` + `UserRejectObservation(rejection_source="hook")` when blocked. +- **Successful hooks** (not blocked): Emit `HookExecutionEvent` with `blocked=false`, `success=true`. These are typically hidden in the UI. + +Key SDK source locations: +- `openhands-sdk/openhands/sdk/agent/agent.py`: `_ActionBatch` class creates `UserRejectObservation` with `rejection_source="hook"` for PreToolUse blocks (around L168-173) +- `openhands-sdk/openhands/sdk/hook/hook_manager.py`: Hook execution and `HookExecutionEvent` emission +- `openhands-sdk/openhands/sdk/event/event.py`: `HookExecutionEvent` class definition (fields: `hook_event_type`, `blocked`, `success`, `exit_code`, `reason`, `stdout`, `stderr`) + ## TUI State Management Architecture The TUI uses a reactive state management pattern with clear separation of concerns. Key files are in `openhands_cli/tui/core/`. From 806eb8151d1c322c7435ca3ad78aa5b93162f871 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 2 Apr 2026 16:11:16 +0000 Subject: [PATCH 10/11] feat: show successful HookExecutionEvents too Show all hook executions in TUI (as 'Hook Executed') and ACP, not just blocked ones. Co-authored-by: openhands --- .../acp_impl/events/shared_event_handler.py | 5 ++--- openhands_cli/tui/widgets/richlog_visualizer.py | 13 +++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/openhands_cli/acp_impl/events/shared_event_handler.py b/openhands_cli/acp_impl/events/shared_event_handler.py index d58a3755f..ad0726997 100644 --- a/openhands_cli/acp_impl/events/shared_event_handler.py +++ b/openhands_cli/acp_impl/events/shared_event_handler.py @@ -122,10 +122,9 @@ async def handle_system_prompt( async def handle_hook_execution( self, ctx: _ACPContext, event: HookExecutionEvent ) -> None: - if not event.blocked: - return text = _event_visualize_to_plain(event) - text = f"{HOOK_BLOCKED_HEADER}{text}" + if event.blocked: + text = f"{HOOK_BLOCKED_HEADER}{text}" await self.send_thought(ctx, text) async def handle_condensation(self, ctx: _ACPContext, event: Condensation) -> None: diff --git a/openhands_cli/tui/widgets/richlog_visualizer.py b/openhands_cli/tui/widgets/richlog_visualizer.py index fbd9a3268..7fbaf15a8 100644 --- a/openhands_cli/tui/widgets/richlog_visualizer.py +++ b/openhands_cli/tui/widgets/richlog_visualizer.py @@ -843,13 +843,14 @@ def _create_event_collapsible(self, event: Event) -> Collapsible | None: if isinstance(event, UserRejectObservation): return self._create_titled_collapsible(event, _get_rejection_title(event)) - # HookExecutionEvent: show blocked hooks, hide successful ones + # HookExecutionEvent: dynamic title based on blocked status if isinstance(event, HookExecutionEvent): - if event.blocked: - return self._create_titled_collapsible( - event, _get_rejection_title(event) - ) - return None + title = ( + _get_rejection_title(event) + if event.blocked + else "Hook Executed" + ) + return self._create_titled_collapsible(event, title) fallback_titles: list[tuple[type[Event], str]] = [ (ObservationEvent, "Observation"), From eee75eee899aca8ef4b7d9e80b879a0bb3a027bd Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 2 Apr 2026 16:12:06 +0000 Subject: [PATCH 11/11] style: ruff format ternary expression Co-authored-by: openhands --- openhands_cli/tui/widgets/richlog_visualizer.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openhands_cli/tui/widgets/richlog_visualizer.py b/openhands_cli/tui/widgets/richlog_visualizer.py index 7fbaf15a8..106858971 100644 --- a/openhands_cli/tui/widgets/richlog_visualizer.py +++ b/openhands_cli/tui/widgets/richlog_visualizer.py @@ -845,11 +845,7 @@ def _create_event_collapsible(self, event: Event) -> Collapsible | None: # HookExecutionEvent: dynamic title based on blocked status if isinstance(event, HookExecutionEvent): - title = ( - _get_rejection_title(event) - if event.blocked - else "Hook Executed" - ) + title = _get_rejection_title(event) if event.blocked else "Hook Executed" return self._create_titled_collapsible(event, title) fallback_titles: list[tuple[type[Event], str]] = [