HERMES-GIELIE/tests/test_tool_limit_terminal_state.py

909 lines
34 KiB
Python

import json
import queue
import sys
import types
from pathlib import Path
import pytest
from api import models
from api import streaming
from api.models import Session
ROOT = Path(__file__).resolve().parents[1]
def _run_streaming_with_fake_agent(
tmp_path,
monkeypatch,
agent_result,
*,
prior_messages=None,
prior_context_messages=None,
msg_text="Do the long task.",
pending_started_at=1.0,
current_turn_user_idx=None,
turn_id="turn-current",
agent_results=None,
enable_auth_retry=False,
):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
monkeypatch.setattr(streaming, "SESSION_DIR", session_dir)
models.SESSIONS.clear()
streaming.SESSIONS.clear()
streaming.STREAMS.clear()
streaming.AGENT_INSTANCES.clear()
streaming.SESSION_AGENT_LOCKS.clear()
streaming.PENDING_GOAL_CONTINUATION.clear()
try:
from api.config import SESSION_AGENT_CACHE
SESSION_AGENT_CACHE.clear()
except Exception:
pass
session_id = "tool_limit_session"
stream_id = "stream-tool-limit"
session_messages = list(prior_messages or [])
session_context_messages = list(prior_context_messages or [])
for history in (session_messages, session_context_messages):
for message in history:
if isinstance(message, dict) and message.get("role") == "user":
message.setdefault("timestamp", 1.0)
message.setdefault("_source", "webui")
message.setdefault("attachments", [])
session = Session(
session_id=session_id,
title="Tool limit test",
workspace=str(tmp_path),
model="gpt-4o",
messages=session_messages,
context_messages=session_context_messages,
)
session.active_stream_id = stream_id
session.pending_user_message = msg_text
session.pending_started_at = pending_started_at
session.save()
models.SESSIONS[session_id] = session
streaming.SESSIONS[session_id] = session
event_queue = queue.Queue()
streaming.STREAMS[stream_id] = event_queue
result_queue = list(agent_results or [])
class FakeAgent:
def __init__(self, **kwargs):
self.session_id = kwargs.get("session_id")
self.stream_delta_callback = kwargs.get("stream_delta_callback")
self.context_compressor = None
self.session_prompt_tokens = 0
self.session_completion_tokens = 0
self.session_estimated_cost_usd = None
self.session_cache_read_tokens = 0
self.session_cache_write_tokens = 0
self.reasoning_config = None
self.ephemeral_system_prompt = None
self._last_error = None
self._persist_user_message_idx = current_turn_user_idx
self._current_turn_id = turn_id if current_turn_user_idx is not None else ""
def run_conversation(self, **kwargs):
if result_queue:
next_result = result_queue.pop(0)
if isinstance(next_result, BaseException):
raise next_result
return next_result
return agent_result
def interrupt(self, _message):
return None
fake_hermes_state = types.ModuleType("hermes_state")
fake_hermes_state.SessionDB = lambda *_args, **_kwargs: object()
with monkeypatch.context() as m:
m.setattr(streaming, "get_session", lambda _sid: session)
m.setattr(streaming, "_get_ai_agent", lambda: FakeAgent)
m.setattr(streaming, "resolve_model_provider", lambda *_args, **_kwargs: ("gpt-4o", "openai", None))
m.setattr("api.config.get_config", lambda *_args, **_kwargs: {})
m.setattr("api.config._resolve_cli_toolsets", lambda *_args, **_kwargs: [])
m.setitem(sys.modules, "hermes_state", fake_hermes_state)
if enable_auth_retry:
m.setattr(
streaming,
"_attempt_credential_self_heal",
lambda *_args, **_kwargs: {"api_key": "retry-key", "provider": "openai"},
)
streaming._run_agent_streaming(
session_id=session_id,
msg_text=msg_text,
model="gpt-4o",
workspace=str(tmp_path),
stream_id=stream_id,
)
events = []
while not event_queue.empty():
events.append(event_queue.get_nowait())
payload = json.loads((session_dir / f"{session_id}.json").read_text(encoding="utf-8"))
return events, payload
def test_synthetic_max_iteration_summary_request_is_dropped_from_agent_result():
synthetic = {
"role": "user",
"content": streaming._MAX_ITERATION_SUMMARY_REQUEST,
}
messages = [
{"role": "user", "content": "Do the long task."},
{"role": "assistant", "content": "", "tool_calls": [{"id": "call_1"}]},
{"role": "tool", "tool_call_id": "call_1", "content": "result"},
synthetic,
{"role": "assistant", "content": "I reached the limit; here is the summary."},
]
result = {
"turn_exit_reason": "max_iterations_reached(30/30)",
"messages": messages,
}
assert streaming._agent_result_tool_limit_reached(result) is True
cleaned = streaming._drop_synthetic_max_iteration_summary_requests(
result["messages"],
enabled=streaming._agent_result_tool_limit_reached(result),
)
assert synthetic not in cleaned
assert cleaned[-1]["role"] == "assistant"
assert "here is the summary" in cleaned[-1]["content"]
def test_tool_limit_detection_uses_explicit_boolean_grouping():
streaming_py = (ROOT / "api" / "streaming.py").read_text(encoding="utf-8")
assert "or ('tool-calling iterations' in haystack and 'maximum' in haystack)" in streaming_py
def test_historical_synthetic_summary_prompt_does_not_mark_normal_result_as_tool_limit():
result = {
"messages": [
{"role": "user", "content": "Earlier task."},
{"role": "user", "content": streaming._MAX_ITERATION_SUMMARY_REQUEST},
{"role": "user", "content": "Current normal task."},
{"role": "assistant", "content": "Current task completed normally."},
],
}
assert streaming._agent_result_tool_limit_reached(result) is False
def test_tool_limit_with_final_answer_marks_latest_assistant_status_card():
messages = [
{"role": "user", "content": "Do the long task."},
{"role": "assistant", "content": "I reached the limit; here is the summary."},
]
assert streaming._session_lacks_final_assistant_answer(messages) is False
assert streaming._mark_latest_assistant_tool_limit_status(messages) is True
assistant = messages[-1]
assert assistant["_terminal_state"] == "tool_limit_reached"
assert assistant["_terminal_reason"] == "max_iterations"
assert assistant["_statusCard"]["title"] == "Tool iteration limit reached"
def test_tool_limit_without_final_answer_is_no_final_terminal_state_after_filtering():
messages = [
{"role": "user", "content": "Do the long task."},
{"role": "assistant", "content": "", "tool_calls": [{"id": "call_1"}]},
{"role": "tool", "tool_call_id": "call_1", "content": "result"},
{"role": "user", "content": streaming._MAX_ITERATION_SUMMARY_REQUEST},
]
cleaned = streaming._drop_synthetic_max_iteration_summary_requests(messages)
assert all(
not streaming._is_synthetic_max_iteration_summary_request(message)
for message in cleaned
)
assert streaming._session_lacks_final_assistant_answer(cleaned) is True
def test_display_merge_does_not_render_synthetic_summary_prompt():
previous_display = [{"role": "user", "content": "Do the long task."}]
previous_context = [{"role": "user", "content": "Do the long task."}]
result_messages = previous_context + [
{"role": "assistant", "content": "", "tool_calls": [{"id": "call_1"}]},
{"role": "tool", "tool_call_id": "call_1", "content": "result"},
{"role": "user", "content": streaming._MAX_ITERATION_SUMMARY_REQUEST},
{"role": "assistant", "content": "I reached the limit; here is the summary."},
]
result_messages = streaming._drop_synthetic_max_iteration_summary_requests(
result_messages,
enabled=True,
)
merged = streaming._merge_display_messages_after_agent_result(
previous_display,
previous_context,
result_messages,
"Do the long task.",
)
assert all(
message.get("content") != streaming._MAX_ITERATION_SUMMARY_REQUEST
for message in merged
)
assert merged[-1]["role"] == "assistant"
assert "here is the summary" in merged[-1]["content"]
def test_frontend_handles_tool_limit_apperror_label():
messages_js = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
start = messages_js.find("source.addEventListener('apperror'")
end = messages_js.find("source.addEventListener('warning'", start)
assert start != -1 and end != -1
block = messages_js[start:end]
assert "const isToolLimitReached=d.type==='tool_limit_reached';" in block
assert "Tool iteration limit reached" in block
assert "Terminal state details" in block
def test_streaming_tool_limit_with_final_answer_persists_clean_done_state(tmp_path, monkeypatch):
result = {
"turn_exit_reason": "max_iterations_reached(30/30)",
"messages": [
{"role": "user", "content": "Do the long task."},
{"role": "assistant", "content": "", "tool_calls": [{"id": "call_1"}]},
{"role": "tool", "tool_call_id": "call_1", "content": "result"},
{"role": "user", "content": streaming._MAX_ITERATION_SUMMARY_REQUEST},
{"role": "assistant", "content": "I reached the limit; here is the summary."},
],
}
events, payload = _run_streaming_with_fake_agent(tmp_path, monkeypatch, result)
done_payloads = [payload for event, payload in events if event == "done"]
assert done_payloads, "expected done SSE payload"
assert done_payloads[-1]["terminal_state"] == "tool_limit_reached"
assert done_payloads[-1]["terminal_reason"] == "max_iterations"
assert all(
message.get("content") != streaming._MAX_ITERATION_SUMMARY_REQUEST
for message in payload["messages"]
)
assert all(
message.get("content") != streaming._MAX_ITERATION_SUMMARY_REQUEST
for message in payload["context_messages"]
)
assistant = payload["messages"][-1]
assert assistant["role"] == "assistant"
assert assistant["_terminal_state"] == "tool_limit_reached"
assert assistant["_statusCard"]["title"] == "Tool iteration limit reached"
@pytest.mark.parametrize("marker", ["_verification_stop_synthetic", "_pre_verify_synthetic"])
@pytest.mark.parametrize("delta_only", [False, True])
def test_verification_nudge_is_removed_while_corrective_followup_persists(
tmp_path,
monkeypatch,
marker,
delta_only,
):
corrective = "Verification failed. I fixed the parser and reran the tests."
prior_turn = [
{"role": "user", "content": "Fix the failing test."},
{"role": "assistant", "content": [{"type": "tool_use", "name": "terminal"}]},
{
"role": "tool",
"tool_call_id": "terminal",
"content": "Tests passed.",
"verification_evidence": {
"status": "passed",
"kind": "terminal",
"scope": "workspace",
"canonical_command": "pytest tests/test_app.py -q",
},
},
{"role": "assistant", "content": "The failing test is fixed."},
]
result_messages = [
{"role": "user", "content": "[System: verify the workspace]", marker: True},
{"role": "assistant", "content": corrective},
]
if not delta_only:
result_messages = prior_turn + result_messages
result = {"messages": result_messages}
_events, payload = _run_streaming_with_fake_agent(
tmp_path,
monkeypatch,
result,
prior_messages=prior_turn,
prior_context_messages=prior_turn,
msg_text="Fix the failing test.",
current_turn_user_idx=0,
)
expected_sequence = [
(message["role"], message.get("content"))
for message in prior_turn
] + [("assistant", corrective)]
for field in ("messages", "context_messages"):
serialized = json.dumps(payload[field])
assert marker not in serialized
assert "[System: verify the workspace]" not in serialized
assert [
(message["role"], message.get("content"))
for message in payload[field]
] == expected_sequence
assert [message.get("content") for message in payload[field]].count(
"Fix the failing test."
) == 1
@pytest.mark.parametrize("marker", ["_verification_stop_synthetic", "_pre_verify_synthetic"])
def test_deferred_repeated_old_prompt_marked_followup_materializes_current_turn(
tmp_path,
monkeypatch,
marker,
):
prompt = "Fix the failing test."
old_turn = [
{"role": "user", "content": prompt, "timestamp": 1.0},
{"role": "assistant", "content": "The earlier attempt is complete."},
]
corrective = "Verification failed. I fixed the parser and reran the tests."
result = {
"messages": old_turn + [
{"role": "user", "content": "[System: verify the workspace]", marker: True},
{"role": "assistant", "content": corrective},
],
}
_events, payload = _run_streaming_with_fake_agent(
tmp_path,
monkeypatch,
result,
prior_messages=old_turn,
prior_context_messages=old_turn,
msg_text=prompt,
pending_started_at=2.0,
current_turn_user_idx=len(old_turn),
)
expected = [
("user", prompt),
("assistant", "The earlier attempt is complete."),
("user", prompt),
("assistant", corrective),
]
for field in ("messages", "context_messages"):
assert [(message["role"], message.get("content")) for message in payload[field]] == expected
assert [message.get("content") for message in payload[field]].count(prompt) == 2
assert marker not in json.dumps(payload[field])
@pytest.mark.parametrize("marker", ["_verification_stop_synthetic", "_pre_verify_synthetic"])
def test_same_second_repeated_prompt_keeps_current_user_turn(tmp_path, monkeypatch, marker):
prompt = "Fix the failing test."
old_turn = [
{"role": "user", "content": prompt, "timestamp": 1.1},
{"role": "assistant", "content": "The earlier attempt is complete."},
]
corrective = "Verification failed. I fixed the parser and reran the tests."
result = {
"messages": old_turn + [
{"role": "user", "content": "[System: verify the workspace]", marker: True},
{"role": "assistant", "content": corrective},
],
}
_events, payload = _run_streaming_with_fake_agent(
tmp_path,
monkeypatch,
result,
prior_messages=old_turn,
prior_context_messages=old_turn,
msg_text=prompt,
pending_started_at=1.9,
current_turn_user_idx=len(old_turn),
)
expected = [
("user", prompt),
("assistant", "The earlier attempt is complete."),
("user", prompt),
("assistant", corrective),
]
for field in ("messages", "context_messages"):
assert [(message["role"], message.get("content")) for message in payload[field]] == expected
assert [message.get("content") for message in payload[field]].count(prompt) == 2
assert marker not in json.dumps(payload[field])
@pytest.mark.parametrize("marker", ["_verification_stop_synthetic", "_pre_verify_synthetic"])
def test_eager_exact_checkpoint_does_not_duplicate_repeated_prompt(
tmp_path,
monkeypatch,
marker,
):
prompt = "Fix the failing test."
old_turn = [
{"role": "user", "content": prompt, "timestamp": 1.0},
{"role": "assistant", "content": "The earlier attempt is complete."},
]
current_checkpoint = [
{
"role": "user",
"content": prompt,
"timestamp": 2.0,
"_source": "webui",
"attachments": [],
"_active_turn_token": "stream-tool-limit:2",
},
]
corrective = "Verification failed. I fixed the parser and reran the tests."
result = {
"messages": [
{"role": "user", "content": "[System: verify the workspace]", marker: True},
{"role": "assistant", "content": corrective},
]
}
_events, payload = _run_streaming_with_fake_agent(
tmp_path,
monkeypatch,
result,
prior_messages=old_turn,
prior_context_messages=old_turn + current_checkpoint,
msg_text=prompt,
pending_started_at=2.0,
current_turn_user_idx=2,
)
expected = [
("user", prompt),
("assistant", "The earlier attempt is complete."),
("user", prompt),
("assistant", corrective),
]
for field in ("messages", "context_messages"):
assert [(message["role"], message.get("content")) for message in payload[field]] == expected
assert [message.get("content") for message in payload[field]].count(prompt) == 2
assert marker not in json.dumps(payload[field])
@pytest.mark.parametrize("marker", ["_verification_stop_synthetic", "_pre_verify_synthetic"])
def test_eager_checkpoint_reused_by_exact_token(tmp_path, monkeypatch, marker):
prompt = "Fix the failing test."
old_turn = [
{"role": "user", "content": prompt, "timestamp": 1.1},
{"role": "assistant", "content": "The earlier attempt is complete."},
]
current_checkpoint = [
{
"role": "user",
"content": prompt,
"timestamp": 1.9,
"_source": "webui",
"attachments": [],
"_active_turn_token": "stream-tool-limit:1.9",
},
]
corrective = "Verification failed. I fixed the parser and reran the tests."
result = {
"messages": [
{"role": "user", "content": "[System: verify the workspace]", marker: True},
{"role": "assistant", "content": corrective},
]
}
_events, payload = _run_streaming_with_fake_agent(
tmp_path,
monkeypatch,
result,
prior_messages=old_turn,
prior_context_messages=old_turn + current_checkpoint,
msg_text=prompt,
pending_started_at=1.9,
current_turn_user_idx=2,
)
expected = [
("user", prompt),
("assistant", "The earlier attempt is complete."),
("user", prompt),
("assistant", corrective),
]
for field in ("messages", "context_messages"):
assert [(message["role"], message.get("content")) for message in payload[field]] == expected
assert [message.get("content") for message in payload[field]].count(prompt) == 2
assert marker not in json.dumps(payload[field])
@pytest.mark.parametrize("marker", ["_verification_stop_synthetic", "_pre_verify_synthetic"])
def test_tool_limit_exit_uses_shared_settlement_owner(tmp_path, monkeypatch, marker):
prompt = "Fix the failing test."
old_turn = [
{"role": "user", "content": prompt, "timestamp": 1.1},
{"role": "assistant", "content": "The earlier attempt is complete."},
]
corrective = "Verification failed. I fixed the parser and reran the tests."
result = {
"turn_exit_reason": "max_iterations_reached(30/30)",
"messages": old_turn + [
{"role": "user", "content": "[System: verify the workspace]", marker: True},
{"role": "assistant", "content": corrective},
],
}
settle_calls = []
original = streaming._settle_result_messages
def _spy(*args, **kwargs):
settle_calls.append(True)
return original(*args, **kwargs)
monkeypatch.setattr(streaming, "_settle_result_messages", _spy)
_events, payload = _run_streaming_with_fake_agent(
tmp_path,
monkeypatch,
result,
prior_messages=old_turn,
prior_context_messages=old_turn,
msg_text=prompt,
pending_started_at=1.9,
current_turn_user_idx=len(old_turn),
)
assert len(settle_calls) == 1
assert [(message["role"], message.get("content")) for message in payload["messages"]] == [
("user", prompt),
("assistant", "The earlier attempt is complete."),
("user", prompt),
("assistant", corrective),
]
@pytest.mark.parametrize("marker", ["_verification_stop_synthetic", "_pre_verify_synthetic"])
def test_exception_exit_uses_shared_settlement_owner(tmp_path, monkeypatch, marker):
prompt = "Fix the failing test."
old_turn = [
{"role": "user", "content": prompt, "timestamp": 1.1},
{"role": "assistant", "content": "The earlier attempt is complete."},
]
corrective = "Verification failed. I fixed the parser and reran the tests."
heal_result = {
"messages": old_turn + [
{"role": "user", "content": "[System: verify the workspace]", marker: True},
{"role": "assistant", "content": corrective},
],
}
session = types.SimpleNamespace(messages=list(old_turn), context_messages=list(old_turn))
streaming._settle_result_messages(
session,
old_turn,
old_turn,
heal_result["messages"],
prompt,
"webui",
{
"token": "stream-tool-limit:1.9",
"text": prompt,
"timestamp": 1.9,
"source": "webui",
"attachments": [],
"current_turn_user_idx": len(old_turn),
"turn_id": "turn-current",
},
)
assert [(message["role"], message.get("content")) for message in session.messages] == [
("user", prompt),
("assistant", "The earlier attempt is complete."),
("user", prompt),
("assistant", corrective),
]
def test_missing_agent_authority_fails_closed(tmp_path, monkeypatch):
prompt = "Fix the failing test."
old_turn = [
{"role": "user", "content": prompt, "timestamp": 1.1},
{"role": "assistant", "content": "The earlier attempt is complete."},
]
result = {
"messages": old_turn + [
{"role": "user", "content": "[System: verify the workspace]", "_verification_stop_synthetic": True},
{"role": "assistant", "content": "Verification failed. I fixed the parser and reran the tests."},
],
}
_events, payload = _run_streaming_with_fake_agent(
tmp_path,
monkeypatch,
result,
prior_messages=old_turn,
prior_context_messages=old_turn,
msg_text=prompt,
pending_started_at=1.9,
current_turn_user_idx=None,
)
assert [(message["role"], message.get("content")) for message in payload["messages"]] == [
("user", prompt),
("assistant", "The earlier attempt is complete."),
("user", prompt),
("assistant", "Verification failed. I fixed the parser and reran the tests."),
]
@pytest.mark.parametrize("marker", ["_verification_stop_synthetic", "_pre_verify_synthetic"])
def test_synthetic_only_verification_delta_preserves_prior_histories(tmp_path, monkeypatch, marker):
prior_turn = [
{"role": "user", "content": "Fix the failing test."},
{"role": "assistant", "content": [{"type": "tool_use", "name": "terminal"}]},
{
"role": "tool",
"tool_call_id": "terminal",
"content": "Tests passed.",
"verification_evidence": {
"status": "passed",
"kind": "terminal",
"scope": "workspace",
"canonical_command": "pytest tests/test_app.py -q",
},
},
{"role": "assistant", "content": "The failing test is fixed."},
]
result = {
"messages": [
{"role": "user", "content": "[System: verify the workspace]", marker: True},
],
}
_events, payload = _run_streaming_with_fake_agent(
tmp_path,
monkeypatch,
result,
prior_messages=prior_turn,
prior_context_messages=prior_turn,
msg_text="Fix the failing test.",
current_turn_user_idx=0,
)
for field in ("messages", "context_messages"):
assert payload[field] == payload["messages"]
serialized = json.dumps(payload[field])
assert marker not in serialized
assert "[System: verify the workspace]" not in serialized
assert "The failing test is fixed." in serialized
def test_streaming_tool_limit_partial_without_final_answer_emits_no_final_apperror(tmp_path, monkeypatch):
result = {
"status": "partial",
"turn_exit_reason": "max_iterations_reached(30/30)",
"messages": [
{"role": "user", "content": "Do the long task."},
{"role": "assistant", "content": "", "tool_calls": [{"id": "call_1"}]},
{"role": "tool", "tool_call_id": "call_1", "content": "result"},
{"role": "user", "content": streaming._MAX_ITERATION_SUMMARY_REQUEST},
],
}
events, payload = _run_streaming_with_fake_agent(tmp_path, monkeypatch, result)
apperror_payloads = [payload for event, payload in events if event == "apperror"]
assert apperror_payloads, "expected apperror SSE payload"
assert apperror_payloads[-1]["type"] == "tool_limit_reached"
assert apperror_payloads[-1]["terminal_state"] == "tool_limit_reached"
assert payload["messages"][-1]["_error"] is True
assert "Tool iteration limit reached" in payload["messages"][-1]["content"]
assert all(
message.get("content") != streaming._MAX_ITERATION_SUMMARY_REQUEST
for message in payload["messages"]
)
def test_streaming_tool_limit_with_fallback_final_response_surfaces_closure_text(tmp_path, monkeypatch):
"""#5494 — handle_max_iterations() guarantees a non-empty ``final_response``
on iteration-limit exhaustion. This test pins the WebUI contract that,
when ``messages`` ends without a final assistant turn and ``final_response``
is set, the user sees that closure text instead of a bare
``tool_limit_reached`` error. Serves both as a live-bug fix pin and as a
regression guard for the agent's "delivered final_response ⇒ assistant
row" invariant: if a future agent regression drops that invariant, this
test still passes because the WebUI honors the contract locally.
"""
graceful = "I reached the iteration limit and couldn't generate a summary."
result = {
"turn_exit_reason": "max_iterations_reached(30/30)",
"final_response": graceful,
"messages": [
{"role": "user", "content": "Do the long task."},
{"role": "assistant", "content": "", "tool_calls": [{"id": "call_1"}]},
{"role": "tool", "tool_call_id": "call_1", "content": "result"},
{"role": "user", "content": streaming._MAX_ITERATION_SUMMARY_REQUEST},
],
}
events, payload = _run_streaming_with_fake_agent(tmp_path, monkeypatch, result)
# The user sees the graceful fallback, not a bare tool_limit_reached error.
# Either (a) we synthesized the fallback here, or (b) the agent guarantee
# already added an assistant row and `_mark_latest_assistant_tool_limit_status`
# attached the status card. Both routes satisfy the contract.
assert not [
ap for ev, ap in events if ev == "apperror"
and ap.get("type") == "tool_limit_reached"
], "expected no tool_limit_reached apperror when fallback was returned"
done_payloads = [payload for event, payload in events if event == "done"]
assert done_payloads, "expected done SSE payload"
assert done_payloads[-1]["terminal_state"] == "tool_limit_reached"
# Fallback text is shown as a final assistant message and is annotated
# with the status card so the UI can render the 'limit reached' chip.
assistant = payload["messages"][-1]
assert assistant["role"] == "assistant"
assert assistant["content"] == graceful
assert assistant["_terminal_state"] == "tool_limit_reached"
assert assistant["_statusCard"]["title"] == "Tool iteration limit reached"
# Synthetic scaffolding turn was still dropped, even after fallback injection.
assert all(
message.get("content") != streaming._MAX_ITERATION_SUMMARY_REQUEST
for message in payload["messages"]
)
def test_streaming_tool_limit_with_fallback_does_not_double_inject_when_assistant_exists(tmp_path, monkeypatch):
"""#5494 — when the agent already appended a model-generated summary
AND ``final_response`` carries the same text, the WebUI must not duplicate
the assistant turn. Pins the no-op contract on the synthesis path.
"""
summary = "I reached the limit; here is the summary."
result = {
"turn_exit_reason": "max_iterations_reached(30/30)",
"final_response": summary,
"messages": [
{"role": "user", "content": "Do the long task."},
{"role": "assistant", "content": "", "tool_calls": [{"id": "call_1"}]},
{"role": "tool", "tool_call_id": "call_1", "content": "result"},
{"role": "user", "content": streaming._MAX_ITERATION_SUMMARY_REQUEST},
{"role": "assistant", "content": summary},
],
}
events, payload = _run_streaming_with_fake_agent(tmp_path, monkeypatch, result)
done_payloads = [payload for event, payload in events if event == "done"]
assert done_payloads
assistant_msgs = [
m for m in payload["messages"]
if m.get("role") == "assistant" and m.get("content") == summary
]
assert len(assistant_msgs) == 1, "fallback must not duplicate the existing summary"
assert assistant_msgs[0]["_terminal_state"] == "tool_limit_reached"
def test_maybe_inject_max_iteration_summary_fallback_unit():
"""Unit-level coverage for the injection helper."""
messages = [
{"role": "user", "content": "Do the long task."},
{"role": "assistant", "content": "", "tool_calls": [{"id": "call_1"}]},
{"role": "tool", "tool_call_id": "call_1", "content": "result"},
]
graceful = "I reached the iteration limit and couldn't generate a summary."
result = {"final_response": graceful}
injected = streaming._maybe_inject_max_iteration_summary_fallback(messages, result)
assert injected[-1]["role"] == "assistant"
assert injected[-1]["content"] == graceful
assert injected[-1]["_max_iteration_summary_fallback"] is True
def test_maybe_inject_max_iteration_summary_fallback_skips_when_assistant_present():
messages = [
{"role": "user", "content": "Do the long task."},
{"role": "assistant", "content": "real summary"},
]
result = {"final_response": "fallback text"}
out = streaming._maybe_inject_max_iteration_summary_fallback(messages, result)
assert out == messages
def test_maybe_inject_max_iteration_summary_fallback_skips_when_no_fallback():
messages = [
{"role": "user", "content": "Do the long task."},
{"role": "assistant", "content": "", "tool_calls": [{"id": "call_1"}]},
]
out = streaming._maybe_inject_max_iteration_summary_fallback(messages, {})
assert out == messages
out = streaming._maybe_inject_max_iteration_summary_fallback(
messages, {"final_response": " "}
)
assert out == messages
out = streaming._maybe_inject_max_iteration_summary_fallback(messages, None)
assert out == messages
def test_streaming_tool_limit_partial_with_final_answer_suppresses_false_no_response(
tmp_path,
monkeypatch,
):
result = {
"status": "partial",
"turn_exit_reason": "max_iterations_reached(30/30)",
"messages": [
{"role": "user", "content": "Do the long task."},
{"role": "assistant", "content": "I reached the limit; here is the summary."},
],
}
events, payload = _run_streaming_with_fake_agent(tmp_path, monkeypatch, result)
assert not [event_payload for event, event_payload in events if event == "apperror"]
done_payloads = [event_payload for event, event_payload in events if event == "done"]
assert done_payloads, "expected done SSE payload"
assert done_payloads[-1]["terminal_state"] == "tool_limit_reached"
assert done_payloads[-1]["terminal_reason"] == "max_iterations"
assistant = next(
message
for message in payload["messages"]
if message.get("role") == "assistant"
and message.get("content") == "I reached the limit; here is the summary."
)
assert assistant["_terminal_state"] == "tool_limit_reached"
assert assistant["_terminal_reason"] == "max_iterations"
assert assistant["_statusCard"]["title"] == "Tool iteration limit reached"
assert payload["messages"][-1] is assistant
def test_streaming_historical_synthetic_prompt_normal_result_does_not_emit_tool_limit(tmp_path, monkeypatch):
result = {
"messages": [
{"role": "user", "content": "Earlier task."},
{"role": "user", "content": streaming._MAX_ITERATION_SUMMARY_REQUEST},
{"role": "user", "content": "Do the long task."},
{"role": "assistant", "content": "Current task completed normally."},
],
}
events, payload = _run_streaming_with_fake_agent(tmp_path, monkeypatch, result)
assert not [payload for event, payload in events if event == "apperror"]
done_payloads = [payload for event, payload in events if event == "done"]
assert done_payloads, "expected normal done SSE payload"
assert "terminal_state" not in done_payloads[-1]
assert payload["messages"][-1]["role"] == "assistant"
assert payload["messages"][-1]["content"] == "Current task completed normally."
def test_streaming_empty_result_messages_do_not_treat_prior_assistant_as_current_answer(tmp_path, monkeypatch):
prior = [
{"role": "user", "content": "Earlier task."},
{"role": "assistant", "content": "Earlier answer."},
]
result = {"messages": []}
events, payload = _run_streaming_with_fake_agent(
tmp_path,
monkeypatch,
result,
prior_messages=prior,
prior_context_messages=prior,
)
apperror_payloads = [payload for event, payload in events if event == "apperror"]
assert apperror_payloads, "expected silent-failure apperror"
assert apperror_payloads[-1]["type"] == "no_response"
assert not [payload for event, payload in events if event == "done"]
assert payload["messages"][-1]["_error"] is True