From b06a3bdb7c75918a45aa9bd9482a46503f568047 Mon Sep 17 00:00:00 2001 From: AY <97109361+Adityauyadav@users.noreply.github.com> Date: Sat, 28 Jun 2025 22:21:25 +0530 Subject: [PATCH] =?UTF-8?q?Fixes=20#9394=20-=20Improve=20CLI=20exit=20mess?= =?UTF-8?q?aging=20to=20distinguish=20intentional=20exits=20and=20inter?= =?UTF-8?q?=E2=80=A6=20(#9432)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openhands/cli/commands.py | 10 ++++- openhands/cli/main.py | 12 +++++- openhands/core/schema/exit_reason.py | 7 ++++ tests/unit/test_cli_commands.py | 14 +++---- tests/unit/test_cli_pause_resume.py | 7 +++- tests/unit/test_exit_reason.py | 56 ++++++++++++++++++++++++++++ 6 files changed, 94 insertions(+), 12 deletions(-) create mode 100644 openhands/core/schema/exit_reason.py create mode 100644 tests/unit/test_exit_reason.py diff --git a/openhands/cli/commands.py b/openhands/cli/commands.py index bcb81fb63e..787799dcfc 100644 --- a/openhands/cli/commands.py +++ b/openhands/cli/commands.py @@ -28,6 +28,7 @@ from openhands.core.config import ( OpenHandsConfig, ) from openhands.core.schema import AgentState +from openhands.core.schema.exit_reason import ExitReason from openhands.events import EventSource from openhands.events.action import ( ChangeAgentStateAction, @@ -45,10 +46,11 @@ async def handle_commands( config: OpenHandsConfig, current_dir: str, settings_store: FileSettingsStore, -) -> tuple[bool, bool, bool]: +) -> tuple[bool, bool, bool, ExitReason]: close_repl = False reload_microagents = False new_session_requested = False + exit_reason = ExitReason.ERROR if command == '/exit': close_repl = handle_exit_command( @@ -57,6 +59,8 @@ async def handle_commands( usage_metrics, sid, ) + if close_repl: + exit_reason = ExitReason.INTENTIONAL elif command == '/help': handle_help_command() elif command == '/init': @@ -69,6 +73,8 @@ async def handle_commands( close_repl, new_session_requested = handle_new_command( config, event_stream, usage_metrics, sid ) + if close_repl: + exit_reason = ExitReason.INTENTIONAL elif command == '/settings': await handle_settings_command(config, settings_store) elif command == '/resume': @@ -78,7 +84,7 @@ async def handle_commands( action = MessageAction(content=command) event_stream.add_event(action, EventSource.USER) - return close_repl, reload_microagents, new_session_requested + return close_repl, reload_microagents, new_session_requested, exit_reason def handle_exit_command( diff --git a/openhands/cli/main.py b/openhands/cli/main.py index f59b9c7940..7d72410f8b 100644 --- a/openhands/cli/main.py +++ b/openhands/cli/main.py @@ -43,6 +43,7 @@ from openhands.core.config.mcp_config import OpenHandsMCPConfigImpl from openhands.core.logger import openhands_logger as logger from openhands.core.loop import run_agent_until_done from openhands.core.schema import AgentState +from openhands.core.schema.exit_reason import ExitReason from openhands.core.setup import ( create_agent, create_controller, @@ -116,6 +117,7 @@ async def run_session( ) -> bool: reload_microagents = False new_session_requested = False + exit_reason = ExitReason.INTENTIONAL sid = generate_sid(config, session_name) is_loaded = asyncio.Event() @@ -152,7 +154,7 @@ async def run_session( usage_metrics = UsageMetrics() async def prompt_for_next_task(agent_state: str) -> None: - nonlocal reload_microagents, new_session_requested + nonlocal reload_microagents, new_session_requested, exit_reason while True: next_message = await read_prompt_input( config, agent_state, multiline=config.cli_multiline_input @@ -165,6 +167,7 @@ async def run_session( close_repl, reload_microagents, new_session_requested, + exit_reason, ) = await handle_commands( next_message, event_stream, @@ -330,6 +333,11 @@ async def run_session( await cleanup_session(loop, agent, runtime, controller) + if exit_reason == ExitReason.INTENTIONAL: + print_formatted_text('✅ Session terminated successfully.\n') + else: + print_formatted_text(f'⚠️ Session was interrupted: {exit_reason.value}\n') + return new_session_requested @@ -478,7 +486,7 @@ def main(): try: loop.run_until_complete(main_with_loop(loop)) except KeyboardInterrupt: - print('Received keyboard interrupt, shutting down...') + print_formatted_text('⚠️ Session was interrupted: interrupted\n') except ConnectionRefusedError as e: print(f'Connection refused: {e}') sys.exit(1) diff --git a/openhands/core/schema/exit_reason.py b/openhands/core/schema/exit_reason.py new file mode 100644 index 0000000000..ffae2505c7 --- /dev/null +++ b/openhands/core/schema/exit_reason.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class ExitReason(Enum): + INTENTIONAL = 'intentional' + INTERRUPTED = 'interrupted' + ERROR = 'error' diff --git a/tests/unit/test_cli_commands.py b/tests/unit/test_cli_commands.py index 5ab7d499e7..d5b7f0e0cf 100644 --- a/tests/unit/test_cli_commands.py +++ b/tests/unit/test_cli_commands.py @@ -45,7 +45,7 @@ class TestHandleCommands: async def test_handle_exit_command(self, mock_handle_exit, mock_dependencies): mock_handle_exit.return_value = True - close_repl, reload_microagents, new_session = await handle_commands( + close_repl, reload_microagents, new_session, _ = await handle_commands( '/exit', **mock_dependencies ) @@ -64,7 +64,7 @@ class TestHandleCommands: async def test_handle_help_command(self, mock_handle_help, mock_dependencies): mock_handle_help.return_value = (False, False, False) - close_repl, reload_microagents, new_session = await handle_commands( + close_repl, reload_microagents, new_session, _ = await handle_commands( '/help', **mock_dependencies ) @@ -78,7 +78,7 @@ class TestHandleCommands: async def test_handle_init_command(self, mock_handle_init, mock_dependencies): mock_handle_init.return_value = (True, True) - close_repl, reload_microagents, new_session = await handle_commands( + close_repl, reload_microagents, new_session, _ = await handle_commands( '/init', **mock_dependencies ) @@ -96,7 +96,7 @@ class TestHandleCommands: async def test_handle_status_command(self, mock_handle_status, mock_dependencies): mock_handle_status.return_value = (False, False, False) - close_repl, reload_microagents, new_session = await handle_commands( + close_repl, reload_microagents, new_session, _ = await handle_commands( '/status', **mock_dependencies ) @@ -112,7 +112,7 @@ class TestHandleCommands: async def test_handle_new_command(self, mock_handle_new, mock_dependencies): mock_handle_new.return_value = (True, True) - close_repl, reload_microagents, new_session = await handle_commands( + close_repl, reload_microagents, new_session, _ = await handle_commands( '/new', **mock_dependencies ) @@ -131,7 +131,7 @@ class TestHandleCommands: async def test_handle_settings_command( self, mock_handle_settings, mock_dependencies ): - close_repl, reload_microagents, new_session = await handle_commands( + close_repl, reload_microagents, new_session, _ = await handle_commands( '/settings', **mock_dependencies ) @@ -147,7 +147,7 @@ class TestHandleCommands: async def test_handle_unknown_command(self, mock_dependencies): user_message = 'Hello, this is not a command' - close_repl, reload_microagents, new_session = await handle_commands( + close_repl, reload_microagents, new_session, _ = await handle_commands( user_message, **mock_dependencies ) diff --git a/tests/unit/test_cli_pause_resume.py b/tests/unit/test_cli_pause_resume.py index d85a03847f..da9743fdb6 100644 --- a/tests/unit/test_cli_pause_resume.py +++ b/tests/unit/test_cli_pause_resume.py @@ -253,7 +253,12 @@ class TestCliCommandsPauseResume: mock_handle_resume.return_value = (False, False) # Call handle_commands - close_repl, reload_microagents, new_session_requested = await handle_commands( + ( + close_repl, + reload_microagents, + new_session_requested, + _, + ) = await handle_commands( message, event_stream, usage_metrics, diff --git a/tests/unit/test_exit_reason.py b/tests/unit/test_exit_reason.py new file mode 100644 index 0000000000..512fe13b28 --- /dev/null +++ b/tests/unit/test_exit_reason.py @@ -0,0 +1,56 @@ +import time +from unittest.mock import MagicMock + +import pytest + +from openhands.cli.commands import handle_commands +from openhands.core.schema.exit_reason import ExitReason + + +def test_exit_reason_enum_values(): + assert ExitReason.INTENTIONAL.value == 'intentional' + assert ExitReason.INTERRUPTED.value == 'interrupted' + assert ExitReason.ERROR.value == 'error' + + +def test_exit_reason_enum_names(): + assert ExitReason['INTENTIONAL'] == ExitReason.INTENTIONAL + assert ExitReason['INTERRUPTED'] == ExitReason.INTERRUPTED + assert ExitReason['ERROR'] == ExitReason.ERROR + + +def test_exit_reason_str_representation(): + assert str(ExitReason.INTENTIONAL) == 'ExitReason.INTENTIONAL' + assert repr(ExitReason.ERROR) == "" + + +@pytest.mark.asyncio +async def test_handle_exit_command_returns_intentional(monkeypatch): + monkeypatch.setattr('openhands.cli.commands.cli_confirm', lambda *a, **k: 0) + + mock_usage_metrics = MagicMock() + mock_usage_metrics.session_init_time = time.time() - 3600 + mock_usage_metrics.metrics.accumulated_cost = 0.123456 + + # Mock all token counts used in display formatting + mock_usage_metrics.metrics.accumulated_token_usage.prompt_tokens = 1234 + mock_usage_metrics.metrics.accumulated_token_usage.cache_read_tokens = 5678 + mock_usage_metrics.metrics.accumulated_token_usage.cache_write_tokens = 9012 + mock_usage_metrics.metrics.accumulated_token_usage.completion_tokens = 3456 + + ( + close_repl, + reload_microagents, + new_session_requested, + exit_reason, + ) = await handle_commands( + '/exit', + MagicMock(), + mock_usage_metrics, + 'test-session', + MagicMock(), + '/tmp/test', + MagicMock(), + ) + + assert exit_reason == ExitReason.INTENTIONAL