mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Merge branch 'v1' into feature/cli-conversation-list
This commit is contained in:
commit
54e2f2aba8
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
55
openhands-cli/openhands_cli/llm_utils.py
Normal file
55
openhands-cli/openhands_cli/llm_utils.py
Normal 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
|
||||
@ -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}")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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" }
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
8
openhands-cli/uv.lock
generated
@ -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" },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user