diff --git a/api/routes.py b/api/routes.py index 920a3663..5a42d1bc 100644 --- a/api/routes.py +++ b/api/routes.py @@ -27688,13 +27688,13 @@ def _handle_handoff_summary(handler, body): 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() + normalized = agent._get_transport("codex_responses").normalize_response(resp) + result["text"] = str((normalized.content or "") if normalized else "").strip() result["incomplete"] = _summary_output_incomplete(result["text"]) return result if getattr(agent, "api_mode", "") == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_kwargs, normalize_anthropic_response + from agent.anthropic_adapter import build_anthropic_kwargs ant_kwargs = build_anthropic_kwargs( model=agent.model, @@ -27707,11 +27707,11 @@ def _handle_handoff_summary(handler, body): base_url=getattr(agent, "_anthropic_base_url", None), ) resp = agent._anthropic_messages_create(ant_kwargs) - assistant_message, _ = normalize_anthropic_response( + normalized = agent._get_transport().normalize_response( resp, strip_tool_prefix=getattr(agent, "_is_anthropic_oauth", False), ) - result["text"] = str((assistant_message.content or "") if assistant_message else "").strip() + result["text"] = str((normalized.content or "") if normalized else "").strip() result["incomplete"] = _summary_output_incomplete(result["text"]) return result diff --git a/api/streaming.py b/api/streaming.py index 4aa70230..d0144bf4 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -4459,12 +4459,12 @@ def generate_title_raw_via_agent(agent, user_text: str, assistant_text: str) -> if 'max_output_tokens' in codex_kwargs: codex_kwargs['max_output_tokens'] = max_tokens resp = agent._run_codex_stream(codex_kwargs) - assistant_message, _ = agent._normalize_codex_response(resp) - raw = (assistant_message.content or '') if assistant_message else '' + normalized = agent._get_transport('codex_responses').normalize_response(resp) + raw = (normalized.content or '') if normalized else '' if not raw: empty_status = 'llm_empty' elif getattr(agent, 'api_mode', '') == 'anthropic_messages': - from agent.anthropic_adapter import build_anthropic_kwargs, normalize_anthropic_response + from agent.anthropic_adapter import build_anthropic_kwargs ant_kwargs = build_anthropic_kwargs( model=agent.model, messages=api_messages, @@ -4476,10 +4476,10 @@ def generate_title_raw_via_agent(agent, user_text: str, assistant_text: str) -> base_url=getattr(agent, '_anthropic_base_url', None), ) resp = agent._anthropic_messages_create(ant_kwargs) - assistant_message, _ = normalize_anthropic_response( + normalized = agent._get_transport().normalize_response( resp, strip_tool_prefix=getattr(agent, '_is_anthropic_oauth', False) ) - raw = (assistant_message.content or '') if assistant_message else '' + raw = (normalized.content or '') if normalized else '' if not raw: empty_status = 'llm_empty' else: diff --git a/tests/test_agent_v021_transport_normalize.py b/tests/test_agent_v021_transport_normalize.py new file mode 100644 index 00000000..752a82dd --- /dev/null +++ b/tests/test_agent_v021_transport_normalize.py @@ -0,0 +1,210 @@ +"""agent v0.21 transport-API port tests for out-of-band LLM completions. + +hermes-agent v0.21 removed ``agent.anthropic_adapter.normalize_anthropic_response`` +and ``AIAgent._normalize_codex_response``. The sanctioned out-of-band contract is +``agent._get_transport().normalize_response(...)`` returning a +``NormalizedResponse`` (``.content``). + +These tests pin the two out-of-band consumers against a v0.21-shaped fake agent +(no legacy normalizer symbols exist; the transport normalizes): + - ``api.streaming.generate_title_raw_via_agent`` (anthropic_messages branch) + - the nested ``_agent_text_completion`` inside + ``api.routes._handle_handoff_summary`` (codex_responses branch) + +On the pre-port code both branches die (ImportError / AttributeError) and +silently fall back to empty titles / local fallback summaries; these tests +fail in that state. +""" +import sys +import types + +TITLE_TEXT = 'Claude Transport Title' +SUMMARY_TEXT = '- The remaining work is the final review pass.' + + +class _FakeTransport: + """v0.21-shaped transport: normalize_response returns a NormalizedResponse-like object.""" + + def __init__(self, content): + self._content = content + self.calls = [] + + def normalize_response(self, resp, **kwargs): + self.calls.append((resp, kwargs)) + return types.SimpleNamespace(content=self._content) + + +def _make_anthropic_fake_agent(transport): + """Fake agent exposing only the v0.21 surface the title branch needs.""" + + class _FakeAgent: + api_mode = 'anthropic_messages' + model = 'anthropic/claude-sonnet-4-5' + provider = 'anthropic' + base_url = '' + + def __init__(self): + self.reasoning_config = None + self._is_anthropic_oauth = True + + def _anthropic_preserve_dots(self): + return False + + def _anthropic_messages_create(self, api_kwargs): + # Opaque response blob; normalizing is the transport's job on v0.21. + return object() + + def _get_transport(self, mode=None): + assert mode is None + return transport + + return _FakeAgent() + + +def test_anthropic_title_uses_transport_normalize_response(monkeypatch): + """The anthropic_messages title branch must normalize via the agent transport. + + v0.21 deleted ``normalize_anthropic_response``; the branch must not import it. + """ + from api.streaming import generate_title_raw_via_agent + + # Simulate the v0.21 adapter module in-process (works in agent-less CI too): + # it still exports build_anthropic_kwargs but deliberately does NOT define + # normalize_anthropic_response — exactly the environment this port targets. + fake_adapter = types.ModuleType('agent.anthropic_adapter') + fake_adapter.build_anthropic_kwargs = lambda **kwargs: {} + fake_agent_pkg = sys.modules.get('agent') or types.ModuleType('agent') + fake_agent_pkg.anthropic_adapter = fake_adapter + monkeypatch.setitem(sys.modules, 'agent', fake_agent_pkg) + monkeypatch.setitem(sys.modules, 'agent.anthropic_adapter', fake_adapter) + + transport = _FakeTransport(TITLE_TEXT) + agent = _make_anthropic_fake_agent(transport) + + result, status = generate_title_raw_via_agent( + agent, + user_text='Hey nur ein kurzer Test', + assistant_text='Alles klar, ich helfe dir dabei.', + ) + + assert result == TITLE_TEXT + assert status == 'llm' + assert transport.calls, 'transport normalize_response must be called' + assert transport.calls[0][1].get('strip_tool_prefix') is True + + +def test_codex_handoff_summary_uses_transport_normalize_response(monkeypatch): + """The codex_responses handoff branch must normalize via the agent transport. + + v0.21 deleted ``AIAgent._normalize_codex_response``; the branch must use + ``agent._get_transport('codex_responses').normalize_response(...)``. + """ + import api.config as cfg + import api.models as models + import api.routes as routes + + 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( + routes, + '_persist_handoff_summary', + lambda sid, summary, channel, rounds, fallback=False: persisted.append( + { + 'sid': sid, + 'summary': summary, + 'channel': channel, + 'rounds': rounds, + 'fallback': fallback, + } + ) + or {'ok': True}, + ) + 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}, + {'role': 'user', 'content': 'And after that?', 'timestamp': 3.0}, + {'role': 'assistant', 'content': 'Then we ship.', 'timestamp': 4.0}, + ], + ) + monkeypatch.setattr( + cfg, + 'resolve_model_provider', + lambda resolved_model=None: ( + 'gpt-test', + 'openai-codex', + 'https://chatgpt.com/backend-api/codex', + ), + ) + + persisted = [] + transport = _FakeTransport(SUMMARY_TEXT) + + 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, api_messages): + return {'model': self.model, 'instructions': 'summary', 'input': [], 'store': False} + + def _run_codex_stream(self, kwargs): + # Opaque response blob; normalizing is the transport's job on v0.21. + return object() + + def _get_transport(self, mode=None): + assert mode == 'codex_responses' + return transport + + def release_clients(self): + return None + + # v0.21: the legacy method is gone from the agent. + assert not hasattr(_CodexAgent, '_normalize_codex_response') + + 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': 'openai-codex', + 'base_url': 'https://chatgpt.com/backend-api/codex', + } + 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-v021-transport'} + ) + + assert response['ok'] is True + assert response['fallback'] is False + assert response['summary'] == SUMMARY_TEXT + assert persisted, 'handoff summary must be persisted' + assert persisted[0]['summary'] == SUMMARY_TEXT + assert persisted[0]['fallback'] is False diff --git a/tests/test_issue1013_handoff_dock.py b/tests/test_issue1013_handoff_dock.py index 59ed12ca..b8691a70 100644 --- a/tests/test_issue1013_handoff_dock.py +++ b/tests/test_issue1013_handoff_dock.py @@ -669,12 +669,21 @@ def test_handoff_summary_codex_output_cap_matches_provider_compatibility( request_kwargs.append(kwargs) return object() - def _normalize_codex_response(self, response): - return types.SimpleNamespace(content="- You should complete the remaining review."), "stop" + def _get_transport(self, mode=None): + # hermes-agent v0.21 contract: normalization goes through the + # provider transport (CodexTransport.normalize_response returns + # NormalizedResponse with .content); the old + # AIAgent._normalize_codex_response method no longer exists. + assert mode == "codex_responses" + return _CodexTransport() def release_clients(self): return None + class _CodexTransport: + def normalize_response(self, response, **kwargs): + return types.SimpleNamespace(content="- You should complete the remaining review.") + fake_run_agent = types.ModuleType("run_agent") fake_run_agent.AIAgent = _CodexAgent monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)