openhands 1.0.3__tar.gz → 1.0.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of openhands might be problematic. Click here for more details.

Files changed (61) hide show
  1. {openhands-1.0.3 → openhands-1.0.5}/PKG-INFO +1 -1
  2. {openhands-1.0.3 → openhands-1.0.5}/build.py +16 -12
  3. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/agent_chat.py +38 -11
  4. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/gui_launcher.py +2 -2
  5. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/setup.py +33 -51
  6. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/tui/settings/settings_screen.py +18 -6
  7. {openhands-1.0.3 → openhands-1.0.5}/pyproject.toml +1 -1
  8. {openhands-1.0.3 → openhands-1.0.5}/tests/commands/test_new_command.py +34 -35
  9. openhands-1.0.5/tests/commands/test_resume_command.py +147 -0
  10. openhands-1.0.5/tests/commands/test_settings_command.py +57 -0
  11. {openhands-1.0.3 → openhands-1.0.5}/tests/settings/test_settings_workflow.py +32 -0
  12. {openhands-1.0.3 → openhands-1.0.5}/tests/test_confirmation_mode.py +3 -2
  13. {openhands-1.0.3 → openhands-1.0.5}/tests/test_gui_launcher.py +1 -1
  14. {openhands-1.0.3 → openhands-1.0.5}/uv.lock +1 -1
  15. {openhands-1.0.3 → openhands-1.0.5}/.gitignore +0 -0
  16. {openhands-1.0.3 → openhands-1.0.5}/Makefile +0 -0
  17. {openhands-1.0.3 → openhands-1.0.5}/README.md +0 -0
  18. {openhands-1.0.3 → openhands-1.0.5}/build.sh +0 -0
  19. {openhands-1.0.3 → openhands-1.0.5}/hooks/rthook_profile_imports.py +0 -0
  20. {openhands-1.0.3 → openhands-1.0.5}/openhands.spec +0 -0
  21. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/__init__.py +0 -0
  22. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/argparsers/main_parser.py +0 -0
  23. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/argparsers/serve_parser.py +0 -0
  24. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/listeners/__init__.py +0 -0
  25. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/listeners/loading_listener.py +0 -0
  26. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/listeners/pause_listener.py +0 -0
  27. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/locations.py +0 -0
  28. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/pt_style.py +0 -0
  29. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/runner.py +0 -0
  30. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/simple_main.py +0 -0
  31. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/tui/__init__.py +0 -0
  32. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/tui/settings/mcp_screen.py +0 -0
  33. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/tui/settings/store.py +0 -0
  34. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/tui/status.py +0 -0
  35. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/tui/tui.py +0 -0
  36. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/tui/utils.py +0 -0
  37. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/user_actions/__init__.py +0 -0
  38. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/user_actions/agent_action.py +0 -0
  39. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/user_actions/exit_session.py +0 -0
  40. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/user_actions/settings_action.py +0 -0
  41. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/user_actions/types.py +0 -0
  42. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/user_actions/utils.py +0 -0
  43. {openhands-1.0.3 → openhands-1.0.5}/openhands_cli/utils.py +0 -0
  44. {openhands-1.0.3 → openhands-1.0.5}/tests/__init__.py +0 -0
  45. {openhands-1.0.3 → openhands-1.0.5}/tests/commands/test_confirm_command.py +0 -0
  46. {openhands-1.0.3 → openhands-1.0.5}/tests/commands/test_status_command.py +0 -0
  47. {openhands-1.0.3 → openhands-1.0.5}/tests/conftest.py +0 -0
  48. {openhands-1.0.3 → openhands-1.0.5}/tests/settings/test_api_key_preservation.py +0 -0
  49. {openhands-1.0.3 → openhands-1.0.5}/tests/settings/test_default_agent_security_analyzer.py +0 -0
  50. {openhands-1.0.3 → openhands-1.0.5}/tests/settings/test_first_time_user_settings.py +0 -0
  51. {openhands-1.0.3 → openhands-1.0.5}/tests/settings/test_settings_input.py +0 -0
  52. {openhands-1.0.3 → openhands-1.0.5}/tests/test_conversation_runner.py +0 -0
  53. {openhands-1.0.3 → openhands-1.0.5}/tests/test_directory_separation.py +0 -0
  54. {openhands-1.0.3 → openhands-1.0.5}/tests/test_exit_session_confirmation.py +0 -0
  55. {openhands-1.0.3 → openhands-1.0.5}/tests/test_loading.py +0 -0
  56. {openhands-1.0.3 → openhands-1.0.5}/tests/test_main.py +0 -0
  57. {openhands-1.0.3 → openhands-1.0.5}/tests/test_mcp_config_validation.py +0 -0
  58. {openhands-1.0.3 → openhands-1.0.5}/tests/test_pause_listener.py +0 -0
  59. {openhands-1.0.3 → openhands-1.0.5}/tests/test_session_prompter.py +0 -0
  60. {openhands-1.0.3 → openhands-1.0.5}/tests/test_tui.py +0 -0
  61. {openhands-1.0.3 → openhands-1.0.5}/tests/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openhands
3
- Version: 1.0.3
3
+ Version: 1.0.5
4
4
  Summary: OpenHands CLI - Terminal User Interface for OpenHands AI Agent
5
5
  Author-email: OpenHands Team <contact@all-hands.dev>
6
6
  License: MIT
@@ -20,15 +20,6 @@ from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR
20
20
 
21
21
  from openhands.sdk import LLM
22
22
 
23
- dummy_agent = get_default_cli_agent(
24
- llm=LLM(
25
- model='dummy-model',
26
- api_key='dummy-key',
27
- metadata=get_llm_metadata(model_name='dummy-model', llm_type='openhands'),
28
- ),
29
- cli_mode=True,
30
- )
31
-
32
23
  # =================================================
33
24
  # SECTION: Build Binary
34
25
  # =================================================
@@ -126,7 +117,7 @@ def _is_welcome(line: str) -> bool:
126
117
  return any(marker in s for marker in WELCOME_MARKERS)
127
118
 
128
119
 
129
- def test_executable() -> bool:
120
+ def test_executable(dummy_agent) -> bool:
130
121
  """Test the built executable, measuring boot time and total test time."""
131
122
  print('🧪 Testing the built executable...')
132
123
 
@@ -274,7 +265,14 @@ def main() -> int:
274
265
 
275
266
  # Test the executable
276
267
  if not args.no_test:
277
- if not test_executable():
268
+ dummy_agent = get_default_cli_agent(
269
+ llm=LLM(
270
+ model='dummy-model',
271
+ api_key='dummy-key',
272
+ metadata=get_llm_metadata(model_name='dummy-model', llm_type='openhands'),
273
+ )
274
+ )
275
+ if not test_executable(dummy_agent):
278
276
  print('❌ Executable test failed, build process failed')
279
277
  return 1
280
278
 
@@ -285,4 +283,10 @@ def main() -> int:
285
283
 
286
284
 
287
285
  if __name__ == '__main__':
288
- sys.exit(main())
286
+ try:
287
+ sys.exit(main())
288
+ except Exception as e:
289
+ print(e)
290
+ print('❌ Executable test failed')
291
+ sys.exit(1)
292
+
@@ -6,6 +6,7 @@ Provides a conversation interface with an AI agent using OpenHands patterns.
6
6
 
7
7
  import sys
8
8
  from datetime import datetime
9
+ import uuid
9
10
 
10
11
  from openhands.sdk import (
11
12
  Message,
@@ -16,7 +17,11 @@ from prompt_toolkit import print_formatted_text
16
17
  from prompt_toolkit.formatted_text import HTML
17
18
 
18
19
  from openhands_cli.runner import ConversationRunner
19
- from openhands_cli.setup import MissingAgentSpec, setup_conversation, start_fresh_conversation
20
+ from openhands_cli.setup import (
21
+ MissingAgentSpec,
22
+ setup_conversation,
23
+ verify_agent_exists_or_setup_agent
24
+ )
20
25
  from openhands_cli.tui.settings.mcp_screen import MCPScreen
21
26
  from openhands_cli.tui.settings.settings_screen import SettingsScreen
22
27
  from openhands_cli.tui.status import display_status
@@ -65,21 +70,33 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
65
70
  EOFError: If EOF is encountered
66
71
  """
67
72
 
73
+ conversation_id = uuid.uuid4()
74
+ if resume_conversation_id:
75
+ try:
76
+ conversation_id = uuid.UUID(resume_conversation_id)
77
+ except ValueError as e:
78
+ print_formatted_text(
79
+ HTML(
80
+ f"<yellow>Warning: '{resume_conversation_id}' is not a valid UUID.</yellow>"
81
+ )
82
+ )
83
+ return
84
+
68
85
  try:
69
- conversation = start_fresh_conversation(resume_conversation_id)
86
+ initialized_agent = verify_agent_exists_or_setup_agent()
70
87
  except MissingAgentSpec:
71
88
  print_formatted_text(HTML('\n<yellow>Setup is required to use OpenHands CLI.</yellow>'))
72
89
  print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
73
90
  return
74
91
 
75
92
 
76
- display_welcome(conversation.id, bool(resume_conversation_id))
93
+ display_welcome(conversation_id, bool(resume_conversation_id))
77
94
 
78
95
  # Track session start time for uptime calculation
79
96
  session_start_time = datetime.now()
80
97
 
81
98
  # Create conversation runner to handle state machine logic
82
- runner = ConversationRunner(conversation)
99
+ runner = None
83
100
  session = get_session_prompter()
84
101
 
85
102
  # Main chat loop
@@ -106,29 +123,29 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
106
123
  exit_confirmation = exit_session_confirmation()
107
124
  if exit_confirmation == UserConfirmation.ACCEPT:
108
125
  print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
109
- _print_exit_hint(conversation.id)
126
+ _print_exit_hint(conversation_id)
110
127
  break
111
128
 
112
129
  elif command == '/settings':
113
- settings_screen = SettingsScreen(conversation)
130
+ settings_screen = SettingsScreen(runner.conversation if runner else None)
114
131
  settings_screen.display_settings()
115
132
  continue
116
133
 
117
134
  elif command == '/mcp':
118
135
  mcp_screen = MCPScreen()
119
- mcp_screen.display_mcp_info(conversation.agent)
136
+ mcp_screen.display_mcp_info(initialized_agent)
120
137
  continue
121
138
 
122
139
  elif command == '/clear':
123
- display_welcome(conversation.id)
140
+ display_welcome(conversation_id)
124
141
  continue
125
142
 
126
143
  elif command == '/new':
127
144
  try:
128
145
  # Start a fresh conversation (no resume ID = new conversation)
129
- conversation = setup_conversation()
146
+ conversation = setup_conversation(conversation_id)
130
147
  runner = ConversationRunner(conversation)
131
- display_welcome(conversation.id, resume=False)
148
+ display_welcome(conversation_id, resume=False)
132
149
  print_formatted_text(
133
150
  HTML('<green>✓ Started fresh conversation</green>')
134
151
  )
@@ -158,6 +175,13 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
158
175
  continue
159
176
 
160
177
  elif command == '/resume':
178
+ if not runner:
179
+ print_formatted_text(
180
+ HTML('<yellow>No active conversation running...</yellow>')
181
+ )
182
+ continue
183
+
184
+ conversation = runner.conversation
161
185
  if not (
162
186
  conversation.state.agent_status == AgentExecutionStatus.PAUSED
163
187
  or conversation.state.agent_status
@@ -171,6 +195,9 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
171
195
  # Resume without new message
172
196
  message = None
173
197
 
198
+ if not runner:
199
+ conversation = setup_conversation(conversation_id)
200
+ runner = ConversationRunner(conversation)
174
201
  runner.process_message(message)
175
202
 
176
203
  print() # Add spacing
@@ -179,7 +206,7 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
179
206
  exit_confirmation = exit_session_confirmation()
180
207
  if exit_confirmation == UserConfirmation.ACCEPT:
181
208
  print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
182
- _print_exit_hint(conversation.id)
209
+ _print_exit_hint(conversation_id)
183
210
  break
184
211
 
185
212
  # Clean up terminal state
@@ -104,8 +104,8 @@ def launch_gui_server(mount_cwd: bool = False, gpu: bool = False) -> None:
104
104
 
105
105
  # Get the current version for the Docker image
106
106
  version = get_openhands_version()
107
- runtime_image = f'docker.all-hands.dev/openhands/runtime:{version}-nikolaik'
108
- app_image = f'docker.all-hands.dev/openhands/openhands:{version}'
107
+ runtime_image = f'docker.openhands.dev/openhands/runtime:{version}-nikolaik'
108
+ app_image = f'docker.openhands.dev/openhands/openhands:{version}'
109
109
 
110
110
  print_formatted_text(HTML('<grey>Pulling required Docker images...</grey>'))
111
111
 
@@ -2,7 +2,7 @@ import uuid
2
2
 
3
3
  from prompt_toolkit import HTML, print_formatted_text
4
4
 
5
- from openhands.sdk import BaseConversation, Conversation, Workspace, register_tool
5
+ from openhands.sdk import Agent, BaseConversation, Conversation, Workspace, register_tool
6
6
  from openhands.tools.execute_bash import BashTool
7
7
  from openhands.tools.file_editor import FileEditorTool
8
8
  from openhands.tools.task_tracker import TaskTrackerTool
@@ -26,8 +26,38 @@ class MissingAgentSpec(Exception):
26
26
  pass
27
27
 
28
28
 
29
- def setup_conversation(
29
+
30
+ def load_agent_specs(
30
31
  conversation_id: str | None = None,
32
+ ) -> Agent:
33
+ agent_store = AgentStore()
34
+ agent = agent_store.load(session_id=conversation_id)
35
+ if not agent:
36
+ raise MissingAgentSpec(
37
+ 'Agent specification not found. Please configure your agent settings.'
38
+ )
39
+ return agent
40
+
41
+
42
+ def verify_agent_exists_or_setup_agent() -> Agent:
43
+ """Verify agent specs exists by attempting to load it.
44
+
45
+ """
46
+ settings_screen = SettingsScreen()
47
+ try:
48
+ agent = load_agent_specs()
49
+ return agent
50
+ except MissingAgentSpec:
51
+ # For first-time users, show the full settings flow with choice between basic/advanced
52
+ settings_screen.configure_settings(first_time=True)
53
+
54
+
55
+ # Try once again after settings setup attempt
56
+ return load_agent_specs()
57
+
58
+
59
+ def setup_conversation(
60
+ conversation_id: uuid,
31
61
  include_security_analyzer: bool = True
32
62
  ) -> BaseConversation:
33
63
  """
@@ -40,28 +70,8 @@ def setup_conversation(
40
70
  MissingAgentSpec: If agent specification is not found or invalid.
41
71
  """
42
72
 
43
- # Use provided conversation_id or generate a random one
44
- if conversation_id is None:
45
- conversation_id = uuid.uuid4()
46
- elif isinstance(conversation_id, str):
47
- try:
48
- conversation_id = uuid.UUID(conversation_id)
49
- except ValueError as e:
50
- print_formatted_text(
51
- HTML(
52
- f"<yellow>Warning: '{conversation_id}' is not a valid UUID.</yellow>"
53
- )
54
- )
55
- raise e
56
-
57
73
  with LoadingContext('Initializing OpenHands agent...'):
58
- agent_store = AgentStore()
59
- agent = agent_store.load(session_id=str(conversation_id))
60
- if not agent:
61
- raise MissingAgentSpec(
62
- 'Agent specification not found. Please configure your agent settings.'
63
- )
64
-
74
+ agent = load_agent_specs(str(conversation_id))
65
75
 
66
76
  if not include_security_analyzer:
67
77
  # Remove security analyzer from agent spec
@@ -86,31 +96,3 @@ def setup_conversation(
86
96
  )
87
97
  return conversation
88
98
 
89
-
90
-
91
- def start_fresh_conversation(
92
- resume_conversation_id: str | None = None
93
- ) -> BaseConversation:
94
- """Start a fresh conversation by creating a new conversation instance.
95
-
96
- Handles the complete conversation setup process including settings screen
97
- if agent configuration is missing.
98
-
99
- Args:
100
- resume_conversation_id: Optional conversation ID to resume
101
-
102
- Returns:
103
- BaseConversation: A new conversation instance
104
- """
105
- conversation = None
106
- settings_screen = SettingsScreen()
107
- try:
108
- conversation = setup_conversation(resume_conversation_id)
109
- return conversation
110
- except MissingAgentSpec:
111
- # For first-time users, show the full settings flow with choice between basic/advanced
112
- settings_screen.configure_settings(first_time=True)
113
-
114
-
115
- # Try once again after settings setup attempt
116
- return setup_conversation(resume_conversation_id)
@@ -1,6 +1,6 @@
1
1
  import os
2
2
 
3
- from openhands.sdk import LLM, BaseConversation, LocalFileStore
3
+ from openhands.sdk import LLM, BaseConversation, LLMSummarizingCondenser, LocalFileStore
4
4
  from prompt_toolkit import HTML, print_formatted_text
5
5
  from prompt_toolkit.shortcuts import print_container
6
6
  from prompt_toolkit.widgets import Frame, TextArea
@@ -33,9 +33,6 @@ class SettingsScreen:
33
33
  agent_spec = self.agent_store.load()
34
34
  if not agent_spec:
35
35
  return
36
- assert self.conversation is not None, (
37
- 'Conversation must be set to display settings.'
38
- )
39
36
 
40
37
  llm = agent_spec.llm
41
38
  advanced_llm_settings = True if llm.base_url else False
@@ -62,12 +59,20 @@ class SettingsScreen:
62
59
  labels_and_values.extend(
63
60
  [
64
61
  (' API Key', '********' if llm.api_key else 'Not Set'),
62
+ ]
63
+ )
64
+
65
+ if self.conversation:
66
+ labels_and_values.extend([
65
67
  (
66
68
  ' Confirmation Mode',
67
69
  'Enabled'
68
70
  if self.conversation.is_confirmation_mode_active
69
71
  else 'Disabled',
70
- ),
72
+ )
73
+ ])
74
+
75
+ labels_and_values.extend([
71
76
  (
72
77
  ' Memory Condensation',
73
78
  'Enabled' if agent_spec.condenser else 'Disabled',
@@ -153,7 +158,7 @@ class SettingsScreen:
153
158
  api_key = prompt_api_key(
154
159
  step_counter,
155
160
  custom_model.split('/')[0] if len(custom_model.split('/')) > 1 else '',
156
- self.conversation.agent.llm.api_key if self.conversation else None,
161
+ self.conversation.state.agent.llm.api_key if self.conversation else None,
157
162
  escapable=escapable,
158
163
  )
159
164
  memory_condensation = choose_memory_condensation(step_counter)
@@ -182,7 +187,14 @@ class SettingsScreen:
182
187
  if not agent:
183
188
  agent = get_default_cli_agent(llm=llm)
184
189
 
190
+ # Must update all LLMs
185
191
  agent = agent.model_copy(update={'llm': llm})
192
+ condenser = LLMSummarizingCondenser(
193
+ llm=llm.model_copy(
194
+ update={"usage_id": "condenser"}
195
+ )
196
+ )
197
+ agent = agent.model_copy(update={'condenser': condenser})
186
198
  self.agent_store.save(agent)
187
199
 
188
200
  def _save_advanced_settings(
@@ -4,7 +4,7 @@ requires = [ "hatchling>=1.25" ]
4
4
 
5
5
  [project]
6
6
  name = "openhands"
7
- version = "1.0.3"
7
+ version = "1.0.5"
8
8
  description = "OpenHands CLI - Terminal User Interface for OpenHands AI Agent"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -4,51 +4,49 @@ from unittest.mock import MagicMock, patch
4
4
  from uuid import UUID
5
5
  from prompt_toolkit.input.defaults import create_pipe_input
6
6
  from prompt_toolkit.output.defaults import DummyOutput
7
- from openhands_cli.setup import MissingAgentSpec, start_fresh_conversation
7
+ from openhands_cli.setup import MissingAgentSpec, verify_agent_exists_or_setup_agent, setup_conversation
8
8
  from openhands_cli.user_actions import UserConfirmation
9
9
 
10
- @patch('openhands_cli.setup.setup_conversation')
11
- def test_start_fresh_conversation_success(mock_setup_conversation):
12
- """Test that start_fresh_conversation creates a new conversation successfully."""
13
- # Mock the conversation object
14
- mock_conversation = MagicMock()
15
- mock_conversation.id = UUID('12345678-1234-5678-9abc-123456789abc')
16
- mock_setup_conversation.return_value = mock_conversation
10
+ @patch('openhands_cli.setup.load_agent_specs')
11
+ def test_verify_agent_exists_or_setup_agent_success(mock_load_agent_specs):
12
+ """Test that verify_agent_exists_or_setup_agent returns agent successfully."""
13
+ # Mock the agent object
14
+ mock_agent = MagicMock()
15
+ mock_load_agent_specs.return_value = mock_agent
17
16
 
18
17
  # Call the function
19
- result = start_fresh_conversation()
18
+ result = verify_agent_exists_or_setup_agent()
20
19
 
21
20
  # Verify the result
22
- assert result == mock_conversation
23
- mock_setup_conversation.assert_called_once_with(None)
21
+ assert result == mock_agent
22
+ mock_load_agent_specs.assert_called_once_with()
24
23
 
25
24
 
26
25
  @patch('openhands_cli.setup.SettingsScreen')
27
- @patch('openhands_cli.setup.setup_conversation')
28
- def test_start_fresh_conversation_missing_agent_spec(
29
- mock_setup_conversation,
26
+ @patch('openhands_cli.setup.load_agent_specs')
27
+ def test_verify_agent_exists_or_setup_agent_missing_agent_spec(
28
+ mock_load_agent_specs,
30
29
  mock_settings_screen_class
31
30
  ):
32
- """Test that start_fresh_conversation handles MissingAgentSpec exception."""
31
+ """Test that verify_agent_exists_or_setup_agent handles MissingAgentSpec exception."""
33
32
  # Mock the SettingsScreen instance
34
33
  mock_settings_screen = MagicMock()
35
34
  mock_settings_screen_class.return_value = mock_settings_screen
36
35
 
37
- # Mock setup_conversation to raise MissingAgentSpec on first call, then succeed
38
- mock_conversation = MagicMock()
39
- mock_conversation.id = UUID('12345678-1234-5678-9abc-123456789abc')
40
- mock_setup_conversation.side_effect = [
36
+ # Mock load_agent_specs to raise MissingAgentSpec on first call, then succeed
37
+ mock_agent = MagicMock()
38
+ mock_load_agent_specs.side_effect = [
41
39
  MissingAgentSpec("Agent spec missing"),
42
- mock_conversation
40
+ mock_agent
43
41
  ]
44
42
 
45
43
  # Call the function
46
- result = start_fresh_conversation()
44
+ result = verify_agent_exists_or_setup_agent()
47
45
 
48
46
  # Verify the result
49
- assert result == mock_conversation
47
+ assert result == mock_agent
50
48
  # Should be called twice: first fails, second succeeds
51
- assert mock_setup_conversation.call_count == 2
49
+ assert mock_load_agent_specs.call_count == 2
52
50
  # Settings screen should be called once with first_time=True (new behavior)
53
51
  mock_settings_screen.configure_settings.assert_called_once_with(first_time=True)
54
52
 
@@ -59,11 +57,11 @@ def test_start_fresh_conversation_missing_agent_spec(
59
57
  @patch('openhands_cli.agent_chat.exit_session_confirmation')
60
58
  @patch('openhands_cli.agent_chat.get_session_prompter')
61
59
  @patch('openhands_cli.agent_chat.setup_conversation')
62
- @patch('openhands_cli.agent_chat.start_fresh_conversation')
60
+ @patch('openhands_cli.agent_chat.verify_agent_exists_or_setup_agent')
63
61
  @patch('openhands_cli.agent_chat.ConversationRunner')
64
62
  def test_new_command_resets_confirmation_mode(
65
63
  mock_runner_cls,
66
- mock_start_fresh_conversation,
64
+ mock_verify_agent,
67
65
  mock_setup_conversation,
68
66
  mock_get_session_prompter,
69
67
  mock_exit_confirm,
@@ -71,15 +69,17 @@ def test_new_command_resets_confirmation_mode(
71
69
  # Auto-accept the exit prompt to avoid interactive UI and EOFError
72
70
  mock_exit_confirm.return_value = UserConfirmation.ACCEPT
73
71
 
72
+ # Mock agent verification to succeed
73
+ mock_agent = MagicMock()
74
+ mock_verify_agent.return_value = mock_agent
75
+
76
+ # Mock conversation - only one is created when /new is called
74
77
  conv1 = MagicMock(); conv1.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
75
- conv2 = MagicMock(); conv2.id = UUID('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')
76
- mock_start_fresh_conversation.return_value = conv1
77
- mock_setup_conversation.side_effect = [conv2]
78
+ mock_setup_conversation.return_value = conv1
78
79
 
79
- # Distinct runner instances for each conversation
80
+ # One runner instance for the conversation
80
81
  runner1 = MagicMock(); runner1.is_confirmation_mode_active = True
81
- runner2 = MagicMock(); runner2.is_confirmation_mode_active = False
82
- mock_runner_cls.side_effect = [runner1, runner2]
82
+ mock_runner_cls.return_value = runner1
83
83
 
84
84
  # Real session fed by a pipe (no interactive confirmation now)
85
85
  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(
89
89
  mock_get_session_prompter.return_value = session
90
90
 
91
91
  from openhands_cli.agent_chat import run_cli_entry
92
- # Trigger /new, then /status, then /exit (exit will be auto-accepted)
92
+ # Trigger /new, then /exit (exit will be auto-accepted)
93
93
  for ch in "/new\r/exit\r":
94
94
  pipe.send_text(ch)
95
95
 
96
96
  run_cli_entry(None)
97
97
 
98
- # Assert we switched to a new runner for conv2
99
- assert mock_runner_cls.call_count == 2
98
+ # Assert we created one runner for the conversation when /new was called
99
+ assert mock_runner_cls.call_count == 1
100
100
  assert mock_runner_cls.call_args_list[0].args[0] is conv1
101
- assert mock_runner_cls.call_args_list[1].args[0] is conv2
@@ -0,0 +1,147 @@
1
+ """Tests for the /resume command functionality."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+ from uuid import UUID
5
+ import pytest
6
+ from prompt_toolkit.input.defaults import create_pipe_input
7
+ from prompt_toolkit.output.defaults import DummyOutput
8
+
9
+ from openhands.sdk.conversation.state import AgentExecutionStatus
10
+ from openhands_cli.user_actions import UserConfirmation
11
+
12
+
13
+ # ---------- Fixtures & helpers ----------
14
+
15
+ @pytest.fixture
16
+ def mock_agent():
17
+ """Mock agent for verification."""
18
+ return MagicMock()
19
+
20
+
21
+ @pytest.fixture
22
+ def mock_conversation():
23
+ """Mock conversation with default settings."""
24
+ conv = MagicMock()
25
+ conv.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
26
+ return conv
27
+
28
+
29
+ @pytest.fixture
30
+ def mock_runner():
31
+ """Mock conversation runner."""
32
+ return MagicMock()
33
+
34
+
35
+ def run_resume_command_test(commands, agent_status=None, expect_runner_created=True):
36
+ """Helper function to run resume command tests with common setup."""
37
+ with patch('openhands_cli.agent_chat.exit_session_confirmation') as mock_exit_confirm, \
38
+ patch('openhands_cli.agent_chat.get_session_prompter') as mock_get_session_prompter, \
39
+ patch('openhands_cli.agent_chat.setup_conversation') as mock_setup_conversation, \
40
+ patch('openhands_cli.agent_chat.verify_agent_exists_or_setup_agent') as mock_verify_agent, \
41
+ patch('openhands_cli.agent_chat.ConversationRunner') as mock_runner_cls:
42
+
43
+ # Auto-accept the exit prompt to avoid interactive UI
44
+ mock_exit_confirm.return_value = UserConfirmation.ACCEPT
45
+
46
+ # Mock agent verification to succeed
47
+ mock_agent = MagicMock()
48
+ mock_verify_agent.return_value = mock_agent
49
+
50
+ # Mock conversation setup
51
+ conv = MagicMock()
52
+ conv.id = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
53
+ if agent_status:
54
+ conv.state.agent_status = agent_status
55
+ mock_setup_conversation.return_value = conv
56
+
57
+ # Mock runner
58
+ runner = MagicMock()
59
+ runner.conversation = conv
60
+ mock_runner_cls.return_value = runner
61
+
62
+ # Real session fed by a pipe
63
+ from openhands_cli.user_actions.utils import get_session_prompter as real_get_session_prompter
64
+ with create_pipe_input() as pipe:
65
+ output = DummyOutput()
66
+ session = real_get_session_prompter(input=pipe, output=output)
67
+ mock_get_session_prompter.return_value = session
68
+
69
+ from openhands_cli.agent_chat import run_cli_entry
70
+
71
+ # Send commands
72
+ for ch in commands:
73
+ pipe.send_text(ch)
74
+
75
+ # Capture printed output
76
+ with patch('openhands_cli.agent_chat.print_formatted_text') as mock_print:
77
+ run_cli_entry(None)
78
+
79
+ return mock_runner_cls, runner, mock_print
80
+
81
+
82
+ # ---------- Warning tests (parametrized) ----------
83
+
84
+ @pytest.mark.parametrize(
85
+ "commands,expected_warning,expect_runner_created",
86
+ [
87
+ # No active conversation - /resume immediately
88
+ ("/resume\r/exit\r", "No active conversation running", False),
89
+ # Conversation exists but not in paused state - send message first, then /resume
90
+ ("hello\r/resume\r/exit\r", "No paused conversation to resume", True),
91
+ ],
92
+ )
93
+ def test_resume_command_warnings(commands, expected_warning, expect_runner_created):
94
+ """Test /resume command shows appropriate warnings."""
95
+ # Set agent status to FINISHED for the "conversation exists but not paused" test
96
+ agent_status = AgentExecutionStatus.FINISHED if expect_runner_created else None
97
+
98
+ mock_runner_cls, runner, mock_print = run_resume_command_test(
99
+ commands, agent_status=agent_status, expect_runner_created=expect_runner_created
100
+ )
101
+
102
+ # Verify warning message was printed
103
+ warning_calls = [call for call in mock_print.call_args_list
104
+ if expected_warning in str(call)]
105
+ assert len(warning_calls) > 0, f"Expected warning about {expected_warning}"
106
+
107
+ # Verify runner creation expectation
108
+ if expect_runner_created:
109
+ assert mock_runner_cls.call_count == 1
110
+ runner.process_message.assert_called()
111
+ else:
112
+ assert mock_runner_cls.call_count == 0
113
+
114
+
115
+ # ---------- Successful resume tests (parametrized) ----------
116
+
117
+ @pytest.mark.parametrize(
118
+ "agent_status",
119
+ [
120
+ AgentExecutionStatus.PAUSED,
121
+ AgentExecutionStatus.WAITING_FOR_CONFIRMATION,
122
+ ],
123
+ )
124
+ def test_resume_command_successful_resume(agent_status):
125
+ """Test /resume command successfully resumes paused/waiting conversations."""
126
+ commands = "hello\r/resume\r/exit\r"
127
+
128
+ mock_runner_cls, runner, mock_print = run_resume_command_test(
129
+ commands, agent_status=agent_status, expect_runner_created=True
130
+ )
131
+
132
+ # Verify runner was created and process_message was called
133
+ assert mock_runner_cls.call_count == 1
134
+
135
+ # Verify process_message was called twice: once with the initial message, once with None for resume
136
+ assert runner.process_message.call_count == 2
137
+
138
+ # Check the calls to process_message
139
+ calls = runner.process_message.call_args_list
140
+
141
+ # First call should have a message (the "hello" message)
142
+ first_call_args = calls[0][0]
143
+ assert first_call_args[0] is not None, "First call should have a message"
144
+
145
+ # Second call should have None (the /resume command)
146
+ second_call_args = calls[1][0]
147
+ assert second_call_args[0] is None, "Second call should have None message for resume"
@@ -0,0 +1,57 @@
1
+ """Test for the /settings command functionality."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+ from prompt_toolkit.input.defaults import create_pipe_input
5
+ from prompt_toolkit.output.defaults import DummyOutput
6
+
7
+ from openhands_cli.agent_chat import run_cli_entry
8
+ from openhands_cli.user_actions import UserConfirmation
9
+
10
+
11
+ @patch('openhands_cli.agent_chat.exit_session_confirmation')
12
+ @patch('openhands_cli.agent_chat.get_session_prompter')
13
+ @patch('openhands_cli.agent_chat.setup_conversation')
14
+ @patch('openhands_cli.agent_chat.verify_agent_exists_or_setup_agent')
15
+ @patch('openhands_cli.agent_chat.ConversationRunner')
16
+ @patch('openhands_cli.agent_chat.SettingsScreen')
17
+ def test_settings_command_works_without_conversation(
18
+ mock_settings_screen_class,
19
+ mock_runner_cls,
20
+ mock_verify_agent,
21
+ mock_setup_conversation,
22
+ mock_get_session_prompter,
23
+ mock_exit_confirm,
24
+ ):
25
+ """Test that /settings command works when no conversation is active (bug fix scenario)."""
26
+ # Auto-accept the exit prompt to avoid interactive UI
27
+ mock_exit_confirm.return_value = UserConfirmation.ACCEPT
28
+
29
+ # Mock agent verification to succeed
30
+ mock_agent = MagicMock()
31
+ mock_verify_agent.return_value = mock_agent
32
+
33
+ # Mock the SettingsScreen instance
34
+ mock_settings_screen = MagicMock()
35
+ mock_settings_screen_class.return_value = mock_settings_screen
36
+
37
+ # No runner initially (simulates starting CLI without a conversation)
38
+ mock_runner_cls.return_value = None
39
+
40
+ # Real session fed by a pipe
41
+ from openhands_cli.user_actions.utils import get_session_prompter as real_get_session_prompter
42
+ with create_pipe_input() as pipe:
43
+ output = DummyOutput()
44
+ session = real_get_session_prompter(input=pipe, output=output)
45
+ mock_get_session_prompter.return_value = session
46
+
47
+ # Trigger /settings, then /exit (exit will be auto-accepted)
48
+ for ch in "/settings\r/exit\r":
49
+ pipe.send_text(ch)
50
+
51
+ run_cli_entry(None)
52
+
53
+ # Assert SettingsScreen was created with None conversation (the bug fix)
54
+ mock_settings_screen_class.assert_called_once_with(None)
55
+
56
+ # Assert display_settings was called (settings screen was shown)
57
+ mock_settings_screen.display_settings.assert_called_once()
@@ -121,6 +121,38 @@ def test_update_existing_settings_workflow(tmp_path: Path):
121
121
  assert True # If we get here, the workflow completed successfully
122
122
 
123
123
 
124
+ def test_all_llms_in_agent_are_updated():
125
+ """Test that modifying LLM settings creates multiple LLMs with same API key but different usage_ids."""
126
+ # Create a screen with existing agent settings
127
+ screen = SettingsScreen(conversation=None)
128
+ initial_llm = LLM(model='openai/gpt-3.5-turbo', api_key=SecretStr('sk-initial'), usage_id='test-service')
129
+ initial_agent = get_default_cli_agent(llm=initial_llm)
130
+
131
+ # Mock the agent store to return the initial agent and capture the save call
132
+ with (
133
+ patch.object(screen.agent_store, 'load', return_value=initial_agent),
134
+ patch.object(screen.agent_store, 'save') as mock_save
135
+ ):
136
+ # Modify the LLM settings with new API key
137
+ screen._save_llm_settings(model='openai/gpt-4o-mini', api_key='sk-updated-123')
138
+ mock_save.assert_called_once()
139
+
140
+ # Get the saved agent from the mock
141
+ saved_agent = mock_save.call_args[0][0]
142
+ all_llms = list(saved_agent.get_all_llms())
143
+ assert len(all_llms) >= 2, f"Expected at least 2 LLMs, got {len(all_llms)}"
144
+
145
+ # Verify all LLMs have the same API key
146
+ api_keys = [llm.api_key.get_secret_value() for llm in all_llms]
147
+ assert all(api_key == 'sk-updated-123' for api_key in api_keys), \
148
+ f"Not all LLMs have the same API key: {api_keys}"
149
+
150
+ # Verify none of the usage_id attributes match
151
+ usage_ids = [llm.usage_id for llm in all_llms]
152
+ assert len(set(usage_ids)) == len(usage_ids), \
153
+ f"Some usage_ids are duplicated: {usage_ids}"
154
+
155
+
124
156
  @pytest.mark.parametrize(
125
157
  'step_to_cancel',
126
158
  ['type', 'provider', 'model', 'apikey', 'save'],
@@ -4,6 +4,7 @@ Tests for confirmation mode functionality in OpenHands CLI.
4
4
  """
5
5
 
6
6
  import os
7
+ import uuid
7
8
  from concurrent.futures import ThreadPoolExecutor
8
9
  from typing import Any
9
10
  from unittest.mock import ANY, MagicMock, patch
@@ -60,7 +61,7 @@ class TestConfirmationMode:
60
61
  mock_conversation_instance = MagicMock()
61
62
  mock_conversation_class.return_value = mock_conversation_instance
62
63
 
63
- result = setup_conversation()
64
+ result = setup_conversation(mock_conversation_id)
64
65
 
65
66
  # Verify conversation was created and returned
66
67
  assert result == mock_conversation_instance
@@ -87,7 +88,7 @@ class TestConfirmationMode:
87
88
 
88
89
  # Should raise MissingAgentSpec
89
90
  with pytest.raises(MissingAgentSpec) as exc_info:
90
- setup_conversation()
91
+ setup_conversation(uuid.uuid4())
91
92
 
92
93
  assert 'Agent specification not found' in str(exc_info.value)
93
94
  mock_agent_store_class.assert_called_once()
@@ -182,7 +182,7 @@ class TestLaunchGuiServer:
182
182
  # Check pull command
183
183
  pull_call = mock_run.call_args_list[0]
184
184
  pull_cmd = pull_call[0][0]
185
- assert pull_cmd[0:3] == ['docker', 'pull', 'docker.all-hands.dev/openhands/runtime:latest-nikolaik']
185
+ assert pull_cmd[0:3] == ['docker', 'pull', 'docker.openhands.dev/openhands/runtime:latest-nikolaik']
186
186
 
187
187
  # Check run command
188
188
  run_call = mock_run.call_args_list[1]
@@ -1828,7 +1828,7 @@ wheels = [
1828
1828
 
1829
1829
  [[package]]
1830
1830
  name = "openhands"
1831
- version = "1.0.3"
1831
+ version = "1.0.5"
1832
1832
  source = { editable = "." }
1833
1833
  dependencies = [
1834
1834
  { name = "openhands-sdk" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes