Files
hermes-agent/tests/test_background_review_list_shapes.py
T
kshitijk4poor c5ff900761 fix: resolve the 33 F821 undefined names outside tui_gateway / feishu / godmode
Sweep of `ruff check . --select F821 --target-version py311`: 2,234 hits. 2,201 are left
alone on purpose: tui_gateway (2,169; bind_module rebinds bodies onto server.py globals,
all names verified to resolve there), the Feishu adapter (27; globals().update() SDK
binding) and the godmode script (5; dead standalone script). The other 33 were all
genuine defects. No lint config change; no TYPE_CHECKING escape hatches — every
annotation names a real, imported type; ty on the touched files: 0 new diagnostics.

- gateway/slash_commands.py: HISTORY_UNREADABLE never imported after #102117
  → NameError on the /btw error branch (same one-liner as #102952).
- gateway/platforms/whatsapp_common.py: `-> Path` return annotation with no Path
  import (the body uses `_Path`). Never raised at runtime thanks to
  `from __future__ import annotations`, but `typing.get_type_hints()` and ty
  both fail on it.
- gateway/run.py: ActivityProvenance imported at module level
  (agent.session_activity has no gateway deps); stringly annotation and the
  lazy in-function import are gone.
- tools/patch_parser.py: PatchResult imported at module level; real return
  annotation. The "avoid circular import" lazy import guarded a cycle that
  does not exist (file_operations_common never imports patch_parser).
- gateway/platforms/helpers.py: base.py imports helpers at module level, so
  MessageEvent cannot be named here; TextBatchAggregator only reads .text and
  .source, so it is typed by a BatchableEvent Protocol that MessageEvent
  satisfies structurally.
- tools/mcp_tool_sampling.py: mcp_tool imports this module, so MCPServerTask
  cannot be named here; ElicitationHandler only reads
  owner._pending_call_context, typed by an ElicitationOwner Protocol.
- plugins/platforms/sms/adapter.py: aiohttp is an optional dep ([messaging] extra) →
  module-level try/except ImportError binding `aiohttp = web = None`, the pattern the
  homeassistant / webhook / whatsapp_cloud adapters already use. Retires three lazy
  in-function imports and the `_aiohttp_available()` wrapper; `_handle_webhook` typed
  `web.Request -> web.Response`.
- plugins/platforms/teams/summary_writer.py: plain module-level `import httpx` — httpx is a
  hard core dependency (pyproject `httpx[socks]==0.28.1`), so the lazy import and the
  "imported on every CLI start" docstring premise were both wrong (plugin discovery never
  imports this module; it is reached only via the Teams adapter / meeting pipeline).

Tests:
- tests/hermes_cli/test_config.py: a test body orphaned by the wave-1 prune
  (6b81590c55) sat inside the class as dead code with self/tmp_path unbound
  — header restored, so the v11→12 custom_providers migration is covered.
- tests/tools/test_mcp_tool.py: @staticmethod recursing on `self` in the
  win32 branch; call portalocker directly.
- tests/test_background_review_list_shapes.py: main() still ran 3 pruned tests.
- tests/agent/test_cursor_optimizations_parity.py: bench() used names only
  imported inside a sibling test.
- GatewayRunner / FeishuAdapter / Dict / Optional: missing imports.
2026-09-07 22:47:33 +05:30

310 lines
11 KiB
Python

"""Regression tests for the list-shape AttributeError guards in
``agent.background_review.summarize_background_review_actions`` (#59437).
The outer ``_run_review_in_thread`` used to crash with
``'list' object has no attribute 'get'`` every time a tool response
returned a list (or any non-dict) where the summarizer expected a
dict — most commonly the ``_change`` field in skill_manage responses
or one of the entries in a memory operations list. The crash took
down the entire background review, discarding every other successful
action that the fork had completed.
What this module guards:
A. ``summarize_background_review_actions`` no longer raises when
``data["_change"]`` is a list. It returns the rest of the
actions normally.
B. ``summarize_background_review_actions`` no longer raises when
``operations`` is a non-list (string, int, None). It treats the
field as empty.
C. ``summarize_background_review_actions`` no longer raises when
``operations[i]`` is a non-dict (string, None). It skips that
entry but processes the rest.
D. ``summarize_background_review_actions`` no longer raises when
``call_details.get(tcid)`` returns a non-dict (e.g. None or a
stray scalar). It coerces to ``{}``.
E. The caller in ``_run_review_in_thread`` no longer aborts the
whole review on an unrelated summarize exception; partial valid
actions are surfaced.
The tests run without pytest (handoff from a prior pattern): they use
plain ``assert`` and a small standalone runner. Importing the module
exercises the new code paths without booting the LLM stack — there
are no I/O or model dependencies in the unit-of-work being tested.
"""
from __future__ import annotations
import importlib
import importlib.util
import json
import os
import sys
import types
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _isolate_hermes_home():
os.environ.setdefault("HERMES_HOME", "/tmp/hermes-bg-review-test")
def _load_module():
"""Lazy import so a missing optional dep doesn't block the suite.
Returns the module or None if import failed.
"""
if REPO_ROOT not in sys.path:
sys.path.insert(0, REPO_ROOT)
try:
return importlib.import_module("agent.background_review")
except Exception:
return None
def _make_skill_tool_message(change, operations=None):
"""Build the messages list that triggered the original crash."""
return [
# Assistant: calls skill_manage
{
"role": "assistant",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "skill_manage",
"arguments": json.dumps(
{
"action": "patch",
"name": "my-skill",
"operations": operations
or [
{
"action": "replace",
"content": "x",
"old_text": "y",
}
],
}
),
},
}
],
},
# Tool: response with a buggy _change field (a list instead of dict)
{
"role": "tool",
"tool_call_id": "call_1",
"content": json.dumps(
{
"success": True,
"message": "Skill 'my-skill' patched.",
"_change": change, # ← the offender, normally a dict
}
),
},
]
def _make_memory_tool_message(operations_field):
"""Memory tool response with a non-canonical operations field."""
return [
{
"role": "assistant",
"tool_calls": [
{
"id": "call_2",
"type": "function",
"function": {
"name": "memory",
"arguments": json.dumps({"action": "add", "target": "memory"}),
},
}
],
},
{
"role": "tool",
"tool_call_id": "call_2",
"content": json.dumps(
{
"success": True,
"message": "Entry added.",
"operations": operations_field,
}
),
},
]
class TestRunner:
def __init__(self):
self.passed = []
self.failed = []
def run(self, name, fn):
try:
fn()
except Exception as e: # noqa: BLE001 — runner summary uses it
import traceback
self.failed.append((name, e, traceback.format_exc()))
else:
self.passed.append(name)
def summary(self):
total = len(self.passed) + len(self.failed)
print(f"\n{'=' * 70}\nResults: {len(self.passed)}/{total} passed")
if self.failed:
print(f"\n--- {len(self.failed)} failure(s) ---")
for n, _e, tb in self.failed:
print(f"\n[FAIL] {n}\n{tb}")
return 0 if not self.failed else 1
# ---------------------------------------------------------------------------
# A. _change as a list (the originally-reported crash class)
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# B. operations as a non-list (string / int / None)
# ---------------------------------------------------------------------------
def test_b_operations_as_none_treated_as_empty():
"""``operations = None`` (missing key, JSON null) is still safe."""
_isolate_hermes_home()
bg = _load_module()
if bg is None:
print("SKIP module not importable")
return
msgs = _make_memory_tool_message(operations_field=None)
actions = bg.summarize_background_review_actions(
review_messages=msgs,
prior_snapshot=[],
notification_mode="verbose",
)
assert isinstance(actions, list)
# ---------------------------------------------------------------------------
# C. operations[i] as a non-dict (str / None)
# ---------------------------------------------------------------------------
def test_c_operations_contains_non_dict_entries():
"""A legacy/half-typed operations list with string entries short-circuits.
In ``verbose`` mode the function should produce the valid entries and
silently skip the non-dict ones without ``AttributeError``. In
non-verbose mode it falls back to a generic "Memory updated" string,
so this test exercises the verbose branch where iteration over
per-entry fields actually happens.
"""
_isolate_hermes_home()
bg = _load_module()
if bg is None:
print("SKIP module not importable")
return
msgs = _make_memory_tool_message(
operations_field=[
"raw-string-no-fields",
{"action": "add", "content": "valid entry"},
None,
{"action": "replace", "content": "another", "old_text": "thing"},
]
)
actions = bg.summarize_background_review_actions(
review_messages=msgs,
prior_snapshot=[],
notification_mode="verbose",
)
assert isinstance(actions, list)
# ``notification_mode='verbose'`` walks per-entry fields; the two
# dict-shaped entries produce action lines, the string and None
# entries are skipped via the isinstance guard. The exact wording is
# not asserted (memory module shapes may vary) but at least one
# action line must be present.
assert len(actions) >= 1, f"expected at least one action line, got {actions!r}"
# ---------------------------------------------------------------------------
# D. detail comes back non-dict (None / stale value)
# ---------------------------------------------------------------------------
def test_d_detail_non_dict_replaced_with_empty():
"""When ``call_details.get(tcid)`` returns None, summarize must coerce
it to ``{}`` rather than calling ``.get(...)`` on ``None``.
"""
_isolate_hermes_home()
bg = _load_module()
if bg is None:
print("SKIP module not importable")
return
# Build a tool-only message whose tcid does NOT have an assistant tool_call.
msgs = _make_skill_tool_message(change={})
# Drop the assistant message so call_details is empty for tcid=call_1.
msgs = [m for m in msgs if m.get("role") != "assistant"]
actions = bg.summarize_background_review_actions(
review_messages=msgs,
prior_snapshot=[],
notification_mode="verbose",
)
assert isinstance(actions, list)
# ---------------------------------------------------------------------------
# E. Caller defends against summarize raising
# ---------------------------------------------------------------------------
def test_e_call_does_not_unwind_module_callables():
"""Structural: the new defensive try/except around the summarize
call is in place. Caught here rather than via a partial mocking
cascade because monkeypatching the AIAgent is too brittle for a
blind regression test — keeping it text-anchored guards the
``_run_review_in_thread`` invariant without a real LLM.
"""
src_path = os.path.join(REPO_ROOT, "agent", "background_review.py")
src = open(src_path, encoding="utf-8").read()
# The fix added: ``try: actions = summarize_background_review_actions(...)``
# followed by ``except Exception as e: ... actions = []``.
assert "actions = summarize_background_review_actions(" in src
assert (
"summarize_background_review_actions returned partial results"
in src
), "expected partial-results guard message present"
# And the non-dict guards on free-form tool payload fields.
assert "if isinstance(ops_raw, list)" in src
assert "if isinstance(change_raw, dict)" in src
# ---------------------------------------------------------------------------
# Runner
# ---------------------------------------------------------------------------
def main():
runner = TestRunner()
runner.run("b_operations_as_none_treated_as_empty", test_b_operations_as_none_treated_as_empty)
runner.run("c_operations_contains_non_dict_entries", test_c_operations_contains_non_dict_entries)
runner.run("d_detail_non_dict_replaced_with_empty", test_d_detail_non_dict_replaced_with_empty)
runner.run("e_call_defends_via_try_except", test_e_call_does_not_unwind_module_callables)
return runner.summary()
if __name__ == "__main__":
sys.exit(main())