5 Commits
Author SHA1 Message Date
ARIA 8657e6afc6 fix(http): test acquire_scoped_lock's bool, not the always-truthy tuple
CI / Kotlin tests (android host + desktop) (push) Successful in 5m48s
CI / Gateway plugin tests (push) Successful in 7m40s
acquire_scoped_lock returns (acquired, existing_record); the old
'if not acquire_scoped_lock(...)' tested the tuple, which is always
truthy, so the 'port in use by another profile' pre-check never fired
and a conflict surfaced as a generic bind failure. Unpack and test the
first element, matching gateway/platforms/base.py's canonical usage.

Bump VERSION / plugin.yaml to 0.1.3.
2026-08-31 23:17:30 +02:00
ARIA 5b78e1566f docs(AGENTS): refresh to match current codebase
CI / Gateway plugin tests (push) Canceled after 0s
CI / Kotlin tests (android host + desktop) (push) Canceled after 0s
- remove hardcoded ADB serial (differs per developer/machine)
- add second pre-commit hook (check_version_sync) to hard rules
- document tests/, scripts/, backdrops/, CI-SETUP.md in layout
- note committed tests/test_android.py copy + test_android_http.py
2026-08-29 00:12:37 +02:00
ARIA 0f5b5a16ab Fix gateway advertising 'unknown' version in production installs
CI / Gateway plugin tests (push) Successful in 5m56s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m29s
hermes plugins install <repo>#gateway-plugin ships ONLY the
gateway-plugin/ subdirectory into ~/.hermes/plugins/iris, so the
repo-root VERSION file is not present there and plugin_version()
fell back to 'unknown' — the app then showed a false
'App and gateway versions differ' warning.

- version.py: resolution chain repo-root VERSION (dev/symlink
  install) -> plugin.yaml version field (production install) ->
  'unknown'; stdlib-only parse, base= param for tests
- plugin.yaml: bump stale version 0.1.0 -> 0.1.2 (matches VERSION)
- scripts/check_version_sync.sh + pre-commit hook: fail commits
  where plugin.yaml drifts from the repo-root VERSION
- tests: regression test simulating the production layout
2026-08-27 09:33:31 +02:00
ARIA ea375fd88c Fix double notification + double unread count for cron deliveries
CI / Gateway plugin tests (push) Successful in 8m58s
CI / Kotlin tests (android host + desktop) (push) Failing after 13m28s
A cron delivery emits two frames (high-priority notification banner +
message). Backgrounded, each posted its own system notification
('Cron: <job_id>' and the channel name), and the message frame's
redelivery (live SSE + sync replay after the push-triggered reconnect)
incremented the unread badge twice.

- Record when a high-priority banner (cron/approval/clarify) was posted
  per lane; suppress the message frame's system notification within a
  5 s window (mirrors the gateway's push-coalesce window).
- Dedupe unread counting per lane by message id so a redelivered frame
  only counts once (recorded only when the message actually counts as
  unread).
2026-08-27 09:23:49 +02:00
ARIA b065d1783b Docs: clarify ntfy privacy — default server is public ntfy.sh
CI / Gateway plugin tests (push) Successful in 8m58s
CI / Kotlin tests (android host + desktop) (push) Failing after 13m32s
The docs claimed ntfy keeps push metadata on your own infrastructure,
but the default NTFY_SERVER_URL is the public https://ntfy.sh cloud
service. Make explicit that push metadata (topic, notification title)
passes through ntfy.sh unless you self-host ntfy.
2026-08-27 09:23:44 +02:00
13 changed files with 241 additions and 43 deletions

No files matched your search

+6
View File
@@ -13,3 +13,9 @@ repos:
language: system
pass_filenames: false
always_run: true
- id: check-version-sync
name: check gateway-plugin/plugin.yaml version == repo-root VERSION
entry: scripts/check_version_sync.sh
language: system
pass_filenames: false
always_run: true
+7 -2
View File
@@ -3,6 +3,7 @@
## Hard rules
- `hermes-agent/` is a **read-only research reference** (git-ignored). Never commit, push, or modify it — a pre-commit hook (`scripts/guard_hermes_agent.sh --staged`) fails any commit that stages it. Never modify hermes core; we only install our plugin into the live hermes home.
- A second pre-commit hook (`scripts/check_version_sync.sh`) fails any commit where the `gateway-plugin/plugin.yaml` version ≠ the repo-root `VERSION` file — keep them in sync.
- **Commit/push scope:** when asked to "commit and push all changes," that means **all** changes in the working tree — it does NOT matter whether a change was made this session or earlier. Stage everything (`git add .`) and commit; do not cherry-pick or second-guess which files are "yours." The only exception is `hermes-agent/` (git-ignored, never staged).
- The plugin is installed by symlink: `~/.hermes/plugins/iris` → `<repo>/gateway-plugin` (already set up on this machine).
@@ -11,12 +12,16 @@
- `gateway-plugin/` — Python hermes platform plugin (`android`). No build step, zero new deps (stdlib + hermes-provided `websockets`/`httpx`). `protocol.py` is the frame source of truth, mirrored in `app/shared/.../protocol/Protocol.kt` and `docs/protocol/frames.schema.json`.
- `app/` — one Compose Multiplatform Gradle project: `:shared` (KMP, most of the code; `jvmMain` is shared by the android and desktop targets since both are JVM-based), `:androidApp` (thin shell, package `dev.iris.app`), `:desktopApp` (thin shell).
- `docs/` — numbered reference library; read `docs/00-overview.md` first. Locked decisions: `docs/16-open-questions.md`.
- `tests/` — committed Python test suite: `test_android.py` (plugin tests; a copy of the hermes-agent mirror described below), `test_android_http.py` (HTTP fallback transport, see `docs/19-http-fallback-transport.md`), `ws_probe.py`, `e2e.py`, `README.md`.
- `scripts/` — pre-commit guards (`guard_hermes_agent.sh`, `check_version_sync.sh`) and `make_release_keystore.sh`.
- `backdrops/` — backdrop/wallpaper images (Pexels) used by the app theme.
- `CI-SETUP.md` — Gitea CI/release setup reference.
## Commands
- `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`).
- Gateway: `hermes gateway setup` (one-time; generates `IRIS_TOKEN` in `~/.hermes/.env`, prints the token only once) → `hermes gateway` (run) → `hermes gateway status`.
- Android: check the device is connected first (`adb devices` → `a5ca2a4b` listed as `device`); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below).
- Android: check the device is connected first (`adb devices` → your device's serial listed as `device`; the serial differs per developer/machine); then `cd app && ./gradlew :androidApp:installDebug` to install on the phone and live-verify changes (launch/screenshot: see ADB below).
- Desktop: `cd app && ./gradlew :desktopApp:run`; packaging: `:desktopApp:jpackage` (app-image; `-PjpackageType=deb` for a .deb).
- Python tests — **never bare `pytest`**: `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (no args = full suite).
- Kotlin tests: `cd app && ./gradlew :shared:testAndroidHostTest` / `:shared:desktopTest` (host-side; `jvmTest` is the shared source set).
@@ -34,7 +39,7 @@
## Testing quirks
- `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `IRIS_PLUGIN_DIR`); HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`.
- `hermes-agent/tests/gateway/test_android.py` is a thin mirror that imports the **live `gateway-plugin/` package from this repo** (override with `IRIS_PLUGIN_DIR`); the committed `tests/test_android.py` is a copy of it, and `tests/test_android_http.py` covers the HTTP fallback transport. HERMES_HOME is sandboxed per-test by the conftest. Tests must never touch the real `~/.hermes`.
- e2e scenarios 3 (reasoning) and 5 (commentary) are model-dependent → SKIP; 11 (push) and 12 (reconnect) are PARTIAL by design.
- ADB: launch `adb shell am start -n dev.iris.app/.MainActivity`; reset pairing state `adb shell pm clear dev.iris.app`; screenshot `adb exec-out screencap -p > /tmp/shot.png`.
- ADB UI taps: **never guess tap coordinates from a screenshot** — dump the hierarchy and tap the element's real bounds: `adb shell uiautomator dump` → `adb pull /sdcard/window_dump.xml` → find the node by `text` / `content-desc` / `resource-id` → `adb shell input tap` at the center of its `bounds="[x1,y1][x2,y2]"`. Re-dump after every navigation; if a tap misses, the dump is stale — re-dump, don't nudge coordinates.
+10 -6
View File
@@ -7,9 +7,11 @@ Iris pairs with your running `hermes gateway` over a private, token-authenticate
## Features
- **Native Hermes-Gateway integration** — your hermes → gateway → Iris app
- **Absolute Privacy!** — everything stays on your own infrastructure
(push: ntfy by default; FCM is opt-in and routes push metadata via Google —
see [Push notifications](#push-notifications))
- **Absolute Privacy!** — chat stays on your own infrastructure
(push: ntfy by default, **but the default ntfy server is the public
`ntfy.sh`** — self-host ntfy to keep push metadata on your own machine;
FCM is opt-in and routes push metadata via Google — see
[Push notifications](#push-notifications))
- **100 MB file uploads by default** — configurable on the gateway via
`max_upload_bytes` (see [Media](docs/07-media.md) §7.7); all limits are set
on the gateway side (hermes), not in the app
@@ -48,9 +50,11 @@ hermes-agent ──> hermes gateway ──(HTTP :8791)──> Iris app (Android
Push wakes a backgrounded/offline device; on reconnect the app syncs the
outbox, so nothing is lost.
- **ntfy (default)** — push metadata stays on your own infrastructure
(self-hosted ntfy recommended). This is the backend for truly private
communication.
- **ntfy (default)** — the backend for truly private communication.
⚠️ **By default it uses the public `https://ntfy.sh` cloud service** — push
metadata (topic, notification title) passes through ntfy.sh's servers.
Set `NTFY_SERVER_URL` to a **self-hosted ntfy** to keep push metadata on
your own infrastructure (recommended; public ntfy.sh SSE is also flaky).
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard/reliable, but FCM push
metadata (notification title, device token) is routed through **Google's
servers**. If you want truly private communication, use ntfy instead.
+1 -1
View File
@@ -1 +1 @@
0.1.2
0.1.3
@@ -95,6 +95,8 @@ import kotlinx.coroutines.launch
import java.util.Collections
import java.util.concurrent.atomic.AtomicLong
import kotlin.random.Random
import kotlin.time.TimeMark
import kotlin.time.TimeSource
/**
* App-level controller (M3): owns the GatewayClient + ChatStore + ChannelStore,
@@ -188,13 +190,34 @@ class IrisController(
_foreground.value = fg
}
/** M8: the last message id whose arrival incremented [lane]'s unread
* badge. The same finalized message can be delivered twice (live SSE
* plus the sync replay after a push-triggered reconnect) — only the
* first delivery may count. Frame-handler coroutine only. */
private val countedMessageIds = HashMap<String, String>()
/** M5/M8: when a high-priority notification banner (cron/approval/clarify)
* was last posted per lane. Cron delivery = notification frame + message
* frame; the banner already announced the delivery, so the accompanying
* message frame must not post a second system notification. Frame-handler
* coroutine only. */
private val lastHighPriorityBannerAt = HashMap<String, TimeMark>()
/** M8: a finalized assistant message arrived in [lane]. Count it as unread
* unless the user is actively reading that lane right now (it is the
* current lane, the app is focused, and the newest content is at the
* bottom of the viewport). */
private fun noteIncomingAssistantMessage(lane: String) {
* bottom of the viewport). [messageId] dedupes redeliveries of the same
* frame (see [countedMessageIds]). */
private fun noteIncomingAssistantMessage(
lane: String,
messageId: String?,
) {
if (messageId != null && countedMessageIds[lane] == messageId) return
val beingRead = lane == chat.currentLane.value && isAppForeground() && currentLaneAtBottom
if (!beingRead) chat.markUnread(lane)
if (!beingRead) {
if (messageId != null) countedMessageIds[lane] = messageId
chat.markUnread(lane)
}
}
/** M8: the user is now viewing the current lane's newest content — clear
@@ -332,6 +355,11 @@ class IrisController(
/** Max simultaneous banners; persistent ones are exempt from the cap. */
private const val MAX_BANNERS = 5
/** Window in which a high-priority banner suppresses the system
* notification for its accompanying message frame (mirrors the
* gateway's push-coalesce window, classify._PUSH_COALESCE_S). */
private const val BANNER_NOTIFY_SUPPRESS_MS = 5_000L
const val FONT_SCALE_MIN = 0.8f
const val FONT_SCALE_MAX = 1.5f
@@ -523,6 +551,13 @@ class IrisController(
) {
if (isAppForeground()) return
if (text.isBlank()) return
// A high-priority banner (cron/approval/clarify) for this lane just
// announced this delivery — don't stack a second notification for the
// accompanying message frame.
chatId?.let { cid ->
val mark = lastHighPriorityBannerAt[chat.laneKey(cid, threadId)]
if (mark != null && mark.elapsedNow().inWholeMilliseconds < BANNER_NOTIFY_SUPPRESS_MS) return
}
val id = chatId ?: "default"
val chatName = channels.byId(id)?.name
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
@@ -647,7 +682,7 @@ class IrisController(
// content — count it as unread unless the
// user is reading this lane right now.
frame.chatId?.let { cid ->
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId), it.messageId)
}
if (!alreadyConsumed && !isPushedReplay(frame)) {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
@@ -660,7 +695,7 @@ class IrisController(
if (it.role == ROLE_ASSISTANT) {
// M8: a finalized (non-streaming) reply.
frame.chatId?.let { cid ->
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId))
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId), it.messageId)
}
if (!alreadyConsumed && !isPushedReplay(frame)) {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
@@ -787,6 +822,15 @@ class IrisController(
// sync replay) must not re-show the banner.
if (!alreadyConsumed) {
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
// Cron delivery = notification frame + message frame: remember
// that the banner announced this lane so the message frame
// doesn't post a second system notification.
if (p.kind in HIGH_PRIORITY_NOTIF_KINDS) {
p.chatId?.let { cid ->
lastHighPriorityBannerAt[chat.laneKey(cid, p.threadId)] =
TimeSource.Monotonic.markNow()
}
}
// M5: the connection is live but the app is backgrounded — the
// in-app banner is invisible, so mirror to a system
// notification (the push backend only fires when
+5 -2
View File
@@ -3,8 +3,11 @@
The gateway can't reach a sleeping phone directly. Push goes through a cloud
relay. **Decision: ntfy default, FCM optional** (`IRIS_PUSH_BACKEND`).
Privacy: FCM push metadata (notification title, device token) is routed
through Google's servers — for truly private communication use ntfy
(self-hosted), which keeps everything on your own infrastructure.
through Google's servers. ntfy is the private option — **but note the default
`NTFY_SERVER_URL` is the public `https://ntfy.sh` cloud service**, so push
metadata passes through ntfy.sh's servers unless you self-host ntfy (set
`NTFY_SERVER_URL`); only a self-hosted ntfy keeps everything on your own
infrastructure.
## 8.1 When push fires
+6 -3
View File
@@ -201,9 +201,12 @@ app is closed. Nothing is lost either way — on reconnect the app syncs its
outbox.
- **ntfy (default)** — the phone generates its own topic automatically; the
gateway publishes to it. Set `NTFY_SERVER_URL` to a **self-hosted ntfy**
for reliability (the public `ntfy.sh` SSE endpoint is flaky). Push metadata
stays on your own infrastructure — this is the private option.
gateway publishes to it. ⚠️ **The default server is the public
`https://ntfy.sh` cloud service** — push metadata (topic, notification
title) passes through ntfy.sh's servers. Set `NTFY_SERVER_URL` to a
**self-hosted ntfy** to keep push metadata on your own infrastructure —
that is the private option (and also more reliable: the public `ntfy.sh`
SSE endpoint is flaky).
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard and reliable, but push
metadata (notification title, device token) is routed through **Google's
servers**. Needs a Firebase project + `google-services.json` in the app
+11 -8
View File
@@ -16,8 +16,9 @@ threads, media, search — Telegram-quality, on your own infrastructure.
**Absolute Privacy!** — everything stays on your own infrastructure:
- Your gateway, your machine, your data. No cloud middleman for chat.
- **Push notifications:** ntfy by default — push metadata stays on your own
(self-hosted) ntfy server.
- **Push notifications:** ntfy by default. Note: out of the box it uses the
public ntfy.sh service; self-host ntfy (one env var) to keep push metadata
on your own server.
- **FCM is opt-in** (`IRIS_PUSH_BACKEND=fcm`): standard and reliable, but FCM
push metadata (notification title, device token) is routed through
**Google's servers**. If you want truly private communication, use ntfy
@@ -32,10 +33,12 @@ Runs on Android and desktop (Linux, macOS, Windows) from one shared codebase.
## Privacy note (for the "Data safety" section / FAQ)
Iris talks directly to your own hermes gateway over a private, token-authenticated
connection. By default, push notifications use ntfy, which you can self-host so
that push metadata never leaves your infrastructure. If you explicitly enable
FCM, push metadata (notification title, device token) is sent via Google's FCM
servers; chat content itself is not sent to Google — FCM only carries a short
preview, and full content is fetched from your gateway over the authenticated
connection. By default, push notifications use ntfy — out of the box via the
public ntfy.sh service (push metadata such as the topic and notification title
passes through ntfy.sh's servers); self-host ntfy (one env var) so that push
metadata never leaves your infrastructure. If you explicitly enable FCM, push
metadata (notification title, device token) is sent via Google's FCM servers;
chat content itself is not sent to Google — FCM only carries a short preview,
and full content is fetched from your gateway over the authenticated
connection. For truly private communication, use the default ntfy backend
(self-hosted).
with a self-hosted ntfy server.
+5 -1
View File
@@ -199,7 +199,11 @@ class HttpServer:
from gateway.status import acquire_scoped_lock
lock_key = f"http:{host}:{port}"
if not acquire_scoped_lock("iris", lock_key):
# acquire_scoped_lock returns (acquired, existing_record); the
# tuple is always truthy, so test the first element (matching
# gateway/platforms/base.py's canonical usage).
acquired, _ = acquire_scoped_lock("iris", lock_key)
if not acquired:
logger.warning(
"iris: HTTP port %s:%s in use by another profile; server disabled",
host,
+5 -1
View File
@@ -1,7 +1,11 @@
name: iris-platform
label: Iris
kind: platform
version: 0.1.0
# MUST match the repo-root VERSION file (checked by
# scripts/check_version_sync.sh on commit). This field is the version the
# gateway advertises in production installs, where only this plugin dir is
# shipped (see version.py).
version: 0.1.3
description: >
Native Android / Desktop client gateway adapter for Hermes Agent.
Runs an HTTP server (optional TLS) inside the gateway; the app connects
+55 -14
View File
@@ -1,26 +1,67 @@
"""Version discovery -- the repo-root ``VERSION`` file is the single source
of truth for the release version ("everything from here on out is vX.Y.Z"
= bump ``VERSION`` and commit).
"""Version discovery.
The plugin lives at ``<repo>/gateway-plugin`` (installed into
``~/.hermes/plugins/iris`` as a symlink in production), so the ``VERSION``
file is one directory up. The value is advertised to the app in
``hello.ack`` (``server_caps.app_version``) so the app can show which
gateway version it is talking to.
The repo-root ``VERSION`` file is the single source of truth for the release
version ("everything from here on out is vX.Y.Z" = bump ``VERSION`` and
commit). It is advertised to the app in ``hello.ack``
(``server_caps.app_version``) so the app can show which gateway version it is
talking to.
Resolution order (first hit wins):
1. ``<repo>/VERSION`` — dev checkout / symlink install: the plugin lives at
``<repo>/gateway-plugin``, so the ``VERSION`` file is one directory up.
2. ``version:`` in the plugin's own ``plugin.yaml`` — production install:
``hermes plugins install <repo>#gateway-plugin`` moves ONLY the
``gateway-plugin/`` subdirectory into ``~/.hermes/plugins/iris``, so the
repo-root ``VERSION`` is not present there. ``plugin.yaml`` ships with the
plugin dir; a pre-commit hook (``scripts/check_version_sync.sh``) keeps its
``version:`` field in sync with the repo-root ``VERSION``.
3. ``"unknown"``.
"""
from __future__ import annotations
import re
from pathlib import Path
_FALLBACK = "unknown"
_VERSION_RE = re.compile(r"^version:\s*[\"']?([^\"'\s]+)")
def plugin_version() -> str:
"""The release version from ``<repo>/VERSION``, or ``"unknown"``."""
candidate = Path(__file__).resolve().parent.parent / "VERSION"
def _read_version_file(path: Path) -> str:
try:
version = candidate.read_text().strip()
return path.read_text().strip()
except OSError:
return _FALLBACK
return version or _FALLBACK
return ""
def _plugin_yaml_version(plugin_dir: Path) -> str:
"""The top-level ``version:`` field of ``plugin.yaml`` (stdlib-only parse)."""
try:
text = (plugin_dir / "plugin.yaml").read_text()
except OSError:
return ""
for line in text.splitlines():
m = _VERSION_RE.match(line)
if m:
return m.group(1)
return ""
def plugin_version(base: Path | None = None) -> str:
"""The release version, or ``"unknown"`` if it cannot be found.
``base`` overrides the plugin directory (tests); by default it is the
directory containing this file.
"""
plugin_dir = base if base is not None else Path(__file__).resolve().parent
# 1. Repo-root VERSION (dev checkout / symlink install).
version = _read_version_file(plugin_dir.parent / "VERSION")
if version:
return version
# 2. plugin.yaml (production install ships only the plugin dir).
version = _plugin_yaml_version(plugin_dir)
if version:
return version
return _FALLBACK
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Fail the commit if gateway-plugin/plugin.yaml's `version:` field drifts
# from the repo-root VERSION file (the single source of truth).
#
# Why: `hermes plugins install <repo>#gateway-plugin` ships ONLY the
# gateway-plugin/ subdirectory into ~/.hermes/plugins/iris, so in production
# the gateway advertises the version from plugin.yaml (see
# gateway-plugin/version.py). If the two drift, the app shows a false
# "versions differ" warning.
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel)"
version_file="$repo_root/VERSION"
plugin_yaml="$repo_root/gateway-plugin/plugin.yaml"
[ -f "$version_file" ] || {
echo "check_version_sync: missing $version_file" >&2
exit 1
}
[ -f "$plugin_yaml" ] || {
echo "check_version_sync: missing $plugin_yaml" >&2
exit 1
}
root_version="$(tr -d '[:space:]' <"$version_file")"
# Mirrors gateway-plugin/version.py's _VERSION_RE: optional single OR double
# quote, at least one captured character.
yaml_version="$(sed -n "s/^version:[[:space:]]*[\"']\{0,1\}\([^\"'[:space:]]\{1,\}\).*/\1/p" "$plugin_yaml" | head -n1)"
if [ -z "$yaml_version" ]; then
echo "check_version_sync: no top-level 'version:' field in gateway-plugin/plugin.yaml" >&2
exit 1
fi
if [ "$root_version" != "$yaml_version" ]; then
echo "check_version_sync: version drift" >&2
echo " VERSION (repo root) = $root_version" >&2
echo " gateway-plugin/plugin.yaml = $yaml_version" >&2
echo "Bump both to the same value (VERSION is the source of truth)." >&2
exit 1
fi
+40
View File
@@ -486,6 +486,46 @@ async def test_hello_ack_advertises_app_version_and_sse_header_is_stored(
assert device2["caps"].get("app_version") == "9.9.9"
def test_plugin_version_falls_back_to_plugin_yaml_in_production_layout(plugin, tmp_path):
"""Production install (``hermes plugins install <repo>#gateway-plugin``)
ships ONLY the ``gateway-plugin/`` subdirectory into
``~/.hermes/plugins/iris`` — the repo-root ``VERSION`` file is not
present there. The advertised version must then come from
``plugin.yaml``, not "unknown" (regression: the app showed
'Gateway vunknown' against a production gateway)."""
v = plugin.version
# Dev checkout / symlink install: repo-root VERSION is the source of truth.
root_version = Path(v.__file__).resolve().parent.parent / "VERSION"
root_value = root_version.read_text().strip() if root_version.is_file() else ""
if root_value:
assert v.plugin_version() == root_value
# Simulate the production layout: a plugin dir with a plugin.yaml but no
# VERSION file one level up.
prod_dir = tmp_path / "plugins" / "iris"
prod_dir.mkdir(parents=True)
(prod_dir / "plugin.yaml").write_text(
(Path(v.__file__).parent / "plugin.yaml").read_text()
)
advertised = v.plugin_version(base=prod_dir)
assert advertised != "unknown"
# The pre-commit hook (scripts/check_version_sync.sh) keeps plugin.yaml in
# sync with the repo-root VERSION, so the production fallback must
# advertise the same real value.
if root_value:
assert advertised == root_value
# A VERSION file one level up still wins when present.
(tmp_path / "plugins" / "VERSION").write_text("9.9.9\n")
assert v.plugin_version(base=prod_dir) == "9.9.9"
# Nothing at all -> "unknown".
empty = tmp_path / "empty"
empty.mkdir()
assert v.plugin_version(base=empty) == "unknown"
@pytest.mark.asyncio
async def test_disconnect_broadcasts_status_restarting(adapter):
"""Teardown broadcasts ``status{state=restarting}`` before closing the