1079 lines
38 KiB
Python
1079 lines
38 KiB
Python
"""Unit tests for the hermes-native session-store forwarder.
|
|
|
|
Builds a fixture SQLite store matching Hermes' ``state.db`` schema (``sessions``
|
|
with ``cwd`` + ``started_at`` and ``messages`` with a monotonic ``id`` cursor,
|
|
plain-text ``content``, and an ``active`` flag) and exercises discovery-by-cwd,
|
|
message decode, attachment stripping, role mapping, the claim guard, and the
|
|
idempotent high-water cursor.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from omnigent import hermes_native_forwarder as f
|
|
from omnigent import hermes_native_status as hstatus
|
|
|
|
_SCHEMA = """
|
|
CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY,
|
|
source TEXT NOT NULL,
|
|
cwd TEXT,
|
|
started_at REAL NOT NULL,
|
|
parent_session_id TEXT
|
|
);
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
content TEXT,
|
|
tool_call_id TEXT,
|
|
tool_calls TEXT,
|
|
tool_name TEXT,
|
|
active INTEGER NOT NULL DEFAULT 1,
|
|
compacted INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
"""
|
|
|
|
|
|
def _seed_db(path: Path, *, cwd: str, started_at: float, session_id: str = "20260620_1") -> None:
|
|
con = sqlite3.connect(path)
|
|
con.executescript(_SCHEMA)
|
|
con.execute(
|
|
"INSERT INTO sessions(id, source, cwd, started_at) VALUES (?,?,?,?)",
|
|
(session_id, "cli", cwd, started_at),
|
|
)
|
|
# (session_id, role, content, tool_call_id, tool_calls, tool_name, active)
|
|
rows = [
|
|
(session_id, "user", "hi [Attached: /x.png]", None, None, None, 1),
|
|
(session_id, "assistant", "hello", None, None, None, 1),
|
|
(session_id, "tool", "{tool-result}", None, None, None, 1), # no tool_call_id -> skipped
|
|
(session_id, "assistant", "", None, None, None, 1), # no prose, no tool_calls -> skipped
|
|
(session_id, "user", "soft-deleted", None, None, None, 0), # inactive -> skipped
|
|
]
|
|
con.executemany(
|
|
"INSERT INTO messages"
|
|
"(session_id, role, content, tool_call_id, tool_calls, tool_name, active)"
|
|
" VALUES (?,?,?,?,?,?,?)",
|
|
rows,
|
|
)
|
|
con.commit()
|
|
con.close()
|
|
|
|
|
|
def test_discover_session_id_by_cwd_and_floor(tmp_path: Path) -> None:
|
|
workspace = str(tmp_path)
|
|
db = tmp_path / "state.db"
|
|
_seed_db(db, cwd=workspace, started_at=1000.0)
|
|
# Launch floor before the session's started_at -> discovered.
|
|
assert f._discover_session_id(db, workspace, 1000.0) == "20260620_1"
|
|
# A floor far in the future (beyond skew) excludes it.
|
|
assert f._discover_session_id(db, workspace, 2000.0) is None
|
|
# A different workspace with no other candidates -> no match.
|
|
assert f._discover_session_id(db, "/some/other/dir", 1000.0) is None
|
|
|
|
|
|
def test_discover_lone_candidate_only_when_no_cwd_recorded(tmp_path: Path) -> None:
|
|
db = tmp_path / "state.db"
|
|
con = sqlite3.connect(db)
|
|
con.executescript(_SCHEMA)
|
|
# Hermes recorded no cwd (NULL) — bind the lone candidate past the floor.
|
|
con.execute(
|
|
"INSERT INTO sessions(id, source, cwd, started_at) VALUES (?,?,?,?)",
|
|
("S_nocwd", "cli", None, 1000.0),
|
|
)
|
|
con.commit()
|
|
con.close()
|
|
assert f._discover_session_id(db, "/whatever", 1000.0) == "S_nocwd"
|
|
|
|
|
|
def test_discover_skips_excluded_session(tmp_path: Path) -> None:
|
|
workspace = str(tmp_path)
|
|
db = tmp_path / "state.db"
|
|
_seed_db(db, cwd=workspace, started_at=1000.0)
|
|
assert (
|
|
f._discover_session_id(db, workspace, 1000.0, excluded=frozenset({"20260620_1"})) is None
|
|
)
|
|
|
|
|
|
def test_discover_child_session_returns_newest_child(tmp_path: Path) -> None:
|
|
"""After compaction Hermes forks a child via parent_session_id; pick the newest."""
|
|
db = tmp_path / "state.db"
|
|
con = sqlite3.connect(db)
|
|
con.executescript(_SCHEMA)
|
|
con.executemany(
|
|
"INSERT INTO sessions(id, source, cwd, started_at, parent_session_id) VALUES (?,?,?,?,?)",
|
|
[
|
|
("parent", "cli", "/w", 1000.0, None),
|
|
("child_old", "cli", "/w", 1005.0, "parent"),
|
|
("child_new", "cli", "/w", 1010.0, "parent"),
|
|
("unrelated", "cli", "/w", 1011.0, None),
|
|
],
|
|
)
|
|
con.commit()
|
|
con.close()
|
|
assert f._discover_child_session(db, "parent") == "child_new"
|
|
# No children -> None (forwarder stays pinned to the parent).
|
|
assert f._discover_child_session(db, "child_new") is None
|
|
|
|
|
|
def test_read_new_items_maps_roles_and_strips_attachments(tmp_path: Path) -> None:
|
|
db = tmp_path / "state.db"
|
|
_seed_db(db, cwd=str(tmp_path), started_at=1000.0)
|
|
items = f._read_new_items(db, "20260620_1", 0, "hermes-native-ui")
|
|
posted = [i for i in items if i.item_type]
|
|
assert len(posted) == 2 # user + assistant("hello"); tool/empty/inactive skipped
|
|
assert posted[0].item_data == {
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "hi"}], # attachment marker stripped
|
|
}
|
|
assert posted[1].item_data["role"] == "assistant"
|
|
assert posted[1].item_data["agent"] == "hermes-native-ui"
|
|
assert posted[1].item_data["content"] == [{"type": "output_text", "text": "hello"}]
|
|
|
|
|
|
def test_read_new_items_mirrors_tool_calls(tmp_path: Path) -> None:
|
|
"""Tool calls on assistant rows become function_call items; tool rows become outputs."""
|
|
db = tmp_path / "state.db"
|
|
con = sqlite3.connect(db)
|
|
con.executescript(_SCHEMA)
|
|
con.execute(
|
|
"INSERT INTO sessions(id, source, cwd, started_at) VALUES (?,?,?,?)",
|
|
("s1", "cli", str(tmp_path), 1000.0),
|
|
)
|
|
import json
|
|
|
|
tool_calls_json = json.dumps(
|
|
[
|
|
{
|
|
"id": "call_abc",
|
|
"call_id": "call_abc",
|
|
"type": "function",
|
|
"function": {"name": "search_files", "arguments": '{"pattern": "*"}'},
|
|
}
|
|
]
|
|
)
|
|
rows = [
|
|
("s1", "assistant", "", None, tool_calls_json, None, 1),
|
|
("s1", "tool", "found 3 files", "call_abc", None, "search_files", 1),
|
|
]
|
|
con.executemany(
|
|
"INSERT INTO messages"
|
|
"(session_id, role, content, tool_call_id, tool_calls, tool_name, active)"
|
|
" VALUES (?,?,?,?,?,?,?)",
|
|
rows,
|
|
)
|
|
con.commit()
|
|
con.close()
|
|
|
|
items = f._read_new_items(db, "s1", 0, "agent")
|
|
posted = [i for i in items if i.item_type]
|
|
assert len(posted) == 2
|
|
assert posted[0].item_type == "function_call"
|
|
assert posted[0].item_data["name"] == "search_files"
|
|
assert posted[0].item_data["call_id"] == "call_abc"
|
|
assert posted[1].item_type == "function_call_output"
|
|
assert posted[1].item_data["call_id"] == "call_abc"
|
|
assert posted[1].item_data["output"] == "found 3 files"
|
|
|
|
|
|
def test_read_new_items_idempotent_past_high_water(tmp_path: Path) -> None:
|
|
db = tmp_path / "state.db"
|
|
_seed_db(db, cwd=str(tmp_path), started_at=1000.0)
|
|
items = f._read_new_items(db, "20260620_1", 0, "hermes-native-ui")
|
|
max_id = max(i.msg_id for i in items)
|
|
assert f._read_new_items(db, "20260620_1", max_id, "hermes-native-ui") == []
|
|
|
|
|
|
def test_session_claimed_by_other_earlier_launch_wins(tmp_path: Path) -> None:
|
|
root = tmp_path / "hermes-native"
|
|
mine = root / "me"
|
|
other = root / "other"
|
|
mine.mkdir(parents=True)
|
|
other.mkdir(parents=True)
|
|
# A live sibling claims the same session id with an EARLIER launch -> it wins.
|
|
f._write_state(other, f._ForwardState(hermes_session_id="S1", last_id=0, launch_epoch_s=100.0))
|
|
assert f._session_claimed_by_other(mine, "S1", my_launch_s=200.0) is True
|
|
# A different session id is not a conflict.
|
|
assert f._session_claimed_by_other(mine, "S2", my_launch_s=200.0) is False
|
|
# If I launched earlier, I keep the row (sibling does not win).
|
|
assert f._session_claimed_by_other(mine, "S1", my_launch_s=50.0) is False
|
|
|
|
|
|
def test_state_roundtrip_and_clear(tmp_path: Path) -> None:
|
|
state = f._ForwardState(hermes_session_id="20260620_1", last_id=7, launch_epoch_s=12.5)
|
|
assert f._write_state(tmp_path, state) is True
|
|
loaded = f._read_state(tmp_path)
|
|
assert loaded.hermes_session_id == "20260620_1"
|
|
assert loaded.last_id == 7
|
|
assert loaded.launch_epoch_s == 12.5
|
|
f.clear_hermes_bridge_state(tmp_path)
|
|
assert f._read_state(tmp_path) == f._ForwardState()
|
|
|
|
|
|
def test_default_state_db_honors_overrides(monkeypatch) -> None:
|
|
monkeypatch.setenv("HERMES_STATE_DB", "/custom/state.db")
|
|
assert f.default_state_db() == Path("/custom/state.db")
|
|
monkeypatch.delenv("HERMES_STATE_DB", raising=False)
|
|
monkeypatch.setenv("HERMES_HOME", "/opt/hermes-home")
|
|
assert f.default_state_db() == Path("/opt/hermes-home/state.db")
|
|
monkeypatch.delenv("HERMES_HOME", raising=False)
|
|
assert f.default_state_db().name == "state.db"
|
|
|
|
|
|
# --- forwarder loop + POST plumbing -------------------------------------------
|
|
|
|
|
|
class _Resp:
|
|
def __init__(self, status: int = 200) -> None:
|
|
self.status_code = status
|
|
|
|
def raise_for_status(self) -> None:
|
|
if self.status_code >= 400:
|
|
raise RuntimeError(f"status {self.status_code}")
|
|
|
|
|
|
class _FakeClient:
|
|
def __init__(self) -> None:
|
|
self.posts: list[tuple[str, dict]] = []
|
|
self.patches: list[tuple[str, dict]] = []
|
|
|
|
async def post(self, url, json=None, **_kwargs):
|
|
self.posts.append((url, json or {}))
|
|
return _Resp()
|
|
|
|
async def patch(self, url, json=None, **_kwargs):
|
|
self.patches.append((url, json or {}))
|
|
return _Resp()
|
|
|
|
|
|
async def test_post_conversation_item_posts_event(tmp_path) -> None:
|
|
client = _FakeClient()
|
|
item = f._MirrorItem(
|
|
msg_id=5,
|
|
item_type="message",
|
|
item_data={"role": "user", "content": [{"type": "input_text", "text": "hi"}]},
|
|
response_id="hermes:5",
|
|
)
|
|
await f._post_conversation_item(client, session_id="conv_q", item=item)
|
|
url, body = client.posts[0]
|
|
assert url == "/v1/sessions/conv_q/events"
|
|
assert body["type"] == "external_conversation_item"
|
|
assert body["data"]["response_id"] == "hermes:5"
|
|
|
|
|
|
async def test_forward_loop_discovers_and_mirrors_new_messages(tmp_path, monkeypatch) -> None:
|
|
"""One forward iteration: discover the session by cwd+floor, mirror user+assistant."""
|
|
workspace = str(tmp_path)
|
|
db = tmp_path / "state.db"
|
|
_seed_db(db, cwd=workspace, started_at=1000.0)
|
|
|
|
posted: list[f._MirrorItem] = []
|
|
|
|
async def _fake_post(_client, *, session_id, item):
|
|
posted.append(item)
|
|
|
|
monkeypatch.setattr(f, "_post_conversation_item", _fake_post)
|
|
|
|
calls = {"n": 0}
|
|
|
|
async def _sleep(_s):
|
|
calls["n"] += 1
|
|
raise asyncio.CancelledError # stop after the first full iteration
|
|
|
|
monkeypatch.setattr(f.asyncio, "sleep", _sleep)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await f.forward_hermes_store_to_session(
|
|
base_url="http://x",
|
|
headers={},
|
|
session_id="conv_f",
|
|
bridge_dir=tmp_path,
|
|
agent_name="hermes-native-ui",
|
|
workspace=workspace,
|
|
launch_epoch_s=1000.0,
|
|
db_path=db,
|
|
)
|
|
# The seeded user + assistant("hello") rows mirrored (tool/empty/inactive skipped).
|
|
roles = [i.item_data.get("role") for i in posted]
|
|
assert roles == ["user", "assistant"]
|
|
# High-water cursor persisted so a restart resumes without re-posting.
|
|
assert f._read_state(tmp_path).hermes_session_id == "20260620_1"
|
|
|
|
|
|
async def test_forward_loop_patches_external_session_id_once(tmp_path, monkeypatch) -> None:
|
|
"""The forwarder PATCHes external_session_id when it first discovers the Hermes session.
|
|
|
|
Runs the full forward loop with all HTTP calls intercepted at the
|
|
``httpx.AsyncClient`` level (constructor replaced by a fake async-context-
|
|
manager). The first ``test_forward_loop_discovers_and_mirrors_new_messages``
|
|
test creates a *real* ``httpx.AsyncClient`` which can interfere with
|
|
class-level patches on subsequent tests, so we replace the constructor
|
|
entirely to stay fully in-process.
|
|
"""
|
|
workspace = str(tmp_path)
|
|
db = tmp_path / "state.db"
|
|
_seed_db(db, cwd=workspace, started_at=1000.0)
|
|
|
|
patched_calls: list[tuple[str, dict]] = []
|
|
|
|
async def _fake_post(_client, *, session_id, item):
|
|
pass # ignore mirrored items for this test
|
|
|
|
monkeypatch.setattr(f, "_post_conversation_item", _fake_post)
|
|
|
|
iteration = {"n": 0}
|
|
|
|
# Build a self-contained fake client + constructor so the forward loop
|
|
# never touches real httpx internals.
|
|
class _Client:
|
|
async def post(self, url, json=None, **_kw):
|
|
return _Resp()
|
|
|
|
async def patch(self, url, json=None, **_kw):
|
|
patched_calls.append((url, json or {}))
|
|
return _Resp()
|
|
|
|
import contextlib
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def _make_client(**_kw):
|
|
yield _Client()
|
|
|
|
# Patch the module attribute that ``forward_hermes_store_to_session`` reads
|
|
# at call time (``httpx.AsyncClient``). Using ``monkeypatch.setattr`` on
|
|
# the *module* object the forwarder imports (``f.httpx``) guarantees the
|
|
# right target and automatic undo.
|
|
monkeypatch.setattr(
|
|
f,
|
|
"httpx",
|
|
type(
|
|
"_httpx",
|
|
(),
|
|
{
|
|
"AsyncClient": _make_client,
|
|
"Timeout": lambda *a, **kw: None,
|
|
"Auth": None,
|
|
"HTTPError": Exception,
|
|
},
|
|
),
|
|
)
|
|
|
|
async def _sleep(_s):
|
|
iteration["n"] += 1
|
|
if iteration["n"] >= 3:
|
|
raise asyncio.CancelledError
|
|
|
|
monkeypatch.setattr(asyncio, "sleep", _sleep)
|
|
|
|
# Use a subdirectory for bridge_dir so the claim guard doesn't see
|
|
# sibling test directories (which may contain state from earlier tests
|
|
# that used the same hermes session id).
|
|
bridge_dir = tmp_path / "bridge"
|
|
bridge_dir.mkdir()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await f.forward_hermes_store_to_session(
|
|
base_url="http://test",
|
|
headers={},
|
|
session_id="conv_patch",
|
|
bridge_dir=bridge_dir,
|
|
agent_name="hermes-native-ui",
|
|
workspace=workspace,
|
|
launch_epoch_s=1000.0,
|
|
db_path=db,
|
|
)
|
|
|
|
# The PATCH should have been called exactly once even though we ran 3 iterations.
|
|
patch_calls = [(url, body) for url, body in patched_calls if "external_session_id" in body]
|
|
assert len(patch_calls) == 1
|
|
url, body = patch_calls[0]
|
|
assert url == "/v1/sessions/conv_patch"
|
|
assert body["external_session_id"] == "20260620_1"
|
|
|
|
|
|
async def test_forward_loop_repins_to_child_after_compaction(tmp_path, monkeypatch) -> None:
|
|
"""Compaction forks a child session; the forwarder re-pins and mirrors its messages.
|
|
|
|
Pre-pins the parent (so discovery is skipped), seeds a compacted parent plus a
|
|
child whose parent_session_id is the parent, then drives a bounded number of
|
|
poll cycles. The first cycle persists the compaction and re-pins to the child;
|
|
the next mirrors the child's messages and persists state under the child id.
|
|
"""
|
|
workspace = str(tmp_path)
|
|
db = tmp_path / "state.db"
|
|
con = sqlite3.connect(db)
|
|
con.executescript(_SCHEMA)
|
|
con.executemany(
|
|
"INSERT INTO sessions(id, source, cwd, started_at, parent_session_id) VALUES (?,?,?,?,?)",
|
|
[
|
|
("parent_1", "cli", workspace, 1000.0, None),
|
|
("child_1", "cli", workspace, 1005.0, "parent_1"),
|
|
],
|
|
)
|
|
con.executemany(
|
|
"INSERT INTO messages(session_id, role, content, active, compacted) VALUES (?,?,?,?,?)",
|
|
[
|
|
("parent_1", "assistant", "compacted summary", 1, 1), # parent has compaction
|
|
("child_1", "user", "child hi", 1, 0),
|
|
("child_1", "assistant", "child reply", 1, 0),
|
|
],
|
|
)
|
|
con.commit()
|
|
con.close()
|
|
|
|
bridge_dir = tmp_path / "bridge"
|
|
bridge_dir.mkdir()
|
|
# Pre-pin the parent so the loop tails it directly (last_id past the parent's
|
|
# only message so we don't mirror it; compaction triggers the re-pin).
|
|
f._write_state(bridge_dir, f._ForwardState(hermes_session_id="parent_1", last_id=1))
|
|
|
|
posted: list[f._MirrorItem] = []
|
|
|
|
async def _fake_post(_client, *, session_id, item):
|
|
posted.append(item)
|
|
|
|
monkeypatch.setattr(f, "_post_conversation_item", _fake_post)
|
|
monkeypatch.setattr(f, "_persist_hermes_compaction_item", lambda *a, **k: _noop())
|
|
|
|
iteration = {"n": 0}
|
|
|
|
async def _sleep(_s):
|
|
iteration["n"] += 1
|
|
if iteration["n"] >= 2:
|
|
raise asyncio.CancelledError
|
|
|
|
monkeypatch.setattr(f.asyncio, "sleep", _sleep)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await f.forward_hermes_store_to_session(
|
|
base_url="http://x",
|
|
headers={},
|
|
session_id="conv_child",
|
|
bridge_dir=bridge_dir,
|
|
agent_name="hermes-native-ui",
|
|
workspace=workspace,
|
|
launch_epoch_s=1000.0,
|
|
db_path=db,
|
|
)
|
|
|
|
# Re-pinned to the child and mirrored its messages.
|
|
roles = [i.item_data.get("role") for i in posted]
|
|
assert roles == ["user", "assistant"]
|
|
assert f._read_state(bridge_dir).hermes_session_id == "child_1"
|
|
|
|
|
|
async def _noop() -> None:
|
|
return None
|
|
|
|
|
|
async def test_forward_loop_rebases_idle_count_on_compaction_repin(tmp_path, monkeypatch) -> None:
|
|
"""Compaction re-pin rebases the idle posted-count to the child's count.
|
|
|
|
Regression: the completed-turn count is per hermes_session_id, but the idle
|
|
dedup baseline (posted_count) is per bridge dir. A parent that accrued N idle
|
|
posts leaves posted_count=N; the child's count restarts near 0, so without a
|
|
rebase the guard ``completed_turns > posted_count`` stays False until the
|
|
child exceeds N terminal turns — suppressing the child's early idle posts and
|
|
hanging the orchestrator. On re-pin the baseline must drop to the child's
|
|
current completed-turn count so the child's next completion still wakes the
|
|
parent.
|
|
"""
|
|
workspace = str(tmp_path)
|
|
db = tmp_path / "state.db"
|
|
con = sqlite3.connect(db)
|
|
con.executescript(_SCHEMA)
|
|
con.executemany(
|
|
"INSERT INTO sessions(id, source, cwd, started_at, parent_session_id) VALUES (?,?,?,?,?)",
|
|
[
|
|
("parent_1", "cli", workspace, 1000.0, None),
|
|
("child_1", "cli", workspace, 1005.0, "parent_1"),
|
|
],
|
|
)
|
|
con.executemany(
|
|
"INSERT INTO messages(session_id, role, content, tool_calls, active, compacted) "
|
|
"VALUES (?,?,?,?,?,?)",
|
|
[
|
|
# Parent: two completed turns (terminal assistant rows) + a compaction.
|
|
("parent_1", "assistant", "done 1", None, 1, 0),
|
|
("parent_1", "assistant", "done 2", None, 1, 0),
|
|
("parent_1", "assistant", "compacted summary", None, 1, 1),
|
|
# Child: one completed turn so far.
|
|
("child_1", "user", "child hi", None, 1, 0),
|
|
("child_1", "assistant", "child done", None, 1, 0),
|
|
],
|
|
)
|
|
con.commit()
|
|
con.close()
|
|
|
|
bridge_dir = tmp_path / "bridge"
|
|
bridge_dir.mkdir()
|
|
# Pin the parent; posted_count reflects the parent's two completed turns.
|
|
f._write_state(bridge_dir, f._ForwardState(hermes_session_id="parent_1", last_id=3))
|
|
hstatus.write_posted_count(bridge_dir, 2)
|
|
|
|
async def _fake_post(_client, *, session_id, item):
|
|
return None
|
|
|
|
monkeypatch.setattr(f, "_post_conversation_item", _fake_post)
|
|
monkeypatch.setattr(f, "_persist_hermes_compaction_item", lambda *a, **k: _noop())
|
|
|
|
idle_posts: list[str] = []
|
|
|
|
async def _fake_idle(_client, *, session_id, status):
|
|
idle_posts.append(status)
|
|
|
|
monkeypatch.setattr(f, "_post_external_session_status", _fake_idle)
|
|
|
|
iteration = {"n": 0}
|
|
|
|
async def _sleep(_s):
|
|
iteration["n"] += 1
|
|
if iteration["n"] >= 2:
|
|
raise asyncio.CancelledError
|
|
|
|
monkeypatch.setattr(f.asyncio, "sleep", _sleep)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await f.forward_hermes_store_to_session(
|
|
base_url="http://x",
|
|
headers={},
|
|
session_id="conv_child",
|
|
bridge_dir=bridge_dir,
|
|
agent_name="hermes-native-ui",
|
|
workspace=workspace,
|
|
launch_epoch_s=1000.0,
|
|
db_path=db,
|
|
)
|
|
|
|
# Re-pinned to the child, and the baseline dropped from the parent's 2 to the
|
|
# child's 1 completed turn — so the child's already-present completion is not
|
|
# suppressed. Without the rebase, posted_count would stay 2 and the guard
|
|
# (1 > 2) would suppress the child's idle.
|
|
assert f._read_state(bridge_dir).hermes_session_id == "child_1"
|
|
assert hstatus.read_posted_count(bridge_dir) == 1
|
|
assert idle_posts == [] # child's single completed turn == baseline, no double-post
|
|
|
|
|
|
# --- Usage tracker tests ---------------------------------------------------
|
|
|
|
|
|
async def test_usage_tracker_posts_model_on_first_flush(tmp_path, monkeypatch) -> None:
|
|
"""The tracker reads the model from the bridge config and posts it."""
|
|
# Write a per-session config with a model.
|
|
hermes_home = tmp_path / "hermes_home"
|
|
hermes_home.mkdir()
|
|
import yaml
|
|
|
|
(hermes_home / "config.yaml").write_text(yaml.dump({"model": "claude-sonnet-4-20250514"}))
|
|
|
|
client = _FakeClient()
|
|
tracker = f._HermesUsageTracker(client, "conv_usage", tmp_path)
|
|
await tracker.flush()
|
|
|
|
assert len(client.posts) == 1
|
|
url, body = client.posts[0]
|
|
assert url == "/v1/sessions/conv_usage/events"
|
|
assert body["type"] == "external_session_usage"
|
|
assert body["data"]["model"] == "claude-sonnet-4-20250514"
|
|
|
|
|
|
async def test_usage_tracker_deduplicates(tmp_path, monkeypatch) -> None:
|
|
"""Consecutive flushes with the same model do not re-post."""
|
|
hermes_home = tmp_path / "hermes_home"
|
|
hermes_home.mkdir()
|
|
import yaml
|
|
|
|
(hermes_home / "config.yaml").write_text(yaml.dump({"model": "gpt-4o"}))
|
|
|
|
client = _FakeClient()
|
|
tracker = f._HermesUsageTracker(client, "conv_dedup", tmp_path)
|
|
await tracker.flush()
|
|
await tracker.flush()
|
|
await tracker.flush()
|
|
|
|
assert len(client.posts) == 1 # only the first flush posts
|
|
|
|
|
|
async def test_usage_tracker_no_post_when_no_model(tmp_path) -> None:
|
|
"""No config / no model -> nothing posted."""
|
|
client = _FakeClient()
|
|
tracker = f._HermesUsageTracker(client, "conv_none", tmp_path)
|
|
await tracker.flush()
|
|
assert len(client.posts) == 0
|
|
|
|
|
|
async def test_read_model_from_hermes_config_fallback(tmp_path, monkeypatch) -> None:
|
|
"""Falls back to ~/.hermes/config.yaml when no per-session config exists."""
|
|
user_hermes = tmp_path / ".hermes"
|
|
user_hermes.mkdir()
|
|
import yaml
|
|
|
|
(user_hermes / "config.yaml").write_text(yaml.dump({"model": "from-user-config"}))
|
|
monkeypatch.setattr(f.Path, "home", staticmethod(lambda: tmp_path))
|
|
|
|
model = f._read_model_from_hermes_config(tmp_path / "nonexistent")
|
|
assert model == "from-user-config"
|
|
|
|
|
|
# --- Compaction persistence tests -------------------------------------------
|
|
|
|
_COMPACTION_SCHEMA = """
|
|
CREATE TABLE messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
content TEXT,
|
|
active INTEGER NOT NULL DEFAULT 1,
|
|
compacted INTEGER NOT NULL DEFAULT 0,
|
|
timestamp REAL,
|
|
tool_call_id TEXT,
|
|
tool_calls TEXT,
|
|
tool_name TEXT
|
|
);
|
|
"""
|
|
|
|
|
|
def _make_compaction_db(path: Path) -> None:
|
|
"""Create a messages-only DB with the compacted column."""
|
|
con = sqlite3.connect(path)
|
|
con.executescript(_COMPACTION_SCHEMA)
|
|
con.commit()
|
|
con.close()
|
|
|
|
|
|
def test_has_new_compaction_returns_true_when_compacted_rows_exist(tmp_path: Path) -> None:
|
|
db = tmp_path / "state.db"
|
|
_make_compaction_db(db)
|
|
con = sqlite3.connect(db)
|
|
con.execute(
|
|
"INSERT INTO messages(session_id, role, content, active, compacted)"
|
|
" VALUES (?, ?, ?, 1, 1)",
|
|
(
|
|
"hermes_sess",
|
|
"assistant",
|
|
"compacted summary",
|
|
),
|
|
)
|
|
con.commit()
|
|
con.close()
|
|
assert f._has_new_compaction(db, "hermes_sess") is True
|
|
|
|
|
|
def test_has_new_compaction_returns_false_when_no_compacted_rows(tmp_path: Path) -> None:
|
|
db = tmp_path / "state.db"
|
|
_make_compaction_db(db)
|
|
con = sqlite3.connect(db)
|
|
con.execute(
|
|
"INSERT INTO messages(session_id, role, content, active, compacted)"
|
|
" VALUES (?, ?, ?, 1, 0)",
|
|
("hermes_sess", "user", "hello"),
|
|
)
|
|
con.commit()
|
|
con.close()
|
|
assert f._has_new_compaction(db, "hermes_sess") is False
|
|
|
|
|
|
async def test_persist_hermes_compaction_item_posts_with_messages(tmp_path: Path) -> None:
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
db = tmp_path / "state.db"
|
|
_make_compaction_db(db)
|
|
con = sqlite3.connect(db)
|
|
con.executemany(
|
|
"INSERT INTO messages(session_id, role, content, active, compacted)"
|
|
" VALUES (?, ?, ?, ?, ?)",
|
|
[
|
|
("hermes_sess", "user", "please help", 1, 0),
|
|
("hermes_sess", "assistant", "sure thing", 1, 0),
|
|
],
|
|
)
|
|
con.commit()
|
|
con.close()
|
|
|
|
get_resp = MagicMock()
|
|
get_resp.raise_for_status = MagicMock()
|
|
get_resp.json = MagicMock(return_value={"data": [{"id": "item_hermes"}]})
|
|
|
|
post_resp = MagicMock()
|
|
post_resp.raise_for_status = MagicMock()
|
|
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(return_value=get_resp)
|
|
client.post = AsyncMock(return_value=post_resp)
|
|
|
|
await f._persist_hermes_compaction_item(
|
|
client,
|
|
session_id="conv_hermes",
|
|
db_path=db,
|
|
hermes_session_id="hermes_sess",
|
|
)
|
|
|
|
client.post.assert_called_once()
|
|
_url, kwargs = client.post.call_args
|
|
body = kwargs.get("json") or client.post.call_args[1]["json"]
|
|
assert body["type"] == "compaction"
|
|
assert body["data"]["last_item_id"] == "item_hermes"
|
|
assert len(body["data"]["compacted_messages"]) == 2
|
|
assert body["data"]["compacted_messages"][0]["role"] == "user"
|
|
assert body["data"]["compacted_messages"][1]["role"] == "assistant"
|
|
|
|
|
|
async def test_persist_hermes_compaction_item_empty_db(tmp_path: Path) -> None:
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
db = tmp_path / "state.db"
|
|
_make_compaction_db(db)
|
|
|
|
get_resp = MagicMock()
|
|
get_resp.raise_for_status = MagicMock()
|
|
get_resp.json = MagicMock(return_value={"data": []})
|
|
|
|
post_resp = MagicMock()
|
|
post_resp.raise_for_status = MagicMock()
|
|
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(return_value=get_resp)
|
|
client.post = AsyncMock(return_value=post_resp)
|
|
|
|
await f._persist_hermes_compaction_item(
|
|
client,
|
|
session_id="conv_hermes",
|
|
db_path=db,
|
|
hermes_session_id="hermes_sess",
|
|
)
|
|
|
|
client.post.assert_called_once()
|
|
_url, kwargs = client.post.call_args
|
|
body = kwargs.get("json") or client.post.call_args[1]["json"]
|
|
assert body["type"] == "compaction"
|
|
assert body["data"]["last_item_id"].startswith("compact_boundary_")
|
|
assert "compacted_messages" not in body["data"]
|
|
|
|
|
|
# --- turn-completion ("idle") parent-wake path --------------------------------
|
|
#
|
|
# Hermes has no per-turn stop hook (only a ``pre_tool_call`` policy hook), so the
|
|
# forwarder derives turn completion from the message log itself: an ``assistant``
|
|
# row with no ``tool_calls`` is the agentic loop's terminal step. It POSTs
|
|
# ``external_session_status: idle`` once per completed turn — the edge that wakes
|
|
# the parent orchestrator — deduped against a persisted posted-count.
|
|
|
|
|
|
def _seed_turns(path: Path, *, cwd: str, started_at: float, session_id: str, n_turns: int) -> None:
|
|
"""Seed *n_turns* completed turns: each is user + assistant(final, no tool_calls)."""
|
|
con = sqlite3.connect(path)
|
|
con.executescript(_SCHEMA)
|
|
con.execute(
|
|
"INSERT INTO sessions(id, source, cwd, started_at) VALUES (?,?,?,?)",
|
|
(session_id, "cli", cwd, started_at),
|
|
)
|
|
rows = []
|
|
for i in range(n_turns):
|
|
rows.append((session_id, "user", f"ask {i}", None, None, None, 1))
|
|
rows.append((session_id, "assistant", f"answer {i}", None, None, None, 1))
|
|
con.executemany(
|
|
"INSERT INTO messages"
|
|
"(session_id, role, content, tool_call_id, tool_calls, tool_name, active)"
|
|
" VALUES (?,?,?,?,?,?,?)",
|
|
rows,
|
|
)
|
|
con.commit()
|
|
con.close()
|
|
|
|
|
|
def test_assistant_row_has_tool_calls() -> None:
|
|
assert f._assistant_row_has_tool_calls(None) is False
|
|
assert f._assistant_row_has_tool_calls("") is False
|
|
assert f._assistant_row_has_tool_calls("[]") is False
|
|
assert f._assistant_row_has_tool_calls("not json") is False
|
|
assert f._assistant_row_has_tool_calls(json.dumps([{"id": "c1"}])) is True
|
|
|
|
|
|
def test_count_completed_turns_counts_no_tool_call_assistant_rows(tmp_path: Path) -> None:
|
|
"""A turn ends on an assistant row with no tool_calls; tool-call steps don't count."""
|
|
db = tmp_path / "state.db"
|
|
con = sqlite3.connect(db)
|
|
con.executescript(_SCHEMA)
|
|
con.execute(
|
|
"INSERT INTO sessions(id, source, cwd, started_at) VALUES (?,?,?,?)",
|
|
("s1", "cli", str(tmp_path), 1000.0),
|
|
)
|
|
tc = json.dumps([{"id": "c1", "call_id": "c1", "function": {"name": "f", "arguments": "{}"}}])
|
|
rows = [
|
|
("s1", "user", "go", None, None, None, 1),
|
|
("s1", "assistant", "", None, tc, None, 1), # tool-call step -> not terminal
|
|
("s1", "tool", "result", "c1", None, "f", 1),
|
|
("s1", "assistant", "final answer", None, None, None, 1), # terminal -> +1
|
|
]
|
|
con.executemany(
|
|
"INSERT INTO messages"
|
|
"(session_id, role, content, tool_call_id, tool_calls, tool_name, active)"
|
|
" VALUES (?,?,?,?,?,?,?)",
|
|
rows,
|
|
)
|
|
con.commit()
|
|
con.close()
|
|
assert f._count_completed_turns(db, "s1") == 1
|
|
|
|
|
|
def test_count_completed_turns_counts_regardless_of_active(tmp_path: Path) -> None:
|
|
"""Soft-deleted (compacted, active=0) terminal rows still count, keeping it monotonic."""
|
|
db = tmp_path / "state.db"
|
|
_seed_turns(db, cwd=str(tmp_path), started_at=1000.0, session_id="s1", n_turns=2)
|
|
con = sqlite3.connect(db)
|
|
con.execute("UPDATE messages SET active = 0 WHERE role = 'assistant'")
|
|
con.commit()
|
|
con.close()
|
|
assert f._count_completed_turns(db, "s1") == 2
|
|
|
|
|
|
def test_count_completed_turns_respects_max_id(tmp_path: Path) -> None:
|
|
"""Terminal rows above the mirrored high-water mark are not counted yet."""
|
|
db = tmp_path / "state.db"
|
|
# Rows: 1=user, 2=assistant(final), 3=user, 4=assistant(final).
|
|
_seed_turns(db, cwd=str(tmp_path), started_at=1000.0, session_id="s1", n_turns=2)
|
|
assert f._count_completed_turns(db, "s1", max_id=1) == 0
|
|
assert f._count_completed_turns(db, "s1", max_id=2) == 1
|
|
assert f._count_completed_turns(db, "s1", max_id=3) == 1
|
|
assert f._count_completed_turns(db, "s1", max_id=4) == 2
|
|
assert f._count_completed_turns(db, "s1") == 2
|
|
|
|
|
|
def test_hermes_status_posted_count_roundtrip_and_clear(tmp_path: Path) -> None:
|
|
bridge = tmp_path / "b"
|
|
assert hstatus.read_posted_count(bridge) == 0
|
|
hstatus.write_posted_count(bridge, 4)
|
|
assert hstatus.read_posted_count(bridge) == 4
|
|
hstatus.clear_hermes_status_state(bridge)
|
|
assert hstatus.read_posted_count(bridge) == 0
|
|
|
|
|
|
async def _run_hermes_loop(
|
|
monkeypatch,
|
|
*,
|
|
db: Path,
|
|
bridge_dir: Path,
|
|
workspace: str,
|
|
statuses: list[str],
|
|
stop_after_iterations: int,
|
|
) -> None:
|
|
"""Drive the real hermes forward loop with HTTP stubbed; record idle statuses."""
|
|
|
|
async def _noop_item(_client, *, session_id, item):
|
|
pass
|
|
|
|
async def _record_status(_client, *, session_id, status):
|
|
statuses.append(status)
|
|
|
|
monkeypatch.setattr(f, "_post_conversation_item", _noop_item)
|
|
monkeypatch.setattr(f, "_post_external_session_status", _record_status)
|
|
|
|
iteration = {"n": 0}
|
|
|
|
async def _sleep(_s):
|
|
iteration["n"] += 1
|
|
if iteration["n"] >= stop_after_iterations:
|
|
raise asyncio.CancelledError
|
|
|
|
monkeypatch.setattr(f.asyncio, "sleep", _sleep)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await f.forward_hermes_store_to_session(
|
|
base_url="http://x",
|
|
headers={},
|
|
session_id="conv_idle",
|
|
bridge_dir=bridge_dir,
|
|
agent_name="hermes-native-ui",
|
|
workspace=workspace,
|
|
launch_epoch_s=1000.0,
|
|
db_path=db,
|
|
)
|
|
|
|
|
|
async def test_forward_loop_posts_idle_once_per_turn(tmp_path, monkeypatch) -> None:
|
|
workspace = str(tmp_path)
|
|
db = tmp_path / "state.db"
|
|
_seed_turns(db, cwd=workspace, started_at=1000.0, session_id="s1", n_turns=1)
|
|
bridge_dir = tmp_path / "bridge"
|
|
bridge_dir.mkdir()
|
|
statuses: list[str] = []
|
|
await _run_hermes_loop(
|
|
monkeypatch,
|
|
db=db,
|
|
bridge_dir=bridge_dir,
|
|
workspace=workspace,
|
|
statuses=statuses,
|
|
stop_after_iterations=2, # discover+mirror+idle in iter 1, then stop
|
|
)
|
|
assert statuses == ["idle"]
|
|
assert hstatus.read_posted_count(bridge_dir) == 1
|
|
|
|
|
|
async def test_forward_loop_idle_restart_safe(tmp_path, monkeypatch) -> None:
|
|
"""A restart whose posted-count already covers the completed turn posts no idle."""
|
|
workspace = str(tmp_path)
|
|
db = tmp_path / "state.db"
|
|
_seed_turns(db, cwd=workspace, started_at=1000.0, session_id="s1", n_turns=1)
|
|
bridge_dir = tmp_path / "bridge"
|
|
bridge_dir.mkdir()
|
|
hstatus.write_posted_count(bridge_dir, 1) # already reported before the "restart"
|
|
statuses: list[str] = []
|
|
await _run_hermes_loop(
|
|
monkeypatch,
|
|
db=db,
|
|
bridge_dir=bridge_dir,
|
|
workspace=workspace,
|
|
statuses=statuses,
|
|
stop_after_iterations=3,
|
|
)
|
|
assert statuses == []
|
|
assert hstatus.read_posted_count(bridge_dir) == 1
|
|
|
|
|
|
async def test_forward_loop_idle_dedupes_and_posts_per_new_turn(tmp_path, monkeypatch) -> None:
|
|
"""No duplicate idle while quiescent; a turn that lands later posts exactly one more."""
|
|
workspace = str(tmp_path)
|
|
db = tmp_path / "state.db"
|
|
_seed_turns(db, cwd=workspace, started_at=1000.0, session_id="s1", n_turns=1)
|
|
bridge_dir = tmp_path / "bridge"
|
|
bridge_dir.mkdir()
|
|
statuses: list[str] = []
|
|
|
|
async def _noop_item(_client, *, session_id, item):
|
|
pass
|
|
|
|
async def _record_status(_client, *, session_id, status):
|
|
statuses.append(status)
|
|
|
|
monkeypatch.setattr(f, "_post_conversation_item", _noop_item)
|
|
monkeypatch.setattr(f, "_post_external_session_status", _record_status)
|
|
|
|
iteration = {"n": 0}
|
|
|
|
async def _sleep(_s):
|
|
iteration["n"] += 1
|
|
# After the first turn has been reported, append a second completed turn
|
|
# so the next poll observes a strictly higher count and posts once more.
|
|
if iteration["n"] == 3:
|
|
con = sqlite3.connect(db)
|
|
con.execute(
|
|
"INSERT INTO messages"
|
|
"(session_id, role, content, tool_call_id, tool_calls, tool_name, active)"
|
|
" VALUES (?,?,?,?,?,?,?)",
|
|
("s1", "assistant", "answer 2", None, None, None, 1),
|
|
)
|
|
con.commit()
|
|
con.close()
|
|
if iteration["n"] >= 6:
|
|
raise asyncio.CancelledError
|
|
|
|
monkeypatch.setattr(f.asyncio, "sleep", _sleep)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await f.forward_hermes_store_to_session(
|
|
base_url="http://x",
|
|
headers={},
|
|
session_id="conv_idle",
|
|
bridge_dir=bridge_dir,
|
|
agent_name="hermes-native-ui",
|
|
workspace=workspace,
|
|
launch_epoch_s=1000.0,
|
|
db_path=db,
|
|
)
|
|
# One idle for the seeded turn, one for the turn appended mid-run — never
|
|
# one-per-poll across the six iterations.
|
|
assert statuses == ["idle", "idle"]
|
|
assert hstatus.read_posted_count(bridge_dir) == 2
|
|
|
|
|
|
async def test_forward_loop_idle_waits_for_mid_delivery_final_message(
|
|
tmp_path, monkeypatch
|
|
) -> None:
|
|
"""A final assistant row landing while a poll's batch is still being
|
|
delivered must not ring idle until that row is itself mirrored — the idle
|
|
edge wakes the parent orchestrator, which then reads the transcript, so the
|
|
completion signal may never overtake the content it announces."""
|
|
workspace = str(tmp_path)
|
|
db = tmp_path / "state.db"
|
|
con = sqlite3.connect(db)
|
|
con.executescript(_SCHEMA)
|
|
con.execute(
|
|
"INSERT INTO sessions(id, source, cwd, started_at) VALUES (?,?,?,?)",
|
|
("s1", "cli", workspace, 1000.0),
|
|
)
|
|
tc = json.dumps([{"id": "c1", "call_id": "c1", "function": {"name": "f", "arguments": "{}"}}])
|
|
con.executemany(
|
|
"INSERT INTO messages"
|
|
"(session_id, role, content, tool_call_id, tool_calls, tool_name, active)"
|
|
" VALUES (?,?,?,?,?,?,?)",
|
|
[
|
|
("s1", "user", "go", None, None, None, 1),
|
|
("s1", "assistant", "", None, tc, None, 1), # mid-turn tool-call step
|
|
("s1", "tool", "result", "c1", None, "f", 1),
|
|
],
|
|
)
|
|
con.commit()
|
|
con.close()
|
|
bridge_dir = tmp_path / "bridge"
|
|
bridge_dir.mkdir()
|
|
|
|
# One ordered log for both channels so the content/signal order is provable.
|
|
events: list[tuple[str, object]] = []
|
|
landed = {"done": False}
|
|
|
|
async def _post_item(_client, *, session_id, item):
|
|
events.append(("item", item.msg_id))
|
|
if not landed["done"]:
|
|
# The turn's final assistant row lands while this batch is still
|
|
# being POSTed — after the mirror's read, before the idle check.
|
|
landed["done"] = True
|
|
con = sqlite3.connect(db)
|
|
con.execute(
|
|
"INSERT INTO messages"
|
|
"(session_id, role, content, tool_call_id, tool_calls, tool_name, active)"
|
|
" VALUES (?,?,?,?,?,?,?)",
|
|
("s1", "assistant", "final answer", None, None, None, 1),
|
|
)
|
|
con.commit()
|
|
con.close()
|
|
|
|
async def _record_status(_client, *, session_id, status):
|
|
events.append(("status", status))
|
|
|
|
monkeypatch.setattr(f, "_post_conversation_item", _post_item)
|
|
monkeypatch.setattr(f, "_post_external_session_status", _record_status)
|
|
|
|
iteration = {"n": 0}
|
|
|
|
async def _sleep(_s):
|
|
iteration["n"] += 1
|
|
if iteration["n"] >= 3:
|
|
raise asyncio.CancelledError
|
|
|
|
monkeypatch.setattr(f.asyncio, "sleep", _sleep)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await f.forward_hermes_store_to_session(
|
|
base_url="http://x",
|
|
headers={},
|
|
session_id="conv_idle",
|
|
bridge_dir=bridge_dir,
|
|
agent_name="hermes-native-ui",
|
|
workspace=workspace,
|
|
launch_epoch_s=1000.0,
|
|
db_path=db,
|
|
)
|
|
|
|
assert ("status", "idle") in events
|
|
assert ("item", 4) in events # the final answer row was mirrored
|
|
# The invariant under test: content first, completion signal second.
|
|
assert events.index(("item", 4)) < events.index(("status", "idle"))
|
|
assert events.count(("status", "idle")) == 1
|
|
assert hstatus.read_posted_count(bridge_dir) == 1
|