Repository: All-Hands-AI/OpenHands
Pull request: Display session ID in CLI mode
This PR adds the session ID to the CLI output at startup, as requested in issue #6417. This helps users to easily identify their session ID when using the CLI.

Fix #6417 

---

To run this PR locally, use the following command:
```
docker run -it --rm   -p 3000:3000   -v /var/run/docker.sock:/var/run/docker.sock   --add-host host.docker.internal:host-gateway   -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:2704a8c-nikolaik   --name openhands-app-2704a8c   docker.all-hands.dev/all-hands-ai/openhands:2704a8c
```

Problem statement:
[Feature] Printing the Session ID for CLI Mode at the start
Within the GUI its really easy to know the session-id as its in the url. 
Unfortunately this information is not available to the user in CLI Mode. Knowing the session is critical to then be able to access the docker workspace by name, which is vital if you want to be able to download the workspace. 

This issue is especially relevant if the user has multiple CLI sessions open. I imagine that it should be really easy to implement this feature.

Hints from issue discussion:
Thank you for the report, it sounds good to me.

We have also the ability to name sessions, with the parameter `-n`, for example `-n fix_print_sid` will start the session name with it, so it's more human-readable. Do you find that useful?
[OpenHands](https://github.com/All-Hands-AI/OpenHands) started fixing the issue! You can monitor the progress [here](https://github.com/All-Hands-AI/OpenHands/actions/runs/12923665093).
@enyst awesome! Yes naming the session is, huge I didn't know about this. I was looking through the documentation to attempt to find a list and description of the CLI parameters but I couldn't find it anywhere, that may be another good thing to add to the CLI Usage webpage. 
This issue is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 7 days.
I'm working on a fix! @enyst can [track my progress at all-hands.dev](https://app.all-hands.dev/conversations/993eaab8e6cf4bd7b890c29d33804f67)

Commit message: fix: mock input() in test_cli_session_id_output to prevent EOFError

Patch to review:
diff --git a/openhands/core/cli.py b/openhands/core/cli.py
index 05a390d8b815..351ced0802bf 100644
--- a/openhands/core/cli.py
+++ b/openhands/core/cli.py
@@ -100,6 +100,7 @@ async def main(loop: asyncio.AbstractEventLoop):
     initial_user_action = MessageAction(content=task_str) if task_str else None
 
     sid = str(uuid4())
+    display_message(f'Session ID: {sid}')
 
     runtime = create_runtime(config, sid=sid, headless_mode=True)
     await runtime.connect()
diff --git a/tests/unit/test_cli_sid.py b/tests/unit/test_cli_sid.py
new file mode 100644
index 000000000000..d830c58374ca
--- /dev/null
+++ b/tests/unit/test_cli_sid.py
@@ -0,0 +1,98 @@
+import asyncio
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from openhands.core.cli import main
+from openhands.core.schema import AgentState
+from openhands.events.action import ChangeAgentStateAction
+
+
+@pytest.fixture
+def mock_runtime():
+    with patch('openhands.core.cli.create_runtime') as mock_create_runtime:
+        mock_runtime_instance = AsyncMock()
+        mock_create_runtime.return_value = mock_runtime_instance
+        yield mock_runtime_instance
+
+
+@pytest.fixture
+def mock_agent():
+    with patch('openhands.core.cli.create_agent') as mock_create_agent:
+        mock_agent_instance = AsyncMock()
+        mock_create_agent.return_value = mock_agent_instance
+        yield mock_agent_instance
+
+
+@pytest.fixture
+def mock_controller():
+    with patch('openhands.core.cli.create_controller') as mock_create_controller:
+        mock_controller_instance = AsyncMock()
+        mock_create_controller.return_value = (mock_controller_instance, None)
+        yield mock_controller_instance
+
+
+@pytest.mark.asyncio
+@patch('builtins.input', return_value="")\nasync def test_cli_session_id_output(mock_runtime, mock_agent, mock_controller, capsys):
+    # display sid in console when starting
+    await main(asyncio.get_event_loop())
+    captured = capsys.readouterr()
+    assert 'Session ID:' in captured.out
+
+
+@pytest.mark.asyncio
+async def test_cli_runs_without_error(mock_runtime, mock_agent, mock_controller):
+    await main(asyncio.get_event_loop())
+    mock_runtime.connect.assert_called_once()
+    mock_agent.assert_called_once()
+    mock_controller.assert_called_once()
+    mock_runtime.event_stream.subscribe.assert_called_once()
+    mock_runtime.connect.assert_called_once()
+    mock_runtime.event_stream.add_event.assert_called()
+
+
+@pytest.mark.asyncio
+async def test_cli_exits_on_keyboard_interrupt(
+    mock_runtime, mock_agent, mock_controller
+):
+    with patch('openhands.core.cli.main') as mock_main:
+        mock_main.side_effect = KeyboardInterrupt()
+        with pytest.raises(SystemExit) as e:
+            await main(asyncio.get_event_loop())
+        assert e.type == SystemExit
+        assert e.value.code is None
+
+
+@pytest.mark.asyncio
+async def test_cli_handles_connection_refused_error(
+    mock_runtime, mock_agent, mock_controller
+):
+    mock_runtime.connect.side_effect = ConnectionRefusedError('Connection refused')
+    with pytest.raises(SystemExit) as e:
+        await main(asyncio.get_event_loop())
+    assert e.type == SystemExit
+    assert e.value.code == 1
+
+
+@pytest.mark.asyncio
+async def test_cli_handles_generic_exception(mock_runtime, mock_agent, mock_controller):
+    mock_runtime.connect.side_effect = Exception('Generic exception')
+    with pytest.raises(SystemExit) as e:
+        await main(asyncio.get_event_loop())
+    assert e.type == SystemExit
+    assert e.value.code == 1
+
+
+@pytest.mark.asyncio
+async def test_prompt_for_next_task_calls_read_input(
+    mock_runtime, mock_agent, mock_controller, monkeypatch
+):
+    async def mock_read_input(_):
+        return 'exit'
+
+    monkeypatch.setattr('openhands.core.cli.read_input', mock_read_input)
+    await main(asyncio.get_event_loop())
+    mock_runtime.event_stream.add_event.assert_called_with(pytest.ANY, 'ENVIRONMENT')
+    args, kwargs = mock_runtime.event_stream.add_event.call_args
+    assert isinstance(args[0], ChangeAgentStateAction)
+    assert args[0].agent_state == AgentState.STOPPED


Code review comments:
diff_hunk: @@ -0,0 +1,98 @@
+import asyncio
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from openhands.core.cli import main
+from openhands.core.schema import AgentState
+from openhands.events.action import ChangeAgentStateAction
+
+
+@pytest.fixture
+def mock_runtime():
+    with patch('openhands.core.cli.create_runtime') as mock_create_runtime:
+        mock_runtime_instance = AsyncMock()
+        mock_create_runtime.return_value = mock_runtime_instance
+        yield mock_runtime_instance
+
+
+@pytest.fixture
+def mock_agent():
+    with patch('openhands.core.cli.create_agent') as mock_create_agent:
+        mock_agent_instance = AsyncMock()
+        mock_create_agent.return_value = mock_agent_instance
+        yield mock_agent_instance
+
+
+@pytest.fixture
+def mock_controller():
+    with patch('openhands.core.cli.create_controller') as mock_create_controller:
+        mock_controller_instance = AsyncMock()
+        mock_create_controller.return_value = (mock_controller_instance, None)
+        yield mock_controller_instance
+
+
+@pytest.mark.asyncio
+@patch('builtins.input', return_value="")\nasync def test_cli_session_id_output(mock_runtime, mock_agent, mock_controller, capsys):
path: tests/unit/test_cli_sid.py
text: @author:
```suggestion
@user1('builtins.input', return_value="")
async def test_cli_session_id_output(mock_runtime, mock_agent, mock_controller, capsys):
```