Skip to content

Commit c37e6f8

Browse files
Debug Agentclaude
andcommitted
Expand ACP resume tests per review feedback
Add three tests requested by @VascoSch92: - ``test_fallback_replacement_id_lands_in_agent_state`` — when load_session raises ACPRequestError and new_session runs, the full init_state path must overwrite agent_state["acp_session_id"] with the replacement id so the next restart doesn't keep chasing the stale one. - ``test_resume_path_still_applies_session_mode_and_model`` — the resume branch must still call set_session_model (for acp_model overrides) and set_session_mode (bypass-permissions), matching new_session's behavior. - ``test_roundtrip_via_conversation_state_persistence`` — real round-trip through ``ConversationState`` persistence: two ``Conversation`` objects share a ``persistence_dir``; the first takes ``new_session``, autosave flushes base_state.json, and the second reads agent_state["acp_session_id"] back and takes ``load_session`` with the correct id + cwd. Also refactored the transport-mocks helper into an ExitStack-based ``_transport_patches`` context manager so tests that need to drive the full ``init_state`` path can stack it without re-patching the method under test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5d81b36 commit c37e6f8

1 file changed

Lines changed: 155 additions & 11 deletions

File tree

tests/sdk/agent/test_acp_agent.py

Lines changed: 155 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2119,8 +2119,12 @@ class TestACPSessionIdPersistence:
21192119
"""
21202120

21212121
@staticmethod
2122-
def _patched_start_acp_server(agent, state, *, conn):
2123-
"""Invoke the real _start_acp_server with ACP transport layers mocked."""
2122+
def _transport_patches(conn):
2123+
"""Context manager stacking the transport-layer mocks that let
2124+
_start_acp_server run without spawning a real subprocess.
2125+
"""
2126+
from contextlib import ExitStack
2127+
21242128
mock_process = MagicMock()
21252129
mock_process.stdin = MagicMock()
21262130
mock_process.stdout = MagicMock()
@@ -2131,27 +2135,40 @@ async def _fake_create_subprocess_exec(*_args, **_kwargs):
21312135
async def _fake_filter(_src, _dst):
21322136
return None
21332137

2134-
from openhands.sdk.utils.async_executor import AsyncExecutor
2135-
2136-
agent._executor = AsyncExecutor()
2137-
with (
2138+
stack = ExitStack()
2139+
stack.enter_context(
21382140
patch(
21392141
"openhands.sdk.agent.acp_agent.asyncio.create_subprocess_exec",
21402142
new=_fake_create_subprocess_exec,
2141-
),
2143+
)
2144+
)
2145+
stack.enter_context(
21422146
patch(
21432147
"openhands.sdk.agent.acp_agent.ClientSideConnection",
21442148
return_value=conn,
2145-
),
2149+
)
2150+
)
2151+
stack.enter_context(
21462152
patch(
21472153
"openhands.sdk.agent.acp_agent._filter_jsonrpc_lines",
21482154
new=_fake_filter,
2149-
),
2155+
)
2156+
)
2157+
stack.enter_context(
21502158
patch(
21512159
"openhands.sdk.agent.acp_agent.asyncio.StreamReader",
21522160
return_value=MagicMock(),
2153-
),
2154-
):
2161+
)
2162+
)
2163+
return stack
2164+
2165+
@staticmethod
2166+
def _patched_start_acp_server(agent, state, *, conn):
2167+
"""Invoke the real _start_acp_server with ACP transport layers mocked."""
2168+
from openhands.sdk.utils.async_executor import AsyncExecutor
2169+
2170+
agent._executor = AsyncExecutor()
2171+
with TestACPSessionIdPersistence._transport_patches(conn):
21552172
agent._start_acp_server(state)
21562173

21572174
@staticmethod
@@ -2321,3 +2338,130 @@ def test_resume_without_stored_cwd_still_works(self, tmp_path):
23212338
conn.load_session.assert_awaited_once()
23222339
conn.new_session.assert_not_awaited()
23232340
assert agent._session_id == "legacy-sess"
2341+
2342+
def test_fallback_replacement_id_lands_in_agent_state(self, tmp_path):
2343+
"""When load_session fails and new_session runs, init_state must
2344+
overwrite state.agent_state['acp_session_id'] with the new id so
2345+
the next restart doesn't keep trying to resume the stale one.
2346+
"""
2347+
from openhands.sdk.utils.async_executor import AsyncExecutor
2348+
2349+
agent = _make_agent()
2350+
state = _make_state(tmp_path)
2351+
state.agent_state = {
2352+
**state.agent_state,
2353+
"acp_session_id": "stale-sess",
2354+
"acp_session_cwd": str(tmp_path),
2355+
}
2356+
conn = self._make_conn(
2357+
new_session_id="replacement-sess",
2358+
load_exc=ACPRequestError(-32602, "unknown session"),
2359+
)
2360+
2361+
agent._executor = AsyncExecutor()
2362+
with self._transport_patches(conn):
2363+
agent.init_state(state, on_event=lambda _: None)
2364+
2365+
conn.load_session.assert_awaited_once()
2366+
conn.new_session.assert_awaited_once()
2367+
assert state.agent_state["acp_session_id"] == "replacement-sess"
2368+
assert state.agent_state["acp_session_cwd"] == str(tmp_path)
2369+
2370+
def test_resume_path_still_applies_session_mode_and_model(self, tmp_path):
2371+
"""load_session must be followed by the same set_session_model and
2372+
set_session_mode calls as new_session, so a resumed session honours
2373+
acp_model overrides and the bypass-permissions mode.
2374+
"""
2375+
agent = _make_agent(acp_model="claude-opus-4-6")
2376+
state = _make_state(tmp_path)
2377+
state.agent_state = {
2378+
**state.agent_state,
2379+
"acp_session_id": "stored-sess",
2380+
"acp_session_cwd": str(tmp_path),
2381+
}
2382+
# Name the server "codex-acp" so _maybe_set_session_model routes
2383+
# acp_model through conn.set_session_model (claude-acp uses _meta,
2384+
# which only applies on new_session and so wouldn't exercise the
2385+
# protocol-level override on the resume path).
2386+
conn = self._make_conn()
2387+
conn.initialize.return_value.agent_info.name = "codex-acp"
2388+
conn.initialize.return_value.auth_methods = []
2389+
2390+
self._patched_start_acp_server(agent, state, conn=conn)
2391+
2392+
conn.load_session.assert_awaited_once()
2393+
conn.new_session.assert_not_awaited()
2394+
conn.set_session_model.assert_awaited_once_with(
2395+
model_id="claude-opus-4-6",
2396+
session_id="stored-sess",
2397+
)
2398+
conn.set_session_mode.assert_awaited_once_with(
2399+
mode_id="full-access",
2400+
session_id="stored-sess",
2401+
)
2402+
2403+
def test_roundtrip_via_conversation_state_persistence(self, tmp_path):
2404+
"""End-to-end round-trip through ConversationState persistence:
2405+
2406+
1. First Conversation with persistence_dir → init_state runs,
2407+
new_session is called, ``state.agent_state["acp_session_id"]`` is
2408+
written, autosave flushes ``base_state.json`` to disk.
2409+
2. Fresh ACPAgent + Conversation pointed at the same persistence_dir
2410+
and id → ConversationState.create() restores ``base_state.json``
2411+
so ``agent_state["acp_session_id"]`` survives; init_state on the
2412+
resumed state triggers ``load_session`` with that id.
2413+
"""
2414+
import uuid as _uuid
2415+
2416+
from openhands.sdk.conversation import Conversation
2417+
from openhands.sdk.utils.async_executor import AsyncExecutor
2418+
2419+
persistence_dir = tmp_path / "persist"
2420+
conv_id = _uuid.uuid4()
2421+
workspace = tmp_path / "work"
2422+
workspace.mkdir()
2423+
2424+
conn1 = self._make_conn(new_session_id="roundtrip-sess")
2425+
agent1 = _make_agent()
2426+
agent1._executor = AsyncExecutor()
2427+
with self._transport_patches(conn1):
2428+
conv1 = Conversation(
2429+
agent=agent1,
2430+
workspace=str(workspace),
2431+
persistence_dir=str(persistence_dir),
2432+
conversation_id=conv_id,
2433+
delete_on_close=False,
2434+
visualizer=None,
2435+
)
2436+
conv1._ensure_agent_ready()
2437+
assert conv1.state.agent_state["acp_session_id"] == "roundtrip-sess"
2438+
conv1.close()
2439+
2440+
conn1.new_session.assert_awaited_once()
2441+
conn1.load_session.assert_not_awaited()
2442+
2443+
# Fresh ACPAgent with no runtime knowledge of the prior session.
2444+
conn2 = self._make_conn()
2445+
agent2 = _make_agent()
2446+
agent2._executor = AsyncExecutor()
2447+
with self._transport_patches(conn2):
2448+
conv2 = Conversation(
2449+
agent=agent2,
2450+
workspace=str(workspace),
2451+
persistence_dir=str(persistence_dir),
2452+
conversation_id=conv_id,
2453+
delete_on_close=True,
2454+
visualizer=None,
2455+
)
2456+
conv2._ensure_agent_ready()
2457+
# base_state.json restored the id into agent_state.
2458+
assert conv2.state.agent_state["acp_session_id"] == "roundtrip-sess"
2459+
conv2.close()
2460+
2461+
# Second launch took the load_session branch with the persisted id.
2462+
conn2.load_session.assert_awaited_once()
2463+
_, kwargs = conn2.load_session.call_args
2464+
assert kwargs["session_id"] == "roundtrip-sess"
2465+
assert kwargs["cwd"] == str(workspace)
2466+
conn2.new_session.assert_not_awaited()
2467+
assert agent2._session_id == "roundtrip-sess"

0 commit comments

Comments
 (0)