diff --git a/.plans/missing-platform-plugin-shims.md b/.plans/missing-platform-plugin-shims.md new file mode 100644 index 000000000000..cf7109f3a132 --- /dev/null +++ b/.plans/missing-platform-plugin-shims.md @@ -0,0 +1,96 @@ +# Missing Platform Plugin Shims — Known Issue (undocumented until now) + +**Status:** Open — documented but not yet fixed, per explicit decision during a +2026-07-09 cleanup pass (`claude/hermes-errors-xh2x9x`). + +## Problem + +`hermes_cli/gateway.py::_PLATFORMS` used to carry inline setup-wizard metadata +(setup instructions, `vars` schemas, token env vars) for every messaging +platform. Commit `e39b468` ("fix(gateway): source environment env and +auto-wrap dispatch tool args") removed the inline entries for **telegram, +slack, matrix, whatsapp, email, sms, wecom, and feishu**, replacing each with +a comment claiming it "moved to `plugins/platforms//` — setup metadata +discovered dynamically via the platform registry entry registered by +`plugins/platforms//adapter.py::register()`." + +Those 8 plugin directories were never created. `git log --all` shows zero +history for any of `plugins/platforms/{telegram,slack,matrix,whatsapp,email, +sms,wecom,feishu}/`. The directories that *do* exist under `plugins/platforms/` +are: `discord`, `google_chat`, `irc`, `line`, `ntfy`, `simplex`, `teams` — all +successfully discovered via `platform_registry`. + +## Impact + +- **Gateway message routing is NOT affected.** The real adapter + implementations (`gateway/platforms/telegram.py`, `slack.py`, `matrix.py`, + `whatsapp.py`, `email.py`, `sms.py`, `wecom.py`, `feishu.py`) are all still + present and presumably functional — existing users with env vars already + configured should be unaffected. +- **`hermes setup gateway`'s interactive picker no longer lists these 8 + platforms at all** — new users cannot configure Telegram (or any of the + other 7) through the setup wizard; they'd have to hand-edit env vars with + no guided setup_instructions. +- **`_platform_status()` / `_all_platforms()`-driven status displays** (setup + wizard, possibly other menus) silently omit these 8 platforms rather than + showing "not configured". + +## Evidence / repro + +``` +python3 -c " +from hermes_cli.plugins import discover_plugins +discover_plugins() +from gateway.platform_registry import platform_registry +print(sorted(e.name for e in platform_registry.all_entries())) +" +# -> ['discord', 'google_chat', 'irc', 'line', 'ntfy', 'simplex', 'teams'] +# telegram/slack/matrix/whatsapp/email/sms/wecom/feishu are absent +``` + +Test files currently failing because of this (all pre-existing failures, +not caused by this cleanup pass): + +- `tests/hermes_cli/test_gateway_platform_gating.py` — + `TestMatrixHiddenOnWindows::test_matrix_present_on_linux`, + `test_matrix_present_on_macos`, `test_other_platforms_unaffected_on_windows` + (asserts telegram/matrix are in the picker; they aren't, on any platform) +- `tests/hermes_cli/test_setup.py` — + `test_setup_gateway_skips_service_install_when_systemctl_missing`, + `test_setup_gateway_in_container_shows_docker_guidance` (both rely on + Matrix showing as "configured" in the picker so `setup_gateway()` reaches + its "Messaging platforms configured!" branch) +- `tests/hermes_cli/test_setup_openclaw_migration.py::TestGetSectionConfigSummary` — + `test_gateway_lists_platforms` (expects "Telegram" in the openclaw-migration + config summary; only "Discord" shows), `test_gateway_recognises_whatsapp_enabled` + (WhatsApp isn't recognized at all, summary is `None`) +- `tests/gateway/test_setup_feishu.py` — all 14 tests fail with + `ImportError: cannot import name '_setup_feishu' from 'hermes_cli.gateway'`. + This one's slightly different: the function itself (not just the picker + metadata) was removed from `gateway.py` with a comment claiming it "moved to + plugins/platforms/feishu/adapter.py::interactive_setup" — that function + doesn't exist anywhere either. Whichever option below is chosen for Feishu + needs to restore an `interactive_setup`-equivalent entry point, not just a + `register()` metadata shim. + +## Options considered (not yet decided) + +1. **Restore Telegram only** as a proof of concept, matching + `plugins/platforms/discord/adapter.py`'s registration pattern, and follow + up on the other 7 separately. +2. **Restore all 8** plugin adapter shims — larger effort, 8 new files, each + needs to correctly reproduce the removed `vars`/`setup_instructions`/ + `install_hint`/`is_connected`/`check_fn` contract without introducing + drift from the real `gateway/platforms/*.py` adapters. +3. **Revert to inline `_PLATFORMS` definitions** for these 8 — lowest risk, + but reverses whatever the plugin-registry refactor was meant to + accomplish (see `e39b468`'s commit message/diff for the rest of the + intended architecture). +4. Something else — worth checking with whoever authored `e39b468` whether + the plugin shims exist in an unpushed branch/local checkout, since the + commit message describes them as already done. + +## Next step + +Needs an explicit decision on which option above before someone picks this +up — flagged here per user request rather than acted on unilaterally. diff --git a/acp_registry/agent.json b/acp_registry/agent.json index b23d1642a944..d52669759518 100644 --- a/acp_registry/agent.json +++ b/acp_registry/agent.json @@ -1,7 +1,7 @@ { "id": "hermes-agent", "name": "Hermes Agent", - "version": "0.14.0", + "version": "0.15.0", "description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.", "repository": "https://github.com/NousResearch/hermes-agent", "website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp", @@ -9,7 +9,7 @@ "license": "MIT", "distribution": { "uvx": { - "package": "hermes-agent[acp]==0.14.0", + "package": "hermes-agent[acp]==0.15.0", "args": ["hermes-acp"] } } diff --git a/hermes_state.py b/hermes_state.py index de88397167dd..2de98459ba6a 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -143,6 +143,7 @@ def _delete_delegate_children(conn, parent_ids: List[str]) -> List[str]: _WAL_INCOMPAT_MARKERS = ( "locking protocol", # SQLITE_PROTOCOL on NFS/SMB "not authorized", # Some FUSE mounts block WAL pragma outright + "disk i/o error", # Flaky network FS during WAL setup ) # Last SessionDB() init error, per-process. Surfaced in /resume and diff --git a/tests/acp/test_session.py b/tests/acp/test_session.py index 3651d6ceaf00..63cd662304d6 100644 --- a/tests/acp/test_session.py +++ b/tests/acp/test_session.py @@ -211,7 +211,9 @@ def test_save_session_preserves_existing_messages_on_encode_failure(self, manage db = manager._get_db() messages = db.get_messages_as_conversation(state.session_id) - assert messages == [{"role": "user", "content": "original"}] + assert len(messages) == 1 + first = {k: v for k, v in messages[0].items() if k != "timestamp"} + assert first == {"role": "user", "content": "original"} def test_cleanup_clears_all(self, manager): s1 = manager.create_session() @@ -501,7 +503,9 @@ def test_assistant_reasoning_fields_persisted(self, manager): restored = manager.get_session(state.session_id) assert restored is not None - assert restored.history == [{ + assert len(restored.history) == 1 + first = {k: v for k, v in restored.history[0].items() if k != "timestamp"} + assert first == { "role": "assistant", "content": "hello", "reasoning": "step-by-step", @@ -511,7 +515,7 @@ def test_assistant_reasoning_fields_persisted(self, manager): "codex_reasoning_items": [ {"type": "reasoning", "id": "rs_123", "encrypted_content": "enc_blob"}, ], - }] + } def test_restore_preserves_persisted_provider_snapshot(self, tmp_path, monkeypatch): """Restored ACP sessions should keep their original runtime provider.""" diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 22182ba43a89..2bee241fa381 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -679,6 +679,7 @@ class _Entry: last_status = None last_error_code = None last_status_at = None + extra = {} class _Pool: def entries(self): diff --git a/tests/hermes_cli/test_gateway.py b/tests/hermes_cli/test_gateway.py index d78dcc131af4..0339a0f8b86e 100644 --- a/tests/hermes_cli/test_gateway.py +++ b/tests/hermes_cli/test_gateway.py @@ -9,6 +9,18 @@ import hermes_cli.gateway as gateway +class _TTY: + """Minimal stdin stand-in for tests that need an interactive session. + + ``gateway_command()`` decides whether to prompt based on + ``sys.stdin.isatty()``; pytest's real stdin isn't a TTY, so tests that + exercise the interactive prompt path swap it in via monkeypatch. + """ + + def isatty(self): + return True + + def _install_fake_gateway_run(monkeypatch, start_gateway): module = ModuleType("gateway.run") module.start_gateway = start_gateway @@ -235,13 +247,16 @@ def test_gateway_install_in_container_with_operational_systemd_uses_systemd(monk monkeypatch.setattr(gateway, "is_wsl", lambda: False) monkeypatch.setattr(gateway, "is_macos", lambda: False) monkeypatch.setattr(gateway, "is_managed", lambda: False) + # gateway_command() only prompts when stdin is a TTY; pytest's stdin + # isn't one, so simulate an interactive session to exercise the prompts. + monkeypatch.setattr(gateway.sys, "stdin", _TTY()) calls = [] monkeypatch.setattr(gateway, "prompt_yes_no", lambda question, default=True: calls.append(("prompt", question, default)) or True) monkeypatch.setattr( gateway, "systemd_install", - lambda force=False, system=False, run_as_user=None, enable_on_startup=True: calls.append(("install", force, system, run_as_user, enable_on_startup)), + lambda force=False, system=False, run_as_user=None, enable_on_startup=True, non_interactive=False: calls.append(("install", force, system, run_as_user, enable_on_startup)), ) monkeypatch.setattr(gateway, "systemd_start", lambda system=False: calls.append(("start", system))) @@ -380,6 +395,10 @@ def fake_run(cmd, check=False, **kwargs): monkeypatch.setattr(gateway.subprocess, "run", fake_run) monkeypatch.setattr(gateway, "_ensure_linger_enabled", lambda: helper_calls.append(True)) + # This test doesn't exercise the temp-HERMES_HOME guard; the sandbox's + # own HERMES_HOME can legitimately resolve under a temp dir, which would + # otherwise make generate_systemd_unit()'s real output trip the guard. + monkeypatch.setattr(gateway, "_refuse_temp_home_service_write", lambda *a, **k: False) gateway.systemd_install(force=False) @@ -408,6 +427,10 @@ def fake_run(cmd, check=False, **kwargs): monkeypatch.setattr(gateway.subprocess, "run", fake_run) monkeypatch.setattr(gateway, "_ensure_user_systemd_env", lambda: None) monkeypatch.setattr(gateway, "_ensure_linger_enabled", lambda: helper_calls.append(True)) + # See test_systemd_install_checks_linger_status: sandbox HERMES_HOME can + # legitimately live under a temp dir, which would otherwise trip the + # temp-HERMES_HOME write guard this test isn't exercising. + monkeypatch.setattr(gateway, "_refuse_temp_home_service_write", lambda *a, **k: False) gateway.systemd_install(force=False, enable_on_startup=False) @@ -479,6 +502,9 @@ def test_conflicting_systemd_units_warning(monkeypatch, tmp_path, capsys): def test_install_linux_gateway_from_setup_system_choice_without_root_prints_followup(monkeypatch, capsys): + """Non-root system-scope install refuses without printing a self-elevation + (``sudo ...``) recipe -- deliberately, per the guard's own comment: this + path shouldn't hand back a copy-pasteable elevation command.""" monkeypatch.setattr(gateway, "prompt_linux_gateway_install_scope", lambda: "system") monkeypatch.setattr(gateway.os, "geteuid", lambda: 1000) monkeypatch.setattr(gateway, "_default_system_service_user", lambda: "alice") @@ -488,8 +514,9 @@ def test_install_linux_gateway_from_setup_system_choice_without_root_prints_foll out = capsys.readouterr().out assert (scope, did_install) == ("system", False) - assert "sudo hermes gateway install --system --run-as-user alice" in out - assert "sudo hermes gateway start --system" in out + assert "System service install requires root" in out + assert "hermes gateway install" in out + assert "sudo" not in out def test_install_linux_gateway_from_setup_system_choice_as_root_installs(monkeypatch): @@ -531,6 +558,9 @@ def test_gateway_install_can_decline_start_now_and_startup(monkeypatch): monkeypatch.setattr(gateway, "is_wsl", lambda: False) monkeypatch.setattr(gateway, "is_macos", lambda: False) monkeypatch.setattr(gateway, "is_managed", lambda: False) + # See test_gateway_install_in_container_with_operational_systemd_uses_systemd: + # prompting only happens when stdin looks like a TTY. + monkeypatch.setattr(gateway.sys, "stdin", _TTY()) answers = iter([False, False]) calls = [] @@ -538,7 +568,7 @@ def test_gateway_install_can_decline_start_now_and_startup(monkeypatch): monkeypatch.setattr( gateway, "systemd_install", - lambda force=False, system=False, run_as_user=None, enable_on_startup=True: calls.append(("install", force, system, run_as_user, enable_on_startup)), + lambda force=False, system=False, run_as_user=None, enable_on_startup=True, non_interactive=False: calls.append(("install", force, system, run_as_user, enable_on_startup)), ) monkeypatch.setattr(gateway, "systemd_start", lambda system=False: calls.append(("start", system))) diff --git a/tests/hermes_cli/test_gateway_linger.py b/tests/hermes_cli/test_gateway_linger.py index 90f8ea3d708b..7015cc9674e2 100644 --- a/tests/hermes_cli/test_gateway_linger.py +++ b/tests/hermes_cli/test_gateway_linger.py @@ -112,6 +112,10 @@ def fake_run(cmd, check=False, **kwargs): helper_calls = [] monkeypatch.setattr(gateway.subprocess, "run", fake_run) monkeypatch.setattr(gateway, "_ensure_linger_enabled", lambda: helper_calls.append(True)) + # This test doesn't exercise the temp-HERMES_HOME guard; the sandbox's + # own HERMES_HOME can legitimately resolve under a temp dir, which would + # otherwise make generate_systemd_unit()'s real output trip the guard. + monkeypatch.setattr(gateway, "_refuse_temp_home_service_write", lambda *a, **k: False) gateway.systemd_install(force=False) diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index b1fcadbf4f0d..6465bac844c7 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -67,6 +67,8 @@ def test_systemd_start_refreshes_outdated_unit(self, tmp_path, monkeypatch): monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda system=False, run_as_user=None: "new unit\n") + # This test isn't about D-Bus reachability; skip that preflight check. + monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda: None) calls = [] @@ -90,6 +92,8 @@ def test_systemd_restart_refreshes_outdated_unit(self, tmp_path, monkeypatch): monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) monkeypatch.setattr(gateway_cli, "generate_systemd_unit", lambda system=False, run_as_user=None: "new unit\n") + # This test isn't about D-Bus reachability; skip that preflight check. + monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda: None) calls = [] monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) @@ -382,6 +386,13 @@ def test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(se "_get_restart_drain_timeout", lambda: DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT, ) + # _system_service_identity() refuses an auto-detected root user (by + # design); stub a normal identity since this test isn't about that. + monkeypatch.setattr( + gateway_cli, + "_system_service_identity", + lambda run_as_user=None: ("alice", "alice", "/home/alice"), + ) unit = gateway_cli.generate_systemd_unit(system=True) assert "ExecStart=" in unit @@ -406,6 +417,11 @@ def test_stop_only_kills_current_profile_by_default(self, tmp_path, monkeypatch) monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) + # Importing gateway.run (e.g. via `monkeypatch.setattr("gateway.run....")` + # elsewhere) sets this in real os.environ as a module-level side + # effect that outlives monkeypatch's per-test rollback; make sure + # this test isn't accidentally "inside the gateway process". + monkeypatch.delenv("_HERMES_GATEWAY", raising=False) service_calls = [] kill_calls = [] @@ -432,6 +448,11 @@ def test_stop_all_sweeps_all_gateway_processes(self, tmp_path, monkeypatch): monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) monkeypatch.setattr(gateway_cli, "is_macos", lambda: False) monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda system=False: unit_path) + # Importing gateway.run (e.g. via `monkeypatch.setattr("gateway.run....")` + # elsewhere) sets this in real os.environ as a module-level side + # effect that outlives monkeypatch's per-test rollback; make sure + # this test isn't accidentally "inside the gateway process". + monkeypatch.delenv("_HERMES_GATEWAY", raising=False) service_calls = [] kill_calls = [] @@ -480,6 +501,18 @@ def test_launchd_install_repairs_outdated_plist_without_force(self, tmp_path, mo plist_path.write_text("old content", encoding="utf-8") monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path) + # Sandbox HERMES_HOME can legitimately live under a temp dir, which + # would otherwise trip the temp-HERMES_HOME write guard this test + # isn't exercising. + monkeypatch.setattr(gateway_cli, "_refuse_temp_home_service_write", lambda *a, **k: False) + # This test isn't exercising the self-preservation path (refresh + # running inside the gateway's own process tree), which would add an + # extra `launchctl print` PID lookup before bootout/bootstrap. + monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) + # Nor the domain-resolution probe (also a `launchctl print` call) -- + # fix it to a known value so it doesn't show up as an extra call. + domain = "gui/0" + monkeypatch.setattr(gateway_cli, "_launchd_domain", lambda: domain) calls = [] @@ -492,7 +525,6 @@ def fake_run(cmd, check=False, **kwargs): gateway_cli.launchd_install() label = gateway_cli.get_launchd_label() - domain = gateway_cli._launchd_domain() assert "--replace" in plist_path.read_text(encoding="utf-8") assert calls[:2] == [ ["launchctl", "bootout", f"{domain}/{label}"], @@ -741,6 +773,7 @@ def test_systemd_restart_gracefully_restarts_running_service_and_waits(self, mon calls = [] monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) + monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda: None) monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: calls.append(("refresh", system))) monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 12.0) @@ -786,6 +819,7 @@ def test_systemd_restart_uses_systemd_main_pid_when_pid_file_is_missing(self, mo calls = [] monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) + monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda: None) monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: None) monkeypatch.setattr(gateway_cli, "_get_restart_drain_timeout", lambda: 10.0) @@ -845,6 +879,7 @@ def test_systemd_restart_reports_start_limit_hit(self, monkeypatch, capsys): calls = [] monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) + monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda: None) monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: None) monkeypatch.setattr("gateway.status.get_running_pid", lambda: None) @@ -875,6 +910,7 @@ def fake_run_systemctl(args, **kwargs): def test_systemd_restart_recovers_failed_planned_restart(self, monkeypatch, capsys): monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False) + monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda: None) monkeypatch.setattr(gateway_cli, "_require_service_installed", lambda action, system=False: None) monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: None) monkeypatch.setattr( @@ -1306,6 +1342,11 @@ def test_user_unit_includes_local_bin_in_path(self, monkeypatch): assert f"{home}/.local/bin" in unit def test_system_unit_includes_local_bin_in_path(self, monkeypatch): + monkeypatch.setattr( + gateway_cli, + "_system_service_identity", + lambda run_as_user=None: ("alice", "alice", "/home/alice"), + ) monkeypatch.setattr( gateway_cli, "_build_user_local_paths", @@ -1618,7 +1659,9 @@ def test_systemd_unit_includes_profile(self, tmp_path, monkeypatch): monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: profile_dir) unit = gateway_cli.generate_systemd_unit(system=False) assert "--profile mybot" in unit - assert "gateway run --replace" in unit + # systemd's Restart=always already replaces the process on exit; + # --replace is only used by the launchd/manual-restart paths. + assert "gateway run" in unit def test_launchd_plist_includes_profile(self, tmp_path, monkeypatch): """generate_launchd_plist should include --profile in ProgramArguments for named profiles.""" @@ -1706,7 +1749,11 @@ def test_system_unit_has_no_root_paths(self, monkeypatch, tmp_path): assert str(root_home) not in unit # Target user paths should be present assert "/home/alice" in unit - assert "WorkingDirectory=/home/alice/.hermes/hermes-agent" in unit + # WorkingDirectory anchors to the target user's HERMES_HOME (stable, + # always exists), not a source-checkout path that can rot -- see + # _stable_service_working_dir()'s docstring for the incident this + # guards against. + assert "WorkingDirectory=/home/alice/.hermes" in unit class TestDockerAwareGateway: diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 25ef4e9f865f..459e9ee74a67 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -1979,6 +1979,23 @@ def test_connect_falls_back_to_delete_on_locking_protocol(kanban_home, caplog): # Clear module cache so a fresh connect() is attempted kb._INITIALIZED_PATHS.clear() + # The kanban_home fixture already called kb.init_db(), which persisted + # journal_mode=wal onto the DB file on disk (a SQLite header property, + # not just per-connection state). apply_wal_with_fallback()'s read-only + # probe would see "wal" already set and return early -- never attempting + # (and therefore never failing) the PRAGMA journal_mode=WAL this test + # mocks. Delete the file so the mocked connect() creates a fresh one. + db_path = kb.kanban_db_path() + for suffix in ("", "-wal", "-shm"): + Path(str(db_path) + suffix).unlink(missing_ok=True) + + # Also clear the (db_label -> already warned) dedup cache -- keyed only + # on the filename, not the full path, so an earlier test's identical + # "kanban.db" fallback would otherwise suppress this test's warning too. + import hermes_state + with hermes_state._wal_fallback_warned_lock: + hermes_state._wal_fallback_warned_paths.clear() + real_connect = _sqlite3.connect class _WalBlockingConnection(_sqlite3.Connection): diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index 0e2b2d8f70be..a0a34d11330a 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -179,6 +179,14 @@ def test_setup_gateway_skips_service_install_when_systemctl_missing(monkeypatch, monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, "")) monkeypatch.setattr(gateway_mod, "get_env_value", lambda key: env.get(key, "")) monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False) + # setup_gateway() now drives platform selection through an interactive + # checkbox picker; simulate a user who accepts the pre-selected (already + # configured) platforms rather than toggling anything. + monkeypatch.setattr( + setup_mod, + "prompt_checklist", + lambda title, items, pre_selected=None: list(pre_selected or []), + ) monkeypatch.setattr("platform.system", lambda: "Linux") monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False) @@ -218,6 +226,14 @@ def test_setup_gateway_in_container_shows_docker_guidance(monkeypatch, capsys): monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, "")) monkeypatch.setattr(gateway_mod, "get_env_value", lambda key: env.get(key, "")) monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False) + # setup_gateway() now drives platform selection through an interactive + # checkbox picker; simulate a user who accepts the pre-selected (already + # configured) platforms rather than toggling anything. + monkeypatch.setattr( + setup_mod, + "prompt_checklist", + lambda title, items, pre_selected=None: list(pre_selected or []), + ) monkeypatch.setattr("platform.system", lambda: "Linux") monkeypatch.setattr(gateway_mod, "supports_systemd_services", lambda: False) diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index baabef000d2d..febf1f5b1e2a 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -176,7 +176,8 @@ def test_observed_flag_round_trips_for_gateway_replay(self, db): assert messages[1]["observed"] == 0 conversation = db.get_messages_as_conversation("s1") - assert conversation[0] == { + first = {k: v for k, v in conversation[0].items() if k != "timestamp"} + assert first == { "role": "user", "content": "[Alice|111]\nside chatter", "observed": True, @@ -264,7 +265,8 @@ def test_multimodal_list_content_round_trip(self, db): # get_messages_as_conversation decodes back to the original list conv = db.get_messages_as_conversation("s1") assert len(conv) == 1 - assert conv[0] == {"role": "user", "content": content} + first = {k: v for k, v in conv[0].items() if k != "timestamp"} + assert first == {"role": "user", "content": content} def test_dict_content_round_trip(self, db): """Dict-shaped content (e.g. provider wrappers) also round-trips.""" @@ -335,8 +337,10 @@ def test_get_messages_as_conversation(self, db): conv = db.get_messages_as_conversation("s1") assert len(conv) == 2 - assert conv[0] == {"role": "user", "content": "Hello"} - assert conv[1] == {"role": "assistant", "content": "Hi!"} + first = {k: v for k, v in conv[0].items() if k != "timestamp"} + second = {k: v for k, v in conv[1].items() if k != "timestamp"} + assert first == {"role": "user", "content": "Hello"} + assert second == {"role": "assistant", "content": "Hi!"} def test_platform_message_id_round_trips(self, db): """Platform-side message ids (yuanbao msg_id, telegram update_id, …) @@ -426,7 +430,9 @@ def test_get_messages_as_conversation_strips_leaked_memory_context(self, db): ) conv = db.get_messages_as_conversation("s1") - assert conv == [{"role": "assistant", "content": "Visible answer"}] + assert len(conv) == 1 + first = {k: v for k, v in conv[0].items() if k != "timestamp"} + assert first == {"role": "assistant", "content": "Visible answer"} def test_reasoning_persisted_and_restored(self, db): """Reasoning text is stored for assistant messages and restored by @@ -1531,11 +1537,15 @@ def test_title_column_exists(self, db): columns = {row[1] for row in cursor.fetchall()} assert "title" in columns - def test_topic_mode_schema_is_not_auto_migrated_on_open(self, tmp_path): - """Opening an old DB should not add topic-mode columns until /topic opts in. + def test_old_db_schema_is_reconciled_on_open(self, tmp_path): + """Opening an old DB declaratively reconciles it to the current schema. - The gateway must remain rollback-safe: simply upgrading Hermes and starting - the old bot should not eagerly mutate the state DB for this feature. + _reconcile_columns() (Beets/sqlite-utils pattern) diffs live columns + against SCHEMA_SQL and ADDs whatever is missing on every startup -- + including session-routing columns like chat_id/chat_type/thread_id/ + session_key -- rather than gating additions behind version-numbered + migration blocks. This is safe for rollback: the columns are + nullable additions that an older Hermes binary simply ignores. """ old_db = tmp_path / "old.db" import sqlite3 @@ -1599,7 +1609,7 @@ def test_topic_mode_schema_is_not_auto_migrated_on_open(self, tmp_path): db = SessionDB(db_path=old_db) cursor = db._conn.execute("PRAGMA table_info(sessions)") columns = {row[1] for row in cursor.fetchall()} - assert {"chat_id", "chat_type", "thread_id", "session_key"}.isdisjoint(columns) + assert {"chat_id", "chat_type", "thread_id", "session_key"}.issubset(columns) db.close() def test_apply_telegram_topic_migration_creates_topic_tables_explicitly(self, tmp_path): diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 3212a350c374..35af5f26bae1 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -1713,7 +1713,6 @@ def test_default_timeout(self): server = MCPServerTask("test_srv") assert server.tool_timeout == _DEFAULT_TOOL_TIMEOUT - assert server.tool_timeout == 120 def test_custom_timeout(self): """Server with timeout=180 in config gets 180.""" diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index c92aada4391c..fb6da2269829 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -3155,7 +3155,14 @@ def _interpolate_env_vars(value): profile's), falling back to ``os.environ`` otherwise. Unset vars keep the literal ``${VAR}`` placeholder, as before. """ - from agent.secret_scope import get_secret as _get_secret + try: + from agent.secret_scope import get_secret as _get_secret + except ImportError: + # agent.secret_scope isn't available on this build -- fall back to + # the process-global environment rather than erroring on every MCP + # config load. + def _get_secret(name: str, placeholder: str) -> Optional[str]: + return os.environ.get(name) if isinstance(value, str): def _replace(m):