diff --git a/openhands-agent-server/openhands/agent_server/docker/Dockerfile b/openhands-agent-server/openhands/agent_server/docker/Dockerfile index 4138c49bb4..37434ca7eb 100644 --- a/openhands-agent-server/openhands/agent_server/docker/Dockerfile +++ b/openhands-agent-server/openhands/agent_server/docker/Dockerfile @@ -182,12 +182,14 @@ RUN set -ux; \ if "$ACP_NODE_DIR/bin/npm" install -g \ @agentclientprotocol/claude-agent-acp@0.63.0 \ @agentclientprotocol/codex-acp@1.1.7 \ - @google/gemini-cli@0.46.0; then \ + @google/gemini-cli@0.46.0 \ + pi-acp@0.0.33 \ + @earendil-works/pi-coding-agent@0.83.0; then \ # 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 \ + for bin in claude-agent-acp codex-acp gemini pi-acp pi; 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" \ diff --git a/openhands-sdk/openhands/sdk/settings/acp_providers.py b/openhands-sdk/openhands/sdk/settings/acp_providers.py index cf71c6b31b..ef0e523664 100644 --- a/openhands-sdk/openhands/sdk/settings/acp_providers.py +++ b/openhands-sdk/openhands/sdk/settings/acp_providers.py @@ -36,7 +36,7 @@ from dataclasses import dataclass, field from pathlib import PurePosixPath from types import MappingProxyType -from typing import Any, Literal +from typing import Any, Literal, Final from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -347,6 +347,10 @@ class ACPProviderInfo: ACPModelOption(id="gemini-2.5-flash", label="Gemini 2.5 Flash"), ) +_PI_MODELS: tuple[ACPModelOption, ...] = ( + ACPModelOption(id="default", label="Default"), +) + # --------------------------------------------------------------------------- # Reserved file-content credential secrets for the built-in providers. @@ -390,9 +394,10 @@ class ACPProviderInfo: # claude-agent-acp 0.44+ / codex-acp select the model via a ``model`` # ``configOptions`` entry (and retain the legacy ``session/set_model`` # extension); the SDK detects which mechanism each session advertises. -CLAUDE_AGENT_ACP_VERSION = "0.63.0" -CODEX_ACP_VERSION = "1.1.7" -GEMINI_CLI_VERSION = "0.46.0" +CLAUDE_AGENT_ACP_VERSION: Final[str] = "0.63.0" +CODEX_ACP_VERSION: Final[str] = "1.1.7" +GEMINI_CLI_VERSION: Final[str] = "0.46.0" +PI_ACP_VERSION: Final[str] = "0.0.33" ACP_PROVIDERS: Mapping[str, ACPProviderInfo] = MappingProxyType( @@ -480,6 +485,26 @@ class ACPProviderInfo: # ``~/.gemini`` (ignoring XDG), so only HOME relocates its state. data_dir_env_var="HOME", ), + "pi": ACPProviderInfo( + key="pi", + display_name="Pi", + default_command=( + "npx", + "-y", + f"pi-acp@{PI_ACP_VERSION}", + ), + api_key_env_var=None, + base_url_env_var=None, + default_session_mode="medium", + agent_name_patterns=("pi-acp",), + supports_set_session_model=True, + supports_runtime_model_switch=True, + session_meta_key=None, + available_models=_PI_MODELS, + default_model="default", + binary_name="pi-acp", + data_dir_env_var="PI_CODING_AGENT_DIR", + ), } ) """Read-only registry of built-in ACP providers keyed by ``acp_server`` value.""" diff --git a/openhands-sdk/openhands/sdk/settings/model.py b/openhands-sdk/openhands/sdk/settings/model.py index 46b2a2dd19..a3358ae770 100644 --- a/openhands-sdk/openhands/sdk/settings/model.py +++ b/openhands-sdk/openhands/sdk/settings/model.py @@ -1148,7 +1148,7 @@ def create_request( AgentKind = Literal["openhands", "llm", "acp"] -ACPServerKind = Literal["claude-code", "codex", "gemini-cli", "custom"] +ACPServerKind = Literal["claude-code", "codex", "gemini-cli", "pi", "custom"] """Known ACP backend servers the GUI can pick from. ``custom`` means the user supplies the raw ``acp_command`` themselves; diff --git a/tests/sdk/agent/test_acp_agent.py b/tests/sdk/agent/test_acp_agent.py index 866443f21a..f576891374 100644 --- a/tests/sdk/agent/test_acp_agent.py +++ b/tests/sdk/agent/test_acp_agent.py @@ -9164,6 +9164,17 @@ def test_claude_isolates_under_oauth_token(self, tmp_path): ) assert Path(env["CLAUDE_CONFIG_DIR"]) == Path(persist) / "acp" / "claude-code" + def test_pi_isolates_data_dir(self, tmp_path): + agent = self._agent(["npx", "-y", "pi-acp"]) + state = self._H._state(tmp_path) + persist = state.persistence_dir + assert persist is not None + with patch.dict("os.environ", {}, clear=True): + env = self._H._run_start( + agent, state, conn=self._H._make_conn(agent_name="pi-acp") + ) + assert Path(env["PI_CODING_AGENT_DIR"]) == Path(persist) / "acp" / "pi" + # --------------------------------------------------------------------------- # Secret masking (#1023) diff --git a/tests/sdk/settings/test_acp_providers.py b/tests/sdk/settings/test_acp_providers.py index 3c67c031d1..6c5bdd97ef 100644 --- a/tests/sdk/settings/test_acp_providers.py +++ b/tests/sdk/settings/test_acp_providers.py @@ -19,7 +19,7 @@ class TestACPProviderInfo: def test_known_providers_are_registered(self): - assert set(ACP_PROVIDERS) == {"claude-code", "codex", "gemini-cli"} + assert set(ACP_PROVIDERS) == {"claude-code", "codex", "gemini-cli", "pi"} def test_all_entries_are_acp_provider_info(self): for info in ACP_PROVIDERS.values(): @@ -92,6 +92,22 @@ def test_gemini_cli_metadata(self): # Gemini CLI has no dedicated config-dir var, so only HOME relocates it. assert info.data_dir_env_var == "HOME" + def test_pi_metadata(self): + info = ACP_PROVIDERS["pi"] + assert info.key == "pi" + assert info.display_name == "Pi" + assert info.default_command[0] == "npx" + assert "pi-acp" in info.default_command[-1] + assert info.api_key_env_var is None + assert info.base_url_env_var is None + assert info.default_session_mode == "medium" + assert "pi-acp" in info.agent_name_patterns + assert info.supports_set_session_model is True + assert info.supports_runtime_model_switch is True + assert info.session_meta_key is None + assert info.binary_name == "pi-acp" + assert info.data_dir_env_var == "PI_CODING_AGENT_DIR" + def test_provider_info_is_frozen(self): info = ACP_PROVIDERS["claude-code"] with pytest.raises((AttributeError, TypeError)): @@ -210,8 +226,14 @@ def test_every_provider_has_non_empty_session_mode(self): ) def test_session_modes_are_distinct(self): - modes = [info.default_session_mode for info in ACP_PROVIDERS.values()] - assert len(modes) == len(set(modes)), "each provider should use a unique mode" + modes = [ + info.default_session_mode + for info in ACP_PROVIDERS.values() + if info.default_session_mode != "default" + ] + assert len(modes) == len(set(modes)), ( + "permission-bypassing modes should be unique" + ) def test_detect_returns_matching_provider_for_all_registered_patterns(self): """Every registered pattern should resolve back to its own provider.""" diff --git a/tests/sdk/test_settings.py b/tests/sdk/test_settings.py index e62d3f53d8..9bd2c32833 100644 --- a/tests/sdk/test_settings.py +++ b/tests/sdk/test_settings.py @@ -388,7 +388,7 @@ def test_export_agent_settings_schema_emits_variant_tagged_sections() -> None: server_field = next(f for f in acp_section.fields if f.key == "acp_server") assert server_field.prominence is SettingProminence.CRITICAL server_choices = {c.value for c in server_field.choices} - assert server_choices == {"claude-code", "codex", "gemini-cli", "custom"} + assert server_choices == {"claude-code", "codex", "gemini-cli", "pi", "custom"} command_field = next(f for f in acp_section.fields if f.key == "acp_command") assert command_field.prominence is SettingProminence.MINOR @@ -1195,7 +1195,7 @@ def test_acp_create_agent_carries_provider_key() -> None: directly (not from settings) defaults to ``None``; and the key survives a serialization round-trip through the ``AgentBase`` discriminated union. """ - for server in ("claude-code", "codex", "gemini-cli", "custom"): + for server in ("claude-code", "codex", "gemini-cli", "pi", "custom"): kwargs: dict[str, Any] = {"acp_server": server} if server == "custom": kwargs["acp_command"] = ["my-acp"] @@ -1218,7 +1218,7 @@ def test_acp_resolve_command_for_known_servers( default stays the ``npx`` invocation. """ monkeypatch.setattr(shutil, "which", lambda _: None) - for server in ("claude-code", "codex", "gemini-cli"): + for server in ("claude-code", "codex", "gemini-cli", "pi"): settings = ACPAgentSettings(acp_server=server) cmd = settings.resolve_acp_command() assert cmd, f"expected default command for {server}, got empty" @@ -1992,7 +1992,7 @@ def test_acp_resolve_command_uses_registry_defaults( # No pinned binary on PATH → registry npx default is returned verbatim. monkeypatch.setattr(shutil, "which", lambda _: None) - for server_key in ("claude-code", "codex", "gemini-cli"): + for server_key in ("claude-code", "codex", "gemini-cli", "pi"): settings = ACPAgentSettings(acp_server=server_key) expected = list(ACP_PROVIDERS[server_key].default_command) assert settings.resolve_acp_command() == expected