hermes-agent/tests/agent/test_summarize_api_error.py
Andre Kurait 5113976a69
fix(run_agent): surface traceback frame for empty-message errors in 📝 Error:
## Problem

When the retry loop's `_summarize_api_error()` receives an exception
with an empty `str(error)` (bare `raise AssertionError()`, bare `raise`
without args, or third-party SDK accumulators that discard the original
payload), users see:

    📝 Error:

...with nothing after the colon, and no way to diagnose the root cause
without re-running under a debugger.

## Real-world trigger

The anthropic SDK's streaming accumulator raises:

    RuntimeError('Unexpected event order, got error before "message_start"')

when Bedrock or Anthropic returns a service-level `error` event as the
first stream event (throttling, overload, 5xx, etc.).  The SDK has the
error payload in hand but throws it away before raising — all the user
sees is the cryptic "event order" message with zero context.  Even
worse, bare `AssertionError()`s in user-authored code paths often have
empty messages entirely.

## Fix

When the error's string representation is empty or whitespace-only,
walk `error.__traceback__` and format the last frame as:

    AssertionError at /path/file.py:123 in some_method() — source line

This gives users an actionable locator without changing the output for
any error with a real message.

## Changes

- `run_agent.py` — `_summarize_api_error()`: added empty-payload guard
  at the top of the function before the existing Cloudflare-HTML path.
- `tests/agent/test_summarize_api_error.py` — 4 new tests covering
  empty AssertionError, empty-message RuntimeError without traceback,
  whitespace-only message, and the fall-through path (non-empty errors
  must not be affected).

## Tests

    pytest tests/agent/test_summarize_api_error.py -v
    # 4 passed in 4.88s

## Risk

- Zero impact on any error with a non-empty message — the new branch
  only fires when `str(error).strip()` is falsy.
- `traceback` is a stdlib module; import is lazy (inside the branch).
- `getattr(error, "__traceback__", None)` is the documented API and
  handles manually-constructed exceptions that were never raised.

Signed-off-by: Andre Kurait <andrekurait@gmail.com>
2026-04-23 20:56:42 +00:00

62 lines
2.3 KiB
Python

"""Tests for AIAgent._summarize_api_error — the error-line formatter
used by the retry loop's `📝 Error:` display.
Regression tests for a gap where exceptions with empty string payloads
(bare `raise`, bare `assert`, or third-party SDK assertions that don't
carry the original error message) would surface as `📝 Error:` with no
content, forcing users to re-run under a debugger to see what failed.
"""
from run_agent import AIAgent
def _raise_and_catch(exc: Exception) -> Exception:
"""Raise and immediately re-catch so the exception has a real traceback."""
try:
raise exc
except type(exc) as caught:
return caught
class TestSummarizeApiErrorEmptyMessage:
"""Empty-message errors still surface actionable context."""
def test_bare_assertion_error_shows_traceback_frame(self):
# `raise AssertionError()` produces a truly empty-message error
# (unlike `assert False` which Python annotates with source).
def _trigger():
raise AssertionError()
try:
_trigger()
except AssertionError as e:
result = AIAgent._summarize_api_error(e)
assert "AssertionError" in result
assert "_trigger" in result
assert ".py:" in result
def test_empty_raise_shows_type_only_when_no_traceback(self):
# Construct an exception without raising it — no __traceback__
err = RuntimeError("")
result = AIAgent._summarize_api_error(err)
assert "RuntimeError" in result
assert "no message" in result
def test_whitespace_only_message_treated_as_empty(self):
err = _raise_and_catch(RuntimeError(" \n\t "))
result = AIAgent._summarize_api_error(err)
assert "RuntimeError" in result
# Must include a frame locator — not just the empty payload
assert ".py:" in result or "no message" in result
def test_non_empty_message_falls_through_to_normal_path(self):
"""Non-empty errors must not be captured by the new branch."""
err = _raise_and_catch(RuntimeError("some real error"))
result = AIAgent._summarize_api_error(err)
# The normal path returns the raw string (possibly truncated),
# NOT the traceback-frame format introduced by this fix.
assert "some real error" in result
assert ".py:" not in result