483 lines
19 KiB
Python
483 lines
19 KiB
Python
"""Unit tests for the hermes-native tmux bridge (no real tmux needed)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
import sys
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from omnigent import hermes_native_bridge as b
|
|
|
|
|
|
def test_bridge_dir_is_per_session_and_under_root() -> None:
|
|
d1 = b.bridge_dir_for_session_id("conv_a")
|
|
d2 = b.bridge_dir_for_session_id("conv_b")
|
|
assert d1 != d2
|
|
assert d1.parent == b.bridge_root()
|
|
# Deterministic for the same session id.
|
|
assert d1 == b.bridge_dir_for_session_id("conv_a")
|
|
|
|
|
|
def test_build_spawn_env_publishes_bridge_dir(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setattr(b, "_BRIDGE_ROOT", tmp_path / "hermes-native")
|
|
env = b.build_hermes_native_spawn_env("conv_x")
|
|
assert env[b.BRIDGE_DIR_ENV_VAR] == str(b.bridge_dir_for_session_id("conv_x"))
|
|
# The dir is created so the executor can read the advertised target.
|
|
assert Path(env[b.BRIDGE_DIR_ENV_VAR]).is_dir()
|
|
|
|
|
|
def test_write_then_read_tmux_target_roundtrip(tmp_path) -> None:
|
|
b.write_tmux_target(tmp_path, socket_path=Path("/tmp/sock"), tmux_target="sess:0.0", pid=42)
|
|
info = b.read_tmux_info(tmp_path)
|
|
assert info == {"socket_path": "/tmp/sock", "tmux_target": "sess:0.0"}
|
|
|
|
|
|
def test_read_tmux_info_missing_and_malformed(tmp_path) -> None:
|
|
assert b.read_tmux_info(tmp_path) is None # no tmux.json
|
|
(tmp_path / "tmux.json").write_text("not json", encoding="utf-8")
|
|
assert b.read_tmux_info(tmp_path) is None
|
|
(tmp_path / "tmux.json").write_text(json.dumps({"socket_path": ""}), encoding="utf-8")
|
|
assert b.read_tmux_info(tmp_path) is None # incomplete
|
|
|
|
|
|
def test_paste_payload_bytes_normalizes() -> None:
|
|
out = b._paste_payload_bytes("a\r\nb\tc\x1b\n")
|
|
# \r\n and \n → CR (0x0D); tab kept; ESC (control) dropped.
|
|
assert out == b"a\rb\tc\r"
|
|
|
|
|
|
def test_submit_needle_prefers_last_qualifying_line() -> None:
|
|
assert b._submit_needle("hi\nthere is a longer tail line") == "there is a longer tail l"[:24]
|
|
# Too-short content yields no needle (blind-submit path).
|
|
assert b._submit_needle("ok") == ""
|
|
|
|
|
|
def _patch_inject_tmux(monkeypatch, calls: list[tuple[str, ...]]) -> None:
|
|
"""Common monkeypatches: live pane, instant settle, capture shows needle."""
|
|
monkeypatch.setattr(
|
|
b, "_wait_for_tmux_info", lambda *_a, **_k: {"socket_path": "/s", "tmux_target": "t"}
|
|
)
|
|
monkeypatch.setattr(b, "_session_alive", lambda *_a, **_k: True)
|
|
monkeypatch.setattr(b, "_settle_pane", lambda *_a, **_k: None)
|
|
# Pane already shows the needle so the commit-wait returns immediately.
|
|
monkeypatch.setattr(b, "_capture_pane", lambda *_a, **_k: "do something now")
|
|
monkeypatch.setattr(b.time, "sleep", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(b, "_run_tmux", lambda _sock, *args: calls.append(args))
|
|
|
|
|
|
def test_inject_user_message_clears_pastes_and_submits(tmp_path, monkeypatch) -> None:
|
|
calls: list[tuple[str, ...]] = []
|
|
_patch_inject_tmux(monkeypatch, calls)
|
|
# No readable store → best-effort single delivery (no confirmation loop).
|
|
monkeypatch.setattr(b, "_state_db_path", lambda _bd: None)
|
|
|
|
b.inject_user_message(tmp_path, content="do something now")
|
|
|
|
flat = [a[0] for a in calls]
|
|
# Draft cleared (C-a, C-k), buffer loaded + pasted, then a single Enter.
|
|
assert "send-keys" in flat and "load-buffer" in flat and "paste-buffer" in flat
|
|
assert calls[0] == ("send-keys", "-t", "t", "C-a")
|
|
assert calls[1] == ("send-keys", "-t", "t", "C-k")
|
|
assert calls[-1] == ("send-keys", "-t", "t", "Enter")
|
|
# The temp paste file is cleaned up.
|
|
assert not list(tmp_path.glob("paste_*.bin"))
|
|
# Exactly one delivery: one load-buffer, one Enter.
|
|
assert sum(1 for a in calls if a[0] == "load-buffer") == 1
|
|
assert sum(1 for a in calls if a[-1] == "Enter") == 1
|
|
|
|
|
|
def test_inject_user_message_single_delivery_when_store_confirms(tmp_path, monkeypatch) -> None:
|
|
"""Store row appears after the first delivery → no retry."""
|
|
calls: list[tuple[str, ...]] = []
|
|
_patch_inject_tmux(monkeypatch, calls)
|
|
monkeypatch.setattr(b, "_state_db_path", lambda _bd: tmp_path / "state.db")
|
|
# Baseline 0, then a new row (id=1) confirms delivery on the first check.
|
|
ids = iter([0, 1])
|
|
monkeypatch.setattr(b, "_max_message_id", lambda _p: next(ids))
|
|
|
|
b.inject_user_message(tmp_path, content="do something now")
|
|
|
|
assert sum(1 for a in calls if a[0] == "load-buffer") == 1, "no retry when confirmed"
|
|
assert sum(1 for a in calls if a[-1] == "Enter") == 1
|
|
|
|
|
|
def test_inject_user_message_retries_once_when_first_not_confirmed(tmp_path, monkeypatch) -> None:
|
|
"""No new store row after delivery #1 → re-deliver once, then confirm."""
|
|
calls: list[tuple[str, ...]] = []
|
|
settle_calls: list[tuple] = []
|
|
_patch_inject_tmux(monkeypatch, calls)
|
|
monkeypatch.setattr(b, "_settle_pane", lambda *a, **k: settle_calls.append((a, k)))
|
|
monkeypatch.setattr(b, "_DELIVERY_CONFIRM_TIMEOUT_S", 0.0) # first confirm fails fast
|
|
monkeypatch.setattr(b, "_state_db_path", lambda _bd: tmp_path / "state.db")
|
|
# baseline=0 → confirm#1 sees 0 (fail) → confirm#2 sees 1 (success after retry).
|
|
seq = iter([0, 0, 1])
|
|
monkeypatch.setattr(b, "_max_message_id", lambda _p: next(seq))
|
|
|
|
b.inject_user_message(tmp_path, content="do something now")
|
|
|
|
# Two deliveries: two settles, two pastes, two Enters (one per attempt).
|
|
assert len(settle_calls) == 2
|
|
assert sum(1 for a in calls if a[0] == "load-buffer") == 2
|
|
assert sum(1 for a in calls if a[-1] == "Enter") == 2
|
|
|
|
|
|
def test_inject_user_message_raises_when_never_confirmed(tmp_path, monkeypatch) -> None:
|
|
"""Store row never appears after two deliveries → raise (FIFO-safe failure)."""
|
|
calls: list[tuple[str, ...]] = []
|
|
_patch_inject_tmux(monkeypatch, calls)
|
|
monkeypatch.setattr(b, "_DELIVERY_CONFIRM_TIMEOUT_S", 0.0)
|
|
monkeypatch.setattr(b, "_state_db_path", lambda _bd: tmp_path / "state.db")
|
|
monkeypatch.setattr(b, "_max_message_id", lambda _p: 0) # never advances
|
|
|
|
with pytest.raises(RuntimeError, match="did not accept"):
|
|
b.inject_user_message(tmp_path, content="do something now")
|
|
|
|
# Both attempts ran (two pastes) before giving up.
|
|
assert sum(1 for a in calls if a[0] == "load-buffer") == 2
|
|
|
|
|
|
def test_inject_user_message_requires_content(tmp_path) -> None:
|
|
with pytest.raises(RuntimeError):
|
|
b.inject_user_message(tmp_path, content="")
|
|
|
|
|
|
def test_state_db_path_prefers_per_session_home(tmp_path, monkeypatch) -> None:
|
|
# No per-session home, no $HERMES_HOME, no ~/.hermes → None.
|
|
monkeypatch.delenv("HERMES_HOME", raising=False)
|
|
monkeypatch.setattr(b.Path, "home", staticmethod(lambda: tmp_path / "nohome"))
|
|
assert b._state_db_path(tmp_path) is None
|
|
# Per-session HERMES_HOME under the bridge dir wins, even before state.db exists.
|
|
home = tmp_path / b._HERMES_HOME_SUBDIR
|
|
home.mkdir()
|
|
assert b._state_db_path(tmp_path) == home / "state.db"
|
|
|
|
|
|
def test_max_message_id_reads_high_water_mark(tmp_path) -> None:
|
|
db = tmp_path / "state.db"
|
|
# Missing file → 0.
|
|
assert b._max_message_id(db) == 0
|
|
con = sqlite3.connect(str(db))
|
|
con.executescript(b._MESSAGES_DDL)
|
|
# Empty table → 0.
|
|
assert b._max_message_id(db) == 0
|
|
con.execute(
|
|
"INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?,?,?,?,?)",
|
|
(7, "s1", "user", "hi", 0.0),
|
|
)
|
|
con.commit()
|
|
con.close()
|
|
assert b._max_message_id(db) == 7
|
|
|
|
|
|
def test_await_new_message_checks_at_least_once(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setattr(b.time, "sleep", lambda *_a, **_k: None)
|
|
# Even with a zero timeout, one check runs: baseline 5, current 6 → True.
|
|
monkeypatch.setattr(b, "_max_message_id", lambda _p: 6)
|
|
assert b._await_new_message(tmp_path / "state.db", 5, 0.0) is True
|
|
# No advance → False.
|
|
monkeypatch.setattr(b, "_max_message_id", lambda _p: 5)
|
|
assert b._await_new_message(tmp_path / "state.db", 5, 0.0) is False
|
|
|
|
|
|
def test_inject_user_message_dead_pane_raises(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setattr(
|
|
b, "_wait_for_tmux_info", lambda *_a, **_k: {"socket_path": "/s", "tmux_target": "t"}
|
|
)
|
|
monkeypatch.setattr(b, "_session_alive", lambda *_a, **_k: False)
|
|
with pytest.raises(RuntimeError, match="no longer running"):
|
|
b.inject_user_message(tmp_path, content="hi")
|
|
|
|
|
|
def test_inject_interrupt_sends_ctrl_c(tmp_path, monkeypatch) -> None:
|
|
calls: list[tuple[str, ...]] = []
|
|
monkeypatch.setattr(
|
|
b, "_wait_for_tmux_info", lambda *_a, **_k: {"socket_path": "/s", "tmux_target": "t"}
|
|
)
|
|
monkeypatch.setattr(b, "_run_tmux", lambda _sock, *args: calls.append(args))
|
|
b.inject_interrupt(tmp_path)
|
|
assert calls == [("send-keys", "-t", "t", "C-c")]
|
|
|
|
|
|
def test_kill_session_kills_target(tmp_path, monkeypatch) -> None:
|
|
calls: list[tuple[str, ...]] = []
|
|
monkeypatch.setattr(
|
|
b, "_wait_for_tmux_info", lambda *_a, **_k: {"socket_path": "/s", "tmux_target": "t"}
|
|
)
|
|
monkeypatch.setattr(b, "_run_tmux", lambda _sock, *args: calls.append(args))
|
|
b.kill_session(tmp_path)
|
|
assert calls == [("kill-session", "-t", "t")]
|
|
|
|
|
|
def test_capture_pane_none_when_no_target_or_dead(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setattr(b, "read_tmux_info", lambda _d: None)
|
|
assert b.capture_hermes_pane(tmp_path) is None
|
|
monkeypatch.setattr(b, "read_tmux_info", lambda _d: {"socket_path": "/s", "tmux_target": "t"})
|
|
monkeypatch.setattr(b, "_session_alive", lambda *_a, **_k: False)
|
|
assert b.capture_hermes_pane(tmp_path) is None
|
|
|
|
|
|
def test_capture_pane_returns_text_when_alive(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setattr(b, "read_tmux_info", lambda _d: {"socket_path": "/s", "tmux_target": "t"})
|
|
monkeypatch.setattr(b, "_session_alive", lambda *_a, **_k: True)
|
|
monkeypatch.setattr(b, "_capture_pane", lambda *_a, **_k: "pane text")
|
|
assert b.capture_hermes_pane(tmp_path) == "pane text"
|
|
|
|
|
|
def test_send_pane_keys_forwards_to_tmux(tmp_path, monkeypatch) -> None:
|
|
calls: list[tuple[str, ...]] = []
|
|
monkeypatch.setattr(b, "read_tmux_info", lambda _d: {"socket_path": "/s", "tmux_target": "t"})
|
|
monkeypatch.setattr(b, "_run_tmux", lambda _sock, *args: calls.append(args))
|
|
b.send_hermes_pane_keys(tmp_path, "4")
|
|
assert calls == [("send-keys", "-t", "t", "4")]
|
|
|
|
|
|
def test_send_pane_keys_raises_without_target(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setattr(b, "read_tmux_info", lambda _d: None)
|
|
with pytest.raises(RuntimeError, match="not advertised"):
|
|
b.send_hermes_pane_keys(tmp_path, "1")
|
|
|
|
|
|
# -- Compress command injection tests --
|
|
|
|
|
|
def test_inject_compress_command_sends_keys(tmp_path, monkeypatch) -> None:
|
|
calls: list[tuple[str, ...]] = []
|
|
|
|
def _fake_tmux(_socket_path, *args):
|
|
calls.append(args)
|
|
|
|
monkeypatch.setattr(b, "_run_tmux", _fake_tmux)
|
|
monkeypatch.setattr(b, "read_tmux_info", lambda _d: {"socket_path": "/s", "tmux_target": "t"})
|
|
# _wait_for_tmux_info calls read_tmux_info internally, so patch it.
|
|
monkeypatch.setattr(
|
|
b,
|
|
"_wait_for_tmux_info",
|
|
lambda _d, timeout_s=30: {"socket_path": "/s", "tmux_target": "t"},
|
|
)
|
|
b.inject_compress_command(tmp_path)
|
|
assert calls == [
|
|
("send-keys", "-t", "t", "C-u"),
|
|
("send-keys", "-l", "-t", "t", "/compress"),
|
|
("send-keys", "-t", "t", "Enter"),
|
|
]
|
|
|
|
|
|
def test_inject_compress_command_raises_without_target(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setattr(b, "read_tmux_info", lambda _d: None)
|
|
with pytest.raises(RuntimeError):
|
|
b.inject_compress_command(tmp_path, timeout_s=0.1)
|
|
|
|
|
|
# -- Policy hook config tests --
|
|
|
|
|
|
def test_write_policy_hook_config_creates_expected_files(tmp_path) -> None:
|
|
bridge_dir = tmp_path / "bridge"
|
|
bridge_dir.mkdir()
|
|
|
|
hermes_home = b.write_policy_hook_config(bridge_dir, "http://localhost:6767", "session-123")
|
|
|
|
assert hermes_home == bridge_dir / "hermes_home"
|
|
assert hermes_home.is_dir()
|
|
|
|
# Wrapper shell script exists and is owner-only (it bakes a one-shot auth
|
|
# token, so the secret is never world-readable).
|
|
wrapper = hermes_home / "omnigent-policy-hook.sh"
|
|
assert wrapper.is_file()
|
|
assert wrapper.stat().st_mode & 0o777 == 0o700
|
|
wrapper_text = wrapper.read_text()
|
|
# Values are shlex-quoted (shell-safe URLs/ids need no quotes).
|
|
assert "_OMNIGENT_SERVER_URL=http://localhost:6767" in wrapper_text
|
|
assert "_OMNIGENT_SESSION_ID=session-123" in wrapper_text
|
|
assert "_OMNIGENT_AUTH_HEADERS=" in wrapper_text
|
|
assert sys.executable in wrapper_text
|
|
assert "hermes_policy_hook.py" in wrapper_text
|
|
|
|
# config.yaml with hook registered.
|
|
config = json.loads((hermes_home / "config.yaml").read_text())
|
|
assert config["hooks_auto_accept"] is True
|
|
hooks = config["hooks"]["pre_tool_call"]
|
|
assert len(hooks) == 1
|
|
assert hooks[0]["command"] == str(wrapper)
|
|
assert hooks[0]["timeout"] == 86400
|
|
|
|
# Allowlist.
|
|
allowlist = json.loads((hermes_home / "shell-hooks-allowlist.json").read_text())
|
|
assert allowlist["approvals"][0]["event"] == "pre_tool_call"
|
|
assert allowlist["approvals"][0]["command"] == str(wrapper)
|
|
|
|
# MCP server registered.
|
|
mcp = config["mcp_servers"]["omnigent"]
|
|
assert mcp["command"] == sys.executable
|
|
assert "serve-mcp" in mcp["args"]
|
|
assert "--bridge-dir" in mcp["args"]
|
|
assert str(bridge_dir) in mcp["args"]
|
|
|
|
# bridge.json written with auth token.
|
|
bridge_config = json.loads((bridge_dir / "bridge.json").read_text())
|
|
assert isinstance(bridge_config["token"], str)
|
|
assert len(bridge_config["token"]) > 0
|
|
|
|
|
|
def test_write_policy_hook_config_copies_user_files(tmp_path, monkeypatch) -> None:
|
|
bridge_dir = tmp_path / "bridge"
|
|
bridge_dir.mkdir()
|
|
user_hermes = tmp_path / ".hermes"
|
|
user_hermes.mkdir()
|
|
(user_hermes / ".env").write_text("API_KEY=secret")
|
|
(user_hermes / "auth.json").write_text('{"token": "abc"}')
|
|
|
|
monkeypatch.setattr(b.Path, "home", staticmethod(lambda: tmp_path))
|
|
|
|
hermes_home = b.write_policy_hook_config(bridge_dir, "http://localhost:6767", "s1")
|
|
assert (hermes_home / ".env").read_text() == "API_KEY=secret"
|
|
assert (hermes_home / "auth.json").read_text() == '{"token": "abc"}'
|
|
|
|
|
|
def test_write_policy_hook_config_merges_user_model(tmp_path, monkeypatch) -> None:
|
|
bridge_dir = tmp_path / "bridge"
|
|
bridge_dir.mkdir()
|
|
user_hermes = tmp_path / ".hermes"
|
|
user_hermes.mkdir()
|
|
|
|
import yaml
|
|
|
|
(user_hermes / "config.yaml").write_text(
|
|
yaml.dump({"model": "claude-sonnet-4-20250514", "providers": {"anthropic": {}}})
|
|
)
|
|
|
|
monkeypatch.setattr(b.Path, "home", staticmethod(lambda: tmp_path))
|
|
|
|
hermes_home = b.write_policy_hook_config(bridge_dir, "http://localhost:6767", "s2")
|
|
config = json.loads((hermes_home / "config.yaml").read_text())
|
|
assert config["model"] == "claude-sonnet-4-20250514"
|
|
assert config["providers"] == {"anthropic": {}}
|
|
assert config["hooks_auto_accept"] is True
|
|
|
|
|
|
def test_read_hermes_home_returns_path_when_exists(tmp_path) -> None:
|
|
(tmp_path / "hermes_home").mkdir()
|
|
assert b.read_hermes_home(tmp_path) == tmp_path / "hermes_home"
|
|
|
|
|
|
def test_read_hermes_home_returns_none_when_missing(tmp_path) -> None:
|
|
assert b.read_hermes_home(tmp_path) is None
|
|
|
|
|
|
def test_build_spawn_env_includes_hermes_home_when_policy_written(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setattr(b, "_BRIDGE_ROOT", tmp_path / "hermes-native")
|
|
bridge_dir = b.bridge_dir_for_session_id("test-session")
|
|
bridge_dir.mkdir(parents=True, exist_ok=True)
|
|
b.write_policy_hook_config(bridge_dir, "http://localhost:6767", "test-session")
|
|
|
|
env = b.build_hermes_native_spawn_env("test-session")
|
|
assert env["HERMES_HOME"] == str(bridge_dir / "hermes_home")
|
|
|
|
|
|
def test_build_spawn_env_no_hermes_home_without_policy(tmp_path, monkeypatch) -> None:
|
|
monkeypatch.setattr(b, "_BRIDGE_ROOT", tmp_path / "hermes-native")
|
|
env = b.build_hermes_native_spawn_env("test-no-policy")
|
|
assert "HERMES_HOME" not in env
|
|
|
|
|
|
# -- Session cloning tests --
|
|
|
|
|
|
def _create_source_db(db_path: Path, session_id: str) -> None:
|
|
"""Create a minimal Hermes state.db with a session and a few messages."""
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.execute(b._SESSIONS_DDL)
|
|
conn.execute(b._MESSAGES_DDL)
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, source, cwd, started_at) VALUES (?, ?, ?, ?)",
|
|
(session_id, "cli", "/old/path", 1700000000.0),
|
|
)
|
|
# user message
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, role, content, timestamp, active) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
(session_id, "user", "hello", 1700000001.0, 1),
|
|
)
|
|
# assistant message
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, role, content, timestamp, active) "
|
|
"VALUES (?, ?, ?, ?, ?)",
|
|
(session_id, "assistant", "hi there", 1700000002.0, 1),
|
|
)
|
|
# tool message with tool_calls
|
|
conn.execute(
|
|
"INSERT INTO messages "
|
|
"(session_id, role, content, tool_calls, tool_name, timestamp, active) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
(session_id, "tool", "result", '[{"id":"tc1"}]', "bash", 1700000003.0, 1),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def test_clone_hermes_session_copies_rows(tmp_path: Path) -> None:
|
|
source_db = tmp_path / "source" / "state.db"
|
|
source_db.parent.mkdir()
|
|
target_db = tmp_path / "target" / "state.db"
|
|
|
|
src_sid = "src-session-id"
|
|
tgt_sid = "tgt-session-id"
|
|
_create_source_db(source_db, src_sid)
|
|
|
|
b.clone_hermes_session(source_db, target_db, src_sid, tgt_sid)
|
|
|
|
# Target has the cloned session.
|
|
tgt = sqlite3.connect(str(target_db))
|
|
sess = tgt.execute("SELECT id, source, cwd, started_at FROM sessions").fetchall()
|
|
assert len(sess) == 1
|
|
assert sess[0][0] == tgt_sid
|
|
assert sess[0][2] == "/old/path" # cwd preserved when workspace not given
|
|
|
|
# Target has all 3 messages with the new session_id.
|
|
msgs = tgt.execute("SELECT session_id, role, content FROM messages ORDER BY id").fetchall()
|
|
assert len(msgs) == 3
|
|
assert all(m[0] == tgt_sid for m in msgs)
|
|
assert msgs[0][1] == "user"
|
|
assert msgs[1][1] == "assistant"
|
|
assert msgs[2][1] == "tool"
|
|
assert msgs[2][2] == "result"
|
|
|
|
# tool_calls preserved on the tool message.
|
|
tool_calls = tgt.execute("SELECT tool_calls FROM messages WHERE role = 'tool'").fetchone()[0]
|
|
assert tool_calls == '[{"id":"tc1"}]'
|
|
tgt.close()
|
|
|
|
# Source is unchanged.
|
|
src = sqlite3.connect(str(source_db))
|
|
src_msgs = src.execute("SELECT session_id FROM messages").fetchall()
|
|
assert all(m[0] == src_sid for m in src_msgs)
|
|
src.close()
|
|
|
|
|
|
def test_clone_hermes_session_remaps_workspace(tmp_path: Path) -> None:
|
|
source_db = tmp_path / "source" / "state.db"
|
|
source_db.parent.mkdir()
|
|
target_db = tmp_path / "target" / "state.db"
|
|
|
|
src_sid = "src-ws"
|
|
tgt_sid = "tgt-ws"
|
|
_create_source_db(source_db, src_sid)
|
|
|
|
b.clone_hermes_session(source_db, target_db, src_sid, tgt_sid, workspace="/new/path")
|
|
|
|
tgt = sqlite3.connect(str(target_db))
|
|
cwd = tgt.execute("SELECT cwd FROM sessions WHERE id = ?", (tgt_sid,)).fetchone()[0]
|
|
assert cwd == "/new/path"
|
|
tgt.close()
|
|
|
|
|
|
def test_mint_hermes_session_id_returns_uuid() -> None:
|
|
sid = b.mint_hermes_session_id()
|
|
# Should be a valid UUID4 string.
|
|
parsed = uuid.UUID(sid, version=4)
|
|
assert str(parsed) == sid
|