Files
hermes-webui/tests/test_merge_key_tool_calls.py
nesquena-hermes bf088cbbc4
Some checks failed
Release & Docker / release (push) Has been cancelled
Release v0.51.302 — Release JR (stage-brick — mobile/iOS brick + large-session perf hotfixes) (#3754)
* fix(ui): stop hidden toast from intercepting clicks on mobile (#3735)

The .toast container kept pointer-events:auto while hidden (opacity:0), so its
fixed padding sat over mobile profile action buttons and ate their clicks. Set
pointer-events:none when hidden; restore auto on .toast.show.

Co-authored-by: timlawrenz <timlawrenz@users.noreply.github.com>

* fix(sessions): rename saves on blur so iOS Safari rename works (#3729)

iOS Safari has no Enter key; the keyboard 'Done' button fires blur, and the old
onblur=cancel discarded the rename. Flip blur to save (Escape still cancels) for
session rename and project create/rename, with a _finishDone guard to prevent a
double-fire between blur and the API callback.

Co-authored-by: reinocheong <reinocheong@users.noreply.github.com>

* perf(session): skip fuzzy dedup matching for giant merge payloads (#3730)

Large tool/log payloads made _matching_visible_duplicate() casefold+regex-tokenize
multi-megabyte contents on every visible key, so /api/session took 10s+ and blocked
/api/sessions for ~19s. Keep loose normalization lazy+cached and skip substring/fuzzy
matching for non-exact payloads >200KB; exact visible-key matches still short-circuit.

Co-authored-by: alvistar <alvistar@users.noreply.github.com>

* docs(changelog): stamp v0.51.302 — Release JR (stage-brick brick/perf hotfixes #3735 #3729 #3730)

---------

Co-authored-by: nesquena-hermes <[email protected]>
Co-authored-by: timlawrenz <timlawrenz@users.noreply.github.com>
Co-authored-by: reinocheong <reinocheong@users.noreply.github.com>
Co-authored-by: alvistar <alvistar@users.noreply.github.com>
2026-06-06 16:58:13 -07:00

174 lines
7.0 KiB
Python

"""Regression tests for PR #3665: tool_calls included in merge/dedup/visible keys.
Verifies that _session_message_merge_key, _session_message_dedup_key,
_session_message_visible_key, and _matching_visible_duplicate correctly
distinguish messages with different tool_calls arrays.
Without tool_calls in the key, assistant messages that invoke different
tools (but share empty content and same-second timestamp) collapse into
a single key, losing tool calls during merge.
"""
from __future__ import annotations
from api import models
from api.models import (
_matching_visible_duplicate,
_session_message_dedup_key,
_session_message_merge_key,
_session_message_visible_key,
merge_session_messages_append_only,
)
def _assistant_tc(tc_id: str, fn_name: str, timestamp=1000) -> dict:
"""Assistant message with tool_calls but empty content."""
return {
"role": "assistant",
"content": "",
"timestamp": timestamp,
"tool_calls": [
{"id": tc_id, "function": {"name": fn_name, "arguments": "{}"}},
],
}
def _tool_result(tc_id: str, name: str, content: str = "ok") -> dict:
return {
"role": "tool",
"tool_call_id": tc_id,
"name": name,
"content": content,
}
# ── _session_message_merge_key ──────────────────────────────────────────────
class TestMergeKeyToolCalls:
def test_same_tool_calls_produce_same_key(self):
a = _assistant_tc("call_1", "read_file")
b = _assistant_tc("call_1", "read_file")
assert _session_message_merge_key(a) == _session_message_merge_key(b)
def test_different_tool_calls_produce_different_keys(self):
a = _assistant_tc("call_1", "read_file")
b = _assistant_tc("call_2", "terminal")
assert _session_message_merge_key(a) != _session_message_merge_key(b)
def test_empty_vs_nonempty_tool_calls_differ(self):
empty = {"role": "assistant", "content": "", "timestamp": 1000}
with_tc = _assistant_tc("call_1", "read_file")
assert _session_message_merge_key(empty) != _session_message_merge_key(with_tc)
# ── _session_message_dedup_key ──────────────────────────────────────────────
class TestDedupKeyToolCalls:
def test_same_tool_calls_produce_same_key(self):
a = _assistant_tc("call_1", "read_file")
b = _assistant_tc("call_1", "read_file")
assert _session_message_dedup_key(a) == _session_message_dedup_key(b)
def test_different_tool_calls_produce_different_keys(self):
a = _assistant_tc("call_1", "read_file")
b = _assistant_tc("call_2", "terminal")
assert _session_message_dedup_key(a) != _session_message_dedup_key(b)
# ── _session_message_visible_key + _matching_visible_duplicate ──────────────
class TestVisibleKeyToolCalls:
def test_same_tool_calls_match(self):
a = _assistant_tc("call_1", "read_file")
b = _assistant_tc("call_1", "read_file")
ka = _session_message_visible_key(a)
kb = _session_message_visible_key(b)
assert ka == kb
assert _matching_visible_duplicate(ka, {kb}) is not None
def test_different_tool_calls_no_match(self):
a = _assistant_tc("call_1", "read_file")
b = _assistant_tc("call_2", "terminal")
ka = _session_message_visible_key(a)
kb = _session_message_visible_key(b)
assert ka != kb
assert _matching_visible_duplicate(ka, {kb}) is None
# ── merge_session_messages_append_only end-to-end ───────────────────────────
class TestMergeToolCallsEndToEnd:
def test_same_tool_calls_sidecar_and_state_merge_to_one(self):
"""Sidecar and state.db have the same assistant message with identical
tool_calls → merge must produce exactly one message (deduplicated)."""
msg = _assistant_tc("call_1", "read_file", timestamp=1000)
sidecar = [msg]
state = [msg]
result = merge_session_messages_append_only(sidecar, state)
assert len(result) == 1, f"expected 1 (deduped), got {len(result)}"
def test_different_tool_calls_sidecar_and_state_both_preserved(self):
"""Sidecar and state.db have assistant messages with different
tool_calls → merge must preserve both (they are distinct turns)."""
msg_a = _assistant_tc("call_1", "read_file", timestamp=1000)
msg_b = _assistant_tc("call_2", "terminal", timestamp=1000)
sidecar = [msg_a]
state = [msg_b]
result = merge_session_messages_append_only(sidecar, state)
assert len(result) == 2, f"expected 2 (distinct), got {len(result)}"
tc_ids = {
m["tool_calls"][0]["id"]
for m in result
if m.get("tool_calls")
}
assert tc_ids == {"call_1", "call_2"}
def test_no_tool_calls_still_deduped(self):
"""Messages without tool_calls are deduplicated by legacy key as before."""
msg = {"role": "assistant", "content": "hello", "timestamp": 1000}
result = merge_session_messages_append_only([msg], [msg])
assert len(result) == 1
# ── large-payload duplicate matching performance ────────────────────────────
class TestVisibleDuplicateLargePayloadPerformance:
def test_large_nonmatching_payload_skips_loose_normalizer(self, monkeypatch):
"""Giant tool/log payloads must not be regex-tokenized for fuzzy matching.
Exact visible-key equality is checked before this path. For non-exact
multi-hundred-KB payloads, fuzzy substring/token matching is too costly
for the /api/session hot path and low-value for deduplication.
"""
def fail_if_called(_content):
raise AssertionError("large payloads should not hit loose normalizer")
monkeypatch.setattr(models, "_loose_session_message_content", fail_if_called)
large_state = ("state output\n" * 25_000).strip()
large_sidecar = ("sidecar output\n" * 25_000).strip()
visible_key = ("assistant", large_state, "")
sidecar_key = ("assistant", large_sidecar, "")
assert _matching_visible_duplicate(visible_key, {sidecar_key}) is None
def test_small_nonmatching_payload_keeps_loose_matching(self, monkeypatch):
"""The large-payload guard must not disable legacy fuzzy matching."""
calls = []
def counted_loose(content):
calls.append(content)
return " ".join(str(content).lower().replace(",", "").replace("!", "").split())
monkeypatch.setattr(models, "_loose_session_message_content", counted_loose)
visible_key = ("assistant", "hello world", "")
sidecar_key = ("assistant", "HELLO, WORLD!!", "")
assert _matching_visible_duplicate(visible_key, {sidecar_key}) == sidecar_key
assert len(calls) == 2