Merge branch 'v1' into feature/cli-conversation-list

This commit is contained in:
Xingyao Wang 2025-11-12 12:19:40 -05:00 committed by GitHub
commit 54e2f2aba8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 223 additions and 130 deletions

View File

@ -13,13 +13,18 @@ import subprocess
import sys
from pathlib import Path
from openhands_cli.locations import PERSISTENCE_DIR, WORK_DIR, AGENT_SETTINGS_PATH
from openhands.sdk.preset.default import get_default_agent
from openhands_cli.llm_utils import get_llm_metadata
from openhands.tools.preset.default import get_default_agent
from openhands.sdk import LLM
import time
import select
dummy_agent = get_default_agent(
llm=LLM(model='dummy-model', api_key='dummy-key'),
llm=LLM(
model='dummy-model',
api_key='dummy-key',
metadata=get_llm_metadata(model_name='dummy-model', agent_name='openhands-cli')
),
working_dir=WORK_DIR,
persistence_dir=PERSISTENCE_DIR,
cli_mode=True

View File

@ -5,6 +5,7 @@ Provides a conversation interface with an AI agent using OpenHands patterns.
"""
import sys
from openhands.sdk import (
Message,
TextContent,
@ -38,10 +39,20 @@ def _restore_tty() -> None:
except Exception:
pass
def _print_exit_hint(conversation_id: str) -> None:
"""Print a resume hint with the current conversation ID."""
print_formatted_text(HTML(f"<grey>Conversation ID:</grey> <yellow>{conversation_id}</yellow>"))
print_formatted_text(
HTML(
f"<grey>Hint:</grey> run <gold>openhands-cli --resume {conversation_id}</gold> "
"to resume this conversation."
)
)
def run_cli_entry() -> None:
def run_cli_entry(resume_conversation_id: str | None = None) -> None:
"""Run the agent chat session using the agent SDK.
Raises:
AgentSetupError: If agent setup fails
KeyboardInterrupt: If user interrupts the session
@ -53,11 +64,11 @@ def run_cli_entry() -> None:
while not conversation:
try:
conversation = setup_conversation()
conversation = setup_conversation(resume_conversation_id)
except MissingAgentSpec:
settings_screen.handle_basic_settings(escapable=False)
display_welcome(conversation.id)
display_welcome(conversation.id, bool(resume_conversation_id))
# Create conversation runner to handle state machine logic
runner = ConversationRunner(conversation)
@ -88,6 +99,7 @@ def run_cli_entry() -> None:
exit_confirmation = exit_session_confirmation()
if exit_confirmation == UserConfirmation.ACCEPT:
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
_print_exit_hint(conversation.id)
break
elif command == "/settings":
@ -209,6 +221,7 @@ def run_cli_entry() -> None:
exit_confirmation = exit_session_confirmation()
if exit_confirmation == UserConfirmation.ACCEPT:
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
_print_exit_hint(conversation.id)
break

View File

@ -2,7 +2,7 @@ import threading
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from openhands.sdk import Conversation
from openhands.sdk import BaseConversation
from prompt_toolkit import HTML, print_formatted_text
from prompt_toolkit.input import Input, create_input
from prompt_toolkit.keys import Keys
@ -71,7 +71,7 @@ class PauseListener(threading.Thread):
@contextmanager
def pause_listener(
conversation: Conversation, input_source: Input | None = None
conversation: BaseConversation, input_source: Input | None = None
) -> Iterator[PauseListener]:
"""Ensure PauseListener always starts/stops cleanly."""
listener = PauseListener(on_pause=conversation.pause, input_source=input_source)

View File

@ -0,0 +1,55 @@
"""Utility functions for LLM configuration in OpenHands CLI."""
import os
from typing import Any
def get_llm_metadata(
model_name: str,
llm_type: str,
session_id: str | None = None,
user_id: str | None = None,
) -> dict[str, Any]:
"""
Generate LLM metadata for OpenHands CLI.
Args:
model_name: Name of the LLM model
agent_name: Name of the agent (defaults to "openhands-cli")
session_id: Optional session identifier
user_id: Optional user identifier
Returns:
Dictionary containing metadata for LLM initialization
"""
# Import here to avoid circular imports
openhands_sdk_version: str = "n/a"
try:
import openhands.sdk
openhands_sdk_version = openhands.sdk.__version__
except (ModuleNotFoundError, AttributeError):
pass
openhands_tools_version: str = "n/a"
try:
import openhands.tools
openhands_tools_version = openhands.tools.__version__
except (ModuleNotFoundError, AttributeError):
pass
metadata = {
"trace_version": openhands_sdk_version,
"tags": [
"app:openhands-cli",
f"model:{model_name}",
f"type:{llm_type}",
f"web_host:{os.environ.get('WEB_HOST', 'unspecified')}",
f"openhands_sdk_version:{openhands_sdk_version}",
f"openhands_tools_version:{openhands_tools_version}",
],
}
if session_id is not None:
metadata["session_id"] = session_id
if user_id is not None:
metadata["trace_user_id"] = user_id
return metadata

View File

@ -3,6 +3,7 @@ from uuid import UUID
# Configuration directory for storing agent settings and CLI configuration
PERSISTENCE_DIR = os.path.expanduser("~/.openhands")
CONVERSATIONS_DIR = os.path.join(PERSISTENCE_DIR, "conversations")
# Working directory for agent operations (current directory where CLI is run)
WORK_DIR = os.getcwd()
@ -11,6 +12,3 @@ AGENT_SETTINGS_PATH = "agent_settings.json"
# MCP configuration file (relative to PERSISTENCE_DIR)
MCP_CONFIG_FILE = "mcp.json"
def get_conversation_perisistence_path(conversation_id: UUID):
return os.path.join(PERSISTENCE_DIR, f"conversation/{conversation_id}")

View File

@ -5,8 +5,7 @@ from openhands.sdk.security.confirmation_policy import (
ConfirmRisky,
ConfirmationPolicyBase
)
from openhands.sdk.conversation.state import AgentExecutionStatus
from openhands.sdk.event.utils import get_unmatched_actions
from openhands.sdk.conversation.state import AgentExecutionStatus, ConversationState
from prompt_toolkit import HTML, print_formatted_text
from openhands_cli.listeners.pause_listener import PauseListener, pause_listener
@ -117,7 +116,7 @@ class ConversationRunner:
UserConfirmation indicating the user's choice
"""
pending_actions = get_unmatched_actions(self.conversation.state.events)
pending_actions = ConversationState.get_unmatched_actions(self.conversation.state.events)
if not pending_actions:
return UserConfirmation.ACCEPT

View File

@ -1,13 +1,13 @@
import uuid
from openhands.sdk import BaseConversation, Conversation, LocalFileStore, register_tool
from openhands.sdk import BaseConversation, Conversation, Workspace, register_tool
from openhands.tools.execute_bash import BashTool
from openhands.tools.str_replace_editor import FileEditorTool
from openhands.tools.task_tracker import TaskTrackerTool
from prompt_toolkit import HTML, print_formatted_text
from openhands_cli.listeners import LoadingContext
from openhands_cli.locations import get_conversation_perisistence_path
from openhands_cli.locations import CONVERSATIONS_DIR, WORK_DIR
from openhands_cli.tui.settings.store import AgentStore
register_tool("BashTool", BashTool)
@ -19,28 +19,41 @@ class MissingAgentSpec(Exception):
"""Raised when agent specification is not found or invalid."""
pass
def setup_conversation() -> BaseConversation:
def setup_conversation(conversation_id: str | None = None) -> BaseConversation:
"""
Setup the conversation with agent.
Args:
conversation_id: conversation ID to use. If not provided, a random UUID will be generated.
Raises:
MissingAgentSpec: If agent specification is not found or invalid.
"""
conversation_id = uuid.uuid4()
# 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..."):
agent_store = AgentStore()
agent = agent_store.load()
agent = agent_store.load(session_id=str(conversation_id))
if not agent:
raise MissingAgentSpec("Agent specification not found. Please configure your agent settings.")
# Create conversation - agent context is now set in AgentStore.load()
conversation = Conversation(
agent=agent,
persist_filestore=LocalFileStore(
get_conversation_perisistence_path(conversation_id)
),
workspace=Workspace(working_dir=WORK_DIR),
# Conversation will add /<conversation_id> to this path
persistence_dir=CONVERSATIONS_DIR,
conversation_id=conversation_id
)

View File

@ -4,6 +4,7 @@ Simple main entry point for OpenHands CLI.
This is a simplified version that demonstrates the TUI functionality.
"""
import argparse
import logging
import os
@ -16,7 +17,6 @@ from prompt_toolkit.formatted_text import HTML
from openhands_cli.agent_chat import run_cli_entry
def main() -> None:
"""Main entry point for the OpenHands CLI.
@ -24,10 +24,20 @@ def main() -> None:
ImportError: If agent chat dependencies are missing
Exception: On other error conditions
"""
parser = argparse.ArgumentParser(
description="OpenHands CLI - Terminal User Interface for OpenHands AI Agent"
)
parser.add_argument(
"--resume",
type=str,
help="Conversation ID to use for the session. If not provided, a random UUID will be generated."
)
args = parser.parse_args()
try:
# Start agent chat directly by default
run_cli_entry()
# Start agent chat
run_cli_entry(resume_conversation_id=args.resume)
except ImportError as e:
print_formatted_text(

View File

@ -1,5 +1,6 @@
import os
from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR, WORK_DIR
from openhands_cli.llm_utils import get_llm_metadata
from openhands_cli.tui.settings.store import AgentStore
from openhands_cli.user_actions.settings_action import (
SettingsType,
@ -14,15 +15,16 @@ from openhands_cli.user_actions.settings_action import (
)
from openhands_cli.tui.utils import StepCounter
from prompt_toolkit import HTML, print_formatted_text
from openhands.sdk import Conversation, LLM, LocalFileStore
from openhands.sdk.preset.default import get_default_agent
from openhands.sdk import BaseConversation, LLM, LocalFileStore
from openhands.sdk.security.confirmation_policy import NeverConfirm
from openhands.tools.preset.default import get_default_agent
from prompt_toolkit.shortcuts import print_container
from prompt_toolkit.widgets import Frame, TextArea
from openhands_cli.pt_style import COLOR_GREY
class SettingsScreen:
def __init__(self, conversation: Conversation | None = None):
def __init__(self, conversation: BaseConversation | None = None):
self.file_store = LocalFileStore(PERSISTENCE_DIR)
self.agent_store = AgentStore()
self.conversation = conversation
@ -31,6 +33,8 @@ class SettingsScreen:
agent_spec = self.agent_store.load()
if not agent_spec:
return
assert self.conversation is not None, \
"Conversation must be set to display settings."
llm = agent_spec.llm
advanced_llm_settings = True if llm.base_url else False
@ -57,7 +61,7 @@ class SettingsScreen:
)
labels_and_values.extend([
(" API Key", "********" if llm.api_key else "Not Set"),
(" Confirmation Mode", "Enabled" if self.conversation.confirmation_policy_active else "Disabled"),
(" Confirmation Mode", "Enabled" if not isinstance(self.conversation.state.confirmation_policy, NeverConfirm) else "Disabled"),
(" Memory Condensation", "Enabled" if agent_spec.condenser else "Disabled"),
(" Configuration File", os.path.join(PERSISTENCE_DIR, AGENT_SETTINGS_PATH))
])
@ -114,7 +118,7 @@ class SettingsScreen:
api_key = prompt_api_key(
step_counter,
provider,
self.conversation.agent.llm.api_key if self.conversation else None,
self.conversation.state.agent.llm.api_key if self.conversation else None,
escapable=escapable
)
save_settings_confirmation()
@ -163,14 +167,14 @@ class SettingsScreen:
model=model,
api_key=api_key,
base_url=base_url,
service_id="agent"
service_id="agent",
metadata=get_llm_metadata(model_name=model, llm_type="agent")
)
agent = self.agent_store.load()
if not agent:
agent = get_default_agent(
llm=llm,
working_dir=WORK_DIR,
cli_mode=True
)

View File

@ -2,9 +2,10 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from openhands.sdk import LocalFileStore, Agent
from openhands.sdk import LocalFileStore, Agent, AgentContext
from openhands.sdk.preset.default import get_default_tools
from openhands.sdk.context.condenser import LLMSummarizingCondenser
from openhands.tools.preset.default import get_default_tools
from openhands_cli.llm_utils import get_llm_metadata
from openhands_cli.locations import AGENT_SETTINGS_PATH, MCP_CONFIG_FILE, PERSISTENCE_DIR, WORK_DIR
from prompt_toolkit import HTML, print_formatted_text
from fastmcp.mcp_config import MCPConfig
@ -23,15 +24,13 @@ class AgentStore:
except Exception as e:
return {}
def load(self) -> Agent | None:
def load(self, session_id: str | None = None) -> Agent | None:
try:
str_spec = self.file_store.read(AGENT_SETTINGS_PATH)
agent = Agent.model_validate_json(str_spec)
# Update tools with most recent working directory
updated_tools = get_default_tools(
working_dir=WORK_DIR,
persistence_dir=PERSISTENCE_DIR,
enable_browser=False
)
@ -44,10 +43,30 @@ class AgentStore:
mcp_config: dict = agent.mcp_config.copy().get('mcpServers', {})
mcp_config.update(additional_mcp_config)
# Update LLM metadata with current information
agent_llm_metadata = get_llm_metadata(
model_name=agent.llm.model,
llm_type="agent",
session_id=session_id
)
updated_llm = agent.llm.model_copy(update={"metadata": agent_llm_metadata})
condenser_updates = {}
if agent.condenser and isinstance(agent.condenser, LLMSummarizingCondenser):
condenser_updates["llm"] = agent.condenser.llm.model_copy(update={"metadata": get_llm_metadata(
model_name=agent.condenser.llm.model,
llm_type="condenser",
session_id=session_id
)})
agent = agent.model_copy(update={
"llm": updated_llm,
"tools": updated_tools,
"mcp_config": {'mcpServers': mcp_config} if mcp_config else {},
"agent_context": agent_context
"agent_context": agent_context,
"condenser": agent.condenser.model_copy(
update=condenser_updates
) if agent.condenser else None
})
return agent

View File

@ -89,7 +89,7 @@ class CommandCompleter(Completer):
)
def display_banner(conversation_id: str) -> None:
def display_banner(conversation_id: str, resume: bool = False) -> None:
print_formatted_text(
HTML(r"""<gold>
___ _ _ _
@ -105,7 +105,10 @@ def display_banner(conversation_id: str) -> None:
print_formatted_text(HTML(f"<grey>OpenHands CLI v{__version__}</grey>"))
print_formatted_text("")
print_formatted_text(HTML(f"<grey>Initialized conversation {conversation_id}</grey>"))
if not resume:
print_formatted_text(HTML(f"<grey>Initialized conversation {conversation_id}</grey>"))
else:
print_formatted_text(HTML(f"<grey>Resumed conversation {conversation_id}</grey>"))
print_formatted_text("")
@ -127,10 +130,10 @@ def display_help() -> None:
print_formatted_text("")
def display_welcome(conversation_id: UUID) -> None:
def display_welcome(conversation_id: UUID, resume: bool = False) -> None:
"""Display welcome message."""
clear()
display_banner(str(conversation_id)[0:8])
display_banner(str(conversation_id), resume)
print_formatted_text(HTML("<gold>Let's start building!</gold>"))
print_formatted_text(
HTML(

View File

@ -27,7 +27,7 @@ def settings_type_confirmation() -> SettingsType:
'Go back',
]
index = cli_confirm(question, choices)
index = cli_confirm(question, choices, escapable=True)
if choices[index] == 'Go back':
raise KeyboardInterrupt
@ -141,7 +141,7 @@ def save_settings_confirmation() -> bool:
discard = 'No, discard'
options = ['Yes, save', discard]
index = cli_confirm(question, options)
index = cli_confirm(question, options, escapable=True)
if options[index] == discard:
raise KeyboardInterrupt

View File

@ -82,5 +82,5 @@ disallow_untyped_defs = true
ignore_missing_imports = true
[tool.uv.sources]
openhands-sdk = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/sdk", rev = "f0b9bcb5de574f5c4fdc8e1c153bbdd0bf1216ab" }
openhands-tools = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/tools", rev = "f0b9bcb5de574f5c4fdc8e1c153bbdd0bf1216ab" }
openhands-sdk = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/sdk", rev = "711efcbadaa78a0b6b20699976e495ddf995767f" }
openhands-tools = { git = "https://github.com/All-Hands-AI/agent-sdk.git", subdirectory = "openhands/tools", rev = "711efcbadaa78a0b6b20699976e495ddf995767f" }

View File

@ -6,10 +6,10 @@ Tests for confirmation mode functionality in OpenHands CLI.
import os
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from unittest.mock import MagicMock, patch
from unittest.mock import ANY, MagicMock, patch
import pytest
from openhands.sdk import ActionBase
from openhands.sdk import ActionBase, Workspace
from openhands.sdk.security.confirmation_policy import AlwaysConfirm, NeverConfirm, ConfirmRisky, SecurityRisk
from prompt_toolkit.input.defaults import create_pipe_input
from prompt_toolkit.output.defaults import DummyOutput
@ -38,17 +38,11 @@ class TestConfirmationMode:
patch('openhands_cli.setup.AgentStore') as mock_agent_store_class,
patch('openhands_cli.setup.print_formatted_text') as mock_print,
patch('openhands_cli.setup.HTML') as mock_html,
patch('openhands_cli.setup.LocalFileStore') as mock_filestore_class,
patch('openhands_cli.setup.get_conversation_perisistence_path') as mock_get_path,
patch('openhands_cli.setup.uuid') as mock_uuid,
):
# Mock dependencies
mock_conversation_id = MagicMock()
mock_uuid.uuid4.return_value = mock_conversation_id
mock_filestore_instance = MagicMock()
mock_filestore_class.return_value = mock_filestore_instance
mock_path = '/test/path'
mock_get_path.return_value = mock_path
# Mock AgentStore
mock_agent_store_instance = MagicMock()
@ -67,11 +61,10 @@ class TestConfirmationMode:
assert result == mock_conversation_instance
mock_agent_store_class.assert_called_once()
mock_agent_store_instance.load.assert_called_once()
mock_get_path.assert_called_once_with(mock_conversation_id)
mock_filestore_class.assert_called_once_with(mock_path)
mock_conversation_class.assert_called_once_with(
agent=mock_agent_instance,
persist_filestore=mock_filestore_instance,
workspace=ANY,
persistence_dir=ANY,
conversation_id=mock_conversation_id
)
# Verify print_formatted_text was called
@ -90,7 +83,7 @@ class TestConfirmationMode:
# Should raise MissingAgentSpec
with pytest.raises(MissingAgentSpec) as exc_info:
setup_conversation()
assert "Agent specification not found" in str(exc_info.value)
mock_agent_store_class.assert_called_once()
mock_agent_store_instance.load.assert_called_once()
@ -380,7 +373,7 @@ class TestConfirmationMode:
assert runner.is_confirmation_mode_enabled is True
# Mock get_unmatched_actions to return some actions
with patch('openhands_cli.runner.get_unmatched_actions') as mock_get_actions:
with patch('openhands_cli.runner.ConversationState.get_unmatched_actions') as mock_get_actions:
mock_action = MagicMock()
mock_action.tool_name = 'bash'
mock_action.action = 'echo test'
@ -431,7 +424,7 @@ class TestConfirmationMode:
assert runner.is_confirmation_mode_enabled is True
# Mock get_unmatched_actions to return some actions
with patch('openhands_cli.runner.get_unmatched_actions') as mock_get_actions:
with patch('openhands_cli.runner.ConversationState.get_unmatched_actions') as mock_get_actions:
mock_action = MagicMock()
mock_action.tool_name = 'bash'
mock_action.action = 'echo test'

View File

@ -5,7 +5,7 @@ from unittest.mock import patch, MagicMock
from openhands.sdk import Agent, LLM, ToolSpec
from openhands_cli.locations import WORK_DIR, PERSISTENCE_DIR
from openhands_cli.tui.settings.store import AgentStore
from openhands.sdk.preset.default import get_default_tools
from openhands.tools.preset.default import get_default_tools
class TestDirectorySeparation:
@ -39,14 +39,14 @@ class TestToolSpecFix:
mock_agent = Agent(
llm=LLM(model="test/model", api_key="test-key", service_id="test-service"),
tools=[
ToolSpec(name="BashTool", params={"working_dir": original_working_dir}),
ToolSpec(name="FileEditorTool", params={"workspace_root": original_working_dir}),
ToolSpec(name="TaskTrackerTool", params={"save_dir": "value"}),
ToolSpec(name="BashTool"),
ToolSpec(name="FileEditorTool"),
ToolSpec(name="TaskTrackerTool"),
]
)
# Mock the file store to return our test agent
with patch('openhands_cli.tui.settings.store.LocalFileStore') as mock_file_store:
with patch("openhands_cli.tui.settings.store.LocalFileStore") as mock_file_store:
mock_store_instance = MagicMock()
mock_file_store.return_value = mock_store_instance
mock_store_instance.read.return_value = mock_agent.model_dump_json()
@ -64,14 +64,3 @@ class TestToolSpecFix:
assert "BashTool" in tool_names
assert "FileEditorTool" in tool_names
assert "TaskTrackerTool" in tool_names
for tool_spec in loaded_agent.tools:
if tool_spec.name == "BashTool":
assert tool_spec.params["working_dir"] == WORK_DIR
assert tool_spec.params["working_dir"] != original_working_dir
elif tool_spec.name == "FileEditorTool":
assert tool_spec.params["workspace_root"] == WORK_DIR
assert tool_spec.params["workspace_root"] != original_working_dir
elif tool_spec.name == "TaskTrackerTool":
# TaskTrackerTool should use WORK_DIR/.openhands_tasks
assert tool_spec.params["save_dir"] == PERSISTENCE_DIR

View File

@ -11,30 +11,23 @@ from openhands_cli import simple_main
class TestMainEntryPoint:
"""Test the main entry point behavior."""
@patch('openhands_cli.agent_chat.setup_conversation')
@patch('openhands_cli.agent_chat.ConversationRunner')
@patch('openhands_cli.agent_chat.get_session_prompter')
@patch('openhands_cli.simple_main.run_cli_entry')
@patch('sys.argv', ['openhands-cli'])
def test_main_starts_agent_chat_directly(
self, mock_get_session_prompter: MagicMock, mock_runner: MagicMock, mock_setup_conversation: MagicMock
self, mock_run_agent_chat: MagicMock
) -> None:
"""Test that main() starts agent chat directly when setup succeeds."""
# Mock setup_conversation to return a valid conversation
mock_conversation = MagicMock()
mock_conversation.id = str(uuid.uuid4())
mock_setup_conversation.return_value = mock_conversation
# Mock prompt session to raise KeyboardInterrupt to exit the loop
mock_session = MagicMock()
mock_session.prompt.side_effect = KeyboardInterrupt()
mock_get_session_prompter.return_value = mock_session
# Mock run_cli_entry to raise KeyboardInterrupt to exit gracefully
mock_run_agent_chat.side_effect = KeyboardInterrupt()
# Should complete without raising an exception (graceful exit)
simple_main.main()
# Should call setup_conversation
mock_setup_conversation.assert_called_once()
# Should call run_cli_entry with no resume conversation ID
mock_run_agent_chat.assert_called_once_with(resume_conversation_id=None)
@patch('openhands_cli.simple_main.run_cli_entry')
@patch('sys.argv', ['openhands-cli'])
def test_main_handles_import_error(self, mock_run_agent_chat: MagicMock) -> None:
"""Test that main() handles ImportError gracefully."""
mock_run_agent_chat.side_effect = ImportError('Missing dependency')
@ -45,47 +38,32 @@ class TestMainEntryPoint:
assert str(exc_info.value) == 'Missing dependency'
@patch('openhands_cli.agent_chat.setup_conversation')
@patch('openhands_cli.agent_chat.ConversationRunner')
@patch('openhands_cli.agent_chat.get_session_prompter')
@patch('openhands_cli.simple_main.run_cli_entry')
@patch('sys.argv', ['openhands-cli'])
def test_main_handles_keyboard_interrupt(
self, mock_get_session_prompter: MagicMock, mock_runner: MagicMock, mock_setup_conversation: MagicMock
self, mock_run_agent_chat: MagicMock
) -> None:
"""Test that main() handles KeyboardInterrupt gracefully."""
# Mock setup_conversation to return a valid conversation
mock_conversation = MagicMock()
mock_conversation.id = str(uuid.uuid4())
mock_setup_conversation.return_value = mock_conversation
# Mock prompt session to raise KeyboardInterrupt
mock_session = MagicMock()
mock_session.prompt.side_effect = KeyboardInterrupt()
mock_get_session_prompter.return_value = mock_session
# Should complete without raising an exception (graceful exit)
simple_main.main()
@patch('openhands_cli.agent_chat.setup_conversation')
@patch('openhands_cli.agent_chat.ConversationRunner')
@patch('openhands_cli.agent_chat.get_session_prompter')
def test_main_handles_eof_error(
self, mock_get_session_prompter: MagicMock, mock_runner: MagicMock, mock_setup_conversation: MagicMock
) -> None:
"""Test that main() handles EOFError gracefully."""
# Mock setup_conversation to return a valid conversation
mock_conversation = MagicMock()
mock_conversation.id = str(uuid.uuid4())
mock_setup_conversation.return_value = mock_conversation
# Mock prompt session to raise EOFError
mock_session = MagicMock()
mock_session.prompt.side_effect = EOFError()
mock_get_session_prompter.return_value = mock_session
# Mock run_cli_entry to raise KeyboardInterrupt
mock_run_agent_chat.side_effect = KeyboardInterrupt()
# Should complete without raising an exception (graceful exit)
simple_main.main()
@patch('openhands_cli.simple_main.run_cli_entry')
@patch('sys.argv', ['openhands-cli'])
def test_main_handles_eof_error(
self, mock_run_agent_chat: MagicMock
) -> None:
"""Test that main() handles EOFError gracefully."""
# Mock run_cli_entry to raise EOFError
mock_run_agent_chat.side_effect = EOFError()
# Should complete without raising an exception (graceful exit)
simple_main.main()
@patch('openhands_cli.simple_main.run_cli_entry')
@patch('sys.argv', ['openhands-cli'])
def test_main_handles_general_exception(
self, mock_run_agent_chat: MagicMock
) -> None:
@ -97,3 +75,18 @@ class TestMainEntryPoint:
simple_main.main()
assert str(exc_info.value) == 'Unexpected error'
@patch('openhands_cli.simple_main.run_cli_entry')
@patch('sys.argv', ['openhands-cli', '--resume', 'test-conversation-id'])
def test_main_with_resume_argument(
self, mock_run_agent_chat: MagicMock
) -> None:
"""Test that main() passes resume conversation ID when provided."""
# Mock run_cli_entry to raise KeyboardInterrupt to exit gracefully
mock_run_agent_chat.side_effect = KeyboardInterrupt()
# Should complete without raising an exception (graceful exit)
simple_main.main()
# Should call run_cli_entry with the provided resume conversation ID
mock_run_agent_chat.assert_called_once_with(resume_conversation_id='test-conversation-id')

View File

@ -4,7 +4,7 @@ from openhands_cli.tui.settings.settings_screen import SettingsScreen
from pathlib import Path
from openhands.sdk import LLM, Conversation, LocalFileStore
from openhands.sdk.preset.default import get_default_agent
from openhands.tools.preset.default import get_default_agent
from openhands_cli.tui.settings.store import AgentStore
from openhands_cli.user_actions.settings_action import SettingsType
from pydantic import SecretStr
@ -26,8 +26,7 @@ def seed_file(path: Path, model: str = "openai/gpt-4o-mini", api_key: str = "sk-
store = AgentStore()
store.file_store = LocalFileStore(root=str(path))
agent = get_default_agent(
llm=LLM(model=model, api_key=SecretStr(api_key), service_id="test-service"),
working_dir=str(path)
llm=LLM(model=model, api_key=SecretStr(api_key), service_id="test-service")
)
store.save(agent)

8
openhands-cli/uv.lock generated
View File

@ -1484,8 +1484,8 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "openhands-sdk", git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Fsdk&rev=f0b9bcb5de574f5c4fdc8e1c153bbdd0bf1216ab" },
{ name = "openhands-tools", git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Ftools&rev=f0b9bcb5de574f5c4fdc8e1c153bbdd0bf1216ab" },
{ name = "openhands-sdk", git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Fsdk&rev=711efcbadaa78a0b6b20699976e495ddf995767f" },
{ name = "openhands-tools", git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Ftools&rev=711efcbadaa78a0b6b20699976e495ddf995767f" },
{ name = "prompt-toolkit", specifier = ">=3" },
{ name = "typer", specifier = ">=0.17.4" },
]
@ -1505,7 +1505,7 @@ dev = [
[[package]]
name = "openhands-sdk"
version = "1.0.0"
source = { git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Fsdk&rev=f0b9bcb5de574f5c4fdc8e1c153bbdd0bf1216ab#f0b9bcb5de574f5c4fdc8e1c153bbdd0bf1216ab" }
source = { git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Fsdk&rev=711efcbadaa78a0b6b20699976e495ddf995767f#711efcbadaa78a0b6b20699976e495ddf995767f" }
dependencies = [
{ name = "fastmcp" },
{ name = "litellm" },
@ -1519,7 +1519,7 @@ dependencies = [
[[package]]
name = "openhands-tools"
version = "1.0.0"
source = { git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Ftools&rev=f0b9bcb5de574f5c4fdc8e1c153bbdd0bf1216ab#f0b9bcb5de574f5c4fdc8e1c153bbdd0bf1216ab" }
source = { git = "https://github.com/All-Hands-AI/agent-sdk.git?subdirectory=openhands%2Ftools&rev=711efcbadaa78a0b6b20699976e495ddf995767f#711efcbadaa78a0b6b20699976e495ddf995767f" }
dependencies = [
{ name = "bashlex" },
{ name = "binaryornot" },