diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 1a0e2d2f4a..edf6e4f347 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -535,7 +535,9 @@ def _start_codex_thread(agent) -> str: def _codex_model_provider(agent) -> str | None: - """codex's ``[model_providers.]`` for a named custom provider; None means codex's own provider.""" + """codex's ``[model_providers.]`` for a named custom provider (``providers.``); None means codex's + own provider. Only the stable id is sent and codex resolves base_url/env_key itself, so Hermes' credential + never enters the JSON-RPC payload (#75186).""" if str(getattr(agent, "provider", "") or "").strip().lower() != "custom": return None from hermes_cli.runtime_provider_custom import codex_model_provider_id @@ -563,10 +565,14 @@ def _ensure_codex_session(agent, messages: List[Dict[str, Any]] | None = None) - today's fresh-thread behaviour and overwrites the binding once its turn is committed. ``messages`` is the turn's transcript (current user row last); a thread started from scratch is seeded with the prior turns.""" developer_instructions = _codex_developer_instructions(agent) + model_provider = _codex_model_provider(agent) if getattr(agent, "_codex_session", None) is not None: # Only a session whose recorded composition differs is stale; one attached without a record is kept. + # The provider is fixed per thread (turn/start can switch the model, not the provider), so an + # in-place switch to or from a named custom provider retires the thread too. recorded = getattr(agent, "_codex_session_prompt", None) - if recorded is None or recorded == developer_instructions: + if recorded is None or (recorded == developer_instructions + and getattr(agent, "_codex_session_model_provider", None) == model_provider): return _close_codex_session(agent) resume_thread_id = None if getattr(agent, "_codex_session_prompt", None) is not None else _stored_codex_thread_id(agent) @@ -599,13 +605,11 @@ def _ensure_codex_session(agent, messages: List[Dict[str, Any]] | None = None) - # once, so a /model switch into codex or a retired thread does not start blind (#74712, #26035). # The recorded composition stays the bare prompt: the seed must not make the next turn retire the thread. agent._codex_session_prompt = developer_instructions + agent._codex_session_model_provider = model_provider from agent.codex_runtime_history_seed import render_history_seed history_seed = render_history_seed(messages) or None - # A named custom provider (``providers.``) maps onto codex's own ``[model_providers.]`` - # table: send the stable id and let codex resolve base_url/env_key itself, so Hermes' credential never - # enters the JSON-RPC payload (#75186). The model always rides along: codex's home is shared, while the - # Hermes model is per profile/session, so omitting it ran codex's own default instead of the selection. - model_provider = _codex_model_provider(agent) + # The model always rides along: codex's home is shared, while the Hermes model is per profile/session, + # so omitting it ran codex's own default instead of the selection. agent._codex_session = CodexAppServerSession( cwd=getattr(agent, "session_cwd", None) or str(resolve_agent_cwd()), approval_callback=approval_callback, codex_bin=get_configured_codex_binary(load_config()), @@ -691,7 +695,7 @@ def run_codex_app_server_turn(agent, *, user_message: str, original_user_message _start_codex_thread(agent) turn = agent._codex_session.run_turn( user_input=user_message, - model=_codex_wire_model(agent, _codex_model_provider(agent))) + model=_codex_wire_model(agent, getattr(agent, "_codex_session_model_provider", None))) except Exception as exc: logger.exception("codex app-server turn failed") _close_codex_session(agent) diff --git a/tests/agent/test_codex_runtime_prompt_handoff.py b/tests/agent/test_codex_runtime_prompt_handoff.py index 49120c5cea..33aa4d5329 100644 --- a/tests/agent/test_codex_runtime_prompt_handoff.py +++ b/tests/agent/test_codex_runtime_prompt_handoff.py @@ -101,3 +101,21 @@ def test_each_turn_sends_the_agent_s_current_wire_model(monkeypatch): codex_runtime.run_codex_app_server_turn(agent, user_message="hi", original_user_message="hi", messages=[], effective_task_id="t") assert turns == ["gpt-5.6-sol", "gpt-5.5"] + + +def test_runtime_retires_thread_when_an_in_place_switch_changes_the_codex_provider(monkeypatch): + """turn/start can change the model but not the provider: switching between codex's own provider and a + named custom provider (``[model_providers.]``) starts a thread carrying the new modelProvider.""" + import hermes_cli.runtime_provider as rp + monkeypatch.setattr(rp, "load_config", lambda: {"providers": {"my-gateway": {"api": "https://gw.example/v1"}}}) + client = _FakeClient() + monkeypatch.setattr(sess_mod, "CodexAppServerClient", lambda **kw: client) + agent = _agent(provider="openai-codex", requested_provider="openai-codex", model="gpt-5.5") + for provider, requested in (("openai-codex", "openai-codex"), ("openai-codex", "openai-codex"), + ("custom", "custom:my-gateway")): + agent.provider, agent.requested_provider = provider, requested + codex_runtime._ensure_codex_session(agent) + agent._codex_session.ensure_started() + starts = [p.get("modelProvider") for (m, p) in client.requests if m == "thread/start"] + assert starts == [None, "my-gateway"] + assert client.closed == 1