hermes-agent/tests/run_agent/test_thinking_only_sanitizer.py
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00

136 lines
5.2 KiB
Python

"""Tests for the thinking-only assistant message sanitizer.
Covers _is_thinking_only_assistant() + _drop_thinking_only_and_merge_users()
in run_agent.py. The sanitizer runs on the per-call api_messages copy and
drops assistant turns that contain only reasoning (no visible content, no
tool_calls). Adjacent user messages left behind are merged so role
alternation is preserved for the provider.
Claude Code uses this exact pattern (filterOrphanedThinkingOnlyMessages +
mergeAdjacentUserMessages in src/utils/messages.ts). See #16823 for the
backstory on why the alternative — fabricating "." stub text — was rejected.
"""
from run_agent import AIAgent
# ---------------------------------------------------------------------------
# _is_thinking_only_assistant — detection
# ---------------------------------------------------------------------------
class TestIsThinkingOnlyAssistant:
def test_plain_assistant_reply_is_not_thinking_only(self):
msg = {"role": "assistant", "content": "Hello there"}
assert not AIAgent._is_thinking_only_assistant(msg)
def test_non_dict_returns_false(self):
assert not AIAgent._is_thinking_only_assistant(None)
assert not AIAgent._is_thinking_only_assistant("hello")
# ---------------------------------------------------------------------------
# _drop_thinking_only_and_merge_users — the full pass
# ---------------------------------------------------------------------------
class TestDropThinkingOnlyAndMergeUsers:
def test_empty_list_passthrough(self):
assert AIAgent._drop_thinking_only_and_merge_users([]) == []
def test_no_thinking_only_messages_is_noop_identity(self):
msgs = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
out = AIAgent._drop_thinking_only_and_merge_users(msgs)
# Should return the original list untouched (identity) when no changes.
assert out is msgs
def test_preserves_alternation_after_drop(self):
msgs = [
{"role": "user", "content": "u1"},
{"role": "assistant", "content": "", "reasoning": "..."},
{"role": "user", "content": "u2"},
{"role": "assistant", "content": "real reply"},
]
out = AIAgent._drop_thinking_only_and_merge_users(msgs)
roles = [m["role"] for m in out]
assert roles == ["user", "assistant"]
assert out[0]["content"] == "u1\n\nu2"
assert out[1]["content"] == "real reply"
def test_does_not_touch_stored_messages_original_list_unmutated(self):
original_first_user = {"role": "user", "content": "u1"}
original_assistant = {"role": "assistant", "content": "", "reasoning": "..."}
original_second_user = {"role": "user", "content": "u2"}
msgs = [original_first_user, original_assistant, original_second_user]
AIAgent._drop_thinking_only_and_merge_users(msgs)
# Caller passes in a per-call copy already, but the sanitizer itself
# must not rewrite the dicts it was handed on the drop path.
# (It CAN mutate merged dicts — those come from the caller's copy.)
assert original_first_user["content"] == "u1"
assert original_second_user["content"] == "u2"
def test_tool_result_between_user_and_thinking_preserved(self):
# Tool results shouldn't block a drop — but they do block the merge
# (user/tool are different roles). This scenario shouldn't happen in
# practice because a thinking-only turn won't have tool_calls, but if
# it did somehow, the surrounding tool result stays put.
msgs = [
{"role": "user", "content": "u1"},
{"role": "assistant", "tool_calls": [{"id": "c1", "function": {"name": "t", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "ok"},
{"role": "assistant", "content": "", "reasoning": "..."},
{"role": "user", "content": "u2"},
]
out = AIAgent._drop_thinking_only_and_merge_users(msgs)
assert [m["role"] for m in out] == ["user", "assistant", "tool", "user"]
def test_merge_concatenates_list_content_user_messages(self):
msgs = [
{"role": "user", "content": [{"type": "text", "text": "first"}]},
{"role": "assistant", "content": "", "reasoning": "..."},
{"role": "user", "content": [{"type": "text", "text": "second"}]},
]
out = AIAgent._drop_thinking_only_and_merge_users(msgs)
assert len(out) == 1
assert out[0]["content"] == [
{"type": "text", "text": "first"},
{"type": "text", "text": "second"},
]
def test_system_messages_ignored_by_pass(self):
msgs = [
{"role": "system", "content": "sys prompt"},
{"role": "user", "content": "u1"},
{"role": "assistant", "content": "", "reasoning": "..."},
{"role": "user", "content": "u2"},
]
out = AIAgent._drop_thinking_only_and_merge_users(msgs)
assert len(out) == 2
assert out[0]["role"] == "system"
assert out[1]["role"] == "user"
assert out[1]["content"] == "u1\n\nu2"