From 997bf8efae863dfebc8fe216d1e10ae37dc189e1 Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Tue, 7 Oct 2025 11:28:16 -0400 Subject: [PATCH] CLI(V1): start new conversations without exiting CLI using /new command (#11262) Co-authored-by: openhands --- openhands-cli/openhands_cli/agent_chat.py | 57 +++++++++--- openhands-cli/openhands_cli/tui/tui.py | 1 + openhands-cli/tests/test_new_command.py | 100 ++++++++++++++++++++++ openhands-cli/tests/test_tui.py | 1 + 4 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 openhands-cli/tests/test_new_command.py diff --git a/openhands-cli/openhands_cli/agent_chat.py b/openhands-cli/openhands_cli/agent_chat.py index 6278c7d96a..2e6216359e 100644 --- a/openhands-cli/openhands_cli/agent_chat.py +++ b/openhands-cli/openhands_cli/agent_chat.py @@ -6,14 +6,15 @@ Provides a conversation interface with an AI agent using OpenHands patterns. import sys -from prompt_toolkit import print_formatted_text -from prompt_toolkit.formatted_text import HTML - from openhands.sdk import ( + BaseConversation, Message, TextContent, ) from openhands.sdk.conversation.state import AgentExecutionStatus +from prompt_toolkit import print_formatted_text +from prompt_toolkit.formatted_text import HTML + from openhands_cli.runner import ConversationRunner from openhands_cli.setup import MissingAgentSpec, setup_conversation from openhands_cli.tui.settings.mcp_screen import MCPScreen @@ -26,6 +27,30 @@ from openhands_cli.user_actions import UserConfirmation, exit_session_confirmati from openhands_cli.user_actions.utils import get_session_prompter +def _start_fresh_conversation(resume_conversation_id: str | None = None) -> BaseConversation: + """Start a fresh conversation by creating a new conversation instance. + + Handles the complete conversation setup process including settings screen + if agent configuration is missing. + + Args: + resume_conversation_id: Optional conversation ID to resume + + Returns: + BaseConversation: A new conversation instance + """ + conversation = None + settings_screen = SettingsScreen() + + while not conversation: + try: + conversation = setup_conversation(resume_conversation_id) + except MissingAgentSpec: + settings_screen.handle_basic_settings(escapable=False) + + return conversation + + def _restore_tty() -> None: """ Ensure terminal modes are reset in case prompt_toolkit cleanup didn't run. @@ -62,15 +87,7 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None: EOFError: If EOF is encountered """ - conversation = None - settings_screen = SettingsScreen() - - while not conversation: - try: - conversation = setup_conversation(resume_conversation_id) - except MissingAgentSpec: - settings_screen.handle_basic_settings(escapable=False) - + conversation = _start_fresh_conversation(resume_conversation_id) display_welcome(conversation.id, bool(resume_conversation_id)) # Create conversation runner to handle state machine logic @@ -118,6 +135,22 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None: display_welcome(conversation.id) continue + elif command == '/new': + try: + # Start a fresh conversation (no resume ID = new conversation) + conversation = _start_fresh_conversation() + runner = ConversationRunner(conversation) + display_welcome(conversation.id, resume=False) + print_formatted_text( + HTML('✓ Started fresh conversation') + ) + continue + except Exception as e: + print_formatted_text( + HTML(f'Error starting fresh conversation: {e}') + ) + continue + elif command == '/help': display_help() continue diff --git a/openhands-cli/openhands_cli/tui/tui.py b/openhands-cli/openhands_cli/tui/tui.py index 08c581edbc..2ead091f5e 100644 --- a/openhands-cli/openhands_cli/tui/tui.py +++ b/openhands-cli/openhands_cli/tui/tui.py @@ -17,6 +17,7 @@ COMMANDS = { '/exit': 'Exit the application', '/help': 'Display available commands', '/clear': 'Clear the screen', + '/new': 'Start a fresh conversation', '/status': 'Display conversation details', '/confirm': 'Toggle confirmation mode on/off', '/resume': 'Resume a paused conversation', diff --git a/openhands-cli/tests/test_new_command.py b/openhands-cli/tests/test_new_command.py new file mode 100644 index 0000000000..4f7031153c --- /dev/null +++ b/openhands-cli/tests/test_new_command.py @@ -0,0 +1,100 @@ +"""Tests for the /new command functionality.""" + +from unittest.mock import MagicMock, patch +from uuid import UUID +from openhands_cli.agent_chat import _start_fresh_conversation +from unittest.mock import MagicMock, patch +from prompt_toolkit.input.defaults import create_pipe_input +from prompt_toolkit.output.defaults import DummyOutput +from openhands_cli.setup import MissingAgentSpec +from openhands_cli.user_actions import UserConfirmation + +@patch('openhands_cli.agent_chat.setup_conversation') +def test_start_fresh_conversation_success(mock_setup_conversation): + """Test that _start_fresh_conversation creates a new conversation successfully.""" + # Mock the conversation object + mock_conversation = MagicMock() + mock_conversation.id = UUID('12345678-1234-5678-9abc-123456789abc') + mock_setup_conversation.return_value = mock_conversation + + # Call the function + result = _start_fresh_conversation() + + # Verify the result + assert result == mock_conversation + mock_setup_conversation.assert_called_once_with(None) + + +@patch('openhands_cli.agent_chat.SettingsScreen') +@patch('openhands_cli.agent_chat.setup_conversation') +def test_start_fresh_conversation_missing_agent_spec( + mock_setup_conversation, + mock_settings_screen_class +): + """Test that _start_fresh_conversation handles MissingAgentSpec exception.""" + # Mock the SettingsScreen instance + mock_settings_screen = MagicMock() + mock_settings_screen_class.return_value = mock_settings_screen + + # Mock setup_conversation to raise MissingAgentSpec on first call, then succeed + mock_conversation = MagicMock() + mock_conversation.id = UUID('12345678-1234-5678-9abc-123456789abc') + mock_setup_conversation.side_effect = [ + MissingAgentSpec("Agent spec missing"), + mock_conversation + ] + + # Call the function + result = _start_fresh_conversation() + + # Verify the result + assert result == mock_conversation + # Should be called twice: first fails, second succeeds + assert mock_setup_conversation.call_count == 2 + # Settings screen should be called once + mock_settings_screen.handle_basic_settings.assert_called_once_with(escapable=False) + + + + + +@patch('openhands_cli.agent_chat.exit_session_confirmation') +@patch('openhands_cli.agent_chat.get_session_prompter') +@patch('openhands_cli.agent_chat.setup_conversation') +@patch('openhands_cli.agent_chat.ConversationRunner') +def test_new_command_resets_confirmation_mode( + mock_runner_cls, + mock_setup_conversation, + mock_get_session_prompter, + mock_exit_confirm, +): + # Auto-accept the exit prompt to avoid interactive UI and EOFError + mock_exit_confirm.return_value = UserConfirmation.ACCEPT + + conv1 = MagicMock(); conv1.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') + conv2 = MagicMock(); conv2.id = UUID('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb') + mock_setup_conversation.side_effect = [conv1, conv2] + + # Distinct runner instances for each conversation + runner1 = MagicMock(); runner1.is_confirmation_mode_enabled = True + runner2 = MagicMock(); runner2.is_confirmation_mode_enabled = False + mock_runner_cls.side_effect = [runner1, runner2] + + # Real session fed by a pipe (no interactive confirmation now) + from openhands_cli.user_actions.utils import get_session_prompter as real_get_session_prompter + with create_pipe_input() as pipe: + output = DummyOutput() + session = real_get_session_prompter(input=pipe, output=output) + mock_get_session_prompter.return_value = session + + from openhands_cli.agent_chat import run_cli_entry + # Trigger /new, then /status, then /exit (exit will be auto-accepted) + for ch in "/new\r/status\r/exit\r": + pipe.send_text(ch) + + run_cli_entry(None) + + # Assert we switched to a new runner for conv2 + assert mock_runner_cls.call_count == 2 + assert mock_runner_cls.call_args_list[0].args[0] is conv1 + assert mock_runner_cls.call_args_list[1].args[0] is conv2 diff --git a/openhands-cli/tests/test_tui.py b/openhands-cli/tests/test_tui.py index 1ffd7d887d..067bef177c 100644 --- a/openhands-cli/tests/test_tui.py +++ b/openhands-cli/tests/test_tui.py @@ -77,6 +77,7 @@ def test_commands_dict() -> None: '/exit', '/help', '/clear', + '/new', '/status', '/confirm', '/resume',