Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 23 additions & 17 deletions libs/acp/deepagents_acp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
SetSessionConfigOptionResponse,
SetSessionModeResponse,
run_agent as run_acp_agent,
schema as _acp_schema,
start_edit_tool_call,
start_tool_call,
text_block,
Expand All @@ -38,6 +39,7 @@
PlanEntry,
PromptCapabilities,
ResourceContentBlock,
SessionConfigOptionBoolean,
SessionConfigOptionSelect,
SessionConfigSelectOption,
SessionModeState,
Expand All @@ -47,26 +49,12 @@
ToolCallUpdate,
ToolKind,
)

try:
from acp.schema import SessionConfigOption
except ImportError:
# agent-client-protocol >=0.9.0 removed the SessionConfigOption wrapper;
# config options are now bare SessionConfigOptionSelect instances.
SessionConfigOption = None # type: ignore[assignment,misc]
from deepagents import create_deep_agent
from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph.state import CompiledStateGraph
from langgraph.types import Command, StateSnapshot

if TYPE_CHECKING:
from collections.abc import Callable

from acp.interfaces import Client
from deepagents.graph import Checkpointer
from langchain_core.runnables import RunnableConfig

from deepagents_acp.utils import (
contains_dangerous_patterns,
convert_audio_block_to_content_blocks,
Expand All @@ -79,6 +67,18 @@
truncate_execute_command_for_display,
)

if TYPE_CHECKING:
from collections.abc import Callable

from acp.interfaces import Client
from deepagents.graph import Checkpointer
from langchain_core.runnables import RunnableConfig

# agent-client-protocol v0.9.0+ removed the SessionConfigOption wrapper; config
# options are now bare SessionConfigOptionSelect instances. Resolve dynamically
# so the module imports cleanly under both v0.8.x and v0.9+.
SessionConfigOption: Any = getattr(_acp_schema, "SessionConfigOption", None)


@dataclass(frozen=True, slots=True)
class AgentSessionContext:
Expand Down Expand Up @@ -144,14 +144,14 @@ def on_connect(self, conn: Client) -> None:
def _build_config_options(
self,
session_id: str,
) -> list[SessionConfigOptionSelect]:
) -> list[SessionConfigOptionSelect | SessionConfigOptionBoolean]:
"""Build the list of session configuration options.

Returns a list combining mode and model selectors if available.
Modes are mapped to config options with category='mode'.
Models are exposed as config options with category='model'.
"""
config_options: list[SessionConfigOptionSelect] = []
config_options: list[SessionConfigOptionSelect | SessionConfigOptionBoolean] = []

# Add mode selector if modes are configured
if self._modes is not None:
Expand Down Expand Up @@ -275,14 +275,19 @@ async def set_config_option(
self,
config_id: str,
session_id: str,
value: str,
value: str | bool,
**kwargs: Any, # noqa: ARG002 # ACP protocol interface parameter
) -> SetSessionConfigOptionResponse:
"""Update a configuration option for the session.

Handles both mode and model switching. When switching models,
the agent is reset to use the new model.
"""
# Only select-type options (mode, model) are supported; reject boolean values.
if not isinstance(value, str):
msg = f"Config option {config_id!r} expects a string value, got {type(value).__name__}"
raise RequestError(-32602, msg)

if config_id == "mode":
# Handle mode switching
if self._modes is not None and session_id in self._session_mode_states:
Expand Down Expand Up @@ -592,6 +597,7 @@ async def prompt( # noqa: C901, PLR0912, PLR0915 # Complex streaming protocol
| EmbeddedResourceContentBlock
],
session_id: str,
message_id: str | None = None, # noqa: ARG002 # ACP protocol interface parameter
**kwargs: Any, # noqa: ARG002 # ACP protocol interface parameter
) -> PromptResponse:
"""Process a user prompt and stream the agent response."""
Expand Down
117 changes: 117 additions & 0 deletions libs/acp/tests/test_agent.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
from dataclasses import dataclass
from typing import Any, Literal

import pytest
Expand All @@ -14,6 +15,7 @@
PermissionOption,
RequestPermissionResponse,
ResourceContentBlock,
SessionConfigOptionSelect,
SessionMode,
SessionModeState,
TextContentBlock,
Expand All @@ -30,6 +32,7 @@
from langgraph.graph.state import CompiledStateGraph
from langgraph.types import interrupt

from deepagents_acp import server as server_module
from deepagents_acp.server import AgentServerACP, AgentSessionContext
from tests.chat_model import GenericFakeChatModel

Expand Down Expand Up @@ -944,3 +947,117 @@ async def test_acp_agent_hitl_requests_permission_only_once() -> None:
"This indicates the double approval bug has regressed."
)
assert permission_requests[0]["tool_call"].title == "Write `/tmp/test.txt`"


def _make_server(*, with_modes: bool = True, with_models: bool = True) -> AgentServerACP:
"""Build a server with optional mode and/or model selectors configured."""

def factory(
context: AgentSessionContext, # noqa: ARG001 # ACP factory signature requires context
) -> CompiledStateGraph:
model = GenericFakeChatModel(
messages=iter([AIMessage(content="OK")]), stream_delimiter=None
)
return create_deep_agent(model=model, checkpointer=MemorySaver())

modes = (
SessionModeState(
current_mode_id="mode_a",
available_modes=[
SessionMode(id="mode_a", name="Mode A", description="First mode"),
SessionMode(id="mode_b", name="Mode B", description="Second mode"),
],
)
if with_modes
else None
)
models = (
[
{"value": "m1", "name": "Model One", "description": "first"},
{"value": "m2", "name": "Model Two", "description": "second"},
]
if with_models
else None
)
return AgentServerACP(agent=factory, modes=modes, models=models)


def test_build_config_options_bare_when_wrapper_absent(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Regression guard: when the v0.9+ import fallback sets SessionConfigOption to None,
`_build_config_options` must emit bare SessionConfigOptionSelect instances. A future
change that drops the `else mode_select` branch in server.py would fail this test.
"""
monkeypatch.setattr(server_module, "SessionConfigOption", None)

agent = _make_server()
options = agent._build_config_options("session-1")

assert len(options) == 2
for opt in options:
assert isinstance(opt, SessionConfigOptionSelect)
assert [opt.category for opt in options] == ["mode", "model"]


def test_build_config_options_wrapped_when_wrapper_present(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Regression guard: when SessionConfigOption is importable (acp v0.8.x), each option
must be wrapped via `root=<Select>`. Dropping the wrapper branch in server.py would
break v0.8 clients; this test pins the behavior.
"""

@dataclass
class FakeWrapper:
root: SessionConfigOptionSelect

monkeypatch.setattr(server_module, "SessionConfigOption", FakeWrapper)

agent = _make_server()
options = agent._build_config_options("session-1")

assert len(options) == 2
for opt in options:
assert isinstance(opt, FakeWrapper)
assert isinstance(opt.root, SessionConfigOptionSelect)
assert [opt.root.category for opt in options] == ["mode", "model"]


def test_build_config_options_modes_only(monkeypatch: pytest.MonkeyPatch) -> None:
"""When only modes are configured, `_build_config_options` emits a single mode entry."""
monkeypatch.setattr(server_module, "SessionConfigOption", None)

agent = _make_server(with_modes=True, with_models=False)
options = agent._build_config_options("session-1")

assert len(options) == 1
assert options[0].category == "mode"


def test_build_config_options_models_only(monkeypatch: pytest.MonkeyPatch) -> None:
"""When only models are configured, `_build_config_options` emits a single model entry."""
monkeypatch.setattr(server_module, "SessionConfigOption", None)

agent = _make_server(with_modes=False, with_models=True)
options = agent._build_config_options("session-1")

assert len(options) == 1
assert options[0].category == "model"


def test_build_config_options_empty_models_list_omits_entry() -> None:
"""An empty models list must be treated as "no models" — not a crash on models[0]."""

def factory(
context: AgentSessionContext, # noqa: ARG001 # ACP factory signature requires context
) -> CompiledStateGraph:
model = GenericFakeChatModel(
messages=iter([AIMessage(content="OK")]), stream_delimiter=None
)
return create_deep_agent(model=model, checkpointer=MemorySaver())

agent = AgentServerACP(agent=factory, models=[])
options = agent._build_config_options("session-1")

assert options == []
47 changes: 33 additions & 14 deletions libs/acp/tests/test_model_switching.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pytest
from acp.schema import (
NewSessionResponse,
SessionConfigOptionSelect,
SetSessionConfigOptionResponse,
)
from deepagents import create_deep_agent
Expand All @@ -15,6 +16,21 @@
from deepagents_acp.server import AgentServerACP, AgentSessionContext


def _select(opt: Any) -> SessionConfigOptionSelect:
"""Normalize cross-version config-option shape.

agent-client-protocol v0.8.x wraps config options in SessionConfigOption
with a .root attribute; v0.9.0+ emits the Select instance directly.
Fails loudly if the unwrapped value isn't a SessionConfigOptionSelect so
a future wrapper shape can't silently pass through.
"""
inner = getattr(opt, "root", opt)
assert isinstance(inner, SessionConfigOptionSelect), (
f"expected SessionConfigOptionSelect, got {type(inner).__name__}"
)
return inner


class MockClient:
"""Mock client for testing."""

Expand Down Expand Up @@ -80,13 +96,13 @@ async def test_new_session_returns_config_options(agent_factory, models):
assert len(response.config_options) == 1

# Check model config option
model_config = response.config_options[0]
assert model_config.root.id == "model"
assert model_config.root.name == "Model"
assert model_config.root.category == "model"
assert model_config.root.type == "select"
assert model_config.root.current_value == "anthropic:claude-opus-4-6"
assert len(model_config.root.options) == 3
model_config = _select(response.config_options[0])
assert model_config.id == "model"
assert model_config.name == "Model"
assert model_config.category == "model"
assert model_config.type == "select"
assert model_config.current_value == "anthropic:claude-opus-4-6"
assert len(model_config.options) == 3


@pytest.mark.asyncio
Expand All @@ -100,7 +116,8 @@ async def test_set_config_option_switches_model(agent_factory, models):
session_id = new_session_response.session_id

# Verify initial model
assert new_session_response.config_options[0].root.current_value == "anthropic:claude-opus-4-6"
initial = _select(new_session_response.config_options[0])
assert initial.current_value == "anthropic:claude-opus-4-6"

# Switch to a different model
response = await server.set_config_option(
Expand All @@ -111,7 +128,7 @@ async def test_set_config_option_switches_model(agent_factory, models):

assert isinstance(response, SetSessionConfigOptionResponse)
assert len(response.config_options) == 1
assert response.config_options[0].root.current_value == "anthropic:claude-sonnet-4"
assert _select(response.config_options[0]).current_value == "anthropic:claude-sonnet-4"

# Verify the model was updated in session state
assert server._session_models[session_id] == "anthropic:claude-sonnet-4"
Expand Down Expand Up @@ -162,12 +179,14 @@ async def test_config_options_with_modes_and_models(agent_factory, models):
assert len(response.config_options) == 2

# Check that mode comes first
assert response.config_options[0].root.id == "mode"
assert response.config_options[0].root.category == "mode"
mode_opt = _select(response.config_options[0])
assert mode_opt.id == "mode"
assert mode_opt.category == "mode"

# Check that model comes second
assert response.config_options[1].root.id == "model"
assert response.config_options[1].root.category == "model"
model_opt = _select(response.config_options[1])
assert model_opt.id == "model"
assert model_opt.category == "model"


@pytest.mark.asyncio
Expand Down Expand Up @@ -197,7 +216,7 @@ async def test_switching_mode_via_config_option(agent_factory, models):
value="manual",
)

assert response.config_options[0].root.current_value == "manual"
assert _select(response.config_options[0]).current_value == "manual"
assert server._session_modes[session_id] == "manual"


Expand Down
Loading