Compare commits

...

14 Commits

Author SHA1 Message Date
87a5d39bb1 Merge pull request 'fix(tests): align systemd unit + service tests with current production shape (partial close #9)' (#15) from fix/systemd-tests-drift-9 into main
Some checks failed
Tests / e2e (push) Failing after 1m9s
Nix / nix (macos-latest) (push) Waiting to run
Docker Build and Publish / build-and-push (push) Has been skipped
Tests / test (push) Failing after 1m29s
Nix / nix (ubuntu-latest) (push) Failing after 20m57s
Build Skills Index / deploy-with-index (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
2026-05-08 21:11:59 +00:00
2cd5c2bd3b Merge pull request 'fix: resolve 5 misc test failures in hermes-agent#9' (#14) from fix/misc-test-failures-issue-9 into main
Some checks failed
Tests / test (push) Has been cancelled
Tests / e2e (push) Has been cancelled
Nix / nix (macos-latest) (push) Waiting to run
Docker Build and Publish / build-and-push (push) Has been skipped
Nix / nix (ubuntu-latest) (push) Has been cancelled
2026-05-08 21:11:11 +00:00
Dev Lead
9dc9a6998f fix(test_gateway_service,test_gateway_wsl): align systemd tests with current production shape (partial close hermes-agent#9)
Some checks failed
Nix / nix (macos-latest) (pull_request) Waiting to run
Contributor Attribution Check / check-attribution (pull_request) Failing after 1m36s
Supply Chain Audit / Scan PR for critical supply chain risks (pull_request) Successful in 1m37s
Tests / e2e (pull_request) Successful in 1m59s
Tests / test (pull_request) Failing after 18m17s
Nix / nix (ubuntu-latest) (pull_request) Failing after 22m16s
Sub-shape A (TimeoutStopSec literal drift):
- generate_systemd_unit() formula: max(60, drain_timeout) + 30
- DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT bumped 60→180 in config.py,
  so emitted TimeoutStopSec went 90→210; tests pinned the literal 90.
- Replace literal with TestGeneratedSystemdUnits._expected_timeout_stop_sec()
  helper that mirrors the production formula via _get_restart_drain_timeout(),
  so future config-default bumps don't silently regress the test.

Sub-shape B (production preflight not stubbed):
- systemd_start() / systemd_restart() now call _preflight_user_systemd()
  before the systemctl call sequence (PR #14531: "preflight user D-Bus
  before systemctl --user start"). The preflight invokes
  loginctl enable-linger and waits for the D-Bus socket — neither of
  which the unit tests' fake subprocess runner answers.
- Unit-tests under TestSystemdServiceRefresh and
  TestGatewaySystemServiceRouting assert the systemctl call sequence,
  not the preflight; preflight has dedicated coverage in
  TestUserSystemdPrivateSocketPreflight. Stub _preflight_user_systemd
  as a no-op in the four affected tests.

Sub-shape B (supports_systemd_services container branch):
- supports_systemd_services() now branches on is_container() to decide
  whether to probe `systemctl is-system-running`. Tests that assert the
  native-Linux True path didn't stub is_container, so a containerized
  CI runner inherited a real probe of the runner image's systemd:
  - test_supports_systemd_services_returns_true_when_systemctl_present
  - TestSupportsSystemdServicesWSL.test_native_linux
- Stub is_container() False in both, plus shutil.which() in the WSL test
  so it also passes on macOS dev boxes (was implicitly Linux-only via
  systemctl-on-PATH).

Tests fixed:
  test_systemd_start_refreshes_outdated_unit
  test_systemd_restart_refreshes_outdated_unit
  test_user_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout
  test_system_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout
  test_supports_systemd_services_returns_true_when_systemctl_present
  test_systemd_restart_self_requests_graceful_restart_and_waits
  test_systemd_restart_recovers_failed_planned_restart
  TestSupportsSystemdServicesWSL.test_native_linux

Verified locally on darwin py3.13: all 8 target tests pass; one
unrelated macOS-only failure (test_wsl_with_systemd) remains because
its body relies on the host having systemctl on PATH — not in this
PR's scope (not in the issue's failing-list).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:10:43 -07:00
dev-lead
04d5633745 test(kanban-ws-auth): patch hermes_cli.web_server attribute alongside sys.modules entry
Some checks failed
Nix / nix (macos-latest) (pull_request) Waiting to run
Supply Chain Audit / Scan PR for critical supply chain risks (pull_request) Successful in 42s
Contributor Attribution Check / check-attribution (pull_request) Failing after 43s
Tests / e2e (pull_request) Successful in 2m13s
Nix / nix (ubuntu-latest) (pull_request) Failing after 14m21s
Tests / test (pull_request) Failing after 23m16s
`monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)` alone
is not enough when another test in the same xdist worker has already
imported `hermes_cli.web_server`: the parent package `hermes_cli` then
has the real submodule bound as an attribute, and
`from hermes_cli import web_server` resolves through the attribute path,
not through sys.modules. Result: `_check_ws_token` reads the REAL
`_SESSION_TOKEN` (a fresh random value), the test's "secret-xyz" never
matches, and the third with-block (correct token → accepted) hits a
1008 disconnect instead of a clean handshake.

Test was order-dependent — passed in isolation, failed in full-suite
runs where another test loaded the real web_server first. Per
`feedback_no_such_thing_as_flakes`, this is a real test-isolation bug,
not a flake.

Fix: also `monkeypatch.setattr(hermes_cli, "web_server", stub,
raising=False)` so both lookup paths see the stub. Inline comment
documents the gotcha for the next reader.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:08:59 -07:00
386c75ecca Merge pull request 'fix(test_concurrent_interrupt): add _tool_guardrails to _Stub fixture (partial close hermes-agent#9)' (#13) from fix/concurrent-interrupt-stub-guardrails into main
Some checks failed
Tests / e2e (push) Successful in 54s
Tests / test (push) Has been cancelled
Nix / nix (macos-latest) (push) Waiting to run
Docker Build and Publish / build-and-push (push) Has been skipped
Nix / nix (ubuntu-latest) (push) Has been cancelled
2026-05-08 21:03:43 +00:00
dev-lead
b200cba562 fix(test_concurrent_interrupt): add _tool_guardrails to _Stub fixture (partial close hermes-agent#9)
Some checks failed
Nix / nix (macos-latest) (pull_request) Waiting to run
Supply Chain Audit / Scan PR for critical supply chain risks (pull_request) Successful in 19s
Contributor Attribution Check / check-attribution (pull_request) Failing after 19s
Tests / e2e (pull_request) Successful in 32s
Tests / test (pull_request) Failing after 8m0s
Nix / nix (ubuntu-latest) (pull_request) Failing after 12m25s
The `_Stub` fixture in tests/run_agent/test_concurrent_interrupt.py
bypasses `AIAgent.__init__`, so it must mirror any new instance attributes
that production methods rely on. Tool-loop guardrails (introduced in
58b89965 "fix(agent): add tool-call loop guardrails", 2026-04-27) added
three integration points to `_execute_tool_calls_concurrent`:

1. `self._tool_guardrails.before_call(...)` per tool (run_agent.py:9447)
2. `self._append_guardrail_observation(...)` per result (run_agent.py:9672)
3. `self._guardrail_block_result(...)` for blocked calls

`_Stub` defined none of these, so both
`test_concurrent_interrupt_cancels_pending` and
`test_running_concurrent_worker_sees_is_interrupted` raised
`AttributeError: '_Stub' object has no attribute '_tool_guardrails'`
on the first concurrent tool call.

Fix:
- Add a real `ToolCallGuardrailController()` instance attribute, matching
  AIAgent.__init__ at run_agent.py:1160. Default config is warning-only
  so the controller observes but never blocks — the tests still exercise
  interrupt fanout, not guardrail behaviour.
- Bind the real `_append_guardrail_observation` and `_guardrail_block_result`
  helpers from AIAgent (same pattern as the existing `_execute_tool_calls_concurrent`
  / `interrupt` / `clear_interrupt` bindings).
- Stub `_set_tool_guardrail_halt` as a no-op + add `_tool_guardrail_halt_decision = None`.
- Widen `slow_tool` and `polling_tool` side-effect signatures with `**kwargs`
  to swallow new production-only `_invoke_tool` kwargs (`messages`,
  `pre_tool_block_checked`).

Verification:
- pytest tests/run_agent/test_concurrent_interrupt.py -v   # 4/4 pass
- pytest tests/run_agent/                                  # 1193 passed,
  9 skipped, only pre-existing test_primary_runtime_restore failure
  (issue #9 cluster, untouched here).

Diff scope: single file, 21 insertions, 2 modifications.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 14:02:21 -07:00
3697e6cea2 fix(tui_gateway): drop pending_title on ValueError, retain on transient errors
Production bug + missing test coverage. c5b4c48 ("fix: lazy session
creation — defer DB row until first message (#18370)") moved
pending_title application from the eager _start_agent_build path to a
post-message-complete handler. The original block had:

    except ValueError as e:
        current["pending_title"] = None
        logger.info("Dropping pending title for session %s: %s", sid, e)
    except Exception:
        logger.warning("Failed to apply pending title ...", exc_info=True)

…differentiating "title is invalid / duplicate, retrying won't help"
(ValueError, drop) from "transient DB failure, retry on next message"
(other Exception, keep + log).

The replacement block collapsed both into:

    except Exception:
        pass  # Best effort — auto-title will handle it below

…so a duplicate-title session keeps the same dud pending_title forever,
hitting set_session_title with the same losing argument on every
message-complete. Auto-title can't kick in because pending_title still
shadows it.

Fix: extract a documented _apply_pending_session_title helper that
restores the three-branch semantics (success → clear, ValueError →
drop, other Exception → retain). Call it from the
message-complete handler instead of the inline try/except.

Test rewrite: the previous test_session_create_drops_pending_title_on_valueerror
exercised an obsolete code path (eager apply during session.create) that
no longer existed after c5b4c48. Replace with four focused tests against
the helper:

  - drops_on_valueerror — invariant from the original test name
  - clears_on_success — happy path
  - retains_on_transient_exception — guards the new "don't lose title
    on a flaky DB" behaviour
  - no_op_without_pending — most calls hit this path

Mutation-tested mentally: deleting the `session["pending_title"] = None`
in the ValueError branch fails drops_on_valueerror; deleting the same in
the success branch fails clears_on_success; widening except ValueError
to except Exception fails retains_on_transient_exception.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:57:52 -07:00
c8bc7cdab5 test(teams): patch adapter's TypingActivityInput binding for test_send_typing
The teams adapter imports TypingActivityInput at module load time:

    try:
        from microsoft_teams.api.activities.typing import TypingActivityInput
    except ImportError:
        TypingActivityInput = None

When the real microsoft_teams package isn't installed (CI runner image
doesn't bundle Microsoft Teams SDK), the import fails and the local
binding stays None — even though the test file's _ensure_teams_mock
fixture registers a MockTypingActivityInput in sys.modules. The
test-time mock-in-sys.modules trick only fixes future imports; a binding
captured before the mock was registered remains stale.

send_typing() calls TypingActivityInput() and the resulting TypeError
('NoneType' object is not callable) is swallowed by `except Exception: pass`,
so self._app.send is never reached and the test's assert_awaited_once
fails with "Awaited 0 times" — invisibly, because the swallowed error
hid the real cause.

Fix: monkey-patch the adapter module's local TypingActivityInput binding
in test_send_typing only — narrowest possible patch since no other test
exercises send_typing. Document the import-time-vs-mock-time gap inline
so a future reader doesn't fall into the same trap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:57:51 -07:00
ddbb1520c9 test(credential-pool): invert obsolete os.environ-wins test for #18254 fix
The stale invariant "os.environ wins over .env" was deliberately inverted
in 2ef1ad2 ("fix: prefer ~/.hermes/.env over os.environ when seeding
credential pool"). The fix targets the case where a parent shell (Codex
CLI, harness scripts) exports a stale OPENROUTER_API_KEY, the user updates
~/.hermes/.env with a fresh value, and Hermes silently 401s because
auth.json cached the stale env-var.

Rename + invert this test to assert the new invariant (.env wins). The
positive load_pool coverage already exists in
tests/agent/test_credential_pool.py::test_load_pool_prefers_dotenv_over_stale_os_environ
(added in 0a6865b alongside the fix); this case still serves a purpose
because it exercises _seed_from_env directly, which is a separate code
path from load_pool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:57:51 -07:00
d5f569581e test(acp): update commands list snapshot for steer + queue
acp_adapter/server.py:_ADVERTISED_COMMANDS now includes "steer" (inject
guidance into active turn) and "queue" (run prompt after current turn
finishes) between "compact" and "version". Production code is the source
of truth; this test was the last reader still on the pre-feature snapshot.

The substantive features were added in PRs that introduced steer/queue
themselves; this is purely test-snapshot follow-through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:57:50 -07:00
4e9e5d7319 Merge pull request 'fix(voice_mode): restore audio-env detection across clean/WSL/Termux scenarios (partial close hermes-agent#9)' (#12) from fix/voice-mode-detect-audio-env-container-stub into main
Some checks failed
Tests / e2e (push) Successful in 1m43s
Nix / nix (macos-latest) (push) Waiting to run
Docker Build and Publish / build-and-push (push) Has been skipped
Nix / nix (ubuntu-latest) (push) Failing after 13m3s
Tests / test (push) Failing after 14m15s
2026-05-08 20:48:47 +00:00
d2ef8095ff Merge pull request 'fix(test_dockerfile_pid1_reaping): align with current Dockerfile shape (partial close hermes-agent#9)' (#11) from fix/dockerfile-tui-test-drift-9 into main
Some checks failed
Tests / test (push) Has been cancelled
Tests / e2e (push) Has been cancelled
Nix / nix (macos-latest) (push) Waiting to run
Docker Build and Publish / build-and-push (push) Has been skipped
Nix / nix (ubuntu-latest) (push) Has been cancelled
2026-05-08 20:47:39 +00:00
dev-lead
a4fc156c8d fix(voice_mode): restore audio-env detection across clean/WSL/Termux scenarios
Some checks failed
Nix / nix (macos-latest) (pull_request) Waiting to run
Contributor Attribution Check / check-attribution (pull_request) Failing after 26s
Supply Chain Audit / Scan PR for critical supply chain risks (pull_request) Successful in 30s
Tests / e2e (pull_request) Successful in 1m48s
Nix / nix (ubuntu-latest) (pull_request) Failing after 9m42s
Tests / test (pull_request) Failing after 13m59s
Commit 5e1197a4 swapped the inline `os.path.exists('/.dockerenv')` check in
`detect_audio_environment()` for the more thorough `is_container()` helper
in `hermes_constants` (also matches /run/.containerenv and /proc/1/cgroup
markers, with module-level caching). That helper correctly returns True on
CI runners that themselves run inside Docker, which silently appended a
"Running inside Docker container" warning to every detection scenario and
broke four tests whose contract is "should be available":

  - test_clean_environment_is_available
  - test_wsl_with_pulse_allows_voice
  - test_wsl_device_query_fails_with_pulse_continues
  - test_termux_api_microphone_allows_voice_without_sounddevice

The five "should be blocked" sibling tests passed only by coincidence —
the extra container warning still left `available=False`.

Fix:
  - Hoist `is_container` to a module-level import in tools/voice_mode.py
    so it's reachable as `tools.voice_mode.is_container` (matches the
    monkeypatch convention used elsewhere in the test file for `shutil`,
    `_import_audio`, `_termux_api_app_installed`, etc).
  - Add an autouse fixture in `TestDetectAudioEnvironment` defaulting
    `is_container` to False, so tests don't inherit the host runner's
    container state. Per `feedback_no_such_thing_as_flakes`: the failures
    were a real environmental coupling bug, not a flake.
  - Add `test_docker_container_blocks_voice` to preserve and pin the
    container-blocks-voice intent that the original inline check encoded.

Partial close hermes-agent#9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:47:38 -07:00
dev-lead
bbd91bfa23 test(dockerfile): align tui-resolution assertions with post-a49f4c6 design
Some checks failed
Nix / nix (macos-latest) (pull_request) Waiting to run
Contributor Attribution Check / check-attribution (pull_request) Failing after 11s
Supply Chain Audit / Scan PR for critical supply chain risks (pull_request) Successful in 11s
Tests / e2e (pull_request) Successful in 32s
Nix / nix (ubuntu-latest) (pull_request) Failing after 12m25s
Tests / test (pull_request) Failing after 14m46s
Two contract tests in test_dockerfile_pid1_reaping.py guarded properties
of the @hermes/ink materialization dance introduced in 5f215b13 (PR
#16690): ``--prefix node_modules/@hermes/ink`` install + ``omit=dev`` +
nested-react cleanup + ``await import('@hermes/ink')`` smoke check.

That mechanism was retired in a49f4c6 ("fix: prevent tui rebuilding
assets") in favour of a simpler approach:

  1. Copy the full ``ui-tui/packages/hermes-ink/`` tree (not just
     manifests) so npm can resolve the ``file:`` workspace dep against
     real content rather than a bare package.json.
  2. Set ``ENV npm_config_install_links=false`` to force npm to install
     ``file:`` deps as symlinks on Debian's bundled npm 9.x (which
     defaults to ``install-links=true`` and installs as copies). The
     host-side lockfile is generated by npm 10+ using symlinks, so
     install-as-copy produces a hidden node_modules/.package-lock.json
     that permanently disagrees with the root lock on @hermes/ink — and
     that disagreement trips the TUI launcher's
     ``_tui_need_npm_install()`` check on every startup, triggering a
     runtime ``npm install`` that fails with EACCES.

The tests were never updated for the new design; they remained pinned
to the retired materialization step and the manifest-only COPY shape.

This commit:

- Updates ``test_dockerfile_installs_tui_dependencies`` to assert the
  full ``COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/``
  shape — catches a regression that reverts to manifest-only copies.

- Replaces ``test_dockerfile_materializes_local_tui_ink_package`` with
  ``test_dockerfile_forces_npm_install_links_false_for_workspace_resolution``,
  which scans the parsed instruction list for an ENV directive (not a
  comment) setting ``npm_config_install_links=false``. Negative-tested:
  removing only the ENV line correctly fails the assertion even with
  the explanatory comment block above it left intact.

PID-1 reaping tests (the file's primary purpose) are unmodified and
continue to assert tini install + ENTRYPOINT routing.

Partial close hermes-agent#9 — addresses 2 of the ~28 real failures
surfaced after the disk-pressure fix; does not touch the other ~19+
unrelated test failures in that issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:44:51 -07:00
12 changed files with 268 additions and 81 deletions

View File

@ -200,6 +200,8 @@ class TestSessionOps:
"context",
"reset",
"compact",
"steer",
"queue",
"version",
]
model_cmd = next(

View File

@ -397,13 +397,22 @@ class TestTeamsSend:
assert "Network error" in result.error
@pytest.mark.asyncio
async def test_send_typing(self):
async def test_send_typing(self, monkeypatch):
adapter = TeamsAdapter(_make_config(
client_id="id", client_secret="secret", tenant_id="tenant",
))
mock_app = MagicMock()
mock_app.send = AsyncMock()
adapter._app = mock_app
# The adapter module imports TypingActivityInput at load time; if
# the real microsoft_teams package isn'"'"'t installed, that local
# binding is None even though the test fixture registers a mock
# in sys.modules. Force a non-None local binding so the call to
# TypingActivityInput() inside send_typing succeeds and we actually
# reach self._app.send.
class _StubTypingActivityInput:
pass
monkeypatch.setattr(_teams_mod, "TypingActivityInput", _StubTypingActivityInput)
await adapter.send_typing("conv-id")
mock_app.send.assert_awaited_once()

View File

@ -64,6 +64,12 @@ class TestSystemdServiceRefresh:
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")
# Production now preflights user-systemd availability (loginctl
# enable-linger + D-Bus socket wait, #14531) before start/restart.
# These unit tests assert the systemctl call sequence, not the
# preflight — stub the preflight as a no-op so the fake subprocess
# runner doesn't have to reproduce the loginctl/D-Bus dance.
monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda *a, **kw: None)
calls = []
@ -87,6 +93,9 @@ class TestSystemdServiceRefresh:
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")
# See note on test_systemd_start_refreshes_outdated_unit — preflight
# is a separate concern and has its own dedicated coverage.
monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda *a, **kw: None)
calls = []
@ -108,6 +117,15 @@ class TestSystemdServiceRefresh:
class TestGeneratedSystemdUnits:
@staticmethod
def _expected_timeout_stop_sec() -> int:
# Mirror the formula in gateway.generate_systemd_unit:
# restart_timeout = max(60, drain_timeout) + 30
# so that bumping the default drain_timeout in config doesn't silently
# break this test — we want to pin the relationship, not a magic number.
drain_timeout = int(gateway_cli._get_restart_drain_timeout() or 0)
return max(60, drain_timeout) + 30
def test_user_unit_avoids_recursive_execstop_and_uses_extended_stop_timeout(self):
unit = gateway_cli.generate_systemd_unit(system=False)
@ -115,10 +133,13 @@ class TestGeneratedSystemdUnits:
assert "ExecStop=" not in unit
assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit
assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit
# TimeoutStopSec must exceed the default drain_timeout (60s) so
# TimeoutStopSec must exceed the configured drain_timeout so
# systemd doesn't SIGKILL the cgroup before post-interrupt cleanup
# (tool subprocess kill, adapter disconnect) runs — issue #8202.
assert "TimeoutStopSec=90" in unit
# Formula is max(60, drain_timeout) + 30; pin the relationship to
# _get_restart_drain_timeout() rather than a literal so a config
# default bump (default jumped 60→180s) doesn't silently regress us.
assert f"TimeoutStopSec={self._expected_timeout_stop_sec()}" in unit
def test_user_unit_includes_resolved_node_directory_in_path(self, monkeypatch):
monkeypatch.setattr(gateway_cli.shutil, "which", lambda cmd: "/home/test/.nvm/versions/node/v24.14.0/bin/node" if cmd == "node" else None)
@ -134,10 +155,13 @@ class TestGeneratedSystemdUnits:
assert "ExecStop=" not in unit
assert "ExecReload=/bin/kill -USR1 $MAINPID" in unit
assert f"RestartForceExitStatus={GATEWAY_SERVICE_RESTART_EXIT_CODE}" in unit
# TimeoutStopSec must exceed the default drain_timeout (60s) so
# TimeoutStopSec must exceed the configured drain_timeout so
# systemd doesn't SIGKILL the cgroup before post-interrupt cleanup
# (tool subprocess kill, adapter disconnect) runs — issue #8202.
assert "TimeoutStopSec=90" in unit
# Formula is max(60, drain_timeout) + 30; pin the relationship to
# _get_restart_drain_timeout() rather than a literal so a config
# default bump (default jumped 60→180s) doesn't silently regress us.
assert f"TimeoutStopSec={self._expected_timeout_stop_sec()}" in unit
assert "WantedBy=multi-user.target" in unit
@ -437,6 +461,10 @@ class TestGatewayServiceDetection:
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
monkeypatch.setattr(gateway_cli, "is_termux", lambda: False)
monkeypatch.setattr(gateway_cli, "is_wsl", lambda: False)
# Native-Linux assertion: explicitly opt out of the container path
# (added after this test was written) so a containerized CI runner
# doesn't inherit a probe of the real systemd in the runner image.
monkeypatch.setattr(gateway_cli, "is_container", lambda: False)
monkeypatch.setattr(gateway_cli.shutil, "which", lambda name: "/usr/bin/systemctl")
assert gateway_cli.supports_systemd_services() is True
@ -487,6 +515,11 @@ class TestGatewaySystemServiceRouting:
calls = []
monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False)
# Production now preflights user-systemd availability (loginctl
# enable-linger + D-Bus socket wait, #14531) before restart. This
# test exercises the restart routing path; preflight has its own
# dedicated coverage in TestUserSystemdPrivateSocketPreflight.
monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda *a, **kw: None)
monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: calls.append(("refresh", system)))
monkeypatch.setattr(
"gateway.status.get_running_pid",
@ -541,6 +574,9 @@ class TestGatewaySystemServiceRouting:
def test_systemd_restart_recovers_failed_planned_restart(self, monkeypatch, capsys):
monkeypatch.setattr(gateway_cli, "_select_systemd_scope", lambda system=False: False)
# See note on test_systemd_restart_self_requests_graceful_restart_and_waits
# — preflight is a separate concern with dedicated coverage.
monkeypatch.setattr(gateway_cli, "_preflight_user_systemd", lambda *a, **kw: None)
monkeypatch.setattr(gateway_cli, "refresh_systemd_unit_if_needed", lambda system=False: None)
monkeypatch.setattr(
"gateway.status.read_runtime_status",

View File

@ -141,10 +141,19 @@ class TestSupportsSystemdServicesWSL:
assert gateway.supports_systemd_services() is False
def test_native_linux(self, monkeypatch):
"""Native Linux (not WSL) → True without checking systemd."""
"""Native Linux (not WSL, not container) → True without further probing."""
monkeypatch.setattr(gateway, "is_linux", lambda: True)
monkeypatch.setattr(gateway, "is_termux", lambda: False)
monkeypatch.setattr(gateway, "is_wsl", lambda: False)
# supports_systemd_services() now also branches on is_container() to
# decide whether to probe `systemctl is-system-running` — explicitly
# opt this case out of the container path so a containerized CI
# runner doesn't inherit the probe of the runner image's systemd.
monkeypatch.setattr(gateway, "is_container", lambda: False)
# On macOS dev boxes shutil.which("systemctl") returns None; stub it
# so the test exercises the native-Linux branch independently of the
# host's $PATH.
monkeypatch.setattr(gateway.shutil, "which", lambda name: "/usr/bin/systemctl")
assert gateway.supports_systemd_services() is True
def test_termux_still_excluded(self, monkeypatch):

View File

@ -478,9 +478,19 @@ def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
kb.init_db()
# Stub web_server so _check_ws_token has a token to compare against.
# NOTE: monkeypatch.setitem(sys.modules, ...) alone is not enough.
# If another test in the same xdist worker has already imported
# hermes_cli.web_server, the parent package `hermes_cli` has the real
# module bound as an attribute. `from hermes_cli import web_server`
# then resolves via the attribute, NOT sys.modules — so the stub is
# bypassed and _check_ws_token compares against the real (random)
# _SESSION_TOKEN, rejecting our "secret-xyz" branch with 1008.
# Patching the parent package attribute keeps both lookup paths in sync.
import types
import hermes_cli
stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
monkeypatch.setattr(hermes_cli, "web_server", stub, raising=False)
app = FastAPI()
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")

View File

@ -20,6 +20,7 @@ def _make_agent(monkeypatch):
monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "")
# Avoid full AIAgent init — just import the class and build a stub
import run_agent as _ra
from agent.tool_guardrails import ToolCallGuardrailController
class _Stub:
_interrupt_requested = False
@ -53,6 +54,12 @@ def _make_agent(monkeypatch):
self._tool_worker_threads: set = set()
self._tool_worker_threads_lock = threading.Lock()
self._active_children_lock = threading.Lock()
# Mirror AIAgent.__init__ (run_agent.py:1160 — added in 58b89965
# "fix(agent): add tool-call loop guardrails", 2026-04-27).
# _execute_tool_calls_concurrent calls self._tool_guardrails
# .before_call(...) on every tool, so the stub needs a real
# controller instance with default (warning-only) config.
self._tool_guardrails = ToolCallGuardrailController()
def _touch_activity(self, desc):
self._last_activity = time.time()
@ -77,6 +84,14 @@ def _make_agent(monkeypatch):
stub._execute_tool_calls_concurrent = _ra.AIAgent._execute_tool_calls_concurrent.__get__(stub)
stub.interrupt = _ra.AIAgent.interrupt.__get__(stub)
stub.clear_interrupt = _ra.AIAgent.clear_interrupt.__get__(stub)
# Tool-loop guardrails (added in 58b89965, 2026-04-27) are invoked
# before/after every concurrent tool. Bind the real helpers — the
# default ToolCallGuardrailController() above is warning-only so
# they never block a tool, just observe.
stub._append_guardrail_observation = _ra.AIAgent._append_guardrail_observation.__get__(stub)
stub._guardrail_block_result = _ra.AIAgent._guardrail_block_result.__get__(stub)
stub._set_tool_guardrail_halt = lambda *a, **kw: None
stub._tool_guardrail_halt_decision = None
# /steer injection (added in PR #12116) fires after every concurrent
# tool batch. Stub it as a no-op — this test exercises interrupt
# fanout, not steer injection.
@ -107,7 +122,9 @@ def test_concurrent_interrupt_cancels_pending(monkeypatch):
original_invoke = agent._invoke_tool
def slow_tool(name, args, task_id, call_id=None):
def slow_tool(name, args, task_id, call_id=None, **kwargs):
# **kwargs swallows production-only kwargs (messages,
# pre_tool_block_checked) added to _invoke_tool over time.
if name == "slow_one":
# Block until the test sets the interrupt
barrier.wait(timeout=10)
@ -184,7 +201,9 @@ def test_running_concurrent_worker_sees_is_interrupted(monkeypatch):
observed = {"saw_true": False, "poll_count": 0, "worker_tid": None}
worker_started = threading.Event()
def polling_tool(name, args, task_id, call_id=None, messages=None):
def polling_tool(name, args, task_id, call_id=None, messages=None, **kwargs):
# **kwargs swallows production-only kwargs (pre_tool_block_checked)
# added to _invoke_tool over time.
observed["worker_tid"] = threading.current_thread().ident
worker_started.set()
deadline = time.monotonic() + 5.0

View File

@ -753,57 +753,63 @@ def test_session_title_set_errors_when_row_lookup_fails_after_noop(monkeypatch):
server._sessions.pop("sid", None)
def test_session_create_drops_pending_title_on_valueerror(monkeypatch):
unblock_agent = threading.Event()
class _FakeWorker:
def __init__(self, key, model):
self.key = key
def close(self):
return None
class _FakeAgent:
model = "x"
provider = "openrouter"
base_url = ""
api_key = ""
class _FakeDB:
def create_session(self, _key, source="tui", model=None):
return None
def test_apply_pending_session_title_drops_on_valueerror():
"""ValueError from set_session_title (e.g. duplicate title) must drop
the pending_title so a stuck title doesn't keep retrying forever.
Originally tested via the eager-apply path in _start_agent_build, which
was removed by c5b4c48 (#18370, lazy session creation) and replaced by
a post-message-complete apply that only `except Exception: pass`'d —
losing the ValueError-specific drop semantics. The helper restores
them; this test asserts that.
"""
class _RaisingDB:
def set_session_title(self, _key, _title):
raise ValueError("Title already in use")
def _make_agent(_sid, _key):
unblock_agent.wait(timeout=2.0)
return _FakeAgent()
monkeypatch.setattr(server, "_make_agent", _make_agent)
monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
monkeypatch.setattr(server, "_session_info", lambda _a: {"model": "x"})
monkeypatch.setattr(server, "_probe_credentials", lambda _a: None)
monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None)
monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
import tools.approval as _approval
monkeypatch.setattr(_approval, "register_gateway_notify", lambda key, cb: None)
monkeypatch.setattr(_approval, "load_permanent_allowlist", lambda: None)
resp = server.handle_request(
{"id": "1", "method": "session.create", "params": {"cols": 80}}
)
sid = resp["result"]["session_id"]
session = server._sessions[sid]
session["pending_title"] = "duplicate title"
unblock_agent.set()
session["agent_ready"].wait(timeout=2.0)
session = {"session_key": "k1", "pending_title": "duplicate title"}
server._apply_pending_session_title(session, "sid-1", _RaisingDB())
assert session["pending_title"] is None
def test_apply_pending_session_title_clears_on_success():
class _OkDB:
def set_session_title(self, _key, _title):
return True
session = {"session_key": "k2", "pending_title": "Real title"}
server._apply_pending_session_title(session, "sid-2", _OkDB())
assert session["pending_title"] is None
def test_apply_pending_session_title_retains_on_transient_exception():
"""A transient (non-ValueError) DB failure should keep the pending
title queued so the next message-complete can retry. Without this
behaviour, a single flaky DB call would silently lose the title."""
class _FlakyDB:
def set_session_title(self, _key, _title):
raise RuntimeError("transient db blip")
session = {"session_key": "k3", "pending_title": "Keep retrying"}
server._apply_pending_session_title(session, "sid-3", _FlakyDB())
assert session["pending_title"] == "Keep retrying"
def test_apply_pending_session_title_no_op_without_pending():
"""Helper must be a no-op when pending_title is None — most calls
look like this (every message-complete on a session that already has
a title applied)."""
class _ShouldNotBeCalledDB:
def set_session_title(self, _key, _title):
raise AssertionError("DB must not be touched when no pending title")
session = {"session_key": "k4", "pending_title": None}
server._apply_pending_session_title(session, "sid-4", _ShouldNotBeCalledDB())
assert session["pending_title"] is None
server._sessions.pop(sid, None)
def test_config_set_yolo_toggles_session_scope():

View File

@ -106,10 +106,20 @@ class TestCredentialPoolSeedsFromDotEnv:
assert active_sources == set()
assert entries == []
def test_os_environ_still_wins_over_dotenv(self, isolated_hermes_home, monkeypatch):
"""get_env_value checks os.environ first — verify seeding picks that up."""
_write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-stale")
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-env-fresh-xyz")
def test_dotenv_wins_over_stale_os_environ(self, isolated_hermes_home, monkeypatch):
""".env should win over a stale os.environ value.
Inverted from the pre-#18254 behaviour. Stale env vars inherited
from parent shells (Codex CLI, test harnesses) used to shadow
deliberate updates to ~/.hermes/.env, causing auth.json to cache
an outdated key and silent 401 errors. The invariant now is:
when a key appears in both sources, .env wins.
Sister coverage in tests/agent/test_credential_pool.py exercises
the load_pool path; this case exercises _seed_from_env directly.
"""
_write_env_file(isolated_hermes_home, DEEPSEEK_API_KEY="sk-dotenv-fresh")
monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-env-stale-xyz")
from agent.credential_pool import _seed_from_env
entries = []
@ -118,7 +128,7 @@ class TestCredentialPoolSeedsFromDotEnv:
assert changed is True
seeded = [e for e in entries if e.source == "env:DEEPSEEK_API_KEY"]
assert len(seeded) == 1
assert seeded[0].access_token == "sk-env-fresh-xyz"
assert seeded[0].access_token == "sk-dotenv-fresh"
class TestAuthResolvesFromDotEnv:

View File

@ -106,8 +106,18 @@ def test_dockerfile_entrypoint_routes_through_the_init(dockerfile_text):
def test_dockerfile_installs_tui_dependencies(dockerfile_text):
"""The Dockerfile must install ui-tui's npm dependencies during build,
and must copy the @hermes/ink workspace tree (not just its manifests)
so npm can resolve the ``file:`` workspace dep without falling back to
the bare manifest. See PR #16690 + a49f4c6 for the design.
"""
assert "ui-tui/package.json" in dockerfile_text
assert "ui-tui/packages/hermes-ink/package-lock.json" in dockerfile_text
# ui-tui/packages/hermes-ink/ is referenced as a `file:` workspace dep
# from ui-tui/package.json. Copying the FULL tree (rather than just
# package.json + package-lock.json as in earlier revisions) is what lets
# npm resolve the workspace to real content. This assertion catches a
# regression that reverts to manifest-only copies.
assert "COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/" in dockerfile_text
assert any(
"ui-tui" in step and "npm" in step and (" install" in step or " ci" in step)
for step in _run_steps(dockerfile_text)
@ -121,17 +131,33 @@ def test_dockerfile_builds_tui_assets(dockerfile_text):
)
def test_dockerfile_materializes_local_tui_ink_package(dockerfile_text):
assert any(
"ui-tui" in step
and "node_modules/@hermes/ink" in step
and "packages/hermes-ink" in step
and "rm -rf packages/hermes-ink/node_modules" in step
and "npm install --omit=dev" in step
and "--prefix node_modules/@hermes/ink" in step
and "rm -rf node_modules/@hermes/ink/node_modules/react" in step
and "await import('@hermes/ink')" in step
for step in _run_steps(dockerfile_text)
def test_dockerfile_forces_npm_install_links_false_for_workspace_resolution(dockerfile_text):
"""The Dockerfile must force npm to install ``file:`` deps as symlinks
rather than copies.
Debian's bundled npm 9.x defaults to ``install-links=true`` (deps
installed as copies). The host-side ``ui-tui/package-lock.json`` is
generated by npm 10+ which uses symlinks, so an install-as-copy in the
image produces a hidden ``node_modules/.package-lock.json`` that
permanently disagrees with the root lockfile on the @hermes/ink entry.
That disagreement trips the TUI launcher's ``_tui_need_npm_install()``
check on every startup and triggers a runtime ``npm install`` that
fails with EACCES (node_modules/ is root-owned from build time).
This assertion replaces the older ``--prefix node_modules/@hermes/ink``
materialization smoke test (PR #16690), which was retired in a49f4c6
in favour of ``install-links=false`` because the materialization step
rebuilt TUI assets unnecessarily on every container start.
"""
instructions = _dockerfile_instructions(dockerfile_text)
has_env_directive = any(
instr.startswith("ENV ") and "npm_config_install_links=false" in instr
for instr in instructions
)
assert has_env_directive, (
"ENV npm_config_install_links=false missing — without it, Debian npm 9.x "
"installs `file:` deps as copies, breaking @hermes/ink workspace "
"resolution at runtime. See PR #16690 + a49f4c6."
)

View File

@ -61,6 +61,16 @@ def mock_sd(monkeypatch):
# ============================================================================
class TestDetectAudioEnvironment:
@pytest.fixture(autouse=True)
def _isolate_container_detection(self, monkeypatch):
"""Default `is_container` to False so tests don't inherit the host
runner's container state (e.g. CI itself runs inside Docker, where
the production `is_container()` returns True via /.dockerenv or
/proc/1/cgroup and silently appended a 'Running inside Docker'
warning to every scenario). Individual tests opt in via setattr.
"""
monkeypatch.setattr("tools.voice_mode.is_container", lambda: False)
def test_clean_environment_is_available(self, monkeypatch):
"""No SSH, Docker, or WSL — should be available."""
monkeypatch.delenv("SSH_CLIENT", raising=False)
@ -85,6 +95,20 @@ class TestDetectAudioEnvironment:
assert result["available"] is False
assert any("SSH" in w for w in result["warnings"])
def test_docker_container_blocks_voice(self, monkeypatch):
"""Running inside a Docker/Podman container should block voice mode."""
monkeypatch.delenv("SSH_CLIENT", raising=False)
monkeypatch.delenv("SSH_TTY", raising=False)
monkeypatch.delenv("SSH_CONNECTION", raising=False)
monkeypatch.setattr("tools.voice_mode.is_container", lambda: True)
monkeypatch.setattr("tools.voice_mode._import_audio",
lambda: (MagicMock(), MagicMock()))
from tools.voice_mode import detect_audio_environment
result = detect_audio_environment()
assert result["available"] is False
assert any("Docker container" in w for w in result["warnings"])
def test_wsl_without_pulse_blocks_voice(self, monkeypatch, tmp_path):
"""WSL without PULSE_SERVER should block voice mode."""
monkeypatch.delenv("SSH_CLIENT", raising=False)

View File

@ -49,7 +49,7 @@ def _audio_available() -> bool:
return False
from hermes_constants import is_termux as _is_termux_environment
from hermes_constants import is_container, is_termux as _is_termux_environment
def _voice_capture_install_hint() -> str:
@ -103,7 +103,6 @@ def detect_audio_environment() -> dict:
warnings.append("Running over SSH -- no audio devices available")
# Docker/Podman container detection
from hermes_constants import is_container
if is_container():
warnings.append("Running inside Docker container -- no audio devices")

View File

@ -515,6 +515,50 @@ def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None:
return _err(rid, 5032, err) if err else None
def _apply_pending_session_title(
session: dict, sid: str, db: object | None
) -> None:
"""Apply session["pending_title"] to the DB via db.set_session_title.
Pending titles are queued during session.create (before the DB row
exists, since c5b4c48 deferred row creation to first message) and
flushed here once a message-complete event lands.
Outcome by branch:
- set_session_title returns truthy: pending_title cleared.
- ValueError (title invalid / duplicate): pending_title dropped,
because retrying with the same value will fail the same way.
Auto-title later picks a fresh title from message content.
- other Exception: pending_title retained likely a transient DB
failure worth retrying on the next message-complete.
No-ops when there is no pending title or no DB.
Pre-c5b4c48 (#18370) the same semantics lived inline in
_start_agent_build. Extracting them here both restores the lost
ValueError handling and makes the invariant testable without
simulating a full message turn.
"""
pending = session.get("pending_title")
if not pending or db is None:
return
key = session.get("session_key") or sid
try:
if db.set_session_title(key, pending):
session["pending_title"] = None
except ValueError as exc:
# Title invalid / duplicate — retrying is futile; drop and let
# auto-title pick something.
session["pending_title"] = None
logger.info("Dropping pending title for session %s: %s", sid, exc)
except Exception:
# Likely transient — keep pending_title so the next
# message-complete can retry. Auto-title is the eventual fallback.
logger.warning(
"Failed to apply pending title for session %s", sid, exc_info=True,
)
def _start_agent_build(sid: str, session: dict) -> None:
"""Start building the real AIAgent for a TUI session, once.
@ -2982,15 +3026,8 @@ def _run_prompt_submit(rid, sid: str, session: dict, text: Any) -> None:
_emit("message.complete", sid, payload)
# Apply pending_title now that the DB row exists.
_pending = session.get("pending_title")
if _pending and status == "complete":
_pdb = _get_db()
if _pdb:
try:
if _pdb.set_session_title(session.get("session_key") or sid, _pending):
session["pending_title"] = None
except Exception:
pass # Best effort — auto-title will handle it below
if status == "complete":
_apply_pending_session_title(session, sid, _get_db())
if (
status == "complete"