diff --git a/api/routes.py b/api/routes.py index 5a6cc0f5..0d9c37bd 100644 --- a/api/routes.py +++ b/api/routes.py @@ -24807,7 +24807,17 @@ def _handle_handoff_summary(handler, body): if getattr(agent, "api_mode", "") == "codex_responses": codex_kwargs = agent._build_api_kwargs(api_messages) codex_kwargs.pop("tools", None) - codex_kwargs["max_output_tokens"] = max_tokens + codex_provider = str(getattr(agent, "provider", "") or "").strip().lower() + codex_base_url = str(getattr(agent, "base_url", "") or "").strip().lower() + is_chatgpt_codex = ( + codex_provider == "openai-codex" + or ( + urlsplit(codex_base_url).hostname == "chatgpt.com" + and "/backend-api/codex" in codex_base_url + ) + ) + if not is_chatgpt_codex: + codex_kwargs["max_output_tokens"] = max_tokens resp = agent._run_codex_stream(codex_kwargs) assistant_message, _ = agent._normalize_codex_response(resp) result["text"] = str((assistant_message.content or "") if assistant_message else "").strip() diff --git a/tests/test_issue1013_handoff_dock.py b/tests/test_issue1013_handoff_dock.py index 2a970bac..87e4e5d0 100644 --- a/tests/test_issue1013_handoff_dock.py +++ b/tests/test_issue1013_handoff_dock.py @@ -7,6 +7,8 @@ from pathlib import Path import sys import types +import pytest + ROOT = Path(__file__).resolve().parents[1] INDEX = (ROOT / "static" / "index.html").read_text(encoding="utf-8") @@ -501,6 +503,105 @@ def test_handoff_summary_retries_once_when_length_limit_reached(monkeypatch): assert persisted[0]["sid"] == "session-length-retry" +@pytest.mark.parametrize( + ("provider", "base_url", "expects_output_cap", "expects_normalized_base_url"), + [ + ("openai-codex", "https://chatgpt.com/backend-api/codex", False, False), + ("custom:chatgpt-codex", "https://chatgpt.com/backend-api/codex", False, False), + ("custom:chatgpt-codex", " https://chatgpt.com/backend-api/codex ", False, True), + ("custom:responses-proxy", "https://responses.example/v1", True, False), + ], +) +def test_handoff_summary_codex_output_cap_matches_provider_compatibility( + monkeypatch, provider, base_url, expects_output_cap, expects_normalized_base_url, +): + """ChatGPT Codex rejects the cap, while other Responses transports retain it.""" + import api.config as cfg + import api.models as models + import api.routes as routes + + if expects_normalized_base_url: + real_urlsplit = routes.urlsplit + + def _strict_urlsplit(value): + assert value == value.strip() + return real_urlsplit(value) + + monkeypatch.setattr(routes, "urlsplit", _strict_urlsplit) + + monkeypatch.setattr(routes, "require", lambda body, *keys: None) + monkeypatch.setattr(routes, "bad", lambda _handler, msg, status=400: {"ok": False, "error": msg, "status": status}) + monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload) + monkeypatch.setattr(models, "count_conversation_rounds", lambda sid, since=None: models.CONVERSATION_ROUND_THRESHOLD) + monkeypatch.setattr( + models, + "get_cli_session_messages", + lambda sid: [ + {"role": "user", "content": "What remains to do?", "timestamp": 1.0}, + {"role": "assistant", "content": "One review step remains.", "timestamp": 2.0}, + ], + ) + monkeypatch.setattr( + cfg, + "resolve_model_provider", + lambda resolved_model=None: ("gpt-test", provider, base_url), + ) + monkeypatch.setattr( + routes, + "_persist_handoff_summary", + lambda *args, **kwargs: {"ok": True}, + ) + + request_kwargs = [] + + class _CodexAgent: + api_mode = "codex_responses" + + def __init__(self, *args, **kwargs): + self.model = kwargs.get("model") + self.provider = kwargs.get("provider") + self.base_url = kwargs.get("base_url") + self.reasoning_config = None + + def _build_api_kwargs(self, *args, **kwargs): + return {"model": self.model, "instructions": "summary", "input": [], "store": False} + + def _run_codex_stream(self, kwargs): + request_kwargs.append(kwargs) + return object() + + def _normalize_codex_response(self, response): + return types.SimpleNamespace(content="- You should complete the remaining review."), "stop" + + def release_clients(self): + return None + + fake_run_agent = types.ModuleType("run_agent") + fake_run_agent.AIAgent = _CodexAgent + monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) + + fake_runtime_module = types.ModuleType("hermes_cli.runtime_provider") + fake_runtime_module.resolve_runtime_provider = lambda requested=None: { + "api_key": "x", + "provider": provider, + "base_url": base_url, + } + fake_hermes_cli = types.ModuleType("hermes_cli") + fake_hermes_cli.__path__ = [] + fake_hermes_cli.runtime_provider = fake_runtime_module + monkeypatch.setitem(sys.modules, "hermes_cli", fake_hermes_cli) + monkeypatch.setitem(sys.modules, "hermes_cli.runtime_provider", fake_runtime_module) + + response = routes._handle_handoff_summary(object(), {"session_id": "session-codex-summary"}) + + assert response["ok"] is True + assert response["fallback"] is False + assert len(request_kwargs) == 1 + assert ("max_output_tokens" in request_kwargs[0]) is expects_output_cap + if expects_output_cap: + assert request_kwargs[0]["max_output_tokens"] == 700 + + def test_handoff_summary_falls_back_when_retry_still_incomplete(monkeypatch): """Retry may still truncate; fallback should still return deterministic concise bullets.""" import api.routes as routes