mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-03 07:21:54 +00:00
fix(agent): recover Codex streams with null output
This commit is contained in:
parent
bb4703c761
commit
43a3f119fc
4 changed files with 250 additions and 62 deletions
|
|
@ -2554,6 +2554,45 @@ class TestCodexAuxiliaryAdapterTimeout:
|
|||
assert time.monotonic() - started < 0.14
|
||||
|
||||
|
||||
class TestCodexAuxiliaryAdapterNullOutputRecovery:
|
||||
def test_recovers_output_item_when_sdk_raises_during_iteration(self):
|
||||
"""Regression for #11179 in auxiliary calls such as compression/title generation."""
|
||||
|
||||
output_item = SimpleNamespace(
|
||||
type="message",
|
||||
content=[SimpleNamespace(type="output_text", text="aux survived")],
|
||||
)
|
||||
|
||||
class NullOutputParseStream:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def __iter__(self):
|
||||
yield SimpleNamespace(type="response.output_item.done", item=output_item)
|
||||
raise TypeError("'NoneType' object is not iterable")
|
||||
|
||||
def get_final_response(self): # pragma: no cover - iterator fails first
|
||||
raise AssertionError("get_final_response should not be reached")
|
||||
|
||||
class FakeResponses:
|
||||
def __init__(self):
|
||||
self.create = MagicMock()
|
||||
|
||||
def stream(self, **kwargs):
|
||||
return NullOutputParseStream()
|
||||
|
||||
fake_client = SimpleNamespace(responses=FakeResponses())
|
||||
adapter = _CodexCompletionsAdapter(fake_client, "gpt-5.5")
|
||||
|
||||
response = adapter.create(messages=[{"role": "user", "content": "summarize"}])
|
||||
|
||||
assert response.choices[0].message.content == "aux survived"
|
||||
fake_client.responses.create.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Issue #23432 — auxiliary timeout poisons cached client; later aux calls fail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -186,6 +186,27 @@ class _FakeCreateStream:
|
|||
self.closed = True
|
||||
|
||||
|
||||
class _IteratorTypeErrorStream:
|
||||
"""Mimic the SDK raising while parsing response.completed.output=None."""
|
||||
|
||||
def __init__(self, events_before_error):
|
||||
self._events_before_error = list(events_before_error)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def __iter__(self):
|
||||
for event in self._events_before_error:
|
||||
yield event
|
||||
raise TypeError("'NoneType' object is not iterable")
|
||||
|
||||
def get_final_response(self): # pragma: no cover - iterator fails first
|
||||
raise AssertionError("get_final_response should not be reached")
|
||||
|
||||
|
||||
def _codex_request_kwargs():
|
||||
return {
|
||||
"model": "gpt-5-codex",
|
||||
|
|
@ -484,6 +505,40 @@ def test_run_codex_stream_fallback_parses_create_stream_events(monkeypatch):
|
|||
assert response.output[0].content[0].text == "streamed create ok"
|
||||
|
||||
|
||||
def test_run_codex_stream_falls_back_when_stream_iteration_parses_null_output(monkeypatch):
|
||||
"""Regression for #11179: the SDK can raise while iterating response.completed.
|
||||
|
||||
The failure happens before get_final_response(), so post-loop backfill alone is
|
||||
not enough. Preserve already streamed output_item.done events.
|
||||
"""
|
||||
agent = _build_agent(monkeypatch)
|
||||
output_item = SimpleNamespace(
|
||||
type="message",
|
||||
status="completed",
|
||||
content=[SimpleNamespace(type="output_text", text="stream item survived")],
|
||||
)
|
||||
calls = {"stream": 0}
|
||||
|
||||
def _fake_stream(**kwargs):
|
||||
calls["stream"] += 1
|
||||
return _IteratorTypeErrorStream([
|
||||
SimpleNamespace(type="response.output_item.done", item=output_item),
|
||||
])
|
||||
|
||||
def _unexpected_create(**kwargs): # pragma: no cover - recovery should avoid fallback call
|
||||
raise AssertionError("create fallback should not be needed when output items were collected")
|
||||
|
||||
agent.client = SimpleNamespace(
|
||||
responses=SimpleNamespace(stream=_fake_stream, create=_unexpected_create),
|
||||
)
|
||||
|
||||
response = agent._run_codex_stream(_codex_request_kwargs())
|
||||
|
||||
assert calls["stream"] == 1
|
||||
assert response.output == [output_item]
|
||||
assert response.status == "completed"
|
||||
|
||||
|
||||
def test_run_conversation_codex_plain_text(monkeypatch):
|
||||
agent = _build_agent(monkeypatch)
|
||||
monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: _codex_message_response("OK"))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue