Hermes-agent
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
"""Tests for SessionDB.get_anchored_view — anchored window + session bookends.
|
||||
|
||||
Used by the discovery shape of session_search: an FTS5 match becomes the
|
||||
anchor, the call returns goal (bookend_start) + match (window) + resolution
|
||||
(bookend_end) in a single round trip, no LLM.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return SessionDB(tmp_path / "state.db")
|
||||
|
||||
|
||||
def _seed_long_session(db, sid="s1", n=30):
|
||||
"""Create a long session with alternating user/assistant prose. Returns ids ascending."""
|
||||
db.create_session(sid, source="cli")
|
||||
ids = []
|
||||
for i in range(n):
|
||||
role = "user" if i % 2 == 0 else "assistant"
|
||||
mid = db.append_message(sid, role=role, content=f"prose msg {i}")
|
||||
ids.append(mid)
|
||||
return ids
|
||||
|
||||
|
||||
class TestWindowAndBookendShape:
|
||||
def test_returns_window_with_bookend_start_and_end(self, db):
|
||||
ids = _seed_long_session(db, n=30)
|
||||
# Anchor mid-session
|
||||
anchor = ids[15]
|
||||
view = db.get_anchored_view("s1", anchor, window=3, bookend=3)
|
||||
assert len(view["window"]) == 7 # ±3 + anchor
|
||||
assert len(view["bookend_start"]) == 3
|
||||
assert len(view["bookend_end"]) == 3
|
||||
# bookend_start is the first 3 ids of the session
|
||||
assert [m["id"] for m in view["bookend_start"]] == ids[:3]
|
||||
# bookend_end is the last 3 ids of the session
|
||||
assert [m["id"] for m in view["bookend_end"]] == ids[-3:]
|
||||
|
||||
def test_window_anchor_marked_correctly(self, db):
|
||||
ids = _seed_long_session(db, n=20)
|
||||
anchor = ids[10]
|
||||
view = db.get_anchored_view("s1", anchor, window=2, bookend=3)
|
||||
# Anchor message is present in the window
|
||||
anchor_msgs = [m for m in view["window"] if m["id"] == anchor]
|
||||
assert len(anchor_msgs) == 1
|
||||
|
||||
|
||||
class TestBookendOverlap:
|
||||
"""Bookends shouldn't duplicate messages that are already in the window."""
|
||||
|
||||
def test_bookend_start_empty_when_window_covers_session_head(self, db):
|
||||
ids = _seed_long_session(db, n=10)
|
||||
# Anchor on msg 1 (id index 1), window=3 → covers ids[0..4]
|
||||
anchor = ids[1]
|
||||
view = db.get_anchored_view("s1", anchor, window=3, bookend=3)
|
||||
# Window includes session head, so bookend_start should be empty
|
||||
assert view["bookend_start"] == []
|
||||
# bookend_end is still populated
|
||||
assert len(view["bookend_end"]) > 0
|
||||
|
||||
def test_bookend_end_empty_when_window_covers_session_tail(self, db):
|
||||
ids = _seed_long_session(db, n=10)
|
||||
# Anchor on second-to-last
|
||||
anchor = ids[-2]
|
||||
view = db.get_anchored_view("s1", anchor, window=3, bookend=3)
|
||||
assert view["bookend_end"] == []
|
||||
assert len(view["bookend_start"]) > 0
|
||||
|
||||
def test_short_session_both_bookends_empty(self, db):
|
||||
ids = _seed_long_session(db, n=5)
|
||||
view = db.get_anchored_view("s1", ids[2], window=10, bookend=3)
|
||||
# Window covers entire session
|
||||
assert view["bookend_start"] == []
|
||||
assert view["bookend_end"] == []
|
||||
# And window has all 5 messages
|
||||
assert len(view["window"]) == 5
|
||||
|
||||
|
||||
class TestRoleFiltering:
|
||||
def test_tool_role_filtered_from_window(self, db):
|
||||
db.create_session("s1", source="cli")
|
||||
user_ids = []
|
||||
for i in range(5):
|
||||
user_ids.append(db.append_message("s1", role="user", content=f"u{i}"))
|
||||
db.append_message("s1", role="tool", content=f"tool output {i}", tool_name="x")
|
||||
# Anchor on user message
|
||||
view = db.get_anchored_view("s1", user_ids[2], window=5, bookend=0)
|
||||
# No tool messages should appear in the window
|
||||
roles = [m.get("role") for m in view["window"]]
|
||||
assert "tool" not in roles
|
||||
|
||||
def test_anchor_preserved_even_when_tool_role(self, db):
|
||||
db.create_session("s1", source="cli")
|
||||
db.append_message("s1", role="user", content="ask")
|
||||
tool_id = db.append_message("s1", role="tool", content="tool output", tool_name="x")
|
||||
db.append_message("s1", role="user", content="follow-up")
|
||||
# Anchor on the tool message — should still appear despite default filter
|
||||
view = db.get_anchored_view("s1", tool_id, window=5, bookend=0)
|
||||
ids_in_window = [m["id"] for m in view["window"]]
|
||||
assert tool_id in ids_in_window
|
||||
|
||||
def test_keep_roles_none_disables_filter(self, db):
|
||||
db.create_session("s1", source="cli")
|
||||
anchor_id = db.append_message("s1", role="user", content="ask")
|
||||
db.append_message("s1", role="tool", content="output", tool_name="x")
|
||||
view = db.get_anchored_view("s1", anchor_id, window=5, bookend=0, keep_roles=None)
|
||||
roles = [m.get("role") for m in view["window"]]
|
||||
assert "tool" in roles
|
||||
|
||||
|
||||
class TestEmptyContentFilter:
|
||||
"""Tool-call-only assistant turns (empty content) should be skipped in bookends."""
|
||||
|
||||
def test_empty_content_messages_excluded_from_bookends(self, db):
|
||||
db.create_session("s1", source="cli")
|
||||
# Real prose opener
|
||||
opener = db.append_message("s1", role="user", content="Let's start the work")
|
||||
# Empty content assistant turn (tool-call-only — common in agent loops)
|
||||
db.append_message("s1", role="assistant", content="", tool_calls=[{"id": "t1", "function": {"name": "x", "arguments": "{}"}}])
|
||||
# More prose
|
||||
for i in range(20):
|
||||
db.append_message("s1", role="user" if i % 2 == 0 else "assistant", content=f"prose {i}")
|
||||
# Another empty assistant near the end
|
||||
db.append_message("s1", role="assistant", content="", tool_calls=[{"id": "t2", "function": {"name": "y", "arguments": "{}"}}])
|
||||
# Prose closer
|
||||
closer = db.append_message("s1", role="assistant", content="Final decision: ship it.")
|
||||
|
||||
# Anchor mid-session
|
||||
view = db.get_anchored_view("s1", opener + 15, window=2, bookend=3)
|
||||
# Bookend_start should not contain the empty-content tool-call turn
|
||||
for m in view["bookend_start"]:
|
||||
assert m.get("content"), "bookend_start should skip empty-content messages"
|
||||
# Bookend_end should include the closer
|
||||
end_contents = [m.get("content") for m in view["bookend_end"]]
|
||||
assert any("Final decision" in (c or "") for c in end_contents)
|
||||
|
||||
|
||||
class TestAnchorValidation:
|
||||
def test_missing_anchor_returns_empty_view(self, db):
|
||||
_seed_long_session(db, n=10)
|
||||
view = db.get_anchored_view("s1", 999999, window=5, bookend=3)
|
||||
assert view["window"] == []
|
||||
assert view["bookend_start"] == []
|
||||
assert view["bookend_end"] == []
|
||||
assert view["messages_before"] == 0
|
||||
assert view["messages_after"] == 0
|
||||
|
||||
|
||||
class TestSessionIsolation:
|
||||
"""Bookends must not cross session boundaries."""
|
||||
|
||||
def test_bookends_only_from_anchor_session(self, db):
|
||||
ids1 = _seed_long_session(db, sid="s1", n=20)
|
||||
_seed_long_session(db, sid="s2", n=20)
|
||||
view = db.get_anchored_view("s1", ids1[10], window=2, bookend=3)
|
||||
# All bookend messages should have session_id = s1 (or session_id col)
|
||||
for m in view["bookend_start"] + view["bookend_end"]:
|
||||
assert m.get("session_id") == "s1"
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Tests for SessionDB.get_messages_around (anchored-window primitive).
|
||||
|
||||
Used by session_search both for the discovery shape (FTS5 match as anchor)
|
||||
and the scroll shape (user-supplied anchor). Returns a window of messages
|
||||
around the anchor plus before/after counts so callers can detect session
|
||||
boundaries.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return SessionDB(tmp_path / "state.db")
|
||||
|
||||
|
||||
def _seed(db, sid="s1", n=10):
|
||||
"""Create session with n alternating user/assistant messages, return ids ascending."""
|
||||
db.create_session(sid, source="cli")
|
||||
ids = []
|
||||
for i in range(n):
|
||||
role = "user" if i % 2 == 0 else "assistant"
|
||||
# append_message returns the new id
|
||||
mid = db.append_message(sid, role=role, content=f"msg {i}")
|
||||
ids.append(mid)
|
||||
return ids
|
||||
|
||||
|
||||
class TestBasicWindow:
|
||||
def test_returns_window_around_anchor(self, db):
|
||||
ids = _seed(db, n=10)
|
||||
anchor = ids[5]
|
||||
view = db.get_messages_around("s1", anchor, window=2)
|
||||
# Expected: 2 before + anchor + 2 after = 5 messages
|
||||
msgs = view["window"]
|
||||
assert len(msgs) == 5
|
||||
assert [m["id"] for m in msgs] == [ids[3], ids[4], ids[5], ids[6], ids[7]]
|
||||
assert view["messages_before"] == 2
|
||||
assert view["messages_after"] == 2
|
||||
|
||||
def test_window_zero_returns_only_anchor(self, db):
|
||||
ids = _seed(db, n=5)
|
||||
view = db.get_messages_around("s1", ids[2], window=0)
|
||||
assert len(view["window"]) == 1
|
||||
assert view["window"][0]["id"] == ids[2]
|
||||
assert view["messages_before"] == 0
|
||||
assert view["messages_after"] == 0
|
||||
|
||||
def test_negative_window_clamps_to_zero(self, db):
|
||||
ids = _seed(db, n=5)
|
||||
view = db.get_messages_around("s1", ids[2], window=-3)
|
||||
# Just anchor, like window=0
|
||||
assert len(view["window"]) == 1
|
||||
assert view["window"][0]["id"] == ids[2]
|
||||
|
||||
|
||||
class TestBoundaryDetection:
|
||||
"""messages_before / messages_after tell the agent it's at start/end."""
|
||||
|
||||
def test_at_session_start_messages_before_is_short(self, db):
|
||||
ids = _seed(db, n=10)
|
||||
# Anchor on first message; ask for window=5
|
||||
view = db.get_messages_around("s1", ids[0], window=5)
|
||||
assert view["messages_before"] == 0 # nothing before the first msg
|
||||
assert view["messages_after"] == 5
|
||||
# window contains anchor + 5 after = 6 messages
|
||||
assert len(view["window"]) == 6
|
||||
|
||||
def test_at_session_end_messages_after_is_short(self, db):
|
||||
ids = _seed(db, n=10)
|
||||
view = db.get_messages_around("s1", ids[-1], window=5)
|
||||
assert view["messages_before"] == 5
|
||||
assert view["messages_after"] == 0
|
||||
assert len(view["window"]) == 6
|
||||
|
||||
def test_window_larger_than_session(self, db):
|
||||
ids = _seed(db, n=3)
|
||||
view = db.get_messages_around("s1", ids[1], window=50)
|
||||
# All 3 messages return, both boundaries hit
|
||||
assert len(view["window"]) == 3
|
||||
assert view["messages_before"] == 1
|
||||
assert view["messages_after"] == 1
|
||||
|
||||
|
||||
class TestAnchorValidation:
|
||||
def test_missing_anchor_returns_empty(self, db):
|
||||
_seed(db, n=5)
|
||||
view = db.get_messages_around("s1", 99999, window=5)
|
||||
assert view["window"] == []
|
||||
assert view["messages_before"] == 0
|
||||
assert view["messages_after"] == 0
|
||||
|
||||
def test_anchor_in_different_session_returns_empty(self, db):
|
||||
# Two sessions, ask for s1's anchor in s2's namespace
|
||||
ids1 = _seed(db, sid="s1", n=5)
|
||||
_seed(db, sid="s2", n=5)
|
||||
view = db.get_messages_around("s2", ids1[2], window=2)
|
||||
assert view["window"] == []
|
||||
|
||||
|
||||
class TestScrollPattern:
|
||||
"""The forward/backward scroll loop the agent will run."""
|
||||
|
||||
def test_scroll_forward_re_anchored_on_last_id(self, db):
|
||||
ids = _seed(db, n=20)
|
||||
anchor = ids[5]
|
||||
v1 = db.get_messages_around("s1", anchor, window=3)
|
||||
last_id = v1["window"][-1]["id"]
|
||||
v2 = db.get_messages_around("s1", last_id, window=3)
|
||||
# Boundary id (last_id) appears in both windows (in v2 it's the anchor)
|
||||
assert last_id in [m["id"] for m in v1["window"]]
|
||||
assert last_id in [m["id"] for m in v2["window"]]
|
||||
# v2's window extends beyond v1
|
||||
assert max(m["id"] for m in v2["window"]) > max(m["id"] for m in v1["window"])
|
||||
|
||||
def test_scroll_backward_re_anchored_on_first_id(self, db):
|
||||
ids = _seed(db, n=20)
|
||||
anchor = ids[10]
|
||||
v1 = db.get_messages_around("s1", anchor, window=3)
|
||||
first_id = v1["window"][0]["id"]
|
||||
v2 = db.get_messages_around("s1", first_id, window=3)
|
||||
assert first_id in [m["id"] for m in v1["window"]]
|
||||
assert first_id in [m["id"] for m in v2["window"]]
|
||||
assert min(m["id"] for m in v2["window"]) < min(m["id"] for m in v1["window"])
|
||||
|
||||
|
||||
class TestContentHydration:
|
||||
def test_content_is_decoded(self, db):
|
||||
ids = _seed(db, n=3)
|
||||
view = db.get_messages_around("s1", ids[1], window=1)
|
||||
for m in view["window"]:
|
||||
assert isinstance(m.get("content"), str)
|
||||
assert m["content"].startswith("msg ")
|
||||
|
||||
def test_tool_calls_deserialized(self, db):
|
||||
db.create_session("s1", source="cli")
|
||||
# Message with tool_calls (pass list — append_message JSON-encodes it)
|
||||
tc_payload = [{"id": "t1", "function": {"name": "x", "arguments": "{}"}}]
|
||||
db.append_message("s1", role="assistant", content="", tool_calls=tc_payload)
|
||||
mid = db.append_message("s1", role="tool", content="result", tool_name="x")
|
||||
|
||||
view = db.get_messages_around("s1", mid, window=2)
|
||||
# Find the assistant message with tool_calls
|
||||
asst = [m for m in view["window"] if m.get("role") == "assistant"]
|
||||
assert asst, "expected an assistant message"
|
||||
# tool_calls should be a list after hydration, not a string
|
||||
assert isinstance(asst[0].get("tool_calls"), list)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Regression guard for #15000: --resume <id> after compression loses messages.
|
||||
|
||||
Context compression ends the current session and forks a new child session
|
||||
(linked by ``parent_session_id``). The SQLite flush cursor is reset, so
|
||||
only the latest descendant ends up with rows in the ``messages`` table —
|
||||
the parent row has ``message_count = 0``. ``hermes --resume <parent_id>``
|
||||
used to load zero rows and show a blank chat.
|
||||
|
||||
``SessionDB.resolve_resume_session_id()`` walks the parent → child chain
|
||||
and redirects to the first descendant that actually has messages. These
|
||||
tests pin that behaviour.
|
||||
"""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
return SessionDB(tmp_path / "state.db")
|
||||
|
||||
|
||||
def _make_chain(db: SessionDB, ids_with_parent):
|
||||
"""Create sessions in order, forcing started_at so ordering is deterministic."""
|
||||
base = int(time.time()) - 10_000
|
||||
for i, (sid, parent) in enumerate(ids_with_parent):
|
||||
db.create_session(sid, source="cli", parent_session_id=parent)
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ? WHERE id = ?",
|
||||
(base + i * 100, sid),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def test_redirects_from_empty_head_to_descendant_with_messages(db):
|
||||
# Reproducer shape from #15000: 6 sessions, only the 5th holds messages.
|
||||
_make_chain(db, [
|
||||
("head", None),
|
||||
("mid1", "head"),
|
||||
("mid2", "mid1"),
|
||||
("mid3", "mid2"),
|
||||
("bulk", "mid3"), # has messages
|
||||
("tail", "bulk"), # empty tail after another compression
|
||||
])
|
||||
for i in range(5):
|
||||
db.append_message("bulk", role="user", content=f"msg {i}")
|
||||
|
||||
assert db.resolve_resume_session_id("head") == "bulk"
|
||||
|
||||
|
||||
def test_returns_self_when_session_has_messages(db):
|
||||
_make_chain(db, [("root", None), ("child", "root")])
|
||||
db.append_message("root", role="user", content="hi")
|
||||
assert db.resolve_resume_session_id("root") == "root"
|
||||
|
||||
|
||||
def test_returns_self_when_no_descendant_has_messages(db):
|
||||
_make_chain(db, [("root", None), ("child1", "root"), ("child2", "child1")])
|
||||
assert db.resolve_resume_session_id("root") == "root"
|
||||
|
||||
|
||||
def test_returns_self_for_isolated_session(db):
|
||||
db.create_session("isolated", source="cli")
|
||||
assert db.resolve_resume_session_id("isolated") == "isolated"
|
||||
|
||||
|
||||
def test_returns_self_for_nonexistent_session(db):
|
||||
assert db.resolve_resume_session_id("does_not_exist") == "does_not_exist"
|
||||
|
||||
|
||||
def test_empty_session_id_passthrough(db):
|
||||
assert db.resolve_resume_session_id("") == ""
|
||||
assert db.resolve_resume_session_id(None) is None
|
||||
|
||||
|
||||
def test_walks_from_middle_of_chain(db):
|
||||
# If the user happens to know an intermediate ID, we still find the msg-bearing descendant.
|
||||
_make_chain(db, [("a", None), ("b", "a"), ("c", "b"), ("d", "c")])
|
||||
db.append_message("d", role="user", content="x")
|
||||
assert db.resolve_resume_session_id("b") == "d"
|
||||
assert db.resolve_resume_session_id("c") == "d"
|
||||
|
||||
|
||||
def test_prefers_most_recent_child_when_fork_exists(db):
|
||||
# If a session was somehow forked (two children), pick the latest one.
|
||||
# In practice, compression only produces single-chain shape, but the helper
|
||||
# should degrade gracefully.
|
||||
_make_chain(db, [
|
||||
("parent", None),
|
||||
("older_fork", "parent"),
|
||||
("newer_fork", "parent"),
|
||||
])
|
||||
db.append_message("newer_fork", role="user", content="x")
|
||||
assert db.resolve_resume_session_id("parent") == "newer_fork"
|
||||
@@ -0,0 +1,51 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
database = SessionDB(tmp_path / "state.db")
|
||||
try:
|
||||
yield database
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
def _compression_pair(db: SessionDB):
|
||||
base = time.time() - 100
|
||||
db.create_session("root", source="cli")
|
||||
db.create_session("tip", source="cli", parent_session_id="root")
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ?, ended_at = ?, end_reason = 'compression', message_count = 1 WHERE id = 'root'",
|
||||
(base, base + 10),
|
||||
)
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET started_at = ?, message_count = 1 WHERE id = 'tip'",
|
||||
(base + 20,),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
def test_archiving_compression_tip_archives_projected_root(db):
|
||||
_compression_pair(db)
|
||||
|
||||
assert db.set_session_archived("tip", True) is True
|
||||
|
||||
assert db.get_session("root")["archived"] == 1
|
||||
assert db.get_session("tip")["archived"] == 1
|
||||
assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True)] == []
|
||||
assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True, archived_only=True)] == ["tip"]
|
||||
|
||||
|
||||
def test_unarchiving_compression_tip_unarchives_projected_root(db):
|
||||
_compression_pair(db)
|
||||
db.set_session_archived("tip", True)
|
||||
|
||||
assert db.set_session_archived("tip", False) is True
|
||||
|
||||
assert db.get_session("root")["archived"] == 0
|
||||
assert db.get_session("tip")["archived"] == 0
|
||||
assert [s["id"] for s in db.list_sessions_rich(order_by_last_active=True)] == ["tip"]
|
||||
Reference in New Issue
Block a user