mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
CLI(V1): start new conversations without exiting CLI using /new command (#11262)
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
parent
23d325cb16
commit
997bf8efae
@ -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('<green>✓ Started fresh conversation</green>')
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
print_formatted_text(
|
||||
HTML(f'<red>Error starting fresh conversation: {e}</red>')
|
||||
)
|
||||
continue
|
||||
|
||||
elif command == '/help':
|
||||
display_help()
|
||||
continue
|
||||
|
||||
@ -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',
|
||||
|
||||
100
openhands-cli/tests/test_new_command.py
Normal file
100
openhands-cli/tests/test_new_command.py
Normal file
@ -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
|
||||
@ -77,6 +77,7 @@ def test_commands_dict() -> None:
|
||||
'/exit',
|
||||
'/help',
|
||||
'/clear',
|
||||
'/new',
|
||||
'/status',
|
||||
'/confirm',
|
||||
'/resume',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user