mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-09 14:57:59 -05:00
CLI: Create conversation last minute (#11576)
Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
This commit is contained in:
@@ -6,6 +6,7 @@ Provides a conversation interface with an AI agent using OpenHands patterns.
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
from openhands.sdk import (
|
from openhands.sdk import (
|
||||||
Message,
|
Message,
|
||||||
@@ -16,7 +17,11 @@ from prompt_toolkit import print_formatted_text
|
|||||||
from prompt_toolkit.formatted_text import HTML
|
from prompt_toolkit.formatted_text import HTML
|
||||||
|
|
||||||
from openhands_cli.runner import ConversationRunner
|
from openhands_cli.runner import ConversationRunner
|
||||||
from openhands_cli.setup import MissingAgentSpec, setup_conversation, start_fresh_conversation
|
from openhands_cli.setup import (
|
||||||
|
MissingAgentSpec,
|
||||||
|
setup_conversation,
|
||||||
|
verify_agent_exists_or_setup_agent
|
||||||
|
)
|
||||||
from openhands_cli.tui.settings.mcp_screen import MCPScreen
|
from openhands_cli.tui.settings.mcp_screen import MCPScreen
|
||||||
from openhands_cli.tui.settings.settings_screen import SettingsScreen
|
from openhands_cli.tui.settings.settings_screen import SettingsScreen
|
||||||
from openhands_cli.tui.status import display_status
|
from openhands_cli.tui.status import display_status
|
||||||
@@ -65,21 +70,33 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
|||||||
EOFError: If EOF is encountered
|
EOFError: If EOF is encountered
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
conversation_id = uuid.uuid4()
|
||||||
|
if resume_conversation_id:
|
||||||
try:
|
try:
|
||||||
conversation = start_fresh_conversation(resume_conversation_id)
|
conversation_id = uuid.UUID(resume_conversation_id)
|
||||||
|
except ValueError as e:
|
||||||
|
print_formatted_text(
|
||||||
|
HTML(
|
||||||
|
f"<yellow>Warning: '{resume_conversation_id}' is not a valid UUID.</yellow>"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
initialized_agent = verify_agent_exists_or_setup_agent()
|
||||||
except MissingAgentSpec:
|
except MissingAgentSpec:
|
||||||
print_formatted_text(HTML('\n<yellow>Setup is required to use OpenHands CLI.</yellow>'))
|
print_formatted_text(HTML('\n<yellow>Setup is required to use OpenHands CLI.</yellow>'))
|
||||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
display_welcome(conversation.id, bool(resume_conversation_id))
|
display_welcome(conversation_id, bool(resume_conversation_id))
|
||||||
|
|
||||||
# Track session start time for uptime calculation
|
# Track session start time for uptime calculation
|
||||||
session_start_time = datetime.now()
|
session_start_time = datetime.now()
|
||||||
|
|
||||||
# Create conversation runner to handle state machine logic
|
# Create conversation runner to handle state machine logic
|
||||||
runner = ConversationRunner(conversation)
|
runner = None
|
||||||
session = get_session_prompter()
|
session = get_session_prompter()
|
||||||
|
|
||||||
# Main chat loop
|
# Main chat loop
|
||||||
@@ -106,7 +123,7 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
|||||||
exit_confirmation = exit_session_confirmation()
|
exit_confirmation = exit_session_confirmation()
|
||||||
if exit_confirmation == UserConfirmation.ACCEPT:
|
if exit_confirmation == UserConfirmation.ACCEPT:
|
||||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||||
_print_exit_hint(conversation.id)
|
_print_exit_hint(conversation_id)
|
||||||
break
|
break
|
||||||
|
|
||||||
elif command == '/settings':
|
elif command == '/settings':
|
||||||
@@ -116,19 +133,19 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
|||||||
|
|
||||||
elif command == '/mcp':
|
elif command == '/mcp':
|
||||||
mcp_screen = MCPScreen()
|
mcp_screen = MCPScreen()
|
||||||
mcp_screen.display_mcp_info(conversation.agent)
|
mcp_screen.display_mcp_info(initialized_agent)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
elif command == '/clear':
|
elif command == '/clear':
|
||||||
display_welcome(conversation.id)
|
display_welcome(conversation_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
elif command == '/new':
|
elif command == '/new':
|
||||||
try:
|
try:
|
||||||
# Start a fresh conversation (no resume ID = new conversation)
|
# Start a fresh conversation (no resume ID = new conversation)
|
||||||
conversation = setup_conversation()
|
conversation = setup_conversation(conversation_id)
|
||||||
runner = ConversationRunner(conversation)
|
runner = ConversationRunner(conversation)
|
||||||
display_welcome(conversation.id, resume=False)
|
display_welcome(conversation_id, resume=False)
|
||||||
print_formatted_text(
|
print_formatted_text(
|
||||||
HTML('<green>✓ Started fresh conversation</green>')
|
HTML('<green>✓ Started fresh conversation</green>')
|
||||||
)
|
)
|
||||||
@@ -158,6 +175,13 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
elif command == '/resume':
|
elif command == '/resume':
|
||||||
|
if not runner:
|
||||||
|
print_formatted_text(
|
||||||
|
HTML('<yellow>No active conversation running...</yellow>')
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
conversation = runner.conversation
|
||||||
if not (
|
if not (
|
||||||
conversation.state.agent_status == AgentExecutionStatus.PAUSED
|
conversation.state.agent_status == AgentExecutionStatus.PAUSED
|
||||||
or conversation.state.agent_status
|
or conversation.state.agent_status
|
||||||
@@ -171,6 +195,9 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
|||||||
# Resume without new message
|
# Resume without new message
|
||||||
message = None
|
message = None
|
||||||
|
|
||||||
|
if not runner:
|
||||||
|
conversation = setup_conversation(conversation_id)
|
||||||
|
runner = ConversationRunner(conversation)
|
||||||
runner.process_message(message)
|
runner.process_message(message)
|
||||||
|
|
||||||
print() # Add spacing
|
print() # Add spacing
|
||||||
@@ -179,7 +206,7 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
|||||||
exit_confirmation = exit_session_confirmation()
|
exit_confirmation = exit_session_confirmation()
|
||||||
if exit_confirmation == UserConfirmation.ACCEPT:
|
if exit_confirmation == UserConfirmation.ACCEPT:
|
||||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||||
_print_exit_hint(conversation.id)
|
_print_exit_hint(conversation_id)
|
||||||
break
|
break
|
||||||
|
|
||||||
# Clean up terminal state
|
# Clean up terminal state
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
|
|
||||||
from prompt_toolkit import HTML, print_formatted_text
|
from prompt_toolkit import HTML, print_formatted_text
|
||||||
|
|
||||||
from openhands.sdk import BaseConversation, Conversation, Workspace, register_tool
|
from openhands.sdk import Agent, BaseConversation, Conversation, Workspace, register_tool
|
||||||
from openhands.tools.execute_bash import BashTool
|
from openhands.tools.execute_bash import BashTool
|
||||||
from openhands.tools.file_editor import FileEditorTool
|
from openhands.tools.file_editor import FileEditorTool
|
||||||
from openhands.tools.task_tracker import TaskTrackerTool
|
from openhands.tools.task_tracker import TaskTrackerTool
|
||||||
@@ -26,8 +26,38 @@ class MissingAgentSpec(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def setup_conversation(
|
|
||||||
|
def load_agent_specs(
|
||||||
conversation_id: str | None = None,
|
conversation_id: str | None = None,
|
||||||
|
) -> Agent:
|
||||||
|
agent_store = AgentStore()
|
||||||
|
agent = agent_store.load(session_id=conversation_id)
|
||||||
|
if not agent:
|
||||||
|
raise MissingAgentSpec(
|
||||||
|
'Agent specification not found. Please configure your agent settings.'
|
||||||
|
)
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
def verify_agent_exists_or_setup_agent() -> Agent:
|
||||||
|
"""Verify agent specs exists by attempting to load it.
|
||||||
|
|
||||||
|
"""
|
||||||
|
settings_screen = SettingsScreen()
|
||||||
|
try:
|
||||||
|
agent = load_agent_specs()
|
||||||
|
return agent
|
||||||
|
except MissingAgentSpec:
|
||||||
|
# For first-time users, show the full settings flow with choice between basic/advanced
|
||||||
|
settings_screen.configure_settings(first_time=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Try once again after settings setup attempt
|
||||||
|
return load_agent_specs()
|
||||||
|
|
||||||
|
|
||||||
|
def setup_conversation(
|
||||||
|
conversation_id: uuid,
|
||||||
include_security_analyzer: bool = True
|
include_security_analyzer: bool = True
|
||||||
) -> BaseConversation:
|
) -> BaseConversation:
|
||||||
"""
|
"""
|
||||||
@@ -40,28 +70,8 @@ def setup_conversation(
|
|||||||
MissingAgentSpec: If agent specification is not found or invalid.
|
MissingAgentSpec: If agent specification is not found or invalid.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Use provided conversation_id or generate a random one
|
|
||||||
if conversation_id is None:
|
|
||||||
conversation_id = uuid.uuid4()
|
|
||||||
elif isinstance(conversation_id, str):
|
|
||||||
try:
|
|
||||||
conversation_id = uuid.UUID(conversation_id)
|
|
||||||
except ValueError as e:
|
|
||||||
print_formatted_text(
|
|
||||||
HTML(
|
|
||||||
f"<yellow>Warning: '{conversation_id}' is not a valid UUID.</yellow>"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
raise e
|
|
||||||
|
|
||||||
with LoadingContext('Initializing OpenHands agent...'):
|
with LoadingContext('Initializing OpenHands agent...'):
|
||||||
agent_store = AgentStore()
|
agent = load_agent_specs(str(conversation_id))
|
||||||
agent = agent_store.load(session_id=str(conversation_id))
|
|
||||||
if not agent:
|
|
||||||
raise MissingAgentSpec(
|
|
||||||
'Agent specification not found. Please configure your agent settings.'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if not include_security_analyzer:
|
if not include_security_analyzer:
|
||||||
# Remove security analyzer from agent spec
|
# Remove security analyzer from agent spec
|
||||||
@@ -86,31 +96,3 @@ def setup_conversation(
|
|||||||
)
|
)
|
||||||
return conversation
|
return conversation
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
try:
|
|
||||||
conversation = setup_conversation(resume_conversation_id)
|
|
||||||
return conversation
|
|
||||||
except MissingAgentSpec:
|
|
||||||
# For first-time users, show the full settings flow with choice between basic/advanced
|
|
||||||
settings_screen.configure_settings(first_time=True)
|
|
||||||
|
|
||||||
|
|
||||||
# Try once again after settings setup attempt
|
|
||||||
return setup_conversation(resume_conversation_id)
|
|
||||||
|
|||||||
@@ -4,51 +4,49 @@ from unittest.mock import MagicMock, patch
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from prompt_toolkit.input.defaults import create_pipe_input
|
from prompt_toolkit.input.defaults import create_pipe_input
|
||||||
from prompt_toolkit.output.defaults import DummyOutput
|
from prompt_toolkit.output.defaults import DummyOutput
|
||||||
from openhands_cli.setup import MissingAgentSpec, start_fresh_conversation
|
from openhands_cli.setup import MissingAgentSpec, verify_agent_exists_or_setup_agent, setup_conversation
|
||||||
from openhands_cli.user_actions import UserConfirmation
|
from openhands_cli.user_actions import UserConfirmation
|
||||||
|
|
||||||
@patch('openhands_cli.setup.setup_conversation')
|
@patch('openhands_cli.setup.load_agent_specs')
|
||||||
def test_start_fresh_conversation_success(mock_setup_conversation):
|
def test_verify_agent_exists_or_setup_agent_success(mock_load_agent_specs):
|
||||||
"""Test that start_fresh_conversation creates a new conversation successfully."""
|
"""Test that verify_agent_exists_or_setup_agent returns agent successfully."""
|
||||||
# Mock the conversation object
|
# Mock the agent object
|
||||||
mock_conversation = MagicMock()
|
mock_agent = MagicMock()
|
||||||
mock_conversation.id = UUID('12345678-1234-5678-9abc-123456789abc')
|
mock_load_agent_specs.return_value = mock_agent
|
||||||
mock_setup_conversation.return_value = mock_conversation
|
|
||||||
|
|
||||||
# Call the function
|
# Call the function
|
||||||
result = start_fresh_conversation()
|
result = verify_agent_exists_or_setup_agent()
|
||||||
|
|
||||||
# Verify the result
|
# Verify the result
|
||||||
assert result == mock_conversation
|
assert result == mock_agent
|
||||||
mock_setup_conversation.assert_called_once_with(None)
|
mock_load_agent_specs.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
@patch('openhands_cli.setup.SettingsScreen')
|
@patch('openhands_cli.setup.SettingsScreen')
|
||||||
@patch('openhands_cli.setup.setup_conversation')
|
@patch('openhands_cli.setup.load_agent_specs')
|
||||||
def test_start_fresh_conversation_missing_agent_spec(
|
def test_verify_agent_exists_or_setup_agent_missing_agent_spec(
|
||||||
mock_setup_conversation,
|
mock_load_agent_specs,
|
||||||
mock_settings_screen_class
|
mock_settings_screen_class
|
||||||
):
|
):
|
||||||
"""Test that start_fresh_conversation handles MissingAgentSpec exception."""
|
"""Test that verify_agent_exists_or_setup_agent handles MissingAgentSpec exception."""
|
||||||
# Mock the SettingsScreen instance
|
# Mock the SettingsScreen instance
|
||||||
mock_settings_screen = MagicMock()
|
mock_settings_screen = MagicMock()
|
||||||
mock_settings_screen_class.return_value = mock_settings_screen
|
mock_settings_screen_class.return_value = mock_settings_screen
|
||||||
|
|
||||||
# Mock setup_conversation to raise MissingAgentSpec on first call, then succeed
|
# Mock load_agent_specs to raise MissingAgentSpec on first call, then succeed
|
||||||
mock_conversation = MagicMock()
|
mock_agent = MagicMock()
|
||||||
mock_conversation.id = UUID('12345678-1234-5678-9abc-123456789abc')
|
mock_load_agent_specs.side_effect = [
|
||||||
mock_setup_conversation.side_effect = [
|
|
||||||
MissingAgentSpec("Agent spec missing"),
|
MissingAgentSpec("Agent spec missing"),
|
||||||
mock_conversation
|
mock_agent
|
||||||
]
|
]
|
||||||
|
|
||||||
# Call the function
|
# Call the function
|
||||||
result = start_fresh_conversation()
|
result = verify_agent_exists_or_setup_agent()
|
||||||
|
|
||||||
# Verify the result
|
# Verify the result
|
||||||
assert result == mock_conversation
|
assert result == mock_agent
|
||||||
# Should be called twice: first fails, second succeeds
|
# Should be called twice: first fails, second succeeds
|
||||||
assert mock_setup_conversation.call_count == 2
|
assert mock_load_agent_specs.call_count == 2
|
||||||
# Settings screen should be called once with first_time=True (new behavior)
|
# Settings screen should be called once with first_time=True (new behavior)
|
||||||
mock_settings_screen.configure_settings.assert_called_once_with(first_time=True)
|
mock_settings_screen.configure_settings.assert_called_once_with(first_time=True)
|
||||||
|
|
||||||
@@ -59,11 +57,11 @@ def test_start_fresh_conversation_missing_agent_spec(
|
|||||||
@patch('openhands_cli.agent_chat.exit_session_confirmation')
|
@patch('openhands_cli.agent_chat.exit_session_confirmation')
|
||||||
@patch('openhands_cli.agent_chat.get_session_prompter')
|
@patch('openhands_cli.agent_chat.get_session_prompter')
|
||||||
@patch('openhands_cli.agent_chat.setup_conversation')
|
@patch('openhands_cli.agent_chat.setup_conversation')
|
||||||
@patch('openhands_cli.agent_chat.start_fresh_conversation')
|
@patch('openhands_cli.agent_chat.verify_agent_exists_or_setup_agent')
|
||||||
@patch('openhands_cli.agent_chat.ConversationRunner')
|
@patch('openhands_cli.agent_chat.ConversationRunner')
|
||||||
def test_new_command_resets_confirmation_mode(
|
def test_new_command_resets_confirmation_mode(
|
||||||
mock_runner_cls,
|
mock_runner_cls,
|
||||||
mock_start_fresh_conversation,
|
mock_verify_agent,
|
||||||
mock_setup_conversation,
|
mock_setup_conversation,
|
||||||
mock_get_session_prompter,
|
mock_get_session_prompter,
|
||||||
mock_exit_confirm,
|
mock_exit_confirm,
|
||||||
@@ -71,15 +69,17 @@ def test_new_command_resets_confirmation_mode(
|
|||||||
# Auto-accept the exit prompt to avoid interactive UI and EOFError
|
# Auto-accept the exit prompt to avoid interactive UI and EOFError
|
||||||
mock_exit_confirm.return_value = UserConfirmation.ACCEPT
|
mock_exit_confirm.return_value = UserConfirmation.ACCEPT
|
||||||
|
|
||||||
conv1 = MagicMock(); conv1.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
# Mock agent verification to succeed
|
||||||
conv2 = MagicMock(); conv2.id = UUID('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')
|
mock_agent = MagicMock()
|
||||||
mock_start_fresh_conversation.return_value = conv1
|
mock_verify_agent.return_value = mock_agent
|
||||||
mock_setup_conversation.side_effect = [conv2]
|
|
||||||
|
|
||||||
# Distinct runner instances for each conversation
|
# Mock conversation - only one is created when /new is called
|
||||||
|
conv1 = MagicMock(); conv1.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||||
|
mock_setup_conversation.return_value = conv1
|
||||||
|
|
||||||
|
# One runner instance for the conversation
|
||||||
runner1 = MagicMock(); runner1.is_confirmation_mode_active = True
|
runner1 = MagicMock(); runner1.is_confirmation_mode_active = True
|
||||||
runner2 = MagicMock(); runner2.is_confirmation_mode_active = False
|
mock_runner_cls.return_value = runner1
|
||||||
mock_runner_cls.side_effect = [runner1, runner2]
|
|
||||||
|
|
||||||
# Real session fed by a pipe (no interactive confirmation now)
|
# 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
|
from openhands_cli.user_actions.utils import get_session_prompter as real_get_session_prompter
|
||||||
@@ -89,13 +89,12 @@ def test_new_command_resets_confirmation_mode(
|
|||||||
mock_get_session_prompter.return_value = session
|
mock_get_session_prompter.return_value = session
|
||||||
|
|
||||||
from openhands_cli.agent_chat import run_cli_entry
|
from openhands_cli.agent_chat import run_cli_entry
|
||||||
# Trigger /new, then /status, then /exit (exit will be auto-accepted)
|
# Trigger /new, then /exit (exit will be auto-accepted)
|
||||||
for ch in "/new\r/exit\r":
|
for ch in "/new\r/exit\r":
|
||||||
pipe.send_text(ch)
|
pipe.send_text(ch)
|
||||||
|
|
||||||
run_cli_entry(None)
|
run_cli_entry(None)
|
||||||
|
|
||||||
# Assert we switched to a new runner for conv2
|
# Assert we created one runner for the conversation when /new was called
|
||||||
assert mock_runner_cls.call_count == 2
|
assert mock_runner_cls.call_count == 1
|
||||||
assert mock_runner_cls.call_args_list[0].args[0] is conv1
|
assert mock_runner_cls.call_args_list[0].args[0] is conv1
|
||||||
assert mock_runner_cls.call_args_list[1].args[0] is conv2
|
|
||||||
|
|||||||
147
openhands-cli/tests/commands/test_resume_command.py
Normal file
147
openhands-cli/tests/commands/test_resume_command.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"""Tests for the /resume command functionality."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from uuid import UUID
|
||||||
|
import pytest
|
||||||
|
from prompt_toolkit.input.defaults import create_pipe_input
|
||||||
|
from prompt_toolkit.output.defaults import DummyOutput
|
||||||
|
|
||||||
|
from openhands.sdk.conversation.state import AgentExecutionStatus
|
||||||
|
from openhands_cli.user_actions import UserConfirmation
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Fixtures & helpers ----------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_agent():
|
||||||
|
"""Mock agent for verification."""
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_conversation():
|
||||||
|
"""Mock conversation with default settings."""
|
||||||
|
conv = MagicMock()
|
||||||
|
conv.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||||
|
return conv
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_runner():
|
||||||
|
"""Mock conversation runner."""
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
def run_resume_command_test(commands, agent_status=None, expect_runner_created=True):
|
||||||
|
"""Helper function to run resume command tests with common setup."""
|
||||||
|
with patch('openhands_cli.agent_chat.exit_session_confirmation') as mock_exit_confirm, \
|
||||||
|
patch('openhands_cli.agent_chat.get_session_prompter') as mock_get_session_prompter, \
|
||||||
|
patch('openhands_cli.agent_chat.setup_conversation') as mock_setup_conversation, \
|
||||||
|
patch('openhands_cli.agent_chat.verify_agent_exists_or_setup_agent') as mock_verify_agent, \
|
||||||
|
patch('openhands_cli.agent_chat.ConversationRunner') as mock_runner_cls:
|
||||||
|
|
||||||
|
# Auto-accept the exit prompt to avoid interactive UI
|
||||||
|
mock_exit_confirm.return_value = UserConfirmation.ACCEPT
|
||||||
|
|
||||||
|
# Mock agent verification to succeed
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_verify_agent.return_value = mock_agent
|
||||||
|
|
||||||
|
# Mock conversation setup
|
||||||
|
conv = MagicMock()
|
||||||
|
conv.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
|
||||||
|
if agent_status:
|
||||||
|
conv.state.agent_status = agent_status
|
||||||
|
mock_setup_conversation.return_value = conv
|
||||||
|
|
||||||
|
# Mock runner
|
||||||
|
runner = MagicMock()
|
||||||
|
runner.conversation = conv
|
||||||
|
mock_runner_cls.return_value = runner
|
||||||
|
|
||||||
|
# Real session fed by a pipe
|
||||||
|
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
|
||||||
|
|
||||||
|
# Send commands
|
||||||
|
for ch in commands:
|
||||||
|
pipe.send_text(ch)
|
||||||
|
|
||||||
|
# Capture printed output
|
||||||
|
with patch('openhands_cli.agent_chat.print_formatted_text') as mock_print:
|
||||||
|
run_cli_entry(None)
|
||||||
|
|
||||||
|
return mock_runner_cls, runner, mock_print
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Warning tests (parametrized) ----------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"commands,expected_warning,expect_runner_created",
|
||||||
|
[
|
||||||
|
# No active conversation - /resume immediately
|
||||||
|
("/resume\r/exit\r", "No active conversation running", False),
|
||||||
|
# Conversation exists but not in paused state - send message first, then /resume
|
||||||
|
("hello\r/resume\r/exit\r", "No paused conversation to resume", True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resume_command_warnings(commands, expected_warning, expect_runner_created):
|
||||||
|
"""Test /resume command shows appropriate warnings."""
|
||||||
|
# Set agent status to FINISHED for the "conversation exists but not paused" test
|
||||||
|
agent_status = AgentExecutionStatus.FINISHED if expect_runner_created else None
|
||||||
|
|
||||||
|
mock_runner_cls, runner, mock_print = run_resume_command_test(
|
||||||
|
commands, agent_status=agent_status, expect_runner_created=expect_runner_created
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify warning message was printed
|
||||||
|
warning_calls = [call for call in mock_print.call_args_list
|
||||||
|
if expected_warning in str(call)]
|
||||||
|
assert len(warning_calls) > 0, f"Expected warning about {expected_warning}"
|
||||||
|
|
||||||
|
# Verify runner creation expectation
|
||||||
|
if expect_runner_created:
|
||||||
|
assert mock_runner_cls.call_count == 1
|
||||||
|
runner.process_message.assert_called()
|
||||||
|
else:
|
||||||
|
assert mock_runner_cls.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Successful resume tests (parametrized) ----------
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"agent_status",
|
||||||
|
[
|
||||||
|
AgentExecutionStatus.PAUSED,
|
||||||
|
AgentExecutionStatus.WAITING_FOR_CONFIRMATION,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resume_command_successful_resume(agent_status):
|
||||||
|
"""Test /resume command successfully resumes paused/waiting conversations."""
|
||||||
|
commands = "hello\r/resume\r/exit\r"
|
||||||
|
|
||||||
|
mock_runner_cls, runner, mock_print = run_resume_command_test(
|
||||||
|
commands, agent_status=agent_status, expect_runner_created=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify runner was created and process_message was called
|
||||||
|
assert mock_runner_cls.call_count == 1
|
||||||
|
|
||||||
|
# Verify process_message was called twice: once with the initial message, once with None for resume
|
||||||
|
assert runner.process_message.call_count == 2
|
||||||
|
|
||||||
|
# Check the calls to process_message
|
||||||
|
calls = runner.process_message.call_args_list
|
||||||
|
|
||||||
|
# First call should have a message (the "hello" message)
|
||||||
|
first_call_args = calls[0][0]
|
||||||
|
assert first_call_args[0] is not None, "First call should have a message"
|
||||||
|
|
||||||
|
# Second call should have None (the /resume command)
|
||||||
|
second_call_args = calls[1][0]
|
||||||
|
assert second_call_args[0] is None, "Second call should have None message for resume"
|
||||||
@@ -4,6 +4,7 @@ Tests for confirmation mode functionality in OpenHands CLI.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import ANY, MagicMock, patch
|
from unittest.mock import ANY, MagicMock, patch
|
||||||
@@ -60,7 +61,7 @@ class TestConfirmationMode:
|
|||||||
mock_conversation_instance = MagicMock()
|
mock_conversation_instance = MagicMock()
|
||||||
mock_conversation_class.return_value = mock_conversation_instance
|
mock_conversation_class.return_value = mock_conversation_instance
|
||||||
|
|
||||||
result = setup_conversation()
|
result = setup_conversation(mock_conversation_id)
|
||||||
|
|
||||||
# Verify conversation was created and returned
|
# Verify conversation was created and returned
|
||||||
assert result == mock_conversation_instance
|
assert result == mock_conversation_instance
|
||||||
@@ -87,7 +88,7 @@ class TestConfirmationMode:
|
|||||||
|
|
||||||
# Should raise MissingAgentSpec
|
# Should raise MissingAgentSpec
|
||||||
with pytest.raises(MissingAgentSpec) as exc_info:
|
with pytest.raises(MissingAgentSpec) as exc_info:
|
||||||
setup_conversation()
|
setup_conversation(uuid.uuid4())
|
||||||
|
|
||||||
assert 'Agent specification not found' in str(exc_info.value)
|
assert 'Agent specification not found' in str(exc_info.value)
|
||||||
mock_agent_store_class.assert_called_once()
|
mock_agent_store_class.assert_called_once()
|
||||||
|
|||||||
Reference in New Issue
Block a user