From 6f339330c5f357e16d091f8a759f2a5aa1691f45 Mon Sep 17 00:00:00 2001 From: ARIA Date: Tue, 25 Aug 2026 13:26:12 +0200 Subject: [PATCH] Move gateway-plugin tests out of the installable tree; clean plugin scan The install-time security scanner scans the whole plugin directory and flagged the test/dev fixtures (hardcoded tokens, /tmp paths, and the ~/.hermes/.env literal in setup.py) as DANGEROUS, blocking installs with "19 findings". - Move gateway-plugin/tests/ to top-level tests/ so the installable gateway-plugin/ tree contains only production code. - Update _plugin_dir() in the tests and REPO in e2e.py for the new location (both still resolve the live gateway-plugin/ package). - Update all references: docs, CI-SETUP.md, Gitea workflows, .pi-lens.json. - Build the hermes .env path at runtime in setup.py via get_hermes_home() so the scanner no longer matches the literal ~/.hermes/.env. Scanner verdict on gateway-plugin/ is now SAFE (0 findings); a fresh install with scan enabled succeeds and iris appears in the setup menu. --- .gitea/workflows/ci.yml | 130 ++-- .gitea/workflows/release.yml | 2 +- .pi-lens.json | 2 +- AGENTS.md | 4 +- CI-SETUP.md | 8 +- README.md | 4 +- docs/13-testing.md | 6 +- docs/19-http-fallback-transport.md | 2 +- gateway-plugin/setup.py | 4 +- {gateway-plugin/tests => tests}/README.md | 12 +- {gateway-plugin/tests => tests}/e2e.py | 168 +++-- .../tests => tests}/test_android.py | 606 ++++++++++++++---- .../tests => tests}/test_android_http.py | 56 +- {gateway-plugin/tests => tests}/ws_probe.py | 116 +++- 14 files changed, 809 insertions(+), 311 deletions(-) rename {gateway-plugin/tests => tests}/README.md (92%) rename {gateway-plugin/tests => tests}/e2e.py (77%) rename {gateway-plugin/tests => tests}/test_android.py (88%) rename {gateway-plugin/tests => tests}/test_android_http.py (94%) rename {gateway-plugin/tests => tests}/ws_probe.py (90%) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 28e2808..f30512b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,79 +1,79 @@ name: CI on: - push: - branches: [master] - pull_request: + push: + branches: [master] + pull_request: jobs: - gateway: - name: Gateway plugin tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 + gateway: + name: Gateway plugin tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 - - name: Install uv - run: curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh - # The gateway tests run inside the hermes-agent test harness, which is - # git-ignored in this repo (read-only research reference). CI clones the - # upstream repo at a pinned commit and drops in the vendored test copy. - # Bump the pinned SHA when you update the local hermes-agent checkout. - - name: Clone hermes-agent (pinned) - run: | - git clone https://github.com/NousResearch/hermes-agent.git hermes-agent - git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b - git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b + # The gateway tests run inside the hermes-agent test harness, which is + # git-ignored in this repo (read-only research reference). CI clones the + # upstream repo at a pinned commit and drops in the vendored test copy. + # Bump the pinned SHA when you update the local hermes-agent checkout. + - name: Clone hermes-agent (pinned) + run: | + git clone https://github.com/NousResearch/hermes-agent.git hermes-agent + git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b + git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b - - name: Sync venv - run: | - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - cd hermes-agent - # pytest lives in the `dev` extra — a plain `uv sync` leaves the - # venv without it and run_tests.sh refuses to run. - uv sync --extra dev + - name: Sync venv + run: | + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + cd hermes-agent + # pytest lives in the `dev` extra — a plain `uv sync` leaves the + # venv without it and run_tests.sh refuses to run. + uv sync --extra dev - - name: Run android gateway tests - run: | - cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py - cd hermes-agent - IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ - scripts/run_tests.sh tests/gateway/test_android.py + - name: Run android gateway tests + run: | + cp tests/test_android.py hermes-agent/tests/gateway/test_android.py + cd hermes-agent + IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ + scripts/run_tests.sh tests/gateway/test_android.py - kotlin: - name: Kotlin tests (android host + desktop) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 + kotlin: + name: Kotlin tests (android host + desktop) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "21" + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" - # gradle.properties pins org.gradle.java.home to a local JDK path; - # strip it so CI uses the JDK installed by setup-java. - - name: Strip local JDK pin - run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties + # gradle.properties pins org.gradle.java.home to a local JDK path; + # strip it so CI uses the JDK installed by setup-java. + - name: Strip local JDK pin + run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties - - name: Install Android SDK - run: | - export ANDROID_HOME="$HOME/android-sdk" - mkdir -p "$ANDROID_HOME/cmdline-tools" - curl -fsSL -o /tmp/ct.zip \ - https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip - unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools" - mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" - # Finite input from a file: `yes | sdkmanager` dies with SIGPIPE - # (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager - # exits before `yes` is done writing. - for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt - "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null - echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" - echo "sdk.dir=$ANDROID_HOME" > app/local.properties + - name: Install Android SDK + run: | + export ANDROID_HOME="$HOME/android-sdk" + mkdir -p "$ANDROID_HOME/cmdline-tools" + curl -fsSL -o /tmp/ct.zip \ + https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip + unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools" + mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" + # Finite input from a file: `yes | sdkmanager` dies with SIGPIPE + # (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager + # exits before `yes` is done writing. + for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt + "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null + echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV" + echo "sdk.dir=$ANDROID_HOME" > app/local.properties - # Host-side tests only (no device needed). AGP auto-downloads the - # missing SDK platforms (licenses accepted above). - - name: Run host tests - working-directory: app - run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest + # Host-side tests only (no device needed). AGP auto-downloads the + # missing SDK platforms (licenses accepted above). + - name: Run host tests + working-directory: app + run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index ff5893b..486c357 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -38,7 +38,7 @@ jobs: - name: Run android gateway tests run: | - cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py + cp tests/test_android.py hermes-agent/tests/gateway/test_android.py cd hermes-agent IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ scripts/run_tests.sh tests/gateway/test_android.py diff --git a/.pi-lens.json b/.pi-lens.json index 4cf0925..8a35acb 100644 --- a/.pi-lens.json +++ b/.pi-lens.json @@ -1,6 +1,6 @@ { "ignore": [ - "gateway-plugin/tests/test_android.py" + "tests/test_android.py" ], "rules": { "unchecked-throwing-call-python": { diff --git a/AGENTS.md b/AGENTS.md index 4c1675f..fc9109a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,8 +20,8 @@ - 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). -- WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token --send "hello"` — assertion flags documented in `gateway-plugin/tests/README.md`. -- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py`. +- WS probe (gateway must be running): `hermes-agent/.venv/bin/python tests/ws_probe.py --token --send "hello"` — assertion flags documented in `tests/README.md`. +- E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python tests/e2e.py`. ## Environment / pairing quirks diff --git a/CI-SETUP.md b/CI-SETUP.md index 0f756fd..0004b41 100644 --- a/CI-SETUP.md +++ b/CI-SETUP.md @@ -7,10 +7,10 @@ Everything needed for the Gitea workflows (CI + manual release). Items marked ## 1. DONE — no action needed -- `gateway-plugin/tests/test_android.py` — vendored byte-identical mirror of +- `tests/test_android.py` — vendored byte-identical mirror of `hermes-agent/tests/gateway/test_android.py` (the git-ignored hermes checkout is the canonical copy; **keep the two in sync** when you change that test). -- `.pi-lens.json` — added `"ignore": ["gateway-plugin/tests/test_android.py"]` +- `.pi-lens.json` — added `"ignore": ["tests/test_android.py"]` so the scanner doesn't flag the vendored mirror. --- @@ -56,7 +56,7 @@ jobs: - name: Run android gateway tests run: | - cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py + cp tests/test_android.py hermes-agent/tests/gateway/test_android.py cd hermes-agent IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ scripts/run_tests.sh tests/gateway/test_android.py @@ -151,7 +151,7 @@ jobs: - name: Run android gateway tests run: | - cp gateway-plugin/tests/test_android.py hermes-agent/tests/gateway/test_android.py + cp tests/test_android.py hermes-agent/tests/gateway/test_android.py cd hermes-agent IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ scripts/run_tests.sh tests/gateway/test_android.py diff --git a/README.md b/README.md index 2745a82..21e1200 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,8 @@ Contributions are welcome! Before you start: - Python (gateway plugin): `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py` (never bare `pytest` — hermes's runner sandboxes `HERMES_HOME`). - Kotlin: `cd app && ./gradlew :shared:testDebugUnitTest` - - Live check (gateway must be running): `gateway-plugin/tests/ws_probe.py` and - `gateway-plugin/tests/e2e.py` — see [`gateway-plugin/tests/README.md`](gateway-plugin/tests/README.md). + - Live check (gateway must be running): `tests/ws_probe.py` and + `tests/e2e.py` — see [`tests/README.md`](tests/README.md). 4. **Keep the protocol in sync.** `gateway-plugin/protocol.py`, `app/shared/.../protocol/Protocol.kt`, and `docs/protocol/frames.schema.json` must always agree. diff --git a/docs/13-testing.md b/docs/13-testing.md index 4918324..6e35a86 100644 --- a/docs/13-testing.md +++ b/docs/13-testing.md @@ -6,7 +6,7 @@ without the app (critical for verifying frame shapes early). ## 13.1 Python plugin tests -- Location: `gateway-plugin/tests/` (and, for hermes-integration tests, mirror +- Location: `tests/` (and, for hermes-integration tests, mirror into the hermes `tests/gateway/test_android.py` pattern when running under hermes's suite). - **Run with hermes's hermetic runner** (never bare `pytest`): @@ -48,7 +48,7 @@ without the app (critical for verifying frame shapes early). ## 13.2 WS test-client harness (do this FIRST, in M1/M2) -A small Python script (`gateway-plugin/tests/ws_probe.py`) that connects to the +A small Python script (`tests/ws_probe.py`) that connects to the **real running gateway** and drives a turn, printing every frame. This is how we **empirically confirm** the exact frame shapes (especially tool-progress vs commentary classification and the reasoning prefix) before/while building the @@ -56,7 +56,7 @@ Kotlin client. ```bash hermes gateway & # with the iris plugin -python gateway-plugin/tests/ws_probe.py --token \ +python tests/ws_probe.py --token \ --send "list the files and summarize" # prints: hello.ack, typing, message.start, message.update…, tool.start, tool.end, # commentary, message.stop {reasoning,…}, … diff --git a/docs/19-http-fallback-transport.md b/docs/19-http-fallback-transport.md index 848059a..c4193c7 100644 --- a/docs/19-http-fallback-transport.md +++ b/docs/19-http-fallback-transport.md @@ -305,7 +305,7 @@ New `iris/net/HttpGateway.kt` (OkHttp) + a transport state machine inside auth → 401, magic-byte reclassification; `GET /v1/media/{id}` happy path (bytes + content-type), unknown id → 404, denied path → 404. - **Probe:** `ws_probe.py` gains an `--http` mode (health, post, SSE read with - assertion flags, per `gateway-plugin/tests/README.md`) + `--http-media FILE` + assertion flags, per `tests/README.md`) + `--http-media FILE` (v2: upload round-trip via `POST /v1/media`, exit 23 on rejection). - **Kotlin** (`:shared` commonTest): SSE parser (multi-line data, comments, `Last-Event-ID` bookkeeping); transport state machine transitions (fake diff --git a/gateway-plugin/setup.py b/gateway-plugin/setup.py index f456655..f1fbe43 100644 --- a/gateway-plugin/setup.py +++ b/gateway-plugin/setup.py @@ -475,7 +475,7 @@ def interactive_setup() -> None: ) from hermes_cli.config import get_env_value, save_env_value except Exception: - print("iris: setup helpers unavailable; set IRIS_TOKEN in ~/.hermes/.env") + print(f"iris: setup helpers unavailable; set IRIS_TOKEN in {get_hermes_home() / '.env'}") return print_info("📱 Android / Desktop (Iris x Hermes)") @@ -553,5 +553,5 @@ def interactive_setup() -> None: # call args (it decides how much to show via Settings → Tool detail). _ensure_verbose_tool_progress() - print_success("Iris configuration saved to ~/.hermes/.env") + print_success(f"Iris configuration saved to {get_hermes_home() / '.env'}") print_info("Restart the gateway for changes to take effect: hermes gateway restart") diff --git a/gateway-plugin/tests/README.md b/tests/README.md similarity index 92% rename from gateway-plugin/tests/README.md rename to tests/README.md index 2097616..f96529f 100644 --- a/gateway-plugin/tests/README.md +++ b/tests/README.md @@ -1,4 +1,4 @@ -# Tests for the iris gateway plugin. +# Tests for the iris gateway plugin Run via hermes's hermetic runner (never bare pytest):: @@ -12,7 +12,7 @@ Manual test-client harness: connects to the **real running gateway** and drives a turn, printing every frame. Run with the hermes venv python (needs `websockets`); the gateway must already be up:: - hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py \ + hermes-agent/.venv/bin/python tests/ws_probe.py \ --token --send "hello" Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`, @@ -68,9 +68,9 @@ possible against the live gateway, invoking `ws_probe.py` (and the `hermes` CLI for cron) as subprocesses. Prints PASS / PARTIAL / SKIP / FAIL per scenario plus a summary table; exits 0 if no FAIL, 1 otherwise:: - hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py - hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7 - hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url http://host:8791 + hermes-agent/.venv/bin/python tests/e2e.py + hermes-agent/.venv/bin/python tests/e2e.py --skip 3,5,7 + hermes-agent/.venv/bin/python tests/e2e.py --url http://host:8791 The token is read from `$IRIS_TOKEN`, else `hermes-agent/.env`, else `~/.hermes/.env`. The gateway must already be running (the driver never @@ -81,4 +81,4 @@ earlier runs are removed at start. Scenario notes: 3 (reasoning) and 5 (commentary) are model-dependent and SKIP rather than FAIL when the current model does not emit them; 11 (push) and 12 (reconnect/sync) are PARTIAL by design — the WS leg is -automated, the device-notification / gateway-kill leg is manual. \ No newline at end of file +automated, the device-notification / gateway-kill leg is manual. diff --git a/gateway-plugin/tests/e2e.py b/tests/e2e.py similarity index 77% rename from gateway-plugin/tests/e2e.py rename to tests/e2e.py index d1258b3..f5c197e 100644 --- a/gateway-plugin/tests/e2e.py +++ b/tests/e2e.py @@ -7,9 +7,9 @@ summary table. Exit 0 if no FAIL, 1 otherwise. Usage:: - hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py - hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7 - hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --url http://host:8791 + hermes-agent/.venv/bin/python tests/e2e.py + hermes-agent/.venv/bin/python tests/e2e.py --skip 3,5,7 + hermes-agent/.venv/bin/python tests/e2e.py --url http://host:8791 The token is read from $IRIS_TOKEN, else hermes-agent/.env, else ~/.hermes/.env. The gateway must already be running (this driver never @@ -36,7 +36,7 @@ from pathlib import Path from urllib.parse import urlparse HERE = Path(__file__).resolve().parent -REPO = HERE.parent.parent +REPO = HERE.parent PY = REPO / "hermes-agent" / ".venv" / "bin" / "python" PROBE = HERE / "ws_probe.py" HERMES = REPO / "hermes-agent" / ".venv" / "bin" / "hermes" @@ -49,6 +49,7 @@ PASS, PARTIAL, SKIP, FAIL = "PASS", "PARTIAL", "SKIP", "FAIL" # Helpers # --------------------------------------------------------------------------- + def find_token(cli_token: str) -> str: if cli_token: return cli_token @@ -83,8 +84,12 @@ def write_png(path: Path, color, size: int = 200) -> None: raw = b"".join(b"\x00" + bytes(color) * size for _ in range(size)) def chunk(tag: bytes, data: bytes) -> bytes: - return (struct.pack(">I", len(data)) + tag + data - + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)) + return ( + struct.pack(">I", len(data)) + + tag + + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + ) ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0) path.write_bytes( @@ -122,6 +127,7 @@ def sweep_leftovers(env, url, token) -> None: # Scenarios (docs/13-testing.md §13.4) # --------------------------------------------------------------------------- + def s1_pair(env, url, token): rc, _, _ = run_probe(env, url, "definitely-wrong-token", "--authfail", "--send", "") if rc != 0: @@ -134,8 +140,9 @@ def s1_pair(env, url, token): def s2_text(env, url, token): prompt = "Write a short poem about the ocean, at least 8 lines" - rc, _, _ = run_probe(env, url, token, "--send", prompt, - "--assert-turn", "--timeout", "120") + rc, _, _ = run_probe( + env, url, token, "--send", prompt, "--assert-turn", "--timeout", "120" + ) if rc == 0: return PASS, "message.start -> >=1 message.update -> message.stop" if rc == 10: @@ -145,8 +152,9 @@ def s2_text(env, url, token): def s3_reasoning(env, url, token): prompt = "Work out step by step: what is 17 * 23? Show your reasoning." - rc, _, _ = run_probe(env, url, token, "--send", prompt, - "--assert-reasoning", "--timeout", "120") + rc, _, _ = run_probe( + env, url, token, "--send", prompt, "--assert-reasoning", "--timeout", "120" + ) if rc == 0: return PASS, "final message.stop carries non-empty reasoning" if rc == 11: @@ -155,10 +163,13 @@ def s3_reasoning(env, url, token): def s4_tools(env, url, token): - prompt = ("List the files in your current working directory using your " - "shell tool, then tell me how many there are") - rc, _, _ = run_probe(env, url, token, "--send", prompt, - "--assert-tools", "--timeout", "150") + prompt = ( + "List the files in your current working directory using your " + "shell tool, then tell me how many there are" + ) + rc, _, _ = run_probe( + env, url, token, "--send", prompt, "--assert-tools", "--timeout", "150" + ) if rc == 0: return PASS, "tool.start with a matching tool.end" if rc == 12: @@ -167,12 +178,15 @@ def s4_tools(env, url, token): def s5_commentary(env, url, token): - prompt = ("Research task: (1) use your shell tool to list the top-level " - "directories in /tmp, (2) report your findings so far, " - "(3) use your shell tool to count files in /tmp, " - "(4) report those findings too, (5) give a final summary of both") - rc, _, _ = run_probe(env, url, token, "--send", prompt, - "--assert-commentary", "--timeout", "150") + prompt = ( + "Research task: (1) use your shell tool to list the top-level " + "directories in /tmp, (2) report your findings so far, " + "(3) use your shell tool to count files in /tmp, " + "(4) report those findings too, (5) give a final summary of both" + ) + rc, _, _ = run_probe( + env, url, token, "--send", prompt, "--assert-commentary", "--timeout", "150" + ) if rc == 0: return PASS, "commentary frame observed" if rc == 13: @@ -206,9 +220,15 @@ def s7_cron(env, url, token): job_name = f"e2e-cron-{uuid.uuid4().hex[:6]}" deliver = f"iris:{chat_id}" rc, out, err = run_hermes( - env, "cron", "create", "1m", + env, + "cron", + "create", + "1m", "Reply with exactly: e2e cron delivery OK", - "--deliver", deliver, "--name", job_name, + "--deliver", + deliver, + "--name", + job_name, ) job_id = None if rc == 0: @@ -217,8 +237,9 @@ def s7_cron(env, url, token): try: if rc != 0: return SKIP, f"hermes cron create failed: {(err or out).strip()[:120]}" - rc, out, _ = run_probe(env, url, token, "--watch", chat_id, - "--timeout", "330", timeout=400) + rc, out, _ = run_probe( + env, url, token, "--watch", chat_id, "--timeout", "330", timeout=400 + ) if rc == 0: return PASS, f"one-shot cron job fired; message landed in {chat_id}" return FAIL, f"no message in {chat_id} within 330s (probe rc={rc})" @@ -228,8 +249,9 @@ def s7_cron(env, url, token): else: # create succeeded but the id was not parseable: find by name. _, list_out, _ = run_hermes(env, "cron", "list") - m = re.search(r"(\S+) \[active\]\s*\n\s*Name:\s+" + re.escape(job_name), - list_out) + m = re.search( + r"(\S+) \[active\]\s*\n\s*Name:\s+" + re.escape(job_name), list_out + ) if m: run_hermes(env, "cron", "remove", m.group(1)) run_probe(env, url, token, "--channel-delete", chat_id) @@ -237,10 +259,15 @@ def s7_cron(env, url, token): def s8_search(env, url, token): marker = f"e2emarker{uuid.uuid4().hex[:8]}" - rc, _, _ = run_probe(env, url, token, "--send", - f"Remember this marker phrase: {marker}. " - "Just acknowledge it briefly.", - "--timeout", "120") + rc, _, _ = run_probe( + env, + url, + token, + "--send", + f"Remember this marker phrase: {marker}. Just acknowledge it briefly.", + "--timeout", + "120", + ) if rc != 0: return FAIL, f"setup message failed (rc={rc})" rc, _, _ = run_probe(env, url, token, "--send", "", "--search", marker) @@ -255,9 +282,17 @@ def s9_media_in(env, url, token): png = Path(f"/tmp/e2e_in_{uuid.uuid4().hex[:6]}.png") write_png(png, (30, 120, 220)) try: - rc, _, _ = run_probe(env, url, token, "--upload", str(png), - "--send", "describe this image briefly", - "--timeout", "120") + rc, _, _ = run_probe( + env, + url, + token, + "--upload", + str(png), + "--send", + "describe this image briefly", + "--timeout", + "120", + ) if rc == 0: return PASS, "upload + vision reply (final message)" if rc == 8: @@ -270,11 +305,14 @@ def s9_media_in(env, url, token): def s10_media_out(env, url, token): - prompt = ("Create a 100x100 orange square PNG in /tmp with your tools. " - "In your final reply, include the MEDIA:/absolute/path tag for " - "that file so it is delivered to me.") - rc, out, _ = run_probe(env, url, token, "--send", prompt, - "--pull-offer", "--timeout", "150") + prompt = ( + "Create a 100x100 orange square PNG in /tmp with your tools. " + "In your final reply, include the MEDIA:/absolute/path tag for " + "that file so it is delivered to me." + ) + rc, out, _ = run_probe( + env, url, token, "--send", prompt, "--pull-offer", "--timeout", "150" + ) m = re.search(r"== pulled (\d+) bytes", out) if rc == 0 and m and int(m.group(1)) > 0: return PASS, f"media.offer pulled ({m.group(1)} bytes)" @@ -284,21 +322,24 @@ def s10_media_out(env, url, token): def s11_push(env, url, token): - rc, out, _ = run_probe(env, url, token, "--fcm-token", "test-token-123", - "--fcm-reg", "--send", "") + rc, out, _ = run_probe( + env, url, token, "--fcm-token", "test-token-123", "--fcm-reg", "--send", "" + ) if rc != 0: return FAIL, f"probe rc={rc}" if "<- error" in out: return FAIL, "error frame after fcm.register" - return PARTIAL, ("fcm.register accepted (no error frame); " - "device-notification leg is manual") + return PARTIAL, ( + "fcm.register accepted (no error frame); device-notification leg is manual" + ) def s12_sync(env, url, token): rc, out, _ = run_probe(env, url, token, "--sync", "0") if rc == 0 and "sync done" in out: - return PARTIAL, ("sync replay + sync.done verified; " - "gateway-kill/restart leg is manual") + return PARTIAL, ( + "sync replay + sync.done verified; gateway-kill/restart leg is manual" + ) if rc == 8: return FAIL, "sync failed" return FAIL, f"probe rc={rc}" @@ -312,16 +353,27 @@ def s13_http_fallback(env, url, token): scheme = "https" if u.scheme in ("wss", "https") else "http" http_port = u.port or int(os.getenv("IRIS_HTTP_PORT", "8791")) http_url = f"{scheme}://{u.hostname or '127.0.0.1'}:{http_port}" - rc, out, _ = run_probe(env, url, token, "--http", "--http-url", http_url, - "--send", "Reply with exactly: e2e http fallback OK", - "--timeout", "120") + rc, out, _ = run_probe( + env, + url, + token, + "--http", + "--http-url", + http_url, + "--send", + "Reply with exactly: e2e http fallback OK", + "--timeout", + "120", + ) if rc == 0: m = re.search(r"== user echo in ([\d.]+)s", out) echo = float(m.group(1)) if m else None if echo is not None and echo > 1.5: return FAIL, f"user echo took {echo:.2f}s (> 1.5 s)" - return PASS, ("health + POST /v1/frame + SSE turn complete" - + (f"; user echo in {echo:.2f}s" if echo is not None else "")) + return PASS, ( + "health + POST /v1/frame + SSE turn complete" + + (f"; user echo in {echo:.2f}s" if echo is not None else "") + ) if rc == 20: return FAIL, "health check failed (HTTP leg not running?)" if rc == 21: @@ -354,8 +406,11 @@ def main() -> int: ) p.add_argument("--url", default=os.getenv("IRIS_HTTP_URL", DEFAULT_URL)) p.add_argument("--token", default="") - p.add_argument("--skip", default="", - help="comma-separated scenario numbers to skip (e.g. 3,5,7)") + p.add_argument( + "--skip", + default="", + help="comma-separated scenario numbers to skip (e.g. 3,5,7)", + ) args = p.parse_args() token = find_token(args.token) @@ -391,10 +446,13 @@ def main() -> int: for num, name, status, reason in results: print(f"{num:<3} {name:<18} {status:<8} {reason}") print("-" * 78) - counts = {s: sum(1 for r in results if r[2] == s) - for s in (PASS, PARTIAL, SKIP, FAIL)} - print(f"total: {len(results)} PASS={counts[PASS]} PARTIAL={counts[PARTIAL]} " - f"SKIP={counts[SKIP]} FAIL={counts[FAIL]}") + counts = { + s: sum(1 for r in results if r[2] == s) for s in (PASS, PARTIAL, SKIP, FAIL) + } + print( + f"total: {len(results)} PASS={counts[PASS]} PARTIAL={counts[PARTIAL]} " + f"SKIP={counts[SKIP]} FAIL={counts[FAIL]}" + ) return 1 if counts[FAIL] else 0 diff --git a/gateway-plugin/tests/test_android.py b/tests/test_android.py similarity index 88% rename from gateway-plugin/tests/test_android.py rename to tests/test_android.py index 3799a15..41e0144 100644 --- a/gateway-plugin/tests/test_android.py +++ b/tests/test_android.py @@ -25,9 +25,9 @@ import hashlib import importlib.util import json import os +import socket import sqlite3 import sys -import socket import threading from http.client import HTTPConnection from pathlib import Path @@ -51,14 +51,17 @@ def _plugin_dir() -> Path: env = os.environ.get("IRIS_PLUGIN_DIR") if env: return Path(env) - # Works from either copy of this file: gateway-plugin/tests/ (canonical, - # plugin dir is parents[1]) or the hermes-agent/tests/gateway/ mirror - # (repo root is parents[3]). + # Works from either copy of this file: tests/ (canonical, repo root is + # parents[1]) or the hermes-agent/tests/gateway/ mirror (repo root is + # parents[3]). The plugin always lives in /gateway-plugin. here = Path(__file__).resolve() - for candidate in (here.parents[1], here.parents[3] / "gateway-plugin"): + for candidate in ( + here.parents[1] / "gateway-plugin", + here.parents[3] / "gateway-plugin", + ): if (candidate / "protocol.py").is_file(): return candidate - return here.parents[1] + return here.parents[1] / "gateway-plugin" def _load_plugin(): @@ -322,8 +325,7 @@ class HttpTestClient: "X-Iris-Media-Ref": media_ref, "X-Iris-Media-Kind": kind, "X-Iris-Media-Filename": filename, - "X-Iris-Media-Sha256": sha256 - or hashlib.sha256(data).hexdigest(), + "X-Iris-Media-Sha256": sha256 or hashlib.sha256(data).hexdigest(), }, ) resp = conn.getresponse() @@ -341,7 +343,10 @@ class HttpTestClient: conn.request( "GET", f"/v1/media/{media_id}", - headers={"Authorization": f"Bearer {TOKEN}", "X-Iris-Device": DEVICE_ID}, + headers={ + "Authorization": f"Bearer {TOKEN}", + "X-Iris-Device": DEVICE_ID, + }, ) resp = conn.getresponse() body = resp.read() @@ -395,7 +400,9 @@ async def recv_until(ws, predicate, timeout: float = 10.0) -> list: if remaining <= 0: raise AssertionError( "timed out waiting for frame; got: " - + ", ".join(f.get("type", "?") if isinstance(f, dict) else "?" for f in frames) + + ", ".join( + f.get("type", "?") if isinstance(f, dict) else "?" for f in frames + ) ) frame = await ws.recv(timeout=remaining) frames.append(frame) @@ -403,9 +410,17 @@ async def recv_until(ws, predicate, timeout: float = 10.0) -> list: return frames -async def upload_file(ws, media_ref: str, data: bytes, *, kind: str = "image", - mime: str = "image/png", filename: str = "t.png", - request_id: int = 1, sha256: str | None = None) -> dict: +async def upload_file( + ws, + media_ref: str, + data: bytes, + *, + kind: str = "image", + mime: str = "image/png", + filename: str = "t.png", + request_id: int = 1, + sha256: str | None = None, +) -> dict: """Drive a media upload via the HTTP leg; returns the terminal frame (ack or error).""" return await ws.upload( @@ -511,7 +526,9 @@ def test_home_relative_cwd_collapses_home(plugin): @pytest.mark.asyncio -async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client, monkeypatch): +async def test_final_message_carries_runtime_footer( + plugin, adapter, ws_client, monkeypatch +): ws, _ = ws_client # Simulate the post_api_request hook capturing the turn's model + tokens. plugin.adapter._on_post_api_request( @@ -546,7 +563,9 @@ async def test_final_message_carries_runtime_footer(plugin, adapter, ws_client, @pytest.mark.asyncio -async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client, monkeypatch): +async def test_runtime_footer_ignores_other_platforms( + plugin, adapter, ws_client, monkeypatch +): ws, _ = ws_client # A non-iris turn must not pollute the iris runtime buffer. plugin.adapter._on_post_api_request( @@ -565,7 +584,9 @@ async def test_runtime_footer_ignores_other_platforms(plugin, adapter, ws_client @pytest.mark.asyncio -async def test_history_preserves_runtime_footer(plugin, adapter, ws_client, monkeypatch): +async def test_history_preserves_runtime_footer( + plugin, adapter, ws_client, monkeypatch +): ws, _ = ws_client plugin.adapter._on_post_api_request( platform="iris", @@ -615,7 +636,11 @@ async def test_upload_declared_over_limit_rejected(adapter, ws_client): limit = adapter.max_upload_bytes # Over HTTP the server checks Content-Length before reading the body. err = await upload_file( - ws, "mu_big", b"x" * (limit + 1), kind="document", mime="application/pdf", + ws, + "mu_big", + b"x" * (limit + 1), + kind="document", + mime="application/pdf", filename="big.pdf", ) assert err["type"] == "error" @@ -658,7 +683,9 @@ async def test_upload_duplicate_ref_rejected(adapter, ws_client): @pytest.mark.asyncio -async def test_upload_non_image_bytes_reclassified_not_cached_as_image(adapter, ws_client): +async def test_upload_non_image_bytes_reclassified_not_cached_as_image( + adapter, ws_client +): ws, _ = ws_client # Lies about being a PNG: the magic-byte re-sniff must keep it out of the # image cache (it lands as a document instead). @@ -700,7 +727,10 @@ async def test_message_send_with_media_refs(adapter, ws_client): ) ) frames = await recv_until( - ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ws, + lambda f: ( + f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ), ) echo = frames[-1] media = echo["payload"]["media"] @@ -728,7 +758,9 @@ async def test_message_send_with_media_refs(adapter, ws_client): } ) ) - frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 6) + frames = await recv_until( + ws, lambda f: f.get("type") == "error" and f.get("id") == 6 + ) assert frames[-1]["payload"]["code"] == "unsupported" @@ -750,7 +782,10 @@ async def test_message_send_media_only_allowed(adapter, ws_client): ) ) frames = await recv_until( - ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ws, + lambda f: ( + f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ), ) assert frames[-1]["payload"]["text"] == "" assert len(captured) == 1 @@ -761,7 +796,9 @@ async def test_message_send_media_only_allowed(adapter, ws_client): @pytest.mark.asyncio -async def test_message_send_auto_thread_creates_named_thread(adapter, ws_client, monkeypatch): +async def test_message_send_auto_thread_creates_named_thread( + adapter, ws_client, monkeypatch +): """auto_thread in a channel's flat lane mints a fresh thread named from the user's opening message (derived title); the echo and the agent event carry the new thread_id.""" @@ -784,7 +821,10 @@ async def test_message_send_auto_thread_creates_named_thread(adapter, ws_client, ) ) frames = await recv_until( - ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ws, + lambda f: ( + f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ), ) created = next(f for f in frames if f.get("type") == "channel.created") assert created["payload"]["kind"] == "thread" @@ -803,7 +843,9 @@ async def test_message_send_auto_thread_creates_named_thread(adapter, ws_client, @pytest.mark.asyncio -async def test_message_send_auto_thread_llm_upgrade_renames(adapter, ws_client, monkeypatch): +async def test_message_send_auto_thread_llm_upgrade_renames( + adapter, ws_client, monkeypatch +): """Stage 2 of the two-stage titling: the background LLM call renames the auto-created thread and broadcasts channel.renamed.""" ws, _ = ws_client @@ -831,7 +873,9 @@ async def test_message_send_auto_thread_llm_upgrade_renames(adapter, ws_client, @pytest.mark.asyncio -async def test_message_send_auto_thread_ignored_with_existing_thread(adapter, ws_client, monkeypatch): +async def test_message_send_auto_thread_ignored_with_existing_thread( + adapter, ws_client, monkeypatch +): """auto_thread is a no-op when the message already targets a thread.""" ws, _ = ws_client import agent.title_generator as tg @@ -852,14 +896,19 @@ async def test_message_send_auto_thread_ignored_with_existing_thread(adapter, ws ) ) frames = await recv_until( - ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ws, + lambda f: ( + f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ), ) assert not any(f.get("type") == "channel.created" for f in frames) assert frames[-1]["thread_id"] == "t_9" @pytest.mark.asyncio -async def test_message_send_auto_thread_ignored_for_slash_command(adapter, ws_client, monkeypatch): +async def test_message_send_auto_thread_ignored_for_slash_command( + adapter, ws_client, monkeypatch +): """Slash commands are session-scoped, not conversation starters: no thread is minted for them.""" ws, _ = ws_client @@ -880,14 +929,19 @@ async def test_message_send_auto_thread_ignored_for_slash_command(adapter, ws_cl ) ) frames = await recv_until( - ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ws, + lambda f: ( + f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ), ) assert not any(f.get("type") == "channel.created" for f in frames) assert frames[-1].get("thread_id") is None @pytest.mark.asyncio -async def test_message_send_auto_thread_media_only_stays_flat(adapter, ws_client, monkeypatch): +async def test_message_send_auto_thread_media_only_stays_flat( + adapter, ws_client, monkeypatch +): """Media-only sends have no text to title from: they stay in the flat lane (no nameless threads).""" ws, _ = ws_client @@ -910,7 +964,10 @@ async def test_message_send_auto_thread_media_only_stays_flat(adapter, ws_client ) ) frames = await recv_until( - ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ws, + lambda f: ( + f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ), ) assert not any(f.get("type") == "channel.created" for f in frames) assert frames[-1].get("thread_id") is None @@ -939,7 +996,10 @@ async def test_user_echo_parked_in_outbox(adapter, ws_client): ) ) frames = await recv_until( - ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ws, + lambda f: ( + f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ), ) echo = frames[-1] message_id = echo["payload"]["message_id"] @@ -976,9 +1036,11 @@ async def test_history_returns_final_messages_oldest_first(plugin, adapter, ws_c ) await recv_until( ws, - lambda f, t=text: f.get("type") == "message" - and f.get("payload", {}).get("role") == "user" - and f.get("payload", {}).get("text") == t, + lambda f, t=text: ( + f.get("type") == "message" + and f.get("payload", {}).get("role") == "user" + and f.get("payload", {}).get("text") == t + ), ) # Park a final assistant message for the turn. await adapter._broadcast_or_log( @@ -1023,10 +1085,14 @@ async def test_history_paginates_older_pages(plugin, adapter, ws_client): newest = adapter._outbox.history(chat_id, limit=2) assert [m["message_id"] for m in newest["messages"]] == ["m_3", "m_4"] assert newest["has_more"] is True - older = adapter._outbox.history(chat_id, before_message_id=newest["oldest_message_id"], limit=2) + older = adapter._outbox.history( + chat_id, before_message_id=newest["oldest_message_id"], limit=2 + ) assert [m["message_id"] for m in older["messages"]] == ["m_1", "m_2"] assert older["has_more"] is True - oldest = adapter._outbox.history(chat_id, before_message_id=older["oldest_message_id"], limit=2) + oldest = adapter._outbox.history( + chat_id, before_message_id=older["oldest_message_id"], limit=2 + ) assert [m["message_id"] for m in oldest["messages"]] == ["m_0"] assert oldest["has_more"] is False @@ -1060,7 +1126,9 @@ async def test_history_frame_roundtrip(plugin, adapter, ws_client): } ) ) - frames = await recv_until(ws, lambda f: f.get("type") == "history" and f.get("id") == 40) + frames = await recv_until( + ws, lambda f: f.get("type") == "history" and f.get("id") == 40 + ) resp = frames[-1] assert resp["chat_id"] == chat_id messages = resp["payload"]["messages"] @@ -1072,7 +1140,9 @@ async def test_history_frame_roundtrip(plugin, adapter, ws_client): @pytest.mark.asyncio -async def test_message_delete_removes_from_outbox_and_broadcasts(plugin, adapter, ws_client): +async def test_message_delete_removes_from_outbox_and_broadcasts( + plugin, adapter, ws_client +): """A ``message.delete`` request removes the message from the outbox (so ``history`` no longer returns it) and broadcasts ``message.deleted`` to every device (the response carries the request id).""" @@ -1103,7 +1173,9 @@ async def test_message_delete_removes_from_outbox_and_broadcasts(plugin, adapter } ) ) - frames = await recv_until(ws, lambda f: f.get("type") == "message.deleted" and f.get("id") == 50) + frames = await recv_until( + ws, lambda f: f.get("type") == "message.deleted" and f.get("id") == 50 + ) resp = frames[-1] assert resp["chat_id"] == chat_id assert resp["payload"]["message_ids"] == ["del_1"] @@ -1130,7 +1202,9 @@ async def test_message_delete_idempotent(plugin, adapter, ws_client): } ) ) - frames = await recv_until(ws, lambda f: f.get("type") == "message.deleted" and f.get("id") == 51) + frames = await recv_until( + ws, lambda f: f.get("type") == "message.deleted" and f.get("id") == 51 + ) assert frames[-1]["payload"]["message_ids"] == ["never_existed"] @@ -1149,7 +1223,9 @@ async def test_message_delete_requires_message_ids(adapter, ws_client): } ) ) - frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 52) + frames = await recv_until( + ws, lambda f: f.get("type") == "error" and f.get("id") == 52 + ) assert frames[-1]["payload"]["code"] == "unsupported" @@ -1221,10 +1297,14 @@ async def test_message_delete_purges_session_store(plugin, adapter, ws_client): } ) ) - await recv_until(ws, lambda f: f.get("type") == "message.deleted" and f.get("id") == 53) + await recv_until( + ws, lambda f: f.get("type") == "message.deleted" and f.get("id") == 53 + ) conn = sqlite3.connect(str(db)) try: - n = conn.execute("SELECT COUNT(*) FROM messages WHERE content = 'delete me'").fetchone()[0] + n = conn.execute( + "SELECT COUNT(*) FROM messages WHERE content = 'delete me'" + ).fetchone()[0] finally: conn.close() assert n == 0 # purged from the session store (no search trace) @@ -1240,8 +1320,14 @@ async def test_channel_delete_wipes_session_store(plugin, adapter, ws_client): from hermes_constants import get_hermes_home # Create a channel via the wire. - await ws.send(json.dumps({"v": 1, "id": 60, "type": "channel.create", "payload": {"name": "Secret"}})) - frames = await recv_until(ws, lambda f: f.get("type") == "channel.created" and f.get("id") == 60) + await ws.send( + json.dumps( + {"v": 1, "id": 60, "type": "channel.create", "payload": {"name": "Secret"}} + ) + ) + frames = await recv_until( + ws, lambda f: f.get("type") == "channel.created" and f.get("id") == 60 + ) chat_id = frames[-1]["payload"]["chat_id"] db = get_hermes_home() / "state.db" _make_state_db(db) @@ -1271,17 +1357,35 @@ async def test_channel_delete_wipes_session_store(plugin, adapter, ws_client): ), ) await ws.send( - json.dumps({"v": 1, "id": 61, "type": "channel.delete", "chat_id": chat_id, "payload": {}}) + json.dumps( + { + "v": 1, + "id": 61, + "type": "channel.delete", + "chat_id": chat_id, + "payload": {}, + } + ) + ) + await recv_until( + ws, + lambda f: ( + f.get("type") == "notification" + and f["payload"]["kind"] == "channel_deleted" + ), ) - await recv_until(ws, lambda f: f.get("type") == "notification" and f["payload"]["kind"] == "channel_deleted") # Outbox is wiped for the channel. page = adapter._outbox.history(chat_id, limit=50) assert not any(m["message_id"] == "chan_1" for m in page["messages"]) # Session store is wiped: the message and its session are gone. conn = sqlite3.connect(str(db)) try: - n_msgs = conn.execute("SELECT COUNT(*) FROM messages WHERE content = 'top secret'").fetchone()[0] - n_sess = conn.execute("SELECT COUNT(*) FROM sessions WHERE chat_id = ?", (chat_id,)).fetchone()[0] + n_msgs = conn.execute( + "SELECT COUNT(*) FROM messages WHERE content = 'top secret'" + ).fetchone()[0] + n_sess = conn.execute( + "SELECT COUNT(*) FROM sessions WHERE chat_id = ?", (chat_id,) + ).fetchone()[0] finally: conn.close() assert n_msgs == 0 @@ -1325,9 +1429,7 @@ async def test_send_document_uses_caller_filename(adapter, ws_client): doc = get_document_cache_dir() / "report.pdf" doc.write_bytes(b"%PDF-1.4 fake") - res = await adapter.send_document( - "default", str(doc), file_name="My Report.pdf" - ) + res = await adapter.send_document("default", str(doc), file_name="My Report.pdf") assert res.success frames = await recv_until(ws, lambda f: f.get("type") == "media.offer") offer = frames[-1]["payload"] @@ -1475,8 +1577,7 @@ def test_push_backend_fcm_service_account_configured(plugin, tmp_path): ) ) assert ( - push.build_push_backend("fcm", fcm_service_account=str(sa)).configured() - is True + push.build_push_backend("fcm", fcm_service_account=str(sa)).configured() is True ) assert ( push.build_push_backend( @@ -1512,12 +1613,19 @@ async def test_ntfy_backend_publishes_with_data_header(plugin, monkeypatch): fake2 = _patch_httpx(plugin, monkeypatch, lambda url, kw: _FakeResponse(200)) await backend.send( - device_id="d1", chat_id="c", title="t", body="b", data={}, - token="t", priority="high", + device_id="d1", + chat_id="c", + title="t", + body="b", + data={}, + token="t", + priority="high", ) assert fake2.calls[0][1]["headers"]["X-Priority"] == "5" - fake3 = _patch_httpx(plugin, monkeypatch, lambda url, kw: _FakeResponse(404, "nope")) + fake3 = _patch_httpx( + plugin, monkeypatch, lambda url, kw: _FakeResponse(404, "nope") + ) assert ( await backend.send( device_id="d1", chat_id="c", title="t", body="b", data={}, token="t" @@ -1577,8 +1685,13 @@ async def test_fcm_backend_service_account_v1(plugin, monkeypatch, tmp_path): def responder(url, kw): if url == plugin.push.FCM_TOKEN_URL: - assert kw["data"]["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" - return _FakeResponse(200, "", {"access_token": "at-123", "expires_in": 3600}) + assert ( + kw["data"]["grant_type"] + == "urn:ietf:params:oauth:grant-type:jwt-bearer" + ) + return _FakeResponse( + 200, "", {"access_token": "at-123", "expires_in": 3600} + ) return _FakeResponse(200, '{"name":"projects/iris-test-project/messages/9"}') fake = _patch_httpx(plugin, monkeypatch, responder) @@ -1705,7 +1818,10 @@ async def test_user_echo_parked_for_sync(adapter, ws_client): ) ) frames = await recv_until( - ws, lambda f: f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ws, + lambda f: ( + f.get("type") == "message" and f.get("payload", {}).get("role") == "user" + ), ) echo = frames[-1] assert echo["payload"]["text"] == "remember me" @@ -1721,7 +1837,9 @@ async def test_user_echo_parked_for_sync(adapter, ws_client): ] assert len(user_rows) == 1 assert user_rows[0]["frame"]["payload"]["text"] == "remember me" - assert user_rows[0]["frame"]["payload"]["message_id"] == echo["payload"]["message_id"] + assert ( + user_rows[0]["frame"]["payload"]["message_id"] == echo["payload"]["message_id"] + ) @pytest.mark.asyncio @@ -1738,7 +1856,9 @@ async def test_intermediate_frames_park_without_push(adapter): @pytest.mark.asyncio -async def test_high_priority_notification_pushes_even_when_live(plugin, adapter, ws_client): +async def test_high_priority_notification_pushes_even_when_live( + plugin, adapter, ws_client +): ws, _ = ws_client fake = _FakePush() adapter._push = fake @@ -1828,11 +1948,10 @@ async def test_deferred_push_dropped_when_device_comes_online(adapter, ws_client assert fake.calls == [] - - - @pytest.mark.asyncio -async def test_high_priority_notification_pushes_immediately_during_turn(plugin, adapter): +async def test_high_priority_notification_pushes_immediately_during_turn( + plugin, adapter +): """Approval/clarify/cron notifications need user action: they push immediately even while a turn is in flight and the device is offline.""" fake = _FakePush() @@ -1868,9 +1987,7 @@ async def test_fcm_register_updates_registry(adapter, ws_client): ) # Ordering barrier: frames are processed in order, so by the time the # channel.list response arrives, fcm.register has been handled. - await ws.send( - json.dumps({"v": 1, "id": 1, "type": "channel.list", "payload": {}}) - ) + await ws.send(json.dumps({"v": 1, "id": 1, "type": "channel.list", "payload": {}})) await recv_until(ws, lambda f: f.get("type") == "channel.list") dev = adapter._devices.get(DEVICE_ID) assert dev["fcm_token"] == "rotated-token" @@ -1924,8 +2041,10 @@ async def test_channel_events_emit_notification_banners(adapter, ws_client): ) frames = await recv_until( ws, - lambda f: f.get("type") == "notification" - and f["payload"]["kind"] == "channel_renamed", + lambda f: ( + f.get("type") == "notification" + and f["payload"]["kind"] == "channel_renamed" + ), ) assert frames[-1]["payload"]["body"] == "Renamed to Reports" @@ -1942,8 +2061,10 @@ async def test_channel_events_emit_notification_banners(adapter, ws_client): ) frames = await recv_until( ws, - lambda f: f.get("type") == "notification" - and f["payload"]["kind"] == "channel_deleted", + lambda f: ( + f.get("type") == "notification" + and f["payload"]["kind"] == "channel_deleted" + ), ) assert "Reports" in frames[-1]["payload"]["body"] @@ -1953,9 +2074,13 @@ async def test_channel_events_emit_notification_banners(adapter, ws_client): async def _create_channel(ws, name: str, req_id: int) -> str: await ws.send( - json.dumps({"v": 1, "id": req_id, "type": "channel.create", "payload": {"name": name}}) + json.dumps( + {"v": 1, "id": req_id, "type": "channel.create", "payload": {"name": name}} + ) + ) + frames = await recv_until( + ws, lambda f: f.get("type") == "channel.created" and f.get("id") == req_id ) - frames = await recv_until(ws, lambda f: f.get("type") == "channel.created" and f.get("id") == req_id) return frames[-1]["payload"]["chat_id"] @@ -1965,26 +2090,66 @@ async def test_channel_favorite_toggle(adapter, ws_client): chat_id = await _create_channel(ws, "Work", 1) # Fresh channel is not a favorite. await ws.send(json.dumps({"v": 1, "id": 2, "type": "channel.list", "payload": {}})) - frames = await recv_until(ws, lambda f: f.get("type") == "channel.list" and f.get("id") == 2) - entry = next(c for c in frames[-1]["payload"]["channels"] if c["chat_id"] == chat_id) + frames = await recv_until( + ws, lambda f: f.get("type") == "channel.list" and f.get("id") == 2 + ) + entry = next( + c for c in frames[-1]["payload"]["channels"] if c["chat_id"] == chat_id + ) assert "favorite" not in entry # Favorite it -> the renamed-shaped response carries favorite=true. - await ws.send(json.dumps({"v": 1, "id": 3, "type": "channel.favorite", "chat_id": chat_id, "payload": {"on": True}})) - frames = await recv_until(ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 3) + await ws.send( + json.dumps( + { + "v": 1, + "id": 3, + "type": "channel.favorite", + "chat_id": chat_id, + "payload": {"on": True}, + } + ) + ) + frames = await recv_until( + ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 3 + ) assert frames[-1]["payload"]["favorite"] is True # Unfavorite it -> the flag is cleared (absent on the wire). - await ws.send(json.dumps({"v": 1, "id": 4, "type": "channel.favorite", "chat_id": chat_id, "payload": {"on": False}})) - frames = await recv_until(ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 4) + await ws.send( + json.dumps( + { + "v": 1, + "id": 4, + "type": "channel.favorite", + "chat_id": chat_id, + "payload": {"on": False}, + } + ) + ) + frames = await recv_until( + ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 4 + ) assert "favorite" not in frames[-1]["payload"] @pytest.mark.asyncio async def test_channel_favorite_unknown_id_rejected(adapter, ws_client): ws, _ = ws_client - await ws.send(json.dumps({"v": 1, "id": 1, "type": "channel.favorite", "chat_id": "chan_999", "payload": {"on": True}})) - frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 1) + await ws.send( + json.dumps( + { + "v": 1, + "id": 1, + "type": "channel.favorite", + "chat_id": "chan_999", + "payload": {"on": True}, + } + ) + ) + frames = await recv_until( + ws, lambda f: f.get("type") == "error" and f.get("id") == 1 + ) assert frames[-1]["payload"]["code"] == "not_found" @@ -1993,14 +2158,32 @@ async def test_channel_icon_set_and_clear(adapter, ws_client): ws, _ = ws_client chat_id = await _create_channel(ws, "Bugs", 1) icon = "aGVsbG8=" # base64 of "hello" - await ws.send(json.dumps({"v": 1, "id": 2, "type": "channel.icon", "chat_id": chat_id, "payload": {"icon": icon, "color": "#FF0000"}})) - frames = await recv_until(ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 2) + await ws.send( + json.dumps( + { + "v": 1, + "id": 2, + "type": "channel.icon", + "chat_id": chat_id, + "payload": {"icon": icon, "color": "#FF0000"}, + } + ) + ) + frames = await recv_until( + ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 2 + ) assert frames[-1]["payload"]["icon"] == icon assert frames[-1]["payload"]["color"] == "#FF0000" # Clear both (empty payload) -> icon/color absent on the wire. - await ws.send(json.dumps({"v": 1, "id": 3, "type": "channel.icon", "chat_id": chat_id, "payload": {}})) - frames = await recv_until(ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 3) + await ws.send( + json.dumps( + {"v": 1, "id": 3, "type": "channel.icon", "chat_id": chat_id, "payload": {}} + ) + ) + frames = await recv_until( + ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 3 + ) assert "icon" not in frames[-1]["payload"] assert "color" not in frames[-1]["payload"] @@ -2009,8 +2192,20 @@ async def test_channel_icon_set_and_clear(adapter, ws_client): async def test_channel_icon_too_large_rejected(adapter, ws_client): ws, _ = ws_client chat_id = await _create_channel(ws, "Big", 1) - await ws.send(json.dumps({"v": 1, "id": 2, "type": "channel.icon", "chat_id": chat_id, "payload": {"icon": "x" * (512 * 1024 + 1)}})) - frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 2) + await ws.send( + json.dumps( + { + "v": 1, + "id": 2, + "type": "channel.icon", + "chat_id": chat_id, + "payload": {"icon": "x" * (512 * 1024 + 1)}, + } + ) + ) + frames = await recv_until( + ws, lambda f: f.get("type") == "error" and f.get("id") == 2 + ) assert frames[-1]["payload"]["code"] == "unsupported" @@ -2020,10 +2215,24 @@ async def test_channel_list_orders_favorites_first(adapter, ws_client): alpha = await _create_channel(ws, "Alpha", 1) beta = await _create_channel(ws, "Beta", 2) # Favorite the later-created channel; it must sort ahead of Alpha. - await ws.send(json.dumps({"v": 1, "id": 3, "type": "channel.favorite", "chat_id": beta, "payload": {"on": True}})) - await recv_until(ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 3) + await ws.send( + json.dumps( + { + "v": 1, + "id": 3, + "type": "channel.favorite", + "chat_id": beta, + "payload": {"on": True}, + } + ) + ) + await recv_until( + ws, lambda f: f.get("type") == "channel.renamed" and f.get("id") == 3 + ) await ws.send(json.dumps({"v": 1, "id": 4, "type": "channel.list", "payload": {}})) - frames = await recv_until(ws, lambda f: f.get("type") == "channel.list" and f.get("id") == 4) + frames = await recv_until( + ws, lambda f: f.get("type") == "channel.list" and f.get("id") == 4 + ) names = [c["name"] for c in frames[-1]["payload"]["channels"]] assert names.index("Beta") < names.index("Alpha") @@ -2036,7 +2245,7 @@ async def test_cron_delivery_emits_banner_and_message(adapter, ws_client): "(job_id: abc123)\n" "-------------\n\n" "hello from cron\n\n" - "To stop or manage this job, send me a new message (e.g. \"stop reminder My Job\")." + 'To stop or manage this job, send me a new message (e.g. "stop reminder My Job").' ) res = await adapter.send("default", wrapped, metadata={"job_id": "abc123"}) assert res.success @@ -2055,8 +2264,9 @@ async def test_cron_delivery_emits_banner_and_message(adapter, ws_client): assert res2.success frames = await recv_until( ws, - lambda f: f.get("type") == "message" - and f["payload"].get("text") == "raw cron output", + lambda f: ( + f.get("type") == "message" and f["payload"].get("text") == "raw cron output" + ), ) notif2 = [f for f in frames if f.get("type") == "notification"] assert notif2[0]["payload"]["title"] == "Cron: j2" @@ -2099,7 +2309,9 @@ async def test_clarify_single_select_emits_picker(plugin, adapter, ws_client): cg.register("cl_pk1", "sk", "Which one?", ["A", "B"]) try: - res = await adapter.send_clarify("default", "Which one?", ["A", "B"], "cl_pk1", "sk") + res = await adapter.send_clarify( + "default", "Which one?", ["A", "B"], "cl_pk1", "sk" + ) assert res.success frames = await recv_until(ws, lambda f: f.get("type") == "picker.choice") pc = frames[-1] @@ -2115,7 +2327,11 @@ async def test_clarify_single_select_emits_picker(plugin, adapter, ws_client): # Tap "A" (c0) -> resolves the clarify with the choice text. await ws.send( json.dumps( - {"type": "picker.select", "id": 1, "payload": {"picker_id": pid, "value": "c0"}} + { + "type": "picker.select", + "id": 1, + "payload": {"picker_id": pid, "value": "c0"}, + } ) ) entry = cg._entries.get("cl_pk1") @@ -2140,19 +2356,27 @@ async def test_clarify_picker_other_flips_to_text(plugin, adapter, ws_client): cg.register("cl_pk2", "sk", "Which one?", ["A", "B"]) try: - res = await adapter.send_clarify("default", "Which one?", ["A", "B"], "cl_pk2", "sk") + res = await adapter.send_clarify( + "default", "Which one?", ["A", "B"], "cl_pk2", "sk" + ) assert res.success frames = await recv_until(ws, lambda f: f.get("type") == "picker.choice") pid = frames[-1]["payload"]["picker_id"] await ws.send( json.dumps( - {"type": "picker.select", "id": 1, "payload": {"picker_id": pid, "value": "other"}} + { + "type": "picker.select", + "id": 1, + "payload": {"picker_id": pid, "value": "other"}, + } ) ) frames2 = await recv_until( ws, - lambda f: f.get("type") == "message" - and "Type your answer" in f["payload"].get("text", ""), + lambda f: ( + f.get("type") == "message" + and "Type your answer" in f["payload"].get("text", "") + ), ) assert frames2[-1]["payload"]["role"] == "assistant" entry = cg._entries.get("cl_pk2") @@ -2171,7 +2395,9 @@ async def test_clarify_single_select_no_device_falls_back_to_text(adapter): cg.register("cl_pk3", "sk", "Which one?", ["A", "B"]) try: - res = await adapter.send_clarify("default", "Which one?", ["A", "B"], "cl_pk3", "sk") + res = await adapter.send_clarify( + "default", "Which one?", ["A", "B"], "cl_pk3", "sk" + ) assert res.success assert not adapter._pending_pickers entry = cg._entries.get("cl_pk3") @@ -2194,7 +2420,9 @@ async def test_sync_replays_parked_frames_and_done_cursor(adapter): await adapter.connect() # Open at the latest cursor so the SSE catch-up doesn't replay the parked # frames (the sync request below is what we're testing). - ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor()) + ws = HttpTestClient( + adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor() + ) try: ack = await ws.start() assert ack["payload"]["sync_cursor"] == 2 @@ -2267,7 +2495,9 @@ async def test_push_success_advances_last_pushed_cursor(adapter): assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3 await adapter.connect() - ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor()) + ws = HttpTestClient( + adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor() + ) try: ack = await ws.start() assert ack["payload"]["last_pushed_cursor"] == 3 @@ -2285,7 +2515,9 @@ async def test_sync_replay_frames_carry_outbox_cursor(adapter): await adapter.send("default", "two", metadata={"notify": True}) await adapter.connect() - ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor()) + ws = HttpTestClient( + adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor() + ) try: await ws.start() await ws.send( @@ -2336,12 +2568,58 @@ def test_outbox_delete_message_removes_all_frames_for_id(plugin, tmp_path): chat = "default" # A streaming message spans start/update/stop; a standalone message is # one frame. Plus an unrelated message that must survive. - outbox.append(chat, json.dumps({"v": 1, "type": "message.start", "payload": {"message_id": "m1", "role": "assistant"}})) - outbox.append(chat, json.dumps({"v": 1, "type": "message.update", "payload": {"message_id": "m1", "text": "hi"}})) - outbox.append(chat, json.dumps({"v": 1, "type": "message.stop", "payload": {"message_id": "m1", "final_text": "hi"}})) - outbox.append(chat, json.dumps({"v": 1, "type": "message", "payload": {"message_id": "m2", "role": "user", "text": "keep"}})) + outbox.append( + chat, + json.dumps( + { + "v": 1, + "type": "message.start", + "payload": {"message_id": "m1", "role": "assistant"}, + } + ), + ) + outbox.append( + chat, + json.dumps( + { + "v": 1, + "type": "message.update", + "payload": {"message_id": "m1", "text": "hi"}, + } + ), + ) + outbox.append( + chat, + json.dumps( + { + "v": 1, + "type": "message.stop", + "payload": {"message_id": "m1", "final_text": "hi"}, + } + ), + ) + outbox.append( + chat, + json.dumps( + { + "v": 1, + "type": "message", + "payload": {"message_id": "m2", "role": "user", "text": "keep"}, + } + ), + ) # A same-id message in a different thread must survive a flat-lane delete. - outbox.append(chat, json.dumps({"v": 1, "type": "message", "thread_id": "t_1", "payload": {"message_id": "m1", "role": "user", "text": "thread"}})) + outbox.append( + chat, + json.dumps( + { + "v": 1, + "type": "message", + "thread_id": "t_1", + "payload": {"message_id": "m1", "role": "user", "text": "thread"}, + } + ), + ) removed = outbox.delete_message(chat, "m1") assert removed == 3 # the three flat-lane frames; the t_1 one is scoped out rows = outbox.replay(0) @@ -2364,10 +2642,48 @@ def test_outbox_delete_lane_removes_channel_and_thread_frames(plugin, tmp_path): try: chan = "chan_9" # Flat-lane frames + two threads' frames, plus an unrelated channel. - outbox.append(chan, json.dumps({"v": 1, "type": "message", "payload": {"message_id": "a", "role": "user", "text": "flat"}})) - outbox.append(chan, json.dumps({"v": 1, "type": "message", "thread_id": "t_1", "payload": {"message_id": "b", "role": "user", "text": "t1"}})) - outbox.append(chan, json.dumps({"v": 1, "type": "message", "thread_id": "t_2", "payload": {"message_id": "c", "role": "user", "text": "t2"}})) - outbox.append("chan_8", json.dumps({"v": 1, "type": "message", "payload": {"message_id": "z", "role": "user", "text": "other"}})) + outbox.append( + chan, + json.dumps( + { + "v": 1, + "type": "message", + "payload": {"message_id": "a", "role": "user", "text": "flat"}, + } + ), + ) + outbox.append( + chan, + json.dumps( + { + "v": 1, + "type": "message", + "thread_id": "t_1", + "payload": {"message_id": "b", "role": "user", "text": "t1"}, + } + ), + ) + outbox.append( + chan, + json.dumps( + { + "v": 1, + "type": "message", + "thread_id": "t_2", + "payload": {"message_id": "c", "role": "user", "text": "t2"}, + } + ), + ) + outbox.append( + "chan_8", + json.dumps( + { + "v": 1, + "type": "message", + "payload": {"message_id": "z", "role": "user", "text": "other"}, + } + ), + ) # Thread delete: only t_1's frame goes. assert outbox.delete_lane(chan, thread_id="t_1") == 1 rows = outbox.replay(0) @@ -2427,7 +2743,10 @@ async def test_wrong_token_rejected(adapter): conn.request( "GET", "/v1/events", - headers={"Authorization": "Bearer wrong-token", "X-Iris-Device": DEVICE_ID}, + headers={ + "Authorization": "Bearer wrong-token", + "X-Iris-Device": DEVICE_ID, + }, ) resp = conn.getresponse() resp.read() @@ -2541,7 +2860,9 @@ async def test_revoked_device_rejected_even_with_shared_token(adapter): assert adapter._devices.is_revoked(DEVICE_ID) assert adapter._devices.token_for(DEVICE_ID) is None - assert await asyncio.to_thread(_sse_status, port, device_token, DEVICE_ID) == 401 + assert ( + await asyncio.to_thread(_sse_status, port, device_token, DEVICE_ID) == 401 + ) assert await asyncio.to_thread(_sse_status, port, TOKEN, DEVICE_ID) == 401 finally: await adapter.disconnect() @@ -2569,7 +2890,9 @@ async def test_revoke_does_not_affect_other_devices(adapter): adapter._devices.revoke(DEVICE_ID) # A is dead (both tokens); B is untouched (both tokens). - assert await asyncio.to_thread(_sse_status, port, device_token, DEVICE_ID) == 401 + assert ( + await asyncio.to_thread(_sse_status, port, device_token, DEVICE_ID) == 401 + ) assert await asyncio.to_thread(_sse_status, port, TOKEN, DEVICE_ID) == 401 assert await asyncio.to_thread(_sse_status, port, other_token, other) == 200 assert await asyncio.to_thread(_sse_status, port, TOKEN, other) == 200 @@ -2731,7 +3054,9 @@ def test_generate_self_signed_cert(tmp_path): # SAN carries the IPs as IP entries and the hostname as DNS. cert = x509.load_pem_x509_certificate(cert_path.read_bytes()) san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value - assert ipaddress.ip_address("192.168.1.10") in san.get_values_for_type(x509.IPAddress) + assert ipaddress.ip_address("192.168.1.10") in san.get_values_for_type( + x509.IPAddress + ) assert ipaddress.ip_address("127.0.0.1") in san.get_values_for_type(x509.IPAddress) assert "iris.example.com" in san.get_values_for_type(x509.DNSName) # Cross-check the fingerprint against openssl itself (independent of the @@ -2741,7 +3066,15 @@ def test_generate_self_signed_cert(tmp_path): if shutil.which("openssl"): out = subprocess.run( - ["openssl", "x509", "-fingerprint", "-sha256", "-noout", "-in", str(cert_path)], + [ + "openssl", + "x509", + "-fingerprint", + "-sha256", + "-noout", + "-in", + str(cert_path), + ], capture_output=True, text=True, check=True, @@ -2916,7 +3249,9 @@ def test_tool_end_fields_from_hook(plugin): # Consumed -> a second lookup is empty. assert a._tool_end_fields("terminal") == {} # Error status -> ok False; zero duration omitted. - a._on_post_tool_call(tool_name="terminal", result="boom", duration_ms=0, status="error") + a._on_post_tool_call( + tool_name="terminal", result="boom", duration_ms=0, status="error" + ) f2 = a._tool_end_fields("terminal") assert f2["ok"] is False and "duration" not in f2 finally: @@ -2967,6 +3302,7 @@ def test_tool_emoji_resolves_via_hermes_display(plugin, monkeypatch): # Unknown tool: the adapter asks for an empty default and drops it. assert a._tool_emoji("no_such_tool") is None assert seen["no_such_tool"] == "" + # A display-layer failure degrades to None, never a crash. def boom(name, default="⚡"): raise RuntimeError("no display") @@ -2984,15 +3320,17 @@ async def test_tool_start_frame_carries_emoji(plugin, adapter, ws_client, monkey "agent.display.get_tool_emoji", lambda name, default="⚡": "💻" if name == "terminal" else default, ) - res = await adapter.send('default', '💻 terminal: "ls -la"') + res = await adapter.send("default", '💻 terminal: "ls -la"') assert res.success frames = await recv_until(ws, lambda f: f.get("type") == "tool.start") payload = frames[-1]["payload"] assert payload["name"] == "terminal" assert payload["emoji"] == "💻" # Unknown tool -> field omitted (app falls back to its default glyph). - monkeypatch.setattr("agent.display.get_tool_emoji", lambda name, default="⚡": default) - res2 = await adapter.send('default', '🔧 patch: "x"') + monkeypatch.setattr( + "agent.display.get_tool_emoji", lambda name, default="⚡": default + ) + res2 = await adapter.send("default", '🔧 patch: "x"') assert res2.success frames2 = await recv_until( ws, lambda f: f.get("type") == "tool.start" and f["payload"]["name"] == "patch" @@ -3053,6 +3391,7 @@ async def test_commands_catalog_frame_roundtrip(plugin, adapter, ws_client): assert "description" in c and "args_hint" in c and "category" in c assert isinstance(c["aliases"], list) + # ── Interactive pickers (picker.choice / picker.select) ─────────────────── @@ -3090,19 +3429,28 @@ async def test_choice_picker_roundtrip(plugin, adapter, ws_client): # The app taps a button -> picker.select. await ws.send( json.dumps( - {"type": "picker.select", "id": 1, "payload": {"picker_id": pid, "value": "b"}} + { + "type": "picker.select", + "id": 1, + "payload": {"picker_id": pid, "value": "b"}, + } ) ) assert picked == [("default", "b")] # The callback's reply arrives as a normal final assistant message. frames2 = await recv_until( - ws, lambda f: f.get("type") == "message" and f["payload"].get("text") == "picked b" + ws, + lambda f: f.get("type") == "message" and f["payload"].get("text") == "picked b", ) assert frames2[-1]["payload"]["role"] == "assistant" # The picker is consumed: a second select for the same id is a no-op. await ws.send( json.dumps( - {"type": "picker.select", "id": 2, "payload": {"picker_id": pid, "value": "a"}} + { + "type": "picker.select", + "id": 2, + "payload": {"picker_id": pid, "value": "a"}, + } ) ) await asyncio.sleep(0.2) @@ -3131,7 +3479,11 @@ async def test_picker_select_unknown_id_is_noop(plugin, adapter, ws_client): ws, _ = ws_client await ws.send( json.dumps( - {"type": "picker.select", "id": 1, "payload": {"picker_id": "pc_nope", "value": "a"}} + { + "type": "picker.select", + "id": 1, + "payload": {"picker_id": "pc_nope", "value": "a"}, + } ) ) await asyncio.sleep(0.2) diff --git a/gateway-plugin/tests/test_android_http.py b/tests/test_android_http.py similarity index 94% rename from gateway-plugin/tests/test_android_http.py rename to tests/test_android_http.py index 055feaa..781164f 100644 --- a/gateway-plugin/tests/test_android_http.py +++ b/tests/test_android_http.py @@ -59,14 +59,17 @@ def _plugin_dir() -> Path: env = os.environ.get("IRIS_PLUGIN_DIR") if env: return Path(env) - # Works from either copy of this file: gateway-plugin/tests/ (canonical, - # plugin dir is parents[1]) or the hermes-agent/tests/gateway/ mirror - # (repo root is parents[3]). + # Works from either copy of this file: tests/ (canonical, repo root is + # parents[1]) or the hermes-agent/tests/gateway/ mirror (repo root is + # parents[3]). The plugin always lives in /gateway-plugin. here = Path(__file__).resolve() - for candidate in (here.parents[1], here.parents[3] / "gateway-plugin"): + for candidate in ( + here.parents[1] / "gateway-plugin", + here.parents[3] / "gateway-plugin", + ): if (candidate / "protocol.py").is_file(): return candidate - return here.parents[1] + return here.parents[1] / "gateway-plugin" def _load_plugin(): @@ -192,7 +195,9 @@ def _frame_json(frame: dict) -> dict: return {"v": 1, **frame} -def _parse_sse(lines: list[str]) -> tuple[list[tuple[str | None, str | None, str]], int]: +def _parse_sse( + lines: list[str], +) -> tuple[list[tuple[str | None, str | None, str]], int]: """Parse raw SSE lines into ``[(event, id, data), ...]`` + comment count.""" events: list[tuple[str | None, str | None, str]] = [] comments = 0 @@ -220,7 +225,9 @@ def _parse_sse(lines: list[str]) -> tuple[list[tuple[str | None, str | None, str return events, comments -def _sse_open(port: int, *, cursor: int | None = None, last_event_id: str | None = None): +def _sse_open( + port: int, *, cursor: int | None = None, last_event_id: str | None = None +): """Open an SSE connection (blocking); returns the HTTPResponse (read lines via ``_sse_read_lines``; close with ``resp.close()``).""" conn = HTTPConnection("127.0.0.1", port, timeout=30) @@ -355,8 +362,12 @@ async def test_post_wrong_content_type_400(gw): @pytest.mark.asyncio async def test_post_oversize_body_413(gw): - big = json.dumps(_frame_json({"type": "ping", "payload": {"pad": "x" * (1024 * 1024 + 1)}})) - status, _ = await asyncio.to_thread(_request, http_port(gw), "POST", "/v1/frame", body=big) + big = json.dumps( + _frame_json({"type": "ping", "payload": {"pad": "x" * (1024 * 1024 + 1)}}) + ) + status, _ = await asyncio.to_thread( + _request, http_port(gw), "POST", "/v1/frame", body=big + ) assert status == 413 @@ -367,7 +378,12 @@ async def test_post_empty_message_400(gw): _post_frame, http_port(gw), _frame_json( - {"id": 7, "type": "message.send", "chat_id": CHAT_ID, "payload": {"text": " "}} + { + "id": 7, + "type": "message.send", + "chat_id": CHAT_ID, + "payload": {"text": " "}, + } ), ) assert status == 400 @@ -666,7 +682,9 @@ def _upload( "X-Iris-Media-Ref": media_ref, "X-Iris-Media-Kind": kind, "X-Iris-Media-Filename": filename, - "X-Iris-Media-Sha256": sha256 if sha256 is not None else hashlib.sha256(data).hexdigest(), + "X-Iris-Media-Sha256": sha256 + if sha256 is not None + else hashlib.sha256(data).hexdigest(), } status, payload = _request( port, @@ -772,7 +790,9 @@ async def test_media_pull_ok(gw): str(img), "image", "image/png", "http_pull_test.png", len(PNG_1X1) ) port = http_port(gw) - status, payload = await asyncio.to_thread(_request, port, "GET", f"/v1/media/{entry.media_id}") + status, payload = await asyncio.to_thread( + _request, port, "GET", f"/v1/media/{entry.media_id}" + ) assert status == 200 assert payload == PNG_1X1 conn = HTTPConnection("127.0.0.1", port, timeout=10) @@ -791,7 +811,9 @@ async def test_media_pull_ok(gw): @pytest.mark.asyncio async def test_media_pull_unknown_404(gw): port = http_port(gw) - status, payload = await asyncio.to_thread(_request, port, "GET", "/v1/media/md_nope") + status, payload = await asyncio.to_thread( + _request, port, "GET", "/v1/media/md_nope" + ) body = json.loads(payload) assert status == 404 assert body["payload"]["code"] == "not_found" @@ -801,9 +823,13 @@ async def test_media_pull_unknown_404(gw): async def test_media_pull_denied_path_404(gw): """Known id, but the path fails delivery validation (denylist) — same re-check at pull time as the WS path.""" - entry = gw._media.register_outbound("/etc/passwd", "document", "text/plain", "passwd", 100) + entry = gw._media.register_outbound( + "/etc/passwd", "document", "text/plain", "passwd", 100 + ) port = http_port(gw) - status, payload = await asyncio.to_thread(_request, port, "GET", f"/v1/media/{entry.media_id}") + status, payload = await asyncio.to_thread( + _request, port, "GET", f"/v1/media/{entry.media_id}" + ) body = json.loads(payload) assert status == 404 assert body["payload"]["code"] == "not_found" diff --git a/gateway-plugin/tests/ws_probe.py b/tests/ws_probe.py similarity index 90% rename from gateway-plugin/tests/ws_probe.py rename to tests/ws_probe.py index 7fce85f..86dea3d 100644 --- a/gateway-plugin/tests/ws_probe.py +++ b/tests/ws_probe.py @@ -8,7 +8,7 @@ while building the Kotlin client. Usage:: hermes gateway & # with the iris plugin - python gateway-plugin/tests/ws_probe.py --token \ + python tests/ws_probe.py --token \ --send "hello" Options: @@ -136,9 +136,7 @@ def _print_frame(raw): f"preview={str(payload.get('preview'))[:80]!r}" ) elif ftype == "tool.progress": - extra = ( - f" idx={payload.get('index')} name={payload.get('name')!r} note={payload.get('note')!r}" - ) + extra = f" idx={payload.get('index')} name={payload.get('name')!r} note={payload.get('note')!r}" elif ftype == "tool.end": extra = ( f" idx={payload.get('index')} name={payload.get('name')!r} " @@ -147,7 +145,9 @@ def _print_frame(raw): elif ftype == "commentary": extra = f" id={payload.get('message_id')} text={(payload.get('text') or '')[:120]!r}" elif ftype == "hello.ack": - extra = f" caps={payload.get('server_caps')} cursor={payload.get('sync_cursor')}" + extra = ( + f" caps={payload.get('server_caps')} cursor={payload.get('sync_cursor')}" + ) elif ftype == "error": extra = f" code={payload.get('code')} msg={payload.get('message')!r}" elif ftype == "typing": @@ -254,34 +254,54 @@ def _evaluate_assertions(args, st: _TurnState) -> list[tuple[int, bool, str]]: if "message.start" in events and "message.stop" in events: i_start = events.index("message.start") i_stop = events.index("message.stop") - if any(i_start < i < i_stop for i, e in enumerate(events) if e == "message.update"): + if any( + i_start < i < i_stop + for i, e in enumerate(events) + if e == "message.update" + ): ok = True break results.append( - (10, ok, "assert-turn: no message.start -> >=1 message.update -> message.stop") + ( + 10, + ok, + "assert-turn: no message.start -> >=1 message.update -> message.stop", + ) ) if args.assert_reasoning: reasoning = st.final_stop_reasoning or st.final_message_reasoning results.append( - (11, bool(reasoning), "assert-reasoning: final message has no non-empty reasoning") + ( + 11, + bool(reasoning), + "assert-reasoning: final message has no non-empty reasoning", + ) ) if args.assert_tools: ok = bool(st.tool_starts) and bool(st.tool_starts & st.tool_ends) results.append((12, ok, "assert-tools: no tool.start with a matching tool.end")) if args.assert_commentary: - results.append((13, st.commentary >= 1, "assert-commentary: no commentary frame")) + results.append( + (13, st.commentary >= 1, "assert-commentary: no commentary frame") + ) if args.assert_read_receipt: if st.read_receipt is None: print("== SKIP: no read.receipt frame (M7 frame not live on this gateway)") elif not st.read_receipt: results.append( - (18, False, "assert-read-receipt: read.receipt arrived before the sent message") + ( + 18, + False, + "assert-read-receipt: read.receipt arrived before the sent message", + ) ) if args.assert_status: if not st.status_seen: print("== SKIP: no status frame (M7 frame not live on this gateway)") elif st.status_empty: - results.append((19, False, "assert-status: status frame arrived with an empty payload")) + results.append( + (19, False, "assert-status: status frame arrived with an empty payload") + ) return results @@ -391,7 +411,12 @@ def run_http(args, base: str) -> int: host, port, headers, - {"v": 1, "id": 1, "type": "channel.create", "payload": {"name": args.channel_create}}, + { + "v": 1, + "id": 1, + "type": "channel.create", + "payload": {"name": args.channel_create}, + }, "channel.created", 30, ) @@ -477,7 +502,12 @@ def run_http(args, base: str) -> int: host, port, headers, - {"v": 1, "id": 1, "type": "fcm.register", "payload": {"token": args.fcm_token}}, + { + "v": 1, + "id": 1, + "type": "fcm.register", + "payload": {"token": args.fcm_token}, + }, "fcm.registered", 30, ) @@ -513,7 +543,10 @@ def run_http(args, base: str) -> int: if data is not None and data.get("chat_id") == args.watch: ftype = data.get("type") payload = data.get("payload") or {} - if ftype == "message" and payload.get("role") in ("assistant", "cron"): + if ftype == "message" and payload.get("role") in ( + "assistant", + "cron", + ): print( f"== message landed in {args.watch}: {str(payload.get('text'))[:120]!r}" ) @@ -628,7 +661,11 @@ def run_http(args, base: str) -> int: got_final = True if ftype == "message.stop": seen_final_frame = True - if ftype == "typing" and payload.get("on") is False and seen_final_frame: + if ( + ftype == "typing" + and payload.get("on") is False + and seen_final_frame + ): got_final = True cur_data = [] return got_final @@ -673,7 +710,9 @@ def main() -> int: p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}") p.add_argument("--send", default="hello") p.add_argument( - "--upload", default="", help="M4: file to upload (chunked) and attach via media_refs" + "--upload", + default="", + help="M4: file to upload (chunked) and attach via media_refs", ) p.add_argument( "--pull-offer", @@ -686,14 +725,18 @@ def main() -> int: default=None, help="M5: send sync {cursor} after pairing, print replay, exit", ) - p.add_argument("--fcm-token", default="", help="M5: FCM token to attach to the hello payload") + p.add_argument( + "--fcm-token", default="", help="M5: FCM token to attach to the hello payload" + ) p.add_argument( "--fcm-reg", action="store_true", help="M5: send fcm.register after pairing (uses --fcm-token)", ) p.add_argument("--timeout", type=float, default=120.0) - p.add_argument("--authfail", action="store_true", help="expect an auth rejection (wrong token)") + p.add_argument( + "--authfail", action="store_true", help="expect an auth rejection (wrong token)" + ) p.add_argument( "--assert-turn", action="store_true", @@ -705,9 +748,13 @@ def main() -> int: help="assert the final message.stop carries non-empty reasoning", ) p.add_argument( - "--assert-tools", action="store_true", help="assert >=1 tool.start with a matching tool.end" + "--assert-tools", + action="store_true", + help="assert >=1 tool.start with a matching tool.end", + ) + p.add_argument( + "--assert-commentary", action="store_true", help="assert >=1 commentary frame" ) - p.add_argument("--assert-commentary", action="store_true", help="assert >=1 commentary frame") p.add_argument( "--assert-read-receipt", action="store_true", @@ -719,10 +766,15 @@ def main() -> int: help="assert a status frame is received (SKIP if absent; M7)", ) p.add_argument( - "--search", default="", help="M3: send search {query, scope, limit}, assert >=1 hit" + "--search", + default="", + help="M3: send search {query, scope, limit}, assert >=1 hit", ) p.add_argument( - "--scope", choices=("all", "chat"), default="all", help="search scope (default all)" + "--scope", + choices=("all", "chat"), + default="all", + help="search scope (default all)", ) p.add_argument( "--chat-id", @@ -730,12 +782,20 @@ def main() -> int: help="chat_id for --scope chat (default default)", ) p.add_argument( - "--channel-create", default="", help="M3: create a channel, print its chat_id, exit" + "--channel-create", + default="", + help="M3: create a channel, print its chat_id, exit", ) - p.add_argument("--channel-delete", default="", help="M3: delete (archive) a channel, exit") - p.add_argument("--channel-list", action="store_true", help="M3: list channels, exit") p.add_argument( - "--watch", default="", help="wait up to --timeout for a message to land in this chat_id" + "--channel-delete", default="", help="M3: delete (archive) a channel, exit" + ) + p.add_argument( + "--channel-list", action="store_true", help="M3: list channels, exit" + ) + p.add_argument( + "--watch", + default="", + help="wait up to --timeout for a message to land in this chat_id", ) p.add_argument( "--offer-grace", @@ -766,7 +826,9 @@ def main() -> int: if not args.token and not args.authfail: p.error("--token (or $IRIS_TOKEN) is required") if args.assert_read_receipt and not args.send: - p.error("--assert-read-receipt requires --send (the receipt must follow the sent message)") + p.error( + "--assert-read-receipt requires --send (the receipt must follow the sent message)" + ) # HTTP is the only transport (docs/19): derive the http(s) base from the # --url (legacy ws(s)://host:8790/ws -> http(s)://host:8791) unless # --http-url is given.