Files
hermes-agent/tests/gateway/test_peer_dm_transient_retry.py
finn763 effcf3af06 fix(gateway): a peer DM retries a transient turn failure once, like the other two lanes
`hermes peer dm` posts to POST /api/sessions/{id}/chat, the third Bot-DM
transport. The local (`tools.bot_mode_dm`) and relayed
(`tui_gateway.methods_bot_relay`) lanes both re-run a transiently failed turn
once under the shared policy (`tools.bot_failure_reasons.retry_action`) and
resume the row the failed attempt left as the transcript's unanswered tail;
this lane ran the turn once and handed the provider's 429 paragraph to the
sender as the reply (#115325).

The policy is asked about a result dict now, not two streams: `result_retry_action`
joins `error` + `failure_reason` (the turn loop's own typed verdict) so one
classifier serves every lane, and the server-error rule accepts the providers'
`server_error` / `overloaded_error` spellings — the codes the in-process lanes
key on instead of a status number.

The resume half is the CLI lane's rule extracted to `agent.session_persistence.
adopt_unanswered_turn`, which `quiet_single_query` (env-gated dispatcher re-run)
and the API lane (in-process re-run, on the agent it just built) now share.

The regression drives the real route and the real `_run_agent` over a real
store: a 429 re-runs the same DM once with the persisted row adopted as this
turn's user message (so no second copy), a 401 still reports one attempt.

(cherry picked from commit 8fe6d46ada5b8064bc7132ee956fda998244c10a)
2026-09-20 10:16:51 -07:00

122 lines
5.4 KiB
Python

"""#115325: the peer-DM transport (``POST /api/sessions/{id}/chat``) retries a transiently failed
turn once, resuming the DM row the failed attempt already persisted.
``hermes peer dm`` is the third Bot-DM transport. The local (``tools.bot_mode_dm``) and relayed
(``tui_gateway.methods_bot_relay``) lanes both re-run a transiently failed turn once and resume the
row the failed attempt left as the transcript's unanswered tail (``tools/bot_failure_reasons``
``retry_action`` / ``RESUME_UNANSWERED_TURN``); the peer lane ran the turn once and handed whatever
came back — the provider's 429 paragraph — to the sender as the reply.
Only the model turn is faked (``_create_agent`` hands out recording agents); the route, the store and
``_run_agent`` are the real ones, so both halves of the contract are asserted on what the retry was
actually handed: the persisted row adopted as this turn's user message, and no second attempt for a
failure the policy never retries.
"""
from unittest.mock import MagicMock, patch
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.config import PlatformConfig
from gateway.platforms.api_server import APIServerAdapter
from hermes_state import SessionDB
SESSION_ID = "peer_dm_retry"
DM = "disk status?"
# Transient: the policy's own class (429 / rate limit).
RATE_LIMIT = {
"final_response": "Rate limited by the provider. API call failed after 3 retries: 429 Too Many Requests",
"failed": True, "completed": False, "error": "429 Too Many Requests",
"failure_reason": "rate_limit", "messages": [], "api_calls": 3,
}
# Permanent: a wall no re-run can fix.
AUTH_WALL = {
"final_response": "Provider authentication failed: invalid api key",
"failed": True, "completed": False, "error": "Error code: 401 - invalid_api_key",
"failure_reason": "auth", "messages": [], "api_calls": 1,
}
def _ok(text: str) -> dict:
return {"final_response": text, "failed": False, "completed": True,
"messages": [], "api_calls": 1}
def _app(adapter: APIServerAdapter) -> web.Application:
app = web.Application()
app.router.add_post("/api/sessions/{session_id}/chat", adapter._handle_session_chat)
return app
def _adapter(tmp_path) -> tuple[APIServerAdapter, str]:
"""A real adapter over a real store, holding the DM row a failed attempt's turn-start persist
would have left as the transcript's unanswered tail."""
db = SessionDB(tmp_path / "state.db")
sid = db.create_session(SESSION_ID, "api_server")
db.append_message(sid, "user", content=DM)
adapter = APIServerAdapter(PlatformConfig(enabled=True))
adapter._session_db = db
return adapter, sid
def _fake_agent(seen: list, outcome: dict) -> MagicMock:
"""Records what each turn was handed; ``_pending_cli_user_message`` is the adopted row."""
agent = MagicMock()
agent.session_id = SESSION_ID
agent.session_prompt_tokens = 0
agent.session_completion_tokens = 0
agent.session_total_tokens = 0
def _run(user_message=None, conversation_history=None, task_id=None, **_kwargs):
seen.append({
"message": user_message,
"history": list(conversation_history or []),
"resumed": getattr(agent, "_pending_cli_user_message", None),
})
return dict(outcome)
agent.run_conversation.side_effect = _run
return agent
@pytest.mark.asyncio
async def test_peer_dm_retries_a_transient_turn_once_and_still_reports_a_permanent_one(tmp_path):
adapter, sid = _adapter(tmp_path)
app = _app(adapter)
# ① Transient failure -> re-run once, resuming the persisted DM row, and the recovered reply is
# what the peer client receives.
seen: list = []
agents = [_fake_agent(seen, RATE_LIMIT), _fake_agent(seen, _ok("2+2 is 4"))]
with patch.object(adapter, "_create_agent", side_effect=lambda **_kw: agents.pop(0)):
async with TestClient(TestServer(app)) as cli:
resp = await cli.post(f"/api/sessions/{sid}/chat", json={"message": DM})
body = await resp.json()
assert resp.status == 200
assert len(seen) == 2, "a transiently failed peer DM must be re-run exactly once"
assert body["message"]["content"] == "2+2 is 4"
retry = seen[1]
assert retry["message"] == DM, "the re-run replays the same DM"
resumed = retry["resumed"]
assert isinstance(resumed, dict) and resumed.get("content") == DM, (
"the re-run must resume the row the failed attempt persisted as the pending user message")
assert resumed.get("_db_persisted") is True, "the resumed row is the durable one, not a copy"
assert all(row.get("content") != DM for row in retry["history"]), (
"the resumed row leaves the history, so the turn appends no second copy of the DM")
# ② Permanent failure -> reported exactly as before: one attempt, its error copy as the reply.
seen2: list = []
walls = [_fake_agent(seen2, AUTH_WALL), _fake_agent(seen2, AUTH_WALL)]
with patch.object(adapter, "_create_agent", side_effect=lambda **_kw: walls.pop(0)):
async with TestClient(TestServer(app)) as cli:
resp2 = await cli.post(f"/api/sessions/{sid}/chat", json={"message": DM})
body2 = await resp2.json()
assert resp2.status == 200
assert len(seen2) == 1, "auth is never auto-retried: it cannot be fixed by a re-run"
assert body2["message"]["content"] == AUTH_WALL["final_response"]