Files
hermes-webui/tests/test_issue_1932_goal_hook_unrelated_turns.py
T
zqy 6fd07c2af4 fix: only evaluate goal hook on goal-related turns (#1932)
The goal evaluation hook was firing on every completed assistant turn
when a goal was active, even for unrelated messages like "what time is
it". This burned the goal budget, triggered continuation prompts that
interrupted unrelated conversations, and made /goal status numbers
misleading.

Add STREAM_GOAL_RELATED and PENDING_GOAL_CONTINUATION flags to gate
the evaluate_goal_after_turn() call in the streaming loop. Only streams
started from goal kickoff (/goal <text>) or goal continuation are
marked as goal-related. Normal user messages skip the hook entirely.
2026-05-09 15:08:13 +08:00

227 lines
9.1 KiB
Python

"""Regression tests for issue #1932: goal hook fires on every assistant turn.
The goal evaluation hook must only run when the turn was triggered by an
explicit goal-related message (goal set, goal continuation). Unrelated
messages like "what time is it" must NOT:
- increment turns_used
- trigger goal_continue SSE events
- burn the goal budget
"""
import pytest
# ---------------------------------------------------------------------------
# Test 1: config exports STREAM_GOAL_RELATED
# ---------------------------------------------------------------------------
def test_config_exports_stream_goal_related():
"""api.config must export STREAM_GOAL_RELATED for the streaming gate."""
from api.config import STREAM_GOAL_RELATED
assert isinstance(STREAM_GOAL_RELATED, dict)
# ---------------------------------------------------------------------------
# Test 2: config exports PENDING_GOAL_CONTINUATION
# ---------------------------------------------------------------------------
def test_config_exports_pending_goal_continuation():
"""api.config must export PENDING_GOAL_CONTINUATION for auto-marking
continuation streams as goal-related."""
from api.config import PENDING_GOAL_CONTINUATION
assert isinstance(PENDING_GOAL_CONTINUATION, (dict, set))
# ---------------------------------------------------------------------------
# Test 3: streaming.py gates evaluate_goal_after_turn on STREAM_GOAL_RELATED
# ---------------------------------------------------------------------------
def test_streaming_source_code_gates_on_stream_goal_related():
"""The streaming code must check STREAM_GOAL_RELATED[stream_id] before
calling evaluate_goal_after_turn, so unrelated turns skip the hook."""
from pathlib import Path
streaming_py = (Path(__file__).resolve().parents[1] / "api" / "streaming.py").read_text()
# Must import STREAM_GOAL_RELATED
assert "STREAM_GOAL_RELATED" in streaming_py, (
"streaming.py must import STREAM_GOAL_RELATED from api.config"
)
# Must check it before calling evaluate_goal_after_turn
goal_related_check = streaming_py.find("STREAM_GOAL_RELATED")
eval_call = streaming_py.find("evaluate_goal_after_turn")
assert goal_related_check != -1 and eval_call != -1
assert goal_related_check < eval_call, (
"STREAM_GOAL_RELATED check must appear before evaluate_goal_after_turn call"
)
# ---------------------------------------------------------------------------
# Test 4: streaming.py sets PENDING_GOAL_CONTINUATION on goal_continue
# ---------------------------------------------------------------------------
def test_streaming_sets_pending_goal_continuation_on_goal_continue():
"""When goal_continue is emitted, streaming.py must set
PENDING_GOAL_CONTINUATION so the next /chat/start marks the stream."""
from pathlib import Path
streaming_py = (Path(__file__).resolve().parents[1] / "api" / "streaming.py").read_text()
assert "PENDING_GOAL_CONTINUATION" in streaming_py, (
"streaming.py must reference PENDING_GOAL_CONTINUATION"
)
# The PENDING_GOAL_CONTINUATION set must happen near goal_continue
goal_continue_idx = streaming_py.find("goal_continue")
pending_idx = streaming_py.find("PENDING_GOAL_CONTINUATION")
assert goal_continue_idx != -1 and pending_idx != -1
# ---------------------------------------------------------------------------
# Test 5: routes.py reads PENDING_GOAL_CONTINUATION and marks stream
# ---------------------------------------------------------------------------
def test_routes_reads_pending_goal_continuation():
"""The chat/start handler must check PENDING_GOAL_CONTINUATION and mark
the new stream as goal-related."""
from pathlib import Path
routes_py = (Path(__file__).resolve().parents[1] / "api" / "routes.py").read_text()
assert "PENDING_GOAL_CONTINUATION" in routes_py, (
"routes.py must reference PENDING_GOAL_CONTINUATION"
)
assert "STREAM_GOAL_RELATED" in routes_py, (
"routes.py must reference STREAM_GOAL_RELATED to mark goal-related streams"
)
# ---------------------------------------------------------------------------
# Test 6: routes.py marks goal kickoff streams as goal-related
# ---------------------------------------------------------------------------
def test_routes_marks_goal_kickoff_as_goal_related():
"""The /api/goal handler must mark the kickoff stream as goal-related."""
from pathlib import Path
routes_py = (Path(__file__).resolve().parents[1] / "api" / "routes.py").read_text()
# After kickoff stream is started, it must mark the stream
kickoff_idx = routes_py.find("kickoff_prompt")
stream_goal_idx = routes_py.find("STREAM_GOAL_RELATED")
assert kickoff_idx != -1 and stream_goal_idx != -1
# ---------------------------------------------------------------------------
# Test 7: _start_chat_stream_for_session passes goal_related through
# ---------------------------------------------------------------------------
def test_start_chat_stream_accepts_goal_related():
"""_start_chat_stream_for_session must accept goal_related kwarg."""
from pathlib import Path
routes_py = (Path(__file__).resolve().parents[1] / "api" / "routes.py").read_text()
assert "goal_related" in routes_py, (
"routes.py must reference goal_related parameter"
)
# ---------------------------------------------------------------------------
# Test 8: _run_agent_streaming accepts and uses goal_related
# ---------------------------------------------------------------------------
def test_run_agent_streaming_uses_goal_related():
"""_run_agent_streaming must accept goal_related kwarg and use it to
gate the goal evaluation hook."""
from pathlib import Path
streaming_py = (Path(__file__).resolve().parents[1] / "api" / "streaming.py").read_text()
# Function must accept goal_related parameter
func_def_idx = streaming_py.find("def _run_agent_streaming")
assert func_def_idx != -1
# The function signature area (within ~200 chars) should contain goal_related
sig_area = streaming_py[func_def_idx:func_def_idx + 500]
assert "goal_related" in sig_area, (
"_run_agent_streaming must accept a goal_related parameter"
)
# ---------------------------------------------------------------------------
# Test 9: STREAM_GOAL_RELATED cleanup on stream exit
# ---------------------------------------------------------------------------
def test_stream_goal_related_cleaned_up():
"""STREAM_GOAL_RELATED entries must be cleaned up when streams end."""
from pathlib import Path
streaming_py = (Path(__file__).resolve().parents[1] / "api" / "streaming.py").read_text()
# Must have cleanup of STREAM_GOAL_RELATED
assert "STREAM_GOAL_RELATED" in streaming_py
# Look for pop or del of STREAM_GOAL_RELATED
assert any(
pattern in streaming_py
for pattern in [
"STREAM_GOAL_RELATED.pop",
"del STREAM_GOAL_RELATED",
]
), "streaming.py must clean up STREAM_GOAL_RELATED entries when streams end"
# ---------------------------------------------------------------------------
# Test 10: functional test with FakeGoalManager at streaming integration level
# ---------------------------------------------------------------------------
def test_goal_evaluate_after_turn_only_increments_for_user_initiated(monkeypatch):
"""Verify that evaluate_goal_after_turn only increments turns_used
when user_initiated=True (goal-related), not when user_initiated=False."""
from api import goals as webui_goals
turns_incremented = []
class FakeState:
goal = "test goal"
status = "active"
turns_used = 0
max_turns = 10
last_turn_at = 0.0
last_verdict = None
last_reason = None
paused_reason = None
def to_json(self):
return {"goal": self.goal, "status": self.status}
class FakeMgr:
def __init__(self, session_id, default_max_turns=20):
self.state = FakeState()
def is_active(self):
return True
def evaluate_after_turn(self, last_response, user_initiated=True):
if user_initiated:
self.state.turns_used += 1
turns_incremented.append(True)
return {
"status": "active",
"should_continue": True,
"continuation_prompt": "continue",
"verdict": "continue",
"reason": "ok",
"message": "ok",
}
monkeypatch.setattr(webui_goals, "GoalManager", FakeMgr)
monkeypatch.setattr(webui_goals, "_default_max_turns", lambda: 10)
# user_initiated=True should increment
result1 = webui_goals.evaluate_goal_after_turn(
"sid-1", "goal response", user_initiated=True, profile_home=None
)
assert len(turns_incremented) == 1
# user_initiated=False should NOT increment
result2 = webui_goals.evaluate_goal_after_turn(
"sid-1", "unrelated response", user_initiated=False, profile_home=None
)
assert len(turns_incremented) == 1, (
"turns_used should NOT increment when user_initiated=False"
)