hermes-agent/tests/test_delegate_cascade_49148.py
JackJin 56255f83f7 fix(agent): stop delegate cascade from deleting the parent session
_collect_delegate_child_ids() walks the _delegate_from marker chain to
gather delegate subagents for cascade deletion, but started its visited
set empty. When the chain loops back onto a parent — a delegation cycle,
or a parent that is also another parent's delegate child when several ids
are deleted together — that parent was collected as one of its own
descendants and then permanently deleted, along with all of its messages,
by _delete_delegate_children().

Seed the visited set with the parent ids so they can never be re-collected,
and exclude them from the returned child set. Callers (delete_session,
bulk delete) remove the parents separately, so this only prevents the
unintended parent deletion; legitimate child collection is unchanged.

Add regression tests (in-memory sqlite) covering single/multi-level
delegate chains, the parent_session_id+marker branch, untagged children
(orphan-don't-delete contract), and the cycle case that previously leaked
the parent into the deletion set.

Fixes #49148
2026-06-21 12:09:16 -07:00

103 lines
4 KiB
Python

"""Regression tests for delegate-child cascade collection (#49148).
`_collect_delegate_child_ids` walks the ``_delegate_from`` marker chain to
find delegate subagents that should be cascade-deleted with their parent.
The parents themselves are deleted separately by the callers, so they must
never appear in the collected child set. A delegation cycle (or a parent
that is also another parent's delegate child) used to leak the parent into
the deletion set, permanently deleting the parent session and its messages.
"""
import json
import sqlite3
from hermes_state import _collect_delegate_child_ids, _delete_delegate_children
def _make_conn():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute(
"CREATE TABLE sessions ("
" id TEXT PRIMARY KEY,"
" parent_session_id TEXT,"
" model_config TEXT)"
)
conn.execute("CREATE TABLE messages (session_id TEXT)")
return conn
def _add_session(conn, sid, *, delegate_from=None, parent_session_id=None, messages=0):
model_config = json.dumps({"_delegate_from": delegate_from}) if delegate_from else None
conn.execute(
"INSERT INTO sessions (id, parent_session_id, model_config) VALUES (?, ?, ?)",
(sid, parent_session_id, model_config),
)
for _ in range(messages):
conn.execute("INSERT INTO messages (session_id) VALUES (?)", (sid,))
class TestCollectDelegateChildIds:
def test_collects_delegate_child_excludes_parent(self):
conn = _make_conn()
_add_session(conn, "P")
_add_session(conn, "C", delegate_from="P")
result = _collect_delegate_child_ids(conn, ["P"])
assert "C" in result
assert "P" not in result
def test_multilevel_chain_collects_all_descendants(self):
conn = _make_conn()
_add_session(conn, "O")
_add_session(conn, "A", delegate_from="O")
_add_session(conn, "B", delegate_from="A")
result = set(_collect_delegate_child_ids(conn, ["O"]))
assert result == {"A", "B"} # parent O excluded, both descendants in
def test_parent_session_id_branch_with_marker_collected(self):
# Second OR clause: parent_session_id match AND _delegate_from present.
conn = _make_conn()
_add_session(conn, "P")
_add_session(conn, "C", parent_session_id="P", delegate_from="something")
assert _collect_delegate_child_ids(conn, ["P"]) == ["C"]
def test_untagged_child_not_collected(self):
# No _delegate_from marker -> orphan-don't-delete contract.
conn = _make_conn()
_add_session(conn, "P")
_add_session(conn, "C", parent_session_id="P")
assert _collect_delegate_child_ids(conn, ["P"]) == []
def test_cycle_terminates_and_excludes_parent(self):
# The #49148 bug: A and B reference each other via _delegate_from.
# Collection must terminate and never return the seed parent A.
conn = _make_conn()
_add_session(conn, "A", delegate_from="B")
_add_session(conn, "B", delegate_from="A")
result = _collect_delegate_child_ids(conn, ["A"])
assert "A" not in result # parent never collected as its own child
assert result == ["B"]
class TestDeleteDelegateChildrenPreservesParent:
def test_cycle_does_not_delete_parent_or_its_messages(self):
conn = _make_conn()
_add_session(conn, "A", delegate_from="B", messages=3)
_add_session(conn, "B", delegate_from="A", messages=2)
removed = _delete_delegate_children(conn, ["A"])
assert "A" not in removed
# Parent A and its messages survive; only delegate child B is gone.
assert conn.execute("SELECT COUNT(*) FROM sessions WHERE id='A'").fetchone()[0] == 1
assert conn.execute("SELECT COUNT(*) FROM messages WHERE session_id='A'").fetchone()[0] == 3
assert conn.execute("SELECT COUNT(*) FROM sessions WHERE id='B'").fetchone()[0] == 0
assert conn.execute("SELECT COUNT(*) FROM messages WHERE session_id='B'").fetchone()[0] == 0