feat(acp): surface session effort state and support model/effort switching via config options - #4384
Conversation
Mirror the codex-acp reasoning_effort handling for claude-agent-acp: acp_model ids like sonnet/high or opus[1m]/max now apply as two set_config_option calls (model + effort). Claude accepts max on top of the codex levels; codex behavior is unchanged and still rejects max. Composes through init, resume-reapply, and runtime switch, which all go via _model_config_options. current_model_id keeps the full composite, matching existing codex semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbDwfwpKHDhrWD4sEcK2Bh
Extract the effort/reasoning_effort select config option from ACP session responses alongside the model option, store it on the agent (current_effort/available_efforts), persist it in agent_state, and lift it into ConversationInfo with the same live-then-persisted precedence as model state. Runtime switches with composite ids refresh current_effort; bare-id switches leave it unchanged. Additive optional REST fields only; strictly typed so no weak-schema allowlist entries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbDwfwpKHDhrWD4sEcK2Bh
Grouped select options (SessionConfigSelectGroup) are flattened before model/effort extraction instead of being silently dropped. The bridge now dispatches ConfigOptionUpdate session notifications to the agent, which re-extracts and refreshes current/available model and effort state in memory (persisted on next init_state). Composite-id reconciliation keeps a tracked model/effort composite when the server reports the same split state, and adopts server state otherwise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbDwfwpKHDhrWD4sEcK2Bh
The 0.44.0 pin predates the adapter's effort (thought_level) config option, which the effort features rely on; 0.64.2 is the version the config-option surface was verified against (model + effort selects in session/new, set_config_option handling). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbDwfwpKHDhrWD4sEcK2Bh
Round-trips acp_current_effort/acp_available_efforts from persisted
agent_state through GET /api/conversations/{id} on a real FastAPI
server, and asserts the native-agent default is None/absent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbDwfwpKHDhrWD4sEcK2Bh
|
Paired Agent Canvas (client) PR: OpenHands/OpenHands#16350 — the UI consuming |
|
Hey @ryanskidmore thanks for the PR. One point:
Repro (drop into a scratch file, run with uv run python repro.py from repo root. It uses the PR's own test helpers): import asyncio, sys
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
sys.path.insert(0, "tests")
from sdk.agent.test_acp_agent import _make_agent
def wire(agent, agent_name):
conn = MagicMock()
conn.set_config_option = AsyncMock()
agent._conn = conn
agent._session_id = "sess-1"
agent._agent_name = agent_name
agent._model_via_config_option = True
executor = MagicMock()
executor.run_async = MagicMock(
side_effect=lambda coro, timeout=None: asyncio.new_event_loop().run_until_complete(coro)
)
agent._executor = executor
return agent
def opt(v): return SimpleNamespace(value=v, name=None)
def model_select(cur, opts): return SimpleNamespace(id="model", type="select", current_value=cur, options=opts)
def effort_select(cur, opts): return SimpleNamespace(id="effort", type="select", current_value=cur, options=opts)
agent = wire(_make_agent(), "claude-agent-acp") # acp_model=None: no initial override
agent.set_acp_model("sonnet/high") # live picker switch
print("after switch:", agent._current_model_id, "/", agent._current_effort)
# Server MUST resend the full configOptions state after set_config_option (ACP spec).
agent._on_config_options_update([
model_select("sonnet", [opt("sonnet"), opt("opus")]),
effort_select("high", [opt("low"), opt("high")]),
])
print("after mandatory resend:", agent._current_model_id, "/", agent._current_effort)The output should be
A fix is to set the flag where the switch actually lands on the wire: |
Automated review (high effort)Correctness
Cleanup — several docstrings/comments are much longer than the change they document and read as PR-description prose pasted into source (multi-paragraph bug narration, Returns-blocks on private helpers, decision tables weighing alternatives, "deliberate scope cut" justifications). Worth trimming to one-line WHYs:
|
|
#4391 (standalone bump of the pinned Could you rebase this branch onto latest |
HUMAN:
👋 I've been trying to use OpenHands with Claude Code but found the existing ACP implementation to be a little out of date and lacking in features (like model/effort switching) that I'd really like to see in a daily driver. I've tested this in my local fork and it works as expected.
AGENT:
End-to-end evidence beyond unit tests:
ConversationInfo, and compositebase/effortswitches split intosession/set_config_optioncalls (model+effort) — 7/7 e2e tests green. The same client suite against the published 1.40.1 agent-server confirms behavior is additive (3/3 with effort features degrading as designed).@agentclientprotocol/claude-agent-acp0.64.2.tests/sdk/agent/test_acp_agent.py(508),tests/agent_server/test_conversation_info_model.py(28),tests/sdk/conversation/test_switch_model.py(26),tests/agent_server/test_conversation_router.py,tests/sdk/settings/test_acp_providers.py,tests/sdk/test_settings.py,tests/cross/test_remote_conversation_live_server.py(new live-server case) — all passing locally.make test-server-schemagreen (deterministic export, weak-schema allowlist unchanged — new fields strictly typed);uv run pre-commit rungreen on every changed file.Why
ACP agents advertise their models and reasoning-effort levels as session config options, but the SDK only consumed the
modeloption and had no effort concept at all — clients could not show which effort levels an agent supports or what's currently active, grouped select options silently produced empty model lists, andconfig_option_updatenotifications were dropped. Codex already had compositemodel/effortid handling; Claude Code (whose adapter exposes aneffortconfig option) had none.Summary
_model_config_optionsgains a claude-code branch mirroring the codex splitter:acp_modelids likesonnet/highoropus[1m]/maxapply as twoset_config_optioncalls (model+effort; claude acceptsmax, codex still rejects it). Composes through init, resume-reapply, and runtime switch with no schema/API changes;current_model_idkeeps the composite, matching codex semantics._extract_session_effortsreads theeffort/reasoning_effortselect, stored asACPAgent.current_effort/available_efforts, persisted inagent_state, and lifted ontoConversationInfoas additive optional fields (current_effort: str | None,available_efforts: list[str] | None) with live→persisted precedence.SessionConfigSelectGroup) are flattened instead of yielding empty lists;ConfigOptionUpdatesession notifications now refresh model/effort state in memory (weakref-bound bridge callback; composite-id reconciliation keeps a trackedbase/effortwhen the server reports the same split state).CLAUDE_AGENT_ACP_VERSION0.44.0 → 0.64.2 (the version theeffort/thought_level config option surface was verified against), kept in sync with the Docker image's npm pin per the comment inacp_providers.py.Issue Number
N/A
How to Test
make build, then run any ACP client against the agent-server with a claude-code agent:GET /api/conversations/{id}now includescurrent_effort/available_effortsalongsideavailable_models.POST /api/conversations/{id}/switch_acp_modelwith{"model": "sonnet/high"}on a live claude-code session applies model + effort as separate config options; a bare id leaves effort unchanged.uv run pytest tests/sdk/agent/test_acp_agent.py tests/agent_server/test_conversation_info_model.py tests/cross/test_remote_conversation_live_server.py.make test-server-schema.Video/Screenshots
N/A (server-side; UI screenshots live on the paired Agent Canvas PR, linked above).
Type
Notes
agent_state(no ConversationState reference there; persisted on nextinit_state), and composite reconciliation gates on_model_override_applied, so a post-runtime-switch server push may demote a tracked composite to its base id — harmless since effort is tracked separately.agent-client-protocoldependency is not touched. Note for maintainers: acp 0.12 removedClientSideConnection.set_session_model, which the legacy non-config-option fallback branch still calls — worth a guard before any future bump.