hermes-agent/tests/agent/test_turn_finalizer_iteration_limit_exit.py
ethernet c363db81e0
fix(desktop, ink): don't wipe messages before final message (#65919)
* fix(desktop): preserve interim assistant text wiped at message.complete

When the agent emits interim text (commentary alongside tool calls, or the
attempted final answer before a verify-on-stop nudge), all UI surfaces
streamed it live but then wiped it at message.complete — keeping only the
final response. The user saw text appear during inference, then disappear.

This is the complete fix across all three layers: agent core, gateway
transport, and all UI surfaces (desktop + Ink TUI).

The verify-on-stop and pre_verify paths flagged the assistant's attempted
final answer as _verification_stop_synthetic, suppressing it from both
state.db and the UI. The user only saw the terse post-verification reply.

Now the assistant response is real content: it's persisted to state.db and
emitted as an interim message via _emit_interim_assistant_message(force_display=True)
before the verification loop runs. Only the synthetic nudge messages keep
the synthetic flags. The turn finalizer drops nudges from live history and
compares content (not just role) to avoid duplicating a published candidate.
Message sequence repair collapses verification candidates in the
consecutive-assistant merge.

Wire agent.interim_assistant_callback both at construction (_agent_cbs())
and per-turn (defense-in-depth), emitting a new message.interim event with
{text, already_streamed}. Gated on display.interim_assistant_messages
(default true). Cleared in the finally block so a stale closure can't
fire on a later turn.

Add message.interim to the GatewayEventName union (apps/shared) and a
typed payload to the TUI's GatewayEvent discriminated union.

The TUI already had the segment-anchoring machinery (flushStreamingSegment +
finalTail) but had no handler for message.interim. Added recordInterimMessage
+ interimBoundaryIndex to seal segments mid-turn, and updated
recordMessageComplete to only dedupe segments after the interim boundary.

Replaced the fragile sealed-set approach with a proper interimBoundaryPending
state flag on ClientSessionState. finalizeInterimAssistantMessage finalizes
the streaming bubble in place (or creates a standalone one), rotates the
stream ID so next deltas create a new bubble, and sets the flag. When the
final text equals an already-sealed interim, they stay as distinct messages.

Extracted mergeFinalAssistantText() as a pure function in chat-messages.ts,
used by both completeAssistantMessage and finalizeInterimAssistantMessage.
Split the bidirectional dedup predicate: reasoning is a restatement only when
the final FULLY covers it. A short final ("Done.") no longer swallows a
longer reasoning block that merely starts with it.

Honor display.interim_assistant_messages (default true) across all layers:
the tui_gateway gates the callback, the desktop wires it to a nanostores
atom via use-hermes-config. Updated hermes_cli/config.py and
cli-config.yaml.example comments to document the Desktop behavior.

_split_segment_tokens now accepts posix=False and _find_ad_hoc_match tries
both posix modes so ad-hoc verification scripts with Windows backslash
paths are matched correctly. (response_previewed forwarding from #53553
is not included — our emit-interim + persist approach makes it unnecessary
since the attempted answer is now surfaced before the verification loop.)

- tsc: clean (desktop + TUI + shared)
- vitest desktop: 73/73 pass (7 interim-sealing + 5 mergeFinalAssistantText + 4 config atom)
- vitest TUI: 83/83 pass (4 new message.interim tests)
- python: 390 tests pass (340 tui_gateway + 33 verification/finalizer + 6 config gating + 3 evidence + 8 continuation budget)

Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>

* fix: prefix-match interim streamed content to avoid benign duplicate bubbles

_interim_content_was_streamed used exact equality (streamed == visible_content),
so a final response that was the streamed text plus a trailing delta — or a
partial stream before the verify nudge fired — failed the match and left
_response_was_previewed false. The turn then showed two bubbles (interim +
identical final) instead of settling the interim in place.

Relax to a prefix check (visible_content.startswith(streamed)) in both the
core match and the desktop's settle-in-place gate. The TUI already used
prefix matching via finalTail. The reverse direction (streamed longer than
final) is intentionally not matched — that could suppress a needed resend
in the gateway path where already_streamed=True calls on_segment_break().

* test(desktop): add partial-stream-then-nudge dedup edge case

Third edge case for the interim-sealing dedup: model streams part of its
answer via message.delta, verify nudge fires, interim seals the streamed
prefix, then the final response is the same text plus a trailing delta.
Asserts one bubble (not two) containing the full final text.

Acceptance protocol #2 — covers all three dedup edges:
  1. interim == final (existing)
  2. interim = strict prefix of final (existing)
  3. partial-stream-then-nudge (this commit)

---------

Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
2026-07-20 11:42:29 -04:00

379 lines
12 KiB
Python

"""Regression tests for iteration-limit exit normalization (#61631)."""
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from agent.turn_finalizer import finalize_turn
class _LimitAgent:
def __init__(
self,
*,
max_iterations=60,
budget_remaining=0,
completion_explainer=False,
):
self.max_iterations = max_iterations
self.iteration_budget = SimpleNamespace(
remaining=budget_remaining, used=max_iterations, max_total=max_iterations
)
self.quiet_mode = True
self.model = "test-model"
self.provider = "test-provider"
self.base_url = ""
self.session_id = "sess-test"
self.context_compressor = SimpleNamespace(last_prompt_tokens=0)
self.session_input_tokens = 0
self.session_output_tokens = 0
self.session_cache_read_tokens = 0
self.session_cache_write_tokens = 0
self.session_reasoning_tokens = 0
self.session_prompt_tokens = 0
self.session_completion_tokens = 0
self.session_total_tokens = 0
self.session_estimated_cost_usd = 0
self.session_cost_status = "unknown"
self.session_cost_source = "test"
self._tool_guardrail_halt_decision = None
self._interrupt_message = None
self._response_was_previewed = False
self._skill_nudge_interval = 0
self._iters_since_skill = 0
self.valid_tool_names = []
self.persisted_messages = None
self._handle_max_iterations_called = False
self._completion_explainer = completion_explainer
def _handle_max_iterations(self, messages, api_call_count):
self._handle_max_iterations_called = True
return "summary from extra call"
def _emit_status(self, *_args, **_kwargs):
pass
def _safe_print(self, *_args, **_kwargs):
pass
def _save_trajectory(self, *_args, **_kwargs):
pass
def _cleanup_task_resources(self, *_args, **_kwargs):
pass
def _drop_trailing_empty_response_scaffolding(self, messages):
pass
def _persist_session(self, messages, conversation_history):
self.persisted_messages = list(messages)
def _file_mutation_verifier_enabled(self):
return False
def _turn_completion_explainer_enabled(self):
return self._completion_explainer
def _format_turn_completion_explanation(self, _reason):
return "iteration-limit explanation"
def _drain_pending_steer(self):
return None
def clear_interrupt(self):
pass
def _sync_external_memory_for_turn(self, **_kwargs):
pass
def _finalize(
agent,
*,
final_response,
exit_reason,
api_call_count=60,
pending_verification_response=None,
):
return finalize_turn(
agent,
final_response=final_response,
api_call_count=api_call_count,
interrupted=False,
failed=False,
messages=[{"role": "user", "content": "task"}],
conversation_history=[],
effective_task_id="task",
turn_id="turn",
user_message="task",
original_user_message="task",
_should_review_memory=False,
_turn_exit_reason=exit_reason,
_pending_verification_response=pending_verification_response,
)
def test_pending_verify_response_is_preserved_for_cron_delivery(monkeypatch):
"""A held-back verification response survives last-turn exhaustion."""
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: [])
agent = _LimitAgent()
report = "complete cron report body"
result = _finalize(
agent,
final_response=None,
exit_reason="unknown",
pending_verification_response=report,
)
assert result["final_response"] == report
assert result["turn_exit_reason"] == "max_iterations_reached(60/60)"
assert agent._handle_max_iterations_called is False
def test_pending_pre_verify_response_is_preserved_on_budget_exhaustion(monkeypatch):
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: [])
agent = _LimitAgent()
report = "budget exhausted but complete"
result = _finalize(
agent,
final_response=None,
exit_reason="budget_exhausted",
pending_verification_response=report,
)
assert result["final_response"] == report
assert result["turn_exit_reason"] == "max_iterations_reached(60/60)"
assert agent._handle_max_iterations_called is False
def test_empty_pending_verification_response_uses_summary_fallback(monkeypatch):
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: [])
agent = _LimitAgent()
result = _finalize(
agent,
final_response=None,
exit_reason="unknown",
pending_verification_response="",
)
assert result["final_response"] == "summary from extra call"
assert result["turn_exit_reason"] == "max_iterations_reached(60/60)"
assert agent._handle_max_iterations_called is True
def test_short_generated_summary_keeps_abnormal_turn_explainer(monkeypatch):
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: [])
agent = _LimitAgent(completion_explainer=True)
agent._handle_max_iterations = lambda *_args: "The"
result = _finalize(agent, final_response=None, exit_reason="unknown")
assert result["final_response"] == "The\n\niteration-limit explanation"
def test_short_preserved_verification_response_is_not_rewritten(monkeypatch):
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: [])
agent = _LimitAgent(completion_explainer=True)
result = _finalize(
agent,
final_response=None,
exit_reason="unknown",
pending_verification_response="The",
)
assert result["final_response"] == "The"
def test_text_response_exit_not_rewritten_at_iteration_limit(monkeypatch):
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: [])
agent = _LimitAgent(budget_remaining=5)
exit_reason = "text_response(finish_reason=stop)"
result = _finalize(
agent,
final_response="normal answer",
exit_reason=exit_reason,
api_call_count=59,
)
assert result["turn_exit_reason"] == exit_reason
assert agent._handle_max_iterations_called is False
@pytest.mark.parametrize(
"exit_reason",
[
"error_near_max_iterations(boom)",
"guardrail_halt",
"partial_stream_recovery",
"fallback_prior_turn_content",
"empty_response_exhausted",
],
)
def test_unrelated_non_success_response_is_not_reclassified(monkeypatch, exit_reason):
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: [])
agent = _LimitAgent()
result = _finalize(
agent,
final_response="diagnostic or partial content",
exit_reason=exit_reason,
)
assert result["turn_exit_reason"] == exit_reason
assert result["completed"] is False
assert agent._handle_max_iterations_called is False
@pytest.mark.parametrize(
("exit_reason", "interrupted", "failed"),
[
("interrupted_by_user", True, False),
("all_retries_exhausted_no_response", False, False),
("provider_failure", False, True),
],
)
def test_pending_response_does_not_mask_later_terminal_exit(
monkeypatch, exit_reason, interrupted, failed
):
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: [])
agent = _LimitAgent()
result = finalize_turn(
agent,
final_response=None,
api_call_count=60,
interrupted=interrupted,
failed=failed,
messages=[{"role": "user", "content": "task"}],
conversation_history=[],
effective_task_id="task",
turn_id="turn",
user_message="task",
original_user_message="task",
_should_review_memory=False,
_turn_exit_reason=exit_reason,
_pending_verification_response="stale premature report",
)
assert result["final_response"] is None
assert result["turn_exit_reason"] == exit_reason
assert result["completed"] is False
assert agent._handle_max_iterations_called is False
def test_pending_response_records_kanban_timeout(monkeypatch):
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: [])
monkeypatch.setenv("HERMES_KANBAN_TASK", "task-123")
record = MagicMock(name="record_task_failure")
conn = SimpleNamespace(close=lambda: None)
monkeypatch.setattr("hermes_cli.kanban_db.connect", lambda: conn)
monkeypatch.setattr("hermes_cli.kanban_db._record_task_failure", record)
agent = _LimitAgent()
result = _finalize(
agent,
final_response=None,
exit_reason="unknown",
pending_verification_response="composed report",
)
assert result["turn_exit_reason"] == "max_iterations_reached(60/60)"
record.assert_called_once_with(
conn,
"task-123",
error=(
"Iteration budget exhausted (60/60) — task could not complete "
"within the allowed iterations"
),
outcome="timed_out",
release_claim=True,
end_run=True,
event_payload_extra={"budget_used": 60, "budget_max": 60},
)
def test_published_pending_candidate_is_not_duplicated_by_finalizer(monkeypatch):
"""When budget exhaustion preserves a verification candidate that is
already the tail assistant message, the finalizer must NOT append a
duplicate. The content-comparison guard prevents this. (#65919 §7)
"""
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: [])
agent = _LimitAgent()
report = "the composed report"
result = finalize_turn(
agent,
final_response=report,
api_call_count=60,
interrupted=False,
failed=False,
# The candidate is already in messages as the tail assistant.
messages=[
{"role": "user", "content": "task"},
{"role": "assistant", "content": report},
],
conversation_history=[],
effective_task_id="task",
turn_id="turn",
user_message="task",
original_user_message="task",
_should_review_memory=False,
_turn_exit_reason="unknown",
_pending_verification_response=report,
)
# The tail assistant already matches final_response — no duplicate appended.
roles = [m["role"] for m in result["messages"]]
assert roles == ["user", "assistant"]
# Persisted messages should also have no duplicate.
assert agent.persisted_messages is not None
persisted_roles = [m["role"] for m in agent.persisted_messages]
assert persisted_roles == ["user", "assistant"]
def test_terminal_verification_failure_is_persisted_as_one_correction(monkeypatch):
"""When verification fails terminally (nudge present but budget exhausted),
the finalizer drops the synthetic nudge and the assistant candidate
persists as a single correction. No duplicate assistant appended. (#65919 §7)
"""
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_a, **_kw: [])
agent = _LimitAgent()
report = "terminal failure correction"
result = finalize_turn(
agent,
final_response=report,
api_call_count=60,
interrupted=False,
failed=False,
messages=[
{"role": "user", "content": "task"},
{"role": "assistant", "content": report},
# Synthetic nudge — should be dropped by _drop_verification_continuation_scaffolding.
{"role": "user", "content": "[System: run tests]", "_verification_stop_synthetic": True},
],
conversation_history=[],
effective_task_id="task",
turn_id="turn",
user_message="task",
original_user_message="task",
_should_review_memory=False,
_turn_exit_reason="unknown",
_pending_verification_response=report,
)
# The nudge is dropped; the assistant candidate is the tail and matches
# final_response, so no duplicate is appended.
roles = [m["role"] for m in result["messages"]]
assert roles == ["user", "assistant"]
# The nudge is gone from persisted messages too.
assert agent.persisted_messages is not None
persisted_contents = [m.get("content") for m in agent.persisted_messages]
assert "[System: run tests]" not in persisted_contents
assert report in persisted_contents