Files
hermes-agent/tests/run_agent/test_message_sequence_repair.py
devsart95 06ae5b6faa perf(state): batch the turn flush into one SQLite transaction
Re-derivation of #23254 (@devsart95) on today's flush loop. The turn
flush in _flush_messages_to_session_db wrote one BEGIN IMMEDIATE
transaction per message row; a typical agent turn (user + assistant +
tool results) paid 3-8 transactions -- and, off WAL (the default on
macOS while the WAL-reset guard is active), 3-8 fsyncs -- per turn.

Adds SessionDB.append_messages_batch: same row shape as append_message
(shared _prepare_message_row serializer + _MESSAGE_INSERT_SQL column
list, so the two writers cannot drift), same compression-lock and
compression-closed guards, one aggregated session-counter UPDATE, one
transaction for the whole batch. Row serialization stays outside the
write lock.

The flush loop now collects the turn's new rows and writes them in one
call. All-or-nothing pairs exactly with the persisted-marker stamping:
on failure no rows landed and no markers were stamped, so the next
flush re-writes the whole tail (same recovery contract as before,
minus the partial-prefix case that could double-count).

Measured (same harness, 5-message turn, journal_mode=DELETE,
synchronous=FULL): 2.32ms -> 0.83ms median per turn flush (64% faster,
5 fsyncs -> 1). On WAL the win is smaller but the atomicity fix holds.
2026-08-03 20:43:38 +05:30

383 lines
13 KiB
Python

"""Tests for pre-API-call message-sequence repair.
Covers ``_repair_message_sequence`` and the extended
``_drop_trailing_empty_response_scaffolding`` behavior that rewinds past
orphan tool-result tails. Together these prevent the self-reinforcing empty-
response loop observed in session 20260507_044111_fa7e65, where a tool-result
followed directly by a user message produced silent empty responses from
providers (violating role alternation), which retriggered the empty-retry
recovery every turn.
"""
from run_agent import AIAgent
def _bare_agent():
return AIAgent.__new__(AIAgent)
# ── _drop_trailing_empty_response_scaffolding ──────────────────────────────
def test_drop_scaffolding_rewinds_orphan_tool_tail():
"""When scaffolding is stripped, also rewind the orphan assistant+tool pair."""
agent = _bare_agent()
messages = [
{"role": "user", "content": "task"},
{"role": "assistant", "content": "",
"tool_calls": [{"id": "t1", "type": "function",
"function": {"name": "f", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "t1", "content": "out"},
{"role": "assistant", "content": "(empty)",
"_empty_terminal_sentinel": True},
]
AIAgent._drop_trailing_empty_response_scaffolding(agent, messages)
assert messages == [{"role": "user", "content": "task"}]
# ── _repair_message_sequence ───────────────────────────────────────────────
def test_repair_merges_consecutive_user_messages():
agent = _bare_agent()
messages = [
{"role": "user", "content": "first"},
{"role": "user", "content": "second"},
]
repairs = AIAgent._repair_message_sequence(agent, messages)
assert repairs == 1
assert len(messages) == 1
assert messages[0]["role"] == "user"
assert messages[0]["content"] == "first\n\nsecond"
def test_repair_preserves_user_content_when_one_side_empty():
agent = _bare_agent()
messages = [
{"role": "user", "content": ""},
{"role": "user", "content": "real message"},
]
AIAgent._repair_message_sequence(agent, messages)
assert messages == [{"role": "user", "content": "real message"}]
def test_repair_does_not_rewind_ongoing_dialog_tool_pair():
"""assistant(tool_calls) + tool + user is a VALID pattern (user redirect
before the model gets its continuation turn). Repair must not touch it —
only the flag-gated scaffolding strip rewinds, and only when the
empty-recovery scaffolding was actually present.
"""
agent = _bare_agent()
messages = [
{"role": "user", "content": "Q1"},
{"role": "assistant", "content": "",
"tool_calls": [{"id": "t1", "type": "function",
"function": {"name": "f", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "t1", "content": "out"},
{"role": "user", "content": "Q2"},
]
original = [dict(m) for m in messages]
repairs = AIAgent._repair_message_sequence(agent, messages)
assert repairs == 0
assert messages == original
def test_repair_drops_stray_tool_with_unknown_tool_call_id():
agent = _bare_agent()
messages = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
{"role": "tool", "tool_call_id": "orphan", "content": "stray"},
{"role": "user", "content": "real"},
]
repairs = AIAgent._repair_message_sequence(agent, messages)
assert repairs >= 1
assert all(m.get("role") != "tool" for m in messages)
def test_repair_keeps_tool_matching_codex_call_id():
"""A valid tool result must survive when the assistant tool_call carries a
Codex-format ``call_id`` distinct from ``id`` and the result matches on
``call_id`` (#58168).
Before the fix, Pass 1 registered only ``tc.get("id")`` (``fc_...``) in the
known-id set, so a result keyed on ``call_id`` (``call_...``) looked
orphaned and was dropped -- leaving the assistant tool_call unanswered and
triggering an HTTP 400 on strict providers (DeepSeek, Kimi):
"Messages with role 'tool' must be a response to a preceding message with
'tool_calls'".
"""
agent = _bare_agent()
messages = [
{"role": "user", "content": "do it"},
{"role": "assistant", "content": "",
"tool_calls": [{"id": "fc_123", "call_id": "call_ABC",
"type": "function",
"function": {"name": "x", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "call_ABC", "content": "result"},
{"role": "user", "content": "next"},
]
repairs = AIAgent._repair_message_sequence(agent, messages)
assert repairs == 0
assert [m["role"] for m in messages] == ["user", "assistant", "tool", "user"]
assert messages[2]["tool_call_id"] == "call_ABC"
def test_repair_keeps_tool_matching_only_call_id():
"""Same as above but the assistant tool_call carries ONLY ``call_id`` (no
``id``). The result keyed on ``call_id`` must still be recognized (#58168).
"""
agent = _bare_agent()
messages = [
{"role": "user", "content": "do it"},
{"role": "assistant", "content": "",
"tool_calls": [{"call_id": "call_XYZ", "type": "function",
"function": {"name": "x", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "call_XYZ", "content": "result"},
{"role": "user", "content": "next"},
]
repairs = AIAgent._repair_message_sequence(agent, messages)
assert repairs == 0
assert any(m.get("role") == "tool" for m in messages)
# ── repair_message_sequence_with_cursor (#44837) ───────────────────────────
from agent.agent_runtime_helpers import repair_message_sequence_with_cursor
def test_cursor_clamped_when_compaction_shrinks_below_cursor():
"""Cursor past the new end of the list must come back in range so the
turn-end flush doesn't skip the assistant/tool chain (#44837)."""
agent = _bare_agent()
messages = [
{"role": "user", "content": "first"},
{"role": "user", "content": "second"},
]
agent._last_flushed_db_idx = 2 # both rows already flushed
repairs = repair_message_sequence_with_cursor(agent, messages)
assert repairs == 1
assert len(messages) == 1
assert agent._last_flushed_db_idx == 1
def test_cursor_rewinds_when_compaction_happens_before_cursor():
"""Repair that drops/merges messages at indexes BELOW the cursor must
rewind it by the number removed, or unflushed rows get skipped.
A plain min() clamp does NOT catch this case."""
agent = _bare_agent()
flushed_a = {"role": "user", "content": "first"}
flushed_b = {"role": "user", "content": "second"} # merged into flushed_a
unflushed_assistant = {"role": "assistant", "content": "answer"}
messages = [flushed_a, flushed_b, unflushed_assistant]
agent._last_flushed_db_idx = 2 # the two user rows are flushed
repairs = repair_message_sequence_with_cursor(agent, messages)
assert repairs == 1
assert len(messages) == 2
# Cursor must now point at the assistant (index 1), not stay at 2 —
# min(2, len=2) would leave it at 2 and the flush would skip it.
assert agent._last_flushed_db_idx == 1
assert messages[agent._last_flushed_db_idx] is unflushed_assistant
def test_flush_guard_clamps_overshooting_cursor():
"""_flush_messages_to_session_db safety net: an overshooting cursor must
not produce a negative-start slice that skips everything (#44837)."""
class _DB:
def __init__(self):
self.rows = []
def append_message(self, **kw):
self.rows.append(kw)
def append_messages_batch(self, session_id, messages, **kw):
for m in messages:
self.rows.append(dict(m, session_id=session_id))
return list(range(1, len(messages) + 1))
agent = _bare_agent()
agent._session_db = _DB()
agent._session_db_created = True
agent.session_id = "s1"
agent._persist_user_message_override = None
agent._last_flushed_db_idx = 5 # stale — past end of compacted list
messages = [
{"role": "user", "content": "q"},
{"role": "assistant", "content": "a"},
]
AIAgent._flush_messages_to_session_db(agent, messages, conversation_history=[])
# min(5, 2) = 2 → nothing skipped below start_idx, cursor settles at 2
assert agent._last_flushed_db_idx == 2
# ── Pass 0: merge consecutive assistant messages (issue #29148, #49147) ─────
# ── tool_call_id de-duplication (#58327) ────────────────────────────────────
# Strict providers (DeepSeek) reject a payload where the same tool_call_id
# appears more than once with HTTP 400 "Duplicate value for 'tool_call_id'".
def test_sanitize_deduplicates_duplicate_tool_results():
"""sanitize_api_messages (final pre-API chokepoint) drops duplicate tool
results sharing a tool_call_id."""
from agent.agent_runtime_helpers import sanitize_api_messages
messages = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "call_X", "type": "function",
"function": {"name": "foo", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "call_X", "content": "A"},
{"role": "tool", "tool_call_id": "call_X", "content": "B (duplicate)"},
{"role": "assistant", "content": "done"},
]
out = sanitize_api_messages(list(messages))
tool_ids = [m["tool_call_id"] for m in out if m.get("role") == "tool"]
assert tool_ids == ["call_X"] # exactly one survives
def test_sanitize_deduplicates_duplicate_assistant_tool_call_ids():
"""sanitize_api_messages collapses duplicate tool_calls sharing an id
WITHIN a single assistant message (the message[6] shape from #58327)."""
from agent.agent_runtime_helpers import sanitize_api_messages
messages = [
{"role": "assistant", "content": None, "tool_calls": [
{"id": "call_Y", "type": "function",
"function": {"name": "foo", "arguments": "{}"}},
{"id": "call_Y", "type": "function",
"function": {"name": "bar", "arguments": "{}"}},
]},
{"role": "tool", "tool_call_id": "call_Y", "content": "r"},
]
out = sanitize_api_messages(list(messages))
assistant = [m for m in out if m.get("role") == "assistant"][0]
ids = [tc["id"] for tc in assistant["tool_calls"]]
assert ids == ["call_Y"] # duplicate collapsed
def test_sanitize_preserves_distinct_tool_call_ids():
"""Negative control: legitimate DISTINCT tool_call_ids must NOT be dropped
(guards against over-dedup)."""
from agent.agent_runtime_helpers import sanitize_api_messages
messages = [
{"role": "assistant", "content": None, "tool_calls": [
{"id": "call_A", "type": "function",
"function": {"name": "a", "arguments": "{}"}},
{"id": "call_B", "type": "function",
"function": {"name": "b", "arguments": "{}"}},
]},
{"role": "tool", "tool_call_id": "call_A", "content": "ra"},
{"role": "tool", "tool_call_id": "call_B", "content": "rb"},
]
out = sanitize_api_messages(list(messages))
assistant = [m for m in out if m.get("role") == "assistant"][0]
assert [tc["id"] for tc in assistant["tool_calls"]] == ["call_A", "call_B"]
assert sorted(m["tool_call_id"] for m in out if m.get("role") == "tool") == ["call_A", "call_B"]
def test_sanitize_drops_empty_tool_calls_array():
"""sanitize_api_messages strips ``tool_calls: []`` from assistant messages.
DeepSeek v4 rejects an empty tool_calls array with HTTP 400 "Invalid
'messages[N].tool_calls': empty array" (#58755). The empty array is
semantically "no tool calls", so the key is dropped while content is
preserved.
"""
from agent.agent_runtime_helpers import sanitize_api_messages
messages = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "answer", "tool_calls": []},
]
out = sanitize_api_messages(list(messages))
assistant = [m for m in out if m.get("role") == "assistant"][0]
assert "tool_calls" not in assistant
assert assistant["content"] == "answer"
# ── Self-recovery: heal empty-content non-final messages ──────────────────
# Repro of the production incident: a dead stream persisted an empty-content
# assistant stub mid-transcript, and every later request 400'd with
# "all messages must have non-empty content except for the optional final
# assistant message" (INVALID_REQUEST_BODY). sanitize_api_messages now heals
# such turns on the per-call copy so the session recovers itself in memory.