diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index 9113c4ecd9..c143003d2c 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -484,6 +484,17 @@ def _ensure_agent_ready(self) -> None: self._agent_ready = True + def _should_initialize_agent_on_send_message(self) -> bool: + """Return whether send_message() should eagerly initialize the agent. + + ACPAgent startup is substantially heavier than regular agent + initialization because it launches and handshakes with an external ACP + subprocess. Deferring that work to run() keeps send_message() fast and + avoids HTTP client read timeouts on the remote conversation endpoint. + """ + agent_kind = getattr(self.agent, "kind", self.agent.__class__.__name__) + return agent_kind != "ACPAgent" + def switch_profile(self, profile_name: str) -> None: """Switch the agent's LLM to a named profile. @@ -520,8 +531,12 @@ def send_message(self, message: str | Message, sender: str | None = None) -> Non one agent delegates to another, the sender can be set to identify which agent is sending the message. """ - # Ensure agent is fully initialized (loads plugins and initializes agent) - self._ensure_agent_ready() + # ACPAgent startup can take much longer than a normal send_message() + # round-trip because it launches and initializes a subprocess-backed + # session. Defer that work to run() so enqueueing the user message + # remains fast for remote callers. + if self._should_initialize_agent_on_send_message(): + self._ensure_agent_ready() if isinstance(message, str): message = Message(role="user", content=[TextContent(text=message)]) diff --git a/tests/sdk/conversation/local/test_conversation_send_message.py b/tests/sdk/conversation/local/test_conversation_send_message.py index e19f87c334..aa46ef6a73 100644 --- a/tests/sdk/conversation/local/test_conversation_send_message.py +++ b/tests/sdk/conversation/local/test_conversation_send_message.py @@ -1,8 +1,14 @@ +from unittest.mock import patch + from pydantic import SecretStr +from openhands.sdk.agent.acp_agent import ACPAgent from openhands.sdk.agent.base import AgentBase from openhands.sdk.conversation import Conversation, LocalConversation -from openhands.sdk.conversation.state import ConversationState +from openhands.sdk.conversation.state import ( + ConversationExecutionStatus, + ConversationState, +) from openhands.sdk.conversation.types import ( ConversationCallbackType, ConversationTokenCallbackType, @@ -153,3 +159,35 @@ def test_send_message_with_message_object(): assert len(user_event.llm_message.content) == 1 assert isinstance(user_event.llm_message.content[0], TextContent) assert user_event.llm_message.content[0].text == test_text + + +def test_acp_send_message_defers_initialization_until_run(tmp_path): + """ACP conversations should enqueue messages before starting ACP bootstrap.""" + + agent = ACPAgent(acp_command=["echo", "test"]) + conversation = LocalConversation(agent=agent, workspace=str(tmp_path)) + + def _finish_immediately(self, conv, on_event, on_token=None): + conv.state.execution_status = ConversationExecutionStatus.FINISHED + + with ( + patch.object(ACPAgent, "init_state", autospec=True) as mock_init_state, + patch.object( + ACPAgent, + "step", + autospec=True, + side_effect=_finish_immediately, + ) as mock_step, + ): + conversation.send_message("Hello from ACP") + + assert mock_init_state.call_count == 0 + assert mock_step.call_count == 0 + assert len(conversation.state.events) == 1 + assert isinstance(conversation.state.events[-1], MessageEvent) + assert conversation.state.events[-1].source == "user" + + conversation.run() + + assert mock_init_state.call_count == 1 + assert mock_step.call_count == 1