Fixes #9394 - Improve CLI exit messaging to distinguish intentional exits and inter… (#9432)

This commit is contained in:
AY 2025-06-28 22:21:25 +05:30 committed by GitHub
parent a7b234d1f6
commit b06a3bdb7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 94 additions and 12 deletions

View File

@ -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(

View File

@ -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)

View File

@ -0,0 +1,7 @@
from enum import Enum
class ExitReason(Enum):
INTENTIONAL = 'intentional'
INTERRUPTED = 'interrupted'
ERROR = 'error'

View File

@ -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
)

View File

@ -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,

View File

@ -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) == "<ExitReason.ERROR: '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