@@ -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