Files
omnigent-ai--omnigent/tests/test_hermes_native_forwarder.py
2026-07-13 13:12:00 +08:00

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