1010from concurrent .futures import Future
1111from pathlib import Path
1212from typing import Any
13- from unittest .mock import AsyncMock , MagicMock , patch
13+ from unittest .mock import AsyncMock , MagicMock , call , patch
1414
1515import pytest
1616from acp .exceptions import RequestError as ACPRequestError
2222 _classify_acp_init_error ,
2323 _codex_auth_file ,
2424 _codex_base_url_overrides ,
25+ _codex_model_config_options ,
2526 _estimate_cost_from_tokens ,
2627 _extract_session_models ,
2728 _extract_token_usage ,
@@ -4419,24 +4420,44 @@ def test_noop_when_caller_already_set_provider(self):
44194420 )
44204421
44214422
4423+ class TestCodexModelConfigOptions :
4424+ def test_splits_combined_model_and_reasoning_effort (self ):
4425+ assert _codex_model_config_options ("gpt-5.5/high" ) == (
4426+ ("model" , "gpt-5.5" ),
4427+ ("reasoning_effort" , "high" ),
4428+ )
4429+
4430+ def test_leaves_base_or_custom_model_id_unchanged (self ):
4431+ assert _codex_model_config_options ("gpt-5.5" ) == (("model" , "gpt-5.5" ),)
4432+ assert _codex_model_config_options ("custom/provider/model" ) == (
4433+ ("model" , "custom/provider/model" ),
4434+ )
4435+
4436+
44224437# ---------------------------------------------------------------------------
44234438# ACP model overrides
44244439# ---------------------------------------------------------------------------
44254440
44264441
44274442class TestMaybeSetSessionModel :
44284443 @pytest .mark .asyncio
4429- async def test_codex_agent_uses_config_option_model_override (self ):
4444+ async def test_codex_agent_splits_model_and_reasoning_effort (self ):
44304445 conn = AsyncMock ()
44314446 applied = await _maybe_set_session_model (
4432- conn , "codex-acp" , "session-1" , "gpt-5.4"
4447+ conn , "codex-acp" , "session-1" , "gpt-5.4/low "
44334448 )
44344449 conn .set_session_model .assert_not_called ()
4435- conn .set_config_option .assert_awaited_once_with (
4436- config_id = "model" ,
4437- value = "gpt-5.4" ,
4438- session_id = "session-1" ,
4450+ conn .set_config_option .assert_has_awaits (
4451+ [
4452+ call (config_id = "model" , value = "gpt-5.4" , session_id = "session-1" ),
4453+ call (
4454+ config_id = "reasoning_effort" ,
4455+ value = "low" ,
4456+ session_id = "session-1" ,
4457+ ),
4458+ ]
44394459 )
4460+ assert conn .set_config_option .await_count == 2
44404461 # The override was actually pushed to the server via the protocol call.
44414462 assert applied is True
44424463
@@ -4525,11 +4546,17 @@ async def test_codex_reapplies_persisted_model_on_resume(self):
45254546 conn , "codex-acp" , "sess-1" , "gpt-5.4/low"
45264547 )
45274548 conn .set_session_model .assert_not_called ()
4528- conn .set_config_option .assert_awaited_once_with (
4529- config_id = "model" ,
4530- value = "gpt-5.4/low" ,
4531- session_id = "sess-1" ,
4549+ conn .set_config_option .assert_has_awaits (
4550+ [
4551+ call (config_id = "model" , value = "gpt-5.4" , session_id = "sess-1" ),
4552+ call (
4553+ config_id = "reasoning_effort" ,
4554+ value = "low" ,
4555+ session_id = "sess-1" ,
4556+ ),
4557+ ]
45324558 )
4559+ assert conn .set_config_option .await_count == 2
45334560 assert applied is True
45344561
45354562 @pytest .mark .asyncio
@@ -4623,22 +4650,36 @@ class TestSetACPModel:
46234650 @staticmethod
46244651 def _wire (agent : ACPAgent , agent_name : str ) -> ACPAgent :
46254652 agent ._conn = MagicMock ()
4653+ agent ._conn .set_config_option = AsyncMock ()
4654+ agent ._conn .set_session_model = AsyncMock ()
46264655 agent ._session_id = "sess-1"
46274656 agent ._agent_name = agent_name
4657+
4658+ def run_async (awaitable , timeout = None ):
4659+ if asyncio .iscoroutine (awaitable ):
4660+ return asyncio .run (awaitable )
4661+ return awaitable
4662+
46284663 executor = MagicMock ()
4629- executor .run_async = MagicMock ()
4664+ executor .run_async = MagicMock (side_effect = run_async )
46304665 agent ._executor = executor
46314666 return agent
46324667
46334668 def test_switches_model_on_live_codex_session (self ):
46344669 agent = self ._wire (_make_agent (), "codex-acp" )
46354670 agent .set_acp_model ("gpt-5.4/low" )
46364671 agent ._conn .set_session_model .assert_not_called ()
4637- agent ._conn .set_config_option .assert_called_once_with (
4638- config_id = "model" ,
4639- value = "gpt-5.4/low" ,
4640- session_id = "sess-1" ,
4672+ agent ._conn .set_config_option .assert_has_awaits (
4673+ [
4674+ call (config_id = "model" , value = "gpt-5.4" , session_id = "sess-1" ),
4675+ call (
4676+ config_id = "reasoning_effort" ,
4677+ value = "low" ,
4678+ session_id = "sess-1" ,
4679+ ),
4680+ ]
46414681 )
4682+ assert agent ._conn .set_config_option .await_count == 2
46424683 agent ._executor .run_async .assert_called_once ()
46434684 # Sentinel LLM + metrics reflect the live model for cost/token tracking.
46444685 assert agent .llm .model == "gpt-5.4/low"
@@ -4648,7 +4689,7 @@ def test_claude_provider_supports_runtime_switch(self):
46484689 agent = self ._wire (_make_agent (), "claude-agent-acp" )
46494690 agent .set_acp_model ("claude-haiku-4-5-20251001" )
46504691 agent ._conn .set_session_model .assert_not_called ()
4651- agent ._conn .set_config_option .assert_called_once_with (
4692+ agent ._conn .set_config_option .assert_awaited_once_with (
46524693 config_id = "model" ,
46534694 value = "claude-haiku-4-5-20251001" ,
46544695 session_id = "sess-1" ,
@@ -4659,7 +4700,7 @@ def test_unknown_provider_still_attempts_switch(self):
46594700 # the call; the ACP layer errors if it isn't actually supported.
46604701 agent = self ._wire (_make_agent (), "some-custom-acp" )
46614702 agent .set_acp_model ("whatever" )
4662- agent ._conn .set_session_model .assert_called_once ()
4703+ agent ._conn .set_session_model .assert_awaited_once ()
46634704
46644705 def test_rejects_empty_model (self ):
46654706 agent = self ._wire (_make_agent (), "codex-acp" )
@@ -4701,9 +4742,13 @@ def test_translates_acp_request_error_to_value_error(self):
47014742 # or an invalid model id) must surface as a ValueError — not leak as a
47024743 # raw acp.exceptions.RequestError — so the agent-server maps it to 400.
47034744 agent = self ._wire (_make_agent (), "codex-acp" )
4704- agent ._executor .run_async .side_effect = ACPRequestError (
4705- code = - 32601 , message = "method not found"
4706- )
4745+
4746+ def reject (awaitable , timeout = None ):
4747+ if asyncio .iscoroutine (awaitable ):
4748+ awaitable .close ()
4749+ raise ACPRequestError (code = - 32601 , message = "method not found" )
4750+
4751+ agent ._executor .run_async .side_effect = reject
47074752 with pytest .raises (ValueError , match = "rejected model switch" ):
47084753 agent .set_acp_model ("bogus-model" )
47094754 # The sentinel LLM must not be mutated when the switch fails.
@@ -4715,9 +4760,13 @@ def test_propagates_server_internal_error(self):
47154760 # than be mislabeled as a 400-class ValueError, mirroring the retriable
47164761 # handling on the prompt path.
47174762 agent = self ._wire (_make_agent (), "codex-acp" )
4718- agent ._executor .run_async .side_effect = ACPRequestError (
4719- code = - 32603 , message = "internal error"
4720- )
4763+
4764+ def fail_internal (awaitable , timeout = None ):
4765+ if asyncio .iscoroutine (awaitable ):
4766+ awaitable .close ()
4767+ raise ACPRequestError (code = - 32603 , message = "internal error" )
4768+
4769+ agent ._executor .run_async .side_effect = fail_internal
47214770 with pytest .raises (ACPRequestError ):
47224771 agent .set_acp_model ("some-model" )
47234772 # The sentinel LLM must not be mutated when the switch fails.
0 commit comments