From f1cb9236ec14cba97f5ff96f64d164a1efbc81d1 Mon Sep 17 00:00:00 2001 From: Debug Agent Date: Wed, 1 Apr 2026 15:27:10 -0300 Subject: [PATCH 1/6] fix: always install Node 22 for ACP packages in agent-server images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SWE-bench base images often ship with NVM-managed old Node.js versions (v8–v14) that already have `npm` in PATH. The previous conditional install (`if ! command -v npm`) would skip Node 22, causing ACP packages (claude-agent-acp, codex-acp, gemini-cli) to be installed into the old Node.js where they crash with SyntaxError on modern ES module syntax. Remove the conditional and always install Node 22 from nodesource so ACP subprocess servers can start reliably regardless of the base image. Fixes: OpenHands/runtime-api#458 Co-Authored-By: Claude Opus 4.6 --- .../openhands/agent_server/docker/Dockerfile | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/docker/Dockerfile b/openhands-agent-server/openhands/agent_server/docker/Dockerfile index b857f7b221..976280c918 100644 --- a/openhands-agent-server/openhands/agent_server/docker/Dockerfile +++ b/openhands-agent-server/openhands/agent_server/docker/Dockerfile @@ -93,13 +93,13 @@ RUN set -eux; \ rm -rf /var/lib/apt/lists/* # Pre-install ACP servers for ACPAgent support (Claude Code, Codex, Gemini CLI) -# Install Node.js/npm if not present (SWE-bench base images may lack them) +# Always install Node.js 22+ even if an older version exists (e.g. SWE-bench +# images ship NVM-managed Node 8-14 which cannot run modern ACP packages). RUN set -eux; \ - if ! command -v npm >/dev/null 2>&1; then \ - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ - apt-get install -y --no-install-recommends nodejs && \ - rm -rf /var/lib/apt/lists/*; \ - fi; \ + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ + apt-get install -y --no-install-recommends nodejs && \ + rm -rf /var/lib/apt/lists/* && \ + node --version && \ npm install -g @zed-industries/claude-agent-acp @zed-industries/codex-acp @google/gemini-cli # Configure Claude Code managed settings for headless operation: From f527e001c9c0d3279030c1611345731f39ba346f Mon Sep 17 00:00:00 2001 From: Debug Agent Date: Wed, 1 Apr 2026 17:35:06 -0300 Subject: [PATCH 2/6] fix: install Node 22 to dedicated prefix to avoid breaking repo tests Instead of replacing the system Node.js with nodesource, download a Node 22 binary tarball to /opt/acp-node/ and install ACP packages there. Wrapper scripts in /usr/local/bin/ prepend the ACP Node to PATH only for the ACP subprocess, so the repo's own Node.js (needed for test suites) stays untouched. Co-Authored-By: Claude Opus 4.6 --- .../openhands/agent_server/docker/Dockerfile | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/docker/Dockerfile b/openhands-agent-server/openhands/agent_server/docker/Dockerfile index 976280c918..6021048d27 100644 --- a/openhands-agent-server/openhands/agent_server/docker/Dockerfile +++ b/openhands-agent-server/openhands/agent_server/docker/Dockerfile @@ -93,14 +93,32 @@ RUN set -eux; \ rm -rf /var/lib/apt/lists/* # Pre-install ACP servers for ACPAgent support (Claude Code, Codex, Gemini CLI) -# Always install Node.js 22+ even if an older version exists (e.g. SWE-bench -# images ship NVM-managed Node 8-14 which cannot run modern ACP packages). +# Install Node.js 22 to a dedicated prefix so ACP packages get a modern runtime +# WITHOUT overwriting the repo-specific Node.js that test suites depend on. +# SWE-bench images ship NVM/apt-managed Node 8-14 which cannot run ACP packages. +ENV ACP_NODE_DIR=/opt/acp-node RUN set -eux; \ - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ - apt-get install -y --no-install-recommends nodejs && \ - rm -rf /var/lib/apt/lists/* && \ - node --version && \ - npm install -g @zed-industries/claude-agent-acp @zed-industries/codex-acp @google/gemini-cli + ARCH=$(dpkg --print-architecture); \ + case "$ARCH" in amd64) NARCH=x64;; arm64) NARCH=arm64;; *) NARCH=$ARCH;; esac; \ + mkdir -p "$ACP_NODE_DIR"; \ + curl -fsSL "https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-${NARCH}.tar.xz" \ + | tar -xJ --strip-components=1 -C "$ACP_NODE_DIR"; \ + "$ACP_NODE_DIR/bin/node" --version; \ + "$ACP_NODE_DIR/bin/npm" install -g \ + @zed-industries/claude-agent-acp \ + @zed-industries/codex-acp \ + @google/gemini-cli; \ + # Create wrappers in /usr/local/bin that prepend ACP's Node 22 to PATH. + # This ensures the ACP binary's #!/usr/bin/env node shebang resolves to + # Node 22, while the repo's own node (NVM/system) stays untouched for tests. + for bin in claude-agent-acp codex-acp gemini; do \ + if [ -e "$ACP_NODE_DIR/bin/$bin" ]; then \ + printf '#!/bin/sh\nPATH="%s/bin:$PATH" exec "%s/bin/%s" "$@"\n' \ + "$ACP_NODE_DIR" "$ACP_NODE_DIR" "$bin" \ + > /usr/local/bin/"$bin"; \ + chmod +x /usr/local/bin/"$bin"; \ + fi; \ + done # Configure Claude Code managed settings for headless operation: # Allow all tool permissions (no human in the loop to approve). From 3890302576ea970104fa5e382589c5b0d000495c Mon Sep 17 00:00:00 2001 From: Debug Agent Date: Wed, 1 Apr 2026 18:54:17 -0300 Subject: [PATCH 3/6] fix(acp): defer remote ACP init until run --- .../conversation/impl/local_conversation.py | 19 ++++++++- .../local/test_conversation_send_message.py | 40 ++++++++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) 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 From 07002b607ff6bf213a1b8b8820cd24c3dd55f2e8 Mon Sep 17 00:00:00 2001 From: Debug Agent Date: Wed, 1 Apr 2026 20:01:35 -0300 Subject: [PATCH 4/6] fix(ci): put ACP node on PATH during npm install --- .../openhands/agent_server/docker/Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/docker/Dockerfile b/openhands-agent-server/openhands/agent_server/docker/Dockerfile index 6021048d27..eb28a65829 100644 --- a/openhands-agent-server/openhands/agent_server/docker/Dockerfile +++ b/openhands-agent-server/openhands/agent_server/docker/Dockerfile @@ -103,8 +103,9 @@ RUN set -eux; \ mkdir -p "$ACP_NODE_DIR"; \ curl -fsSL "https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-${NARCH}.tar.xz" \ | tar -xJ --strip-components=1 -C "$ACP_NODE_DIR"; \ - "$ACP_NODE_DIR/bin/node" --version; \ - "$ACP_NODE_DIR/bin/npm" install -g \ + PATH="$ACP_NODE_DIR/bin:$PATH"; \ + node --version; \ + npm install -g \ @zed-industries/claude-agent-acp \ @zed-industries/codex-acp \ @google/gemini-cli; \ From 88c963c0d4e60358b7ca0c34ba7ba7fa9609fd23 Mon Sep 17 00:00:00 2001 From: Debug Agent Date: Wed, 1 Apr 2026 22:54:58 -0300 Subject: [PATCH 5/6] test(acp): strengthen deferred init regression coverage --- .../local/test_conversation_send_message.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/sdk/conversation/local/test_conversation_send_message.py b/tests/sdk/conversation/local/test_conversation_send_message.py index aa46ef6a73..b43c4c7e7b 100644 --- a/tests/sdk/conversation/local/test_conversation_send_message.py +++ b/tests/sdk/conversation/local/test_conversation_send_message.py @@ -166,6 +166,7 @@ def test_acp_send_message_defers_initialization_until_run(tmp_path): agent = ACPAgent(acp_command=["echo", "test"]) conversation = LocalConversation(agent=agent, workspace=str(tmp_path)) + test_text = "Hello from ACP" def _finish_immediately(self, conv, on_event, on_token=None): conv.state.execution_status = ConversationExecutionStatus.FINISHED @@ -179,15 +180,24 @@ def _finish_immediately(self, conv, on_event, on_token=None): side_effect=_finish_immediately, ) as mock_step, ): - conversation.send_message("Hello from ACP") + conversation.send_message(test_text) 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" + user_event = conversation.state.events[-1] + assert isinstance(user_event, MessageEvent) + assert user_event.source == "user" + assert user_event.llm_message.role == "user" + 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 conversation.run() assert mock_init_state.call_count == 1 assert mock_step.call_count == 1 + assert ( + conversation.state.execution_status == ConversationExecutionStatus.FINISHED + ) + assert conversation.state.events[-1] == user_event From 1dc1a76a95ac44d89efd3a3a5a0fae588e4e103e Mon Sep 17 00:00:00 2001 From: Debug Agent Date: Thu, 2 Apr 2026 08:46:54 -0300 Subject: [PATCH 6/6] fix(acp): address review feedback --- .../openhands/agent_server/docker/Dockerfile | 13 ++++++++++--- .../sdk/conversation/impl/local_conversation.py | 4 ++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/openhands-agent-server/openhands/agent_server/docker/Dockerfile b/openhands-agent-server/openhands/agent_server/docker/Dockerfile index eb28a65829..72020191e4 100644 --- a/openhands-agent-server/openhands/agent_server/docker/Dockerfile +++ b/openhands-agent-server/openhands/agent_server/docker/Dockerfile @@ -99,10 +99,17 @@ RUN set -eux; \ ENV ACP_NODE_DIR=/opt/acp-node RUN set -eux; \ ARCH=$(dpkg --print-architecture); \ - case "$ARCH" in amd64) NARCH=x64;; arm64) NARCH=arm64;; *) NARCH=$ARCH;; esac; \ + case "$ARCH" in \ + amd64) NARCH=x64; NODE_SHA256=69b09dba5c8dcb05c4e4273a4340db1005abeafe3927efda2bc5b249e80437ec;; \ + arm64) NARCH=arm64; NODE_SHA256=08bfbf538bad0e8cbb0269f0173cca28d705874a67a22f60b57d99dc99e30050;; \ + *) echo "Unsupported architecture for ACP Node install: $ARCH" >&2; exit 1;; \ + esac; \ + NODE_TARBALL="/tmp/node-v22.14.0-linux-${NARCH}.tar.xz"; \ mkdir -p "$ACP_NODE_DIR"; \ - curl -fsSL "https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-${NARCH}.tar.xz" \ - | tar -xJ --strip-components=1 -C "$ACP_NODE_DIR"; \ + curl -fsSL "https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-${NARCH}.tar.xz" -o "$NODE_TARBALL"; \ + echo "$NODE_SHA256 $NODE_TARBALL" | sha256sum -c -; \ + tar -xJ --strip-components=1 -C "$ACP_NODE_DIR" -f "$NODE_TARBALL"; \ + rm -f "$NODE_TARBALL"; \ PATH="$ACP_NODE_DIR/bin:$PATH"; \ node --version; \ npm install -g \ diff --git a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py index c143003d2c..a171a3576f 100644 --- a/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py +++ b/openhands-sdk/openhands/sdk/conversation/impl/local_conversation.py @@ -3,6 +3,7 @@ from collections.abc import Mapping from pathlib import Path +from openhands.sdk.agent.acp_agent import ACPAgent from openhands.sdk.agent.base import AgentBase from openhands.sdk.context.prompts.prompt import render_template from openhands.sdk.conversation.base import BaseConversation @@ -492,8 +493,7 @@ def _should_initialize_agent_on_send_message(self) -> bool: 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" + return not isinstance(self.agent, ACPAgent) def switch_profile(self, profile_name: str) -> None: """Switch the agent's LLM to a named profile.