12 Commits
Author SHA1 Message Date
Pakobbix 2c1d444348 Merge pull request 'fix(http): don't let a half-open TLS connection wedge the accept loop' (#16) from fix/iris-tls-handshake-wedge into master
CI / Kotlin tests (android host + desktop) (push) Successful in 5m47s
CI / Gateway plugin tests (push) Failing after 15m58s
Reviewed-on: #16
2026-09-23 13:10:53 +00:00
ARIA 2c20b1c8a5 fix(http): don't let a half-open TLS connection wedge the accept loop
CI / Kotlin tests (android host + desktop) (pull_request) Successful in 8m18s
CI / Gateway plugin tests (pull_request) Failing after 15m7s
A client that completes TCP but vanishes mid-TLS-handshake (e.g. a
phone losing its network/VPN while traveling) blocked
ssl.SSLSocket.accept() inside serve_forever forever: the gateway
stopped accepting any new device connections (the app could not
reconnect), and on the next restart httpd.shutdown() froze the whole
event loop until the shutdown watchdog killed the process (ARIA
journal 2026-09-11 / 2026-09-23).

- Move the TLS handshake out of the accept loop: it now runs in the
  per-connection thread under a hard timeout (HANDSHAKE_TIMEOUT_S,
  10 s); a failed/timed-out handshake just closes the socket.
- stop() no longer blocks the event loop: shutdown()/server_close()/
  join run in an executor under asyncio.wait_for(10 s); if the bound
  expires the daemon threads are abandoned.
- Regression test: a silent half-open TCP connection must not stop
  fresh TLS connections from being served, and stop() must stay
  bounded.
2026-09-23 15:10:16 +02:00
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
ARIA fb980d12b4 Release version management: single VERSION file as source of truth
CI / Gateway plugin tests (push) Successful in 5m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m0s
- VERSION at repo root (0.1.2); bump it to cut a release
- App: generated AppVersion.kt (config-cache-safe Gradle task with
  VERSION as declared input) shown in Settings; sent to the gateway
  via X-Iris-App-Version header on the SSE open
- Gateway: reports its own version in hello.ack server_caps.app_version
  (read from the repo-root VERSION via the plugin symlink); stores the
  app's version in the device registry caps (merge, not overwrite, so
  an old app reconnecting without the header doesn't wipe it)
- Settings: app + gateway version rows, mismatch hint, and a best-effort
  Gitea latest-release check (ReleaseCheck) with an 'update available' hint
- Release workflow: reads VERSION from the repo (no manual input), with
  a guard against an empty file
- Docs: frames.schema.json + 04-wire-protocol.md updated for app_version
2026-08-25 14:42:39 +02:00
ARIA f3f1b37221 Fix iris setup: embed generated token in the pairing URL/QR
CI / Gateway plugin tests (push) Successful in 5m23s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m53s
interactive_setup() generated a fresh IRIS_TOKEN and saved it to .env,
but the local `token` variable was never updated, so the pairing URL and
QR payload were built with an empty token (token=). The gateway accepted
the saved token, but the app never received it, so pairing was impossible.

Assign the generated value back to `token` so the pairing URL/QR carry it.
Add a regression test (test_interactive_setup_generates_token_in_pairing_url).
2026-08-25 13:42:46 +02:00
ARIA 6f339330c5 Move gateway-plugin tests out of the installable tree; clean plugin scan
CI / Gateway plugin tests (push) Successful in 5m13s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m43s
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.
2026-08-25 13:26:12 +02:00
ARIA 573291fc1e threads: order topic chips newest-first beneath General
CI / Gateway plugin tests (push) Successful in 5m17s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m58s
The gateway already stored a created timestamp per channel/thread but
never sent it over the wire. Now:

- protocol.py: _channel_payload() includes created (unix seconds)
- Protocol.kt: ChannelInfo.created (default 0.0 for legacy gateways)
- ChatScreen: topic switcher sorts threads created-desc (newest right
  after General, swipe new -> old), name as tie-break
- frames.schema.json: document the created field
- ChannelCreatedWireTest: wire deserialization + ordering tests
2026-08-25 11:22:00 +02:00
ARIA 83a67f6fb1 app: smooth streaming markdown via incremental parser + typewriter reveal
CI / Gateway plugin tests (push) Successful in 6m4s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m36s
- MarkdownText: streaming branch on the library's StreamingMarkdownState
  (rememberStreamingMarkdownPipeline) — incremental append, no full
  re-parse per snapshot; static path keeps retainState=true
- client-side reveal: buffer snapshots, reveal at a steady rate
  (streamCharsPerSecond = 60/smoothness, 300..75 c/s) with rate-relative
  catch-up (jump when backlog > 2s of reveal time)
- keep the streaming renderer alive after message.stop until the reveal
  catches up (useStreaming gate); artifact card waits for the same
- cursor drawn via annotator, never part of the parse input
- new 'Streaming speed' setting (0.2-0.8s) in Settings, persisted per
  device via SecureStore, live-updatable
- docs/05-streaming: rate mapping, catch-up, wait-for-reveal semantics
2026-08-25 11:00:22 +02:00
44 changed files with 2334 additions and 494 deletions

No files matched your search

+65 -65
View File
@@ -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
+8 -8
View File
@@ -3,10 +3,8 @@ name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Release version (e.g. 0.2.0)"
required: true
type: string
# The release version comes from the repo-root VERSION file (the single
# source of truth) — bump it in a commit, then dispatch this workflow.
changelog:
description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks."
required: false
@@ -38,7 +36,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
@@ -133,7 +131,8 @@ jobs:
- name: Build APK + AAB
run: |
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
cd app
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
# APK for direct sideloading, AAB for Play Store uploads.
@@ -155,7 +154,8 @@ jobs:
# for it (jpackage picks the native type: msi on Windows, dmg on macOS).
- name: Build desktop app-image + deb
run: |
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
cd app
# Self-contained app image (JRE bundled via jlink).
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
@@ -177,7 +177,7 @@ jobs:
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
# The dispatch input is a single-line field; turn literal \n into real newlines.
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
TAG="v$VERSION"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"ignore": [
"gateway-plugin/tests/test_android.py"
"tests/test_android.py"
],
"rules": {
"unchecked-throwing-call-python": {
+7 -1
View File
@@ -12,4 +12,10 @@ repos:
entry: scripts/guard_hermes_agent.sh --staged
language: system
pass_filenames: false
always_run: true
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
+9 -4
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,17 +12,21 @@
- `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).
- WS probe (gateway must be running): `hermes-agent/.venv/bin/python gateway-plugin/tests/ws_probe.py --token <IRIS_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 <IRIS_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
@@ -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.
+52 -24
View File
@@ -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
@@ -100,11 +100,13 @@ jobs:
## 3. NEW FILE: `.gitea/workflows/release.yml`
Manual trigger: **repo → Actions → Release → Run workflow**, enter a
`version` (e.g. `0.2.0`) and a `changelog`. It runs the same tests as CI,
builds a signed Android APK + AAB and the Linux desktop packages (jpackage,
JRE bundled), then creates the Gitea release `v<version>` with all artifacts
as download attachments.
The release version is the repo-root **`VERSION` file** (single source of
truth — "everything from here on out is vX.Y.Z" = bump `VERSION` and
commit). Manual trigger: **repo → Actions → Release → Run workflow**,
optionally with a `changelog`. It runs the same tests as CI, builds a signed
Android APK + AAB and the Linux desktop packages (jpackage, JRE bundled),
then creates the Gitea release `v<VERSION>` with all artifacts as download
attachments.
Note: builds + release creation happen in ONE job because Gitea/act_runner
does not implement the GitHub artifacts API (`upload-artifact@v4+` fails
@@ -116,12 +118,10 @@ name: Release
on:
workflow_dispatch:
inputs:
version:
description: "Release version (e.g. 0.2.0)"
required: true
type: string
# The release version comes from the repo-root VERSION file (the single
# source of truth) — bump it in a commit, then dispatch this workflow.
changelog:
description: "Release notes (markdown, shown on the release page)"
description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks."
required: false
type: string
@@ -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
@@ -246,7 +246,8 @@ jobs:
- name: Build APK + AAB
run: |
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
cd app
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
# APK for direct sideloading, AAB for Play Store uploads.
@@ -268,7 +269,8 @@ jobs:
# for it (jpackage picks the native type: msi on Windows, dmg on macOS).
- name: Build desktop app-image + deb
run: |
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
[ -n "$VERSION" ] || { echo "::error::VERSION file is missing or empty"; exit 1; }
cd app
# Self-contained app image (JRE bundled via jlink).
./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
@@ -290,20 +292,39 @@ jobs:
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH")
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH")
VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
# The dispatch input is a single-line field; turn literal \n into real newlines.
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
TAG="v$VERSION"
API="$SERVER/api/v1/repos/$REPO"
AUTH="Authorization: token $TOKEN"
# Re-run safety: drop a previous release (and its tag) for this version.
OLD_ID=$(curl -sf -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty')
# curl wrapper: on HTTP >= 400, print the response body (Gitea's error
# message) before failing — plain `curl -f` hides it (exit 22).
api() {
local code body
body=$(mktemp)
code=$(curl -s -o "$body" -w '%{http_code}' "$@") || { cat "$body"; rm -f "$body"; return 1; }
if [ "${code:0:1}" != "2" ]; then
echo "API error $code: $(cat "$body")" >&2
rm -f "$body"
return 1
fi
cat "$body"
rm -f "$body"
}
# Re-run safety: drop a previous release AND its tag for this version.
# (Gitea's DELETE /releases/:id does NOT remove the tag; a leftover tag
# makes the POST below fail with 409.)
OLD_ID=$(api -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') || true
if [ -n "$OLD_ID" ]; then
curl -sf -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
api -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
fi
api -X DELETE -H "$AUTH" "$API/tags/$TAG" > /dev/null || true
# Gitea creates the tag at the default branch HEAD automatically.
RELEASE_ID=$(curl -sf -X POST -H "$AUTH" -H "Content-Type: application/json" \
RELEASE_ID=$(api -X POST -H "$AUTH" -H "Content-Type: application/json" \
"$API/releases" \
-d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \
'{tag_name:$tag, title:$title, body:$body}')" \
@@ -313,15 +334,22 @@ jobs:
for f in "$GITHUB_WORKSPACE"/iris-android-v* "$GITHUB_WORKSPACE"/iris-desktop-*; do
[ -f "$f" ] || continue
echo "Uploading $(basename "$f")"
curl -sf -X POST -H "$AUTH" -F "attachment=@$f" \
"$API/releases/$RELEASE_ID/attachments" > /dev/null
# Forgejo-style API: release assets live under /assets, not /attachments.
api -X POST -H "$AUTH" -F "attachment=@$f" \
"$API/releases/$RELEASE_ID/assets" > /dev/null
done
echo "Done: $SERVER/$REPO/releases/tag/$TAG"
```
---
## 4. EDITS to existing Gradle files
> **Note (versioning):** the `versionName` / `appVersion` lines shown below
> have since been changed to read the repo-root **`VERSION` file** (single
> source of truth; `-PappVersion` still overrides in CI). See
> `.gitea/workflows/release.yml` and `gateway-plugin/version.py`.
### 4a. `app/androidApp/build.gradle.kts`
**Change 1** — in `defaultConfig`, replace:
+12 -8
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.
@@ -168,8 +172,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.
+1
View File
@@ -0,0 +1 @@
0.1.3
+10 -3
View File
@@ -16,9 +16,16 @@ android {
// Play Store requires an incrementing versionCode per upload; CI can
// pass -PappVersionCode=<n>. Local builds keep the default.
versionCode = (project.findProperty("appVersionCode")?.toString()?.toIntOrNull()) ?: 1
// CI passes -PappVersion=<version> (release workflow); local builds
// keep the default.
versionName = (project.findProperty("appVersion") as? String) ?: "0.1.0"
// The repo-root VERSION file is the single source of truth (bump it
// to cut a release); CI can still override with -PappVersion.
versionName =
(project.findProperty("appVersion") as? String)
?: project
.file("../../VERSION")
.takeIf { it.exists() }
?.readText()
?.trim()
?: "0.1.0"
}
buildTypes {
+11 -3
View File
@@ -9,9 +9,17 @@ plugins {
val composeVersion = "1.11.1"
val os = OperatingSystem.current()
val arch = System.getProperty("os.arch") ?: "amd64"
// CI passes -PappVersion=<version> (release workflow); local builds keep the
// default. jpackage requires a plain semver (no leading "v").
val appVersion = (project.findProperty("appVersion") as? String) ?: "0.1.0"
// The repo-root VERSION file is the single source of truth (bump it to cut
// a release); CI can still override with -PappVersion. jpackage requires a
// plain semver (no leading "v").
val appVersion =
(project.findProperty("appVersion") as? String)
?: project
.file("../../VERSION")
.takeIf { it.exists() }
?.readText()
?.trim()
?: "0.1.0"
val desktopTarget =
when {
os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64"
+52
View File
@@ -33,6 +33,48 @@ val sqldelightVersion = "2.3.2"
val cameraxVersion = "1.5.1"
val mlKitVersion = "16.1.1"
// ── App version (generated) ─────────────────────────────────────────────
// The repo-root VERSION file is the single source of truth (bump it to cut
// a release; CI can still override with -PappVersion). Generate AppVersion.kt
// into commonMain so both targets can display it in Settings and send it to
// the gateway (X-Iris-App-Version header).
//
// A real task with the VERSION file as a DECLARED input: on a
// configuration-cache hit the script body does not re-run, so only the task
// (keyed on the file's content) can regenerate AppVersion.kt after a bump.
// The doLast reads everything from the task's own inputs/outputs (which are
// configuration-cache serializable) — it must not reference script-scope
// vals, because a .kts script lambda captures the script object and the
// configuration cache rejects that.
val generatedVersionDir = layout.buildDirectory.dir("generated/app-version")
val generateAppVersion by tasks.registering {
inputs.file(project.file("../../VERSION"))
inputs.property("appVersionOverride", (project.findProperty("appVersion") as? String).orEmpty())
val outFile = generatedVersionDir.map { it.file("AppVersion.kt") }
outputs.file(outFile)
doLast {
val override = inputs.properties["appVersionOverride"] as? String ?: ""
val versionFile = inputs.files.singleFile
val version =
override.ifBlank {
versionFile.takeIf { it.exists() }?.readText()?.trim() ?: "0.1.0"
}
val f = outFile.get().asFile
f.parentFile?.mkdirs()
f.writeText(
"""
|package iris
|
|/** Generated from the repo-root VERSION file - do not edit. */
|object AppVersion {
| const val VERSION = "$version"
|}
|
""".trimMargin(),
)
}
}
kotlin {
android {
namespace = "iris.shared"
@@ -53,6 +95,11 @@ kotlin {
}
sourceSets {
// AppVersion.kt is generated from the repo-root VERSION file (see
// generateAppVersion above) into commonMain so both targets can read it.
commonMain {
kotlin.srcDir(generatedVersionDir)
}
// Both targets are JVM-based (androidTarget + jvm("desktop")), so
// shared JVM code (File I/O, SHA-256, media cache) lives in jvmMain.
val jvmMain by creating { dependsOn(commonMain.get()) }
@@ -138,3 +185,8 @@ sqldelight {
}
}
}
// Every Kotlin compile depends on the generated AppVersion.kt being current.
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
dependsOn(generateAppVersion)
}
@@ -9,6 +9,7 @@ import iris.data.SecureStore
import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme
import iris.util.STREAM_SMOOTHNESS_DEFAULT
import java.util.UUID
/**
@@ -134,6 +135,10 @@ class AndroidSecureStore(
get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true)
set(value) = prefs.edit().putBoolean(KEY_STREAMING_ENABLED, value).apply()
override var streamSmoothness: Float
get() = prefs.getFloat(KEY_STREAM_SMOOTHNESS, STREAM_SMOOTHNESS_DEFAULT)
set(value) = prefs.edit().putFloat(KEY_STREAM_SMOOTHNESS, value).apply()
override var reasoningAutoCollapse: Boolean
get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true)
set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply()
@@ -225,6 +230,7 @@ class AndroidSecureStore(
const val KEY_THREADS_ENABLED = "threads_enabled"
const val KEY_TOOL_DETAIL = "tool_detail"
const val KEY_STREAMING_ENABLED = "streaming_enabled"
const val KEY_STREAM_SMOOTHNESS = "stream_smoothness"
const val KEY_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse"
const val KEY_USER_BUBBLE_COLOR = "user_bubble_color"
const val KEY_AGENT_BUBBLE_COLOR = "agent_bubble_color"
@@ -54,6 +54,10 @@ interface SecureStore {
/** UI setting: stream assistant replies live (token by token). */
var streamingEnabled: Boolean
/** UI setting: streaming smoothness — seconds between visible text
* updates while streaming (0.2–0.8; lower = faster/smoother). */
var streamSmoothness: Float
/** UI setting: auto-collapse long reasoning blocks in the chat view. */
var reasoningAutoCollapse: Boolean
@@ -1,5 +1,6 @@
package iris.net
import iris.AppVersion
import iris.media.Sha256
import iris.media.isValidMediaId
import iris.protocol.ErrorPayload
@@ -135,6 +136,9 @@ class HttpGateway(
deviceName?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Device-Name", it) }
fcmToken()?.takeIf { !it.isNullOrBlank() }?.let { b.add("X-Iris-Fcm-Token", it) }
ntfyTopic()?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Ntfy-Topic", it) }
// Release version (repo-root VERSION baked in at build time); the
// gateway stores it in the device registry (docs/04 hello.ack note).
b.add("X-Iris-App-Version", AppVersion.VERSION)
return b.build()
}
@@ -0,0 +1,96 @@
package iris.net
import iris.protocol.IrisJson
import iris.util.IrisLog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
/**
* Latest-release check (docs/04 hello.ack note): the repo is public on Gitea,
* so the app can ask for the newest release tag without any token. Settings
* shows "vX.Y.Z available" when the running build is older.
*
* Best-effort by design: any failure (offline, DNS, rate limit) just yields
* `null` — the check must never surface an error in the UI.
*/
object ReleaseCheck {
const val LATEST_RELEASE_URL =
"https://gitea.zephyre.one/api/v1/repos/ARIA/iris_x_hermes/releases/latest"
/**
* One shared client for the process lifetime: an OkHttpClient owns a
* thread pool and connection pool, so it must not be rebuilt per screen
* open (and never needs explicit shutdown — the pools idle out). Short
* timeouts so a black-holed network can't linger the LaunchedEffect.
*/
private val client: OkHttpClient =
OkHttpClient
.Builder()
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.build()
/**
* The latest release version (tag without the leading "v"), or `null`
* when the check could not be performed.
*/
suspend fun latestVersion(): String? =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(LATEST_RELEASE_URL)
.get()
.build()
try {
client.newCall(request).execute().use { response: Response ->
if (!response.isSuccessful) {
IrisLog.d("ReleaseCheck: HTTP ${response.code}")
return@withContext null
}
val body = response.body?.string() ?: return@withContext null
val tag =
IrisJson
.instance
.parseToJsonElement(body)
.jsonObject
.get("tag_name")
?.jsonPrimitive
?.content
.orEmpty()
tag.removePrefix("v").ifBlank { null }
}
} catch (e: Exception) {
IrisLog.d("ReleaseCheck: ${e.message}")
null
}
}
/**
* True when [latest] is a newer release than [running]. Numeric
* component-wise comparison (so "0.1.10" > "0.1.9"); anything that does
* not parse as dotted numbers is treated as "not newer" — the hint is
* best-effort and must never fire for dev builds ahead of the latest
* release.
*/
fun isNewer(
latest: String,
running: String,
): Boolean {
val a = latest.split('.').mapNotNull { it.takeWhile(Char::isDigit).toIntOrNull() }
val b = running.split('.').mapNotNull { it.takeWhile(Char::isDigit).toIntOrNull() }
if (a.isEmpty() || b.isEmpty()) return false
val n = maxOf(a.size, b.size)
for (i in 0 until n) {
val x = a.getOrElse(i) { 0 }
val y = b.getOrElse(i) { 0 }
if (x != y) return x > y
}
return false
}
}
@@ -140,6 +140,8 @@ data class ServerCaps(
val push: String = "fcm",
@SerialName("push_ntfy_server") val pushNtfyServer: String = "",
val pickers: Boolean = false,
/** Release version of the gateway plugin (repo-root VERSION file). */
@SerialName("app_version") val appVersion: String = "",
)
@Serializable
@@ -150,6 +152,9 @@ data class ChannelInfo(
@SerialName("is_default") val isDefault: Boolean = false,
@SerialName("parent_chat_id") val parentChatId: String? = null,
val archived: Boolean = false,
/** Unix timestamp (seconds) when the channel/thread was created; 0.0 if
* the gateway predates the field. Used to order threads newest-first. */
val created: Double = 0.0,
/** Gateway minted this thread for an incoming message (auto-threading);
* the name is a derived title, upgraded by the LLM via channel.renamed. */
val auto: Boolean = false,
@@ -80,6 +80,8 @@ import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme
import iris.util.IrisLog
import iris.util.STREAM_SMOOTHNESS_MAX
import iris.util.STREAM_SMOOTHNESS_MIN
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -93,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,
@@ -156,6 +160,15 @@ class IrisController(
private val _gatewayStatus = MutableStateFlow<String?>(null)
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
/**
* Release version of the connected gateway (hello.ack `server_caps.app_version`,
* the repo-root VERSION file). Empty when not connected or the gateway is
* old enough not to report it. Settings shows it next to the app version
* and hints when the two differ.
*/
private val _gatewayVersion = MutableStateFlow("")
val gatewayVersion: StateFlow<String> = _gatewayVersion.asStateFlow()
// ── M8: unread indicator ──────────────────────────────────────────────
/** True while the current lane's newest content sits at the bottom of the
@@ -177,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
@@ -267,6 +301,21 @@ class IrisController(
chat.streamingEnabled = _streamingEnabled.value
}
// Streaming smoothness (Settings → "Streaming speed"): seconds between
// visible text updates while streaming (lower = faster/smoother).
// Per-device display preference; applied on the fly by the reveal loop
// in MarkdownText (docs/05 §5.1).
private val _streamSmoothness =
MutableStateFlow(store.streamSmoothness.coerceIn(STREAM_SMOOTHNESS_MIN, STREAM_SMOOTHNESS_MAX))
val streamSmoothness: StateFlow<Float> = _streamSmoothness.asStateFlow()
fun setStreamSmoothness(value: Float) {
val clamped = value.coerceIn(STREAM_SMOOTHNESS_MIN, STREAM_SMOOTHNESS_MAX)
if (clamped == _streamSmoothness.value) return
_streamSmoothness.value = clamped
store.streamSmoothness = clamped
}
// ── Reasoning auto-collapse (Settings → "Reasoning") ───────────────────
// Persisted. When on, long reasoning blocks start collapsed (short ones
// stay expanded); when off, all reasoning blocks start expanded.
@@ -306,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
@@ -497,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)
@@ -621,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)
@@ -634,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)
@@ -761,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
@@ -872,6 +942,11 @@ class IrisController(
// just close the in-flight turn's dangling tool cards /
// streaming bubble (nothing spins forever).
chat.finalizeInterrupted()
// The gateway is gone — clear the advertised version so
// Settings doesn't keep showing it (and the mismatch
// hint) while disconnected (matches the KDoc: empty when
// not connected).
_gatewayVersion.value = ""
}
}
}
@@ -910,6 +985,7 @@ class IrisController(
* refreshes (skipped on a plain reconnect via historyLoaded).
*/
private fun onConnectedLane(connected: GatewayClient.State.Connected) {
_gatewayVersion.value = connected.caps.appVersion
// Never wipe the directory with an empty list: the long-poll restore
// path carries no channels when there was no prior SSE hello (lastAck
// null), and the cached directory is still valid then.
@@ -9,17 +9,21 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
@@ -37,15 +41,28 @@ import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCode
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.StreamingMarkdownState
import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.model.rememberMarkdownState
import com.mikepenz.markdown.model.rememberStreamingMarkdownState
import com.mikepenz.markdown.utils.getUnescapedTextInNode
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.SyntaxThemes
import iris.ui.theme.IrisColors
import iris.util.STREAM_SMOOTHNESS_DEFAULT
import iris.util.appendChunk
import iris.util.prepareForMarkdown
import iris.util.preserveNewlinesAsHardBreaks
import iris.util.preserveNewlinesAsHardBreaksStreaming
import iris.util.revealStep
import iris.util.streamCharsPerSecond
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
/** Reveal tick for the streaming typewriter effect (docs/05 §5.1). */
private const val STREAM_REVEAL_TICK_MS = 50L
/**
* Renders a chat message as markdown (M8): bold / italic / underscore, GFM
@@ -53,6 +70,12 @@ import org.intellij.markdown.MarkdownElementTypes
* syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the
* raw markdown; [color] and [fontSize] match the surrounding bubble so the
* rendered text blends in.
*
* While [isStreaming], the text is revealed at a steady rate (controlled by
* [streamSmoothness], Settings → Streaming) and parsed INCREMENTALLY: settled
* blocks are parsed once and never re-laid out, only the tail re-renders per
* tick — so the bubble stays readable instead of reflowing (and flashing raw
* markdown) on every gateway update.
*/
@Composable
fun MarkdownText(
@@ -64,13 +87,32 @@ fun MarkdownText(
// plain highlighted code — the artifact card (and its runnable preview)
// only appears once the message is complete.
isStreaming: Boolean = false,
streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
) {
val state = rememberMarkdownState(text)
// Static path: transform once per text change (leading blanks stripped,
// single newlines → hard breaks).
val displayText = remember(text) { text.prepareForMarkdown().preserveNewlinesAsHardBreaks() }
// Whether this bubble has ever been streamed. Static history messages
// skip the reveal pipeline entirely.
val hasStreamed = remember { mutableStateOf(isStreaming) }
if (isStreaming) hasStreamed.value = true
val pipeline =
rememberStreamingMarkdownPipeline(
text = text,
smoothness = streamSmoothness,
active = hasStreamed.value,
streaming = isStreaming,
)
// Keep the streaming renderer until the reveal catches up, even after
// message.stop — a fast model that dumps the whole text at once still
// plays out the typewriter instead of jumping to the full message.
val useStreaming = hasStreamed.value && (isStreaming || !pipeline.done)
// The app is always dark-themed, so force the dark highlight palette
// (isSystemInDarkTheme() is unreliable on desktop).
val highlights = remember {
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
}
val highlights =
remember {
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
}
val base = TextStyle(color = color, fontSize = fontSize)
val inlineCodeBackground = Color.White.copy(alpha = 0.08f)
// Inline-code span style (mirrors the library's codeSpanStyle): the
@@ -80,7 +122,8 @@ fun MarkdownText(
// Brief green flash shown on the chip right after a copy, so the tap is
// visible feedback (the clipboard write itself is silent).
val copiedCodeSpanStyle =
base.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f))
base
.copy(fontFamily = FontFamily.Monospace, background = IrisColors.statusGreen.copy(alpha = 0.30f))
.toSpanStyle()
val clipboard = LocalClipboardManager.current
val scope = rememberCoroutineScope()
@@ -92,57 +135,74 @@ fun MarkdownText(
// Re-render it without the padding, and wrap it in a link so tapping the
// inline code copies it to the clipboard (Telegram-style). The
// LinkAnnotation carries the click listener; the URL is never opened.
val annotator = markdownAnnotator(
annotate = { content, child ->
if (child.type == MarkdownElementTypes.CODE_SPAN) {
val children = child.children
// Drop the surrounding backtick tokens (present as first/last child).
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
val code = inner.joinToString("") { it.getUnescapedTextInNode(content) }
val spanStyle = if (code == copiedCode.value) copiedCodeSpanStyle else codeSpanStyle
pushStyle(spanStyle)
withLink(
LinkAnnotation.Url(
url = "iris:copy-code",
// Keep every interaction state identical to the chip so
// hover/press never restyles the inline code.
styles = TextLinkStyles(
style = spanStyle,
focusedStyle = spanStyle,
hoveredStyle = spanStyle,
pressedStyle = spanStyle,
),
linkInteractionListener = LinkInteractionListener {
clipboard.setText(AnnotatedString(code))
copiedCode.value = code
scope.launch {
delay(600)
copiedCode.value = null
}
},
),
) {
append(code)
val annotator =
markdownAnnotator(
annotate = { content, child ->
when {
child.type == MarkdownElementTypes.CODE_SPAN -> {
val children = child.children
// Drop the surrounding backtick tokens (present as first/last child).
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
val code = inner.joinToString("") { it.getUnescapedTextInNode(content) }
val spanStyle = if (code == copiedCode.value) copiedCodeSpanStyle else codeSpanStyle
pushStyle(spanStyle)
withLink(
LinkAnnotation.Url(
url = "iris:copy-code",
// Keep every interaction state identical to the chip so
// hover/press never restyles the inline code.
styles =
TextLinkStyles(
style = spanStyle,
focusedStyle = spanStyle,
hoveredStyle = spanStyle,
pressedStyle = spanStyle,
),
linkInteractionListener =
LinkInteractionListener {
clipboard.setText(AnnotatedString(code))
copiedCode.value = code
scope.launch {
delay(600)
copiedCode.value = null
}
},
),
) {
append(code)
}
pop()
true
}
// Streaming cursor: append ▉ to the last text leaf of the
// document so the cursor sits at the end of the visible text.
// The tail is re-annotated on every reveal tick, so the cursor
// follows the text (and vanishes once the message is final).
useStreaming &&
child.type == MarkdownTokenTypes.TEXT &&
content.length - child.endOffset <= 1 -> {
append(child.getUnescapedTextInNode(content))
append(" ▉")
true
}
else -> {
false
}
}
pop()
true
} else {
false
}
},
)
Markdown(
markdownState = state,
modifier = modifier,
annotator = annotator,
colors = markdownColor(
},
)
val colors =
markdownColor(
text = color,
codeBackground = Color.Black.copy(alpha = 0.8f),
inlineCodeBackground = inlineCodeBackground,
dividerColor = IrisColors.divider,
tableBackground = Color.White.copy(alpha = 0.03f),
),
typography = markdownTypography(
)
val typography =
markdownTypography(
h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold),
h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold),
h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold),
@@ -157,15 +217,17 @@ fun MarkdownText(
ordered = base,
bullet = base,
list = base,
textLink = TextLinkStyles(
style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(),
),
textLink =
TextLinkStyles(
style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(),
),
table = base.copy(fontSize = fontSize * 0.95f),
),
components = markdownComponents(
)
val components =
markdownComponents(
codeFence = { model ->
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
if (!isStreaming && isHtmlArtifact(language, code)) {
if (!useStreaming && isHtmlArtifact(language, code)) {
HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
@@ -174,7 +236,7 @@ fun MarkdownText(
},
codeBlock = { model ->
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
if (!isStreaming && isHtmlArtifact(language, code)) {
if (!useStreaming && isHtmlArtifact(language, code)) {
HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
@@ -184,17 +246,129 @@ fun MarkdownText(
// The core default checkbox renders literal "[x]"/"[ ]" text; use the
// Material 3 checkbox instead.
checkbox = {
com.mikepenz.markdown.m3.elements.MarkdownCheckBox(it.content, it.node, it.typography.text)
com.mikepenz.markdown.m3.elements
.MarkdownCheckBox(it.content, it.node, it.typography.text)
},
),
loading = { m ->
// While (re)parsing — e.g. on each streaming update — show the raw
// text so the bubble never goes blank between updates.
Text(text, modifier = m, color = color, fontSize = fontSize)
},
)
)
if (useStreaming) {
if (pipeline.displayed.isEmpty()) {
// No text revealed yet (message.start, first tick pending): show
// just the cursor so the bubble is visible immediately.
Text("▉", modifier = modifier, color = color, fontSize = fontSize)
} else {
Markdown(
streamingMarkdownState = pipeline.state,
modifier = modifier,
annotator = annotator,
colors = colors,
typography = typography,
components = components,
)
}
} else {
// retainState: keep the last formatted output visible while the new
// text re-parses (async) — no raw-markdown flash between updates.
val state = rememberMarkdownState(displayText, retainState = true)
Markdown(
markdownState = state,
modifier = modifier,
annotator = annotator,
colors = colors,
typography = typography,
components = components,
loading = { m ->
// First parse of a (long) message: show the text so the bubble
// never goes blank.
Text(displayText, modifier = m, color = color, fontSize = fontSize)
},
)
}
}
/**
* Incremental streaming pipeline (docs/05 §5.1). [text] is the latest FULL
* snapshot from the gateway; it is revealed at a steady rate controlled by
* [smoothness] (typewriter effect), and the revealed prefix is fed into an
* append-only [StreamingMarkdownState]. Settled blocks are parsed once and
* keep their AST identity, so Compose never re-lays them out — only the
* unstable tail re-renders per tick.
*
* [active] is false for history messages (never streamed): the reveal is
* skipped and [StreamingPipeline.done] is true immediately. [streaming]
* indicates the message is still receiving updates; once the reveal catches
* up and the message is final, the reveal loop stops.
*
* Returns the parser state, the currently revealed text (read inside the
* composition so reveal ticks recompose the caller), and whether the reveal
* has caught up with the target.
*/
@Composable
private fun rememberStreamingMarkdownPipeline(
text: String,
smoothness: Float,
active: Boolean,
streaming: Boolean,
): StreamingPipeline {
// Prefix-preserving transform (see preserveNewlinesAsHardBreaksStreaming):
// a prefix of the revealed text always maps to a prefix of the target, so
// the diff between consecutive reveals is a pure append.
val target = remember(text) { text.prepareForMarkdown().preserveNewlinesAsHardBreaksStreaming() }
val displayed = remember { mutableStateOf("") }
val latestTarget = rememberUpdatedState(target)
val latestSmoothness = rememberUpdatedState(smoothness)
val latestStreaming = rememberUpdatedState(streaming)
// Bumped when the target is rewritten (not extended): the parser state is
// recreated via key() below and re-seeded with the full text.
val generation = remember { mutableIntStateOf(0) }
val state = key(generation.value) { rememberStreamingMarkdownState() }
LaunchedEffect(state, active) {
if (!active) {
// Never streamed (history message): reveal everything at once so
// the caller falls back to the static renderer immediately.
displayed.value = latestTarget.value
return@LaunchedEffect
}
var last = ""
while (true) {
delay(STREAM_REVEAL_TICK_MS)
val t = latestTarget.value
val cur = displayed.value
if (cur == t) {
// Reveal complete: keep ticking only while the message is
// still streaming (more text may arrive); otherwise stop so
// finished bubbles don't burn battery.
if (!latestStreaming.value) return@LaunchedEffect
continue
}
val step =
revealStep(
remaining = t.length - cur.length,
charsPerSecond = streamCharsPerSecond(latestSmoothness.value),
tickSeconds = STREAM_REVEAL_TICK_MS / 1000.0,
)
val next = t.take(cur.length + step)
displayed.value = next
val chunk = appendChunk(last, next)
if (chunk == null) {
// Rewritten, not extended: recreate the parser state. The
// effect restarts (keyed on [state]) and re-seeds it.
generation.value++
return@LaunchedEffect
}
if (chunk.isNotEmpty()) state.append(chunk)
last = next
}
}
return StreamingPipeline(state, displayed.value, displayed.value == target)
}
/** Result of the streaming reveal pipeline. */
private data class StreamingPipeline(
val state: StreamingMarkdownState,
val displayed: String,
val done: Boolean,
)
/**
* Renders a highlighted code block with a copy button in the top-right corner
* (Telegram-style). Tapping the button copies the whole block to the clipboard.
@@ -213,12 +387,13 @@ internal fun CodeBlockWithCopy(
if (code.isNotBlank()) {
IconButton(
onClick = { clipboard.setText(AnnotatedString(code)) },
modifier = Modifier
.align(Alignment.TopEnd)
// MarkdownHighlightedCode insets its background by 8dp top;
// match that so the button sits inside the block, not above it.
.padding(top = 8.dp, end = 4.dp)
.size(28.dp),
modifier =
Modifier
.align(Alignment.TopEnd)
// MarkdownHighlightedCode insets its background by 8dp top;
// match that so the button sits inside the block, not above it.
.padding(top = 8.dp, end = 4.dp)
.size(28.dp),
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
@@ -228,4 +403,4 @@ internal fun CodeBlockWithCopy(
}
}
}
}
}
@@ -143,12 +143,11 @@ import iris.ui.theme.IrisColors
import iris.ui.theme.LocalUserTheme
import iris.ui.theme.avatarColor
import iris.ui.theme.contrastText
import iris.util.STREAM_SMOOTHNESS_DEFAULT
import iris.util.formatDayLabel
import iris.util.formatTime
import iris.util.fuzzyScore
import iris.util.localDayKey
import iris.util.prepareForMarkdown
import iris.util.preserveNewlinesAsHardBreaks
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.Locale
@@ -171,11 +170,17 @@ fun ChatScreen(controller: IrisController) {
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
val streamSmoothness by controller.streamSmoothness.collectAsState()
val channels by controller.channels.channels.collectAsState()
val (currentChatId, currentThreadId) = controller.chat.parseLane(currentLane)
val currentChannel = channels.firstOrNull { it.chatId == currentChatId }
val threads = channels.filter { it.kind == "thread" && it.parentChatId == currentChatId }
// Threads newest-first (right after "General") so the user swipes from
// new to old; ties (e.g. created==0 from an old cache) fall back to name.
val threads =
channels
.filter { it.kind == "thread" && it.parentChatId == currentChatId }
.sortedWith(compareByDescending<ChannelInfo> { it.created }.thenBy { it.name.lowercase() })
// M8: unread counts. Per-lane from the store; per-channel aggregated
// (flat lane + all threads) for the drawer/rail badges.
@@ -712,6 +717,7 @@ fun ChatScreen(controller: IrisController) {
},
runtimeFooterEnabled = runtimeFooterEnabled,
runtimeFooterFields = runtimeFooterFields,
streamSmoothness = streamSmoothness,
)
}
}
@@ -2521,6 +2527,7 @@ private fun MessageBubble(
onSelect: (() -> Unit)? = null,
runtimeFooterEnabled: Boolean = false,
runtimeFooterFields: List<String> = emptyList(),
streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
) {
val isUser = msg.role == ROLE_USER
val isCommentary = msg.isCommentary
@@ -2594,9 +2601,7 @@ private fun MessageBubble(
// in replies.
if (msg.text.isNotBlank() || msg.streaming) {
MarkdownText(
text =
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
if (msg.streaming) " ▉" else "",
text = msg.text,
color = textColor,
fontSize = 15.sp,
isStreaming = msg.streaming,
@@ -2641,20 +2646,18 @@ private fun MessageBubble(
} else {
if (msg.text.isNotBlank() || msg.streaming) {
// M8: render the agent's reply as markdown (bold / italic /
// underscore, tables, highlighted code blocks). Leading
// newlines are stripped so the text hugs the top of the
// bubble; single newlines become hard breaks (models write
// status lines and wrapped text expecting a break per line,
// same as user input); the ▉ cursor is kept while streaming.
val displayText =
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
if (msg.streaming) " ▉" else ""
// underscore, tables, highlighted code blocks). The
// transform (leading-newline strip, hard breaks) and the ▉
// streaming cursor live inside MarkdownText; while
// streaming the text is revealed at a steady rate and
// parsed incrementally (docs/05 §5.1).
MarkdownText(
text = displayText,
text = msg.text,
color = textColor,
fontSize = if (isCommentary) 13.sp else 15.sp,
modifier = Modifier.fillMaxWidth(),
isStreaming = msg.streaming,
streamSmoothness = streamSmoothness,
)
}
}
@@ -44,6 +44,8 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import iris.AppVersion
import iris.net.ReleaseCheck
import iris.platform.ImageFilePicker
import iris.platform.decodeImageBytes
import iris.platform.loadScaledImage
@@ -54,6 +56,8 @@ import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode
import iris.ui.theme.IrisColors
import iris.ui.theme.LocalUserTheme
import iris.util.STREAM_SMOOTHNESS_MAX
import iris.util.STREAM_SMOOTHNESS_MIN
import kotlin.math.roundToInt
/**
@@ -71,15 +75,24 @@ fun SettingsScreen(
) {
val threadsEnabled by controller.threadsEnabled.collectAsState()
val streamingEnabled by controller.streamingEnabled.collectAsState()
val streamSmoothness by controller.streamSmoothness.collectAsState()
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
val toolDetail by controller.toolDetail.collectAsState()
val fontSizeScale by controller.fontSizeScale.collectAsState()
val gatewayVersion by controller.gatewayVersion.collectAsState()
val theme = LocalUserTheme.current
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
var showForgetConfirm by remember { mutableStateOf(false) }
// Best-effort latest-release check (docs/04): one query per screen open,
// failures stay silent (ReleaseCheck returns null).
var latestVersion by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
latestVersion = ReleaseCheck.latestVersion()
}
Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
Column(
modifier =
@@ -142,6 +155,27 @@ fun SettingsScreen(
onCheckedChange = { controller.toggleStreaming() },
)
}
if (streamingEnabled) {
Spacer(modifier = Modifier.height(8.dp))
Text("Streaming speed", fontSize = 12.sp, color = IrisColors.textDim)
Text(
"How fast new text appears while streaming — lower is faster",
fontSize = 11.sp,
color = IrisColors.textDim,
)
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Fast", fontSize = 12.sp, color = IrisColors.textDim)
Slider(
value = streamSmoothness,
onValueChange = { controller.setStreamSmoothness(it) },
valueRange = STREAM_SMOOTHNESS_MIN..STREAM_SMOOTHNESS_MAX,
steps = 5, // 0.2 / 0.3 / 0.4 / 0.5 / 0.6 / 0.7 / 0.8
modifier = Modifier.weight(1f),
)
Text("Slow", fontSize = 12.sp, color = IrisColors.textDim)
}
}
}
SettingsCard {
Row(
@@ -410,6 +444,19 @@ fun SettingsScreen(
Text("Forget pairing", fontSize = 12.sp)
}
}
Text(
"About",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
SettingsCard {
VersionCard(
appVersion = AppVersion.VERSION,
gatewayVersion = gatewayVersion,
latestVersion = latestVersion,
)
}
}
when (pickerTarget) {
@@ -510,6 +557,47 @@ private fun BackArrow(
}
}
/**
* About card (docs/04 hello.ack note): app version (repo-root VERSION baked in
* at build time), the connected gateway's version (hello.ack
* `server_caps.app_version`), a mismatch hint, and the latest Gitea release
* when the running build is older.
*/
@Composable
private fun VersionCard(
appVersion: String,
gatewayVersion: String,
latestVersion: String?,
) {
Text("📦 Version", fontSize = 14.sp)
Text(
"Iris app v$appVersion",
fontSize = 12.sp,
color = IrisColors.textSecondary,
)
if (gatewayVersion.isNotBlank()) {
Text(
"Gateway v$gatewayVersion",
fontSize = 12.sp,
color = IrisColors.textSecondary,
)
if (gatewayVersion != appVersion) {
Text(
"App and gateway versions differ — update the other side to match.",
fontSize = 12.sp,
color = IrisColors.statusAmber,
)
}
}
latestVersion?.takeIf { ReleaseCheck.isNewer(it, appVersion) }?.let { latest ->
Text(
"New version v$latest available — see the Gitea releases page.",
fontSize = 12.sp,
color = IrisColors.statusAmber,
)
}
}
/** Settings row container (panel card). */
@Composable
private fun SettingsCard(content: @Composable () -> Unit) {
@@ -25,16 +25,107 @@ fun String.prepareForMarkdown(): String {
fun String.preserveNewlinesAsHardBreaks(): String {
val lines = split("\n")
var inCodeBlock = false
val out = lines.map { line ->
val trimmed = line.trimStart()
when {
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
inCodeBlock = !inCodeBlock
line
val out =
lines.map { line ->
val trimmed = line.trimStart()
when {
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
inCodeBlock = !inCodeBlock
line
}
inCodeBlock || line.isBlank() -> {
line
}
else -> {
line + " "
}
}
inCodeBlock || line.isBlank() -> line
else -> line + " "
}
}
return out.joinToString("\n")
}
}
/**
* Streaming variant of [preserveNewlinesAsHardBreaks]: identical, except the
* trailing hard-break spaces are NOT added to the last (still-incomplete) line.
*
* This makes the transform *prefix-preserving*: for every prefix `p` of `s`,
* `p.preserveNewlinesAsHardBreaksStreaming()` is a prefix of
* `s.preserveNewlinesAsHardBreaksStreaming()`. That is what lets the streaming
* renderer feed the diff between consecutive snapshots into an append-only
* markdown parser (see `MarkdownText`). The final line's trailing spaces are
* invisible at the end of the document, so a finished message renders exactly
* like the static transform.
*/
fun String.preserveNewlinesAsHardBreaksStreaming(): String {
val lines = split("\n")
var inCodeBlock = false
val out =
lines.mapIndexed { index, line ->
val trimmed = line.trimStart()
when {
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
inCodeBlock = !inCodeBlock
line
}
inCodeBlock || line.isBlank() -> {
line
}
index == lines.lastIndex -> {
line
}
// still being typed
else -> {
line + " "
}
}
}
return out.joinToString("\n")
}
/**
* Returns the suffix [next] adds on top of [last] (i.e. [next] minus its
* [last] prefix), or `null` when [next] is NOT an extension of [last] — the
* content was rewritten, not appended. The gateway sends full text snapshots
* on every `message.update`; this converts them into append chunks for the
* append-only streaming parser.
*/
fun appendChunk(
last: String,
next: String,
): String? = if (next.startsWith(last)) next.substring(last.length) else null
/**
* Number of characters to reveal on one streaming tick. The reveal rate comes
* from the smoothness setting; when the backlog exceeds ~2 s of reveal time
* (fast model, reconnect catch-up) everything is revealed at once so the
* display never lags far behind the received text.
*/
fun revealStep(
remaining: Int,
charsPerSecond: Double,
tickSeconds: Double,
): Int {
if (remaining <= 0) return 0
if (remaining > charsPerSecond * 2.0) return remaining
return maxOf(1, (charsPerSecond * tickSeconds).toInt())
}
/**
* Range of the streaming-smoothness setting (seconds between visible text
* updates while streaming; lower = faster/smoother).
*/
const val STREAM_SMOOTHNESS_MIN = 0.2f
const val STREAM_SMOOTHNESS_MAX = 0.8f
const val STREAM_SMOOTHNESS_DEFAULT = 0.4f
/**
* Reveal rate for a smoothness value. `60 / smoothness` keeps the display
* well ahead of the gateway's arrival rate (~24 chars per 0.8 s) across the
* whole range (0.2 → 300 chars/s, 0.8 → 75 chars/s).
*/
fun streamCharsPerSecond(smoothness: Float): Double = 60.0 / smoothness.coerceIn(STREAM_SMOOTHNESS_MIN, STREAM_SMOOTHNESS_MAX)
@@ -0,0 +1,52 @@
package iris.protocol
import kotlin.test.Test
import kotlin.test.assertEquals
/** Wire tests for the channel directory `created` field (thread ordering). */
class ChannelCreatedWireTest {
@Test
fun channelInfoDeserializesCreated() {
val raw =
"""
{"v":1,"type":"channel.created","payload":{"chat_id":"t_9","name":"New topic",
"kind":"thread","parent_chat_id":"default","created":1787648374.37}}
""".trimIndent()
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
val info = frame.payloadAs<ChannelInfo>()
assertEquals("t_9", info?.chatId)
assertEquals(1787648374.37, info?.created)
}
@Test
fun channelInfoDefaultsCreatedToZero() {
// Legacy gateways omit the field; the app falls back to name ordering.
val raw =
"""{"v":1,"type":"channel.created","payload":{"chat_id":"t_1","name":"Old","kind":"thread"}}"""
val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw)
assertEquals(0.0, frame.payloadAs<ChannelInfo>()?.created)
}
@Test
fun threadsSortNewestFirst() {
// Mirrors the topic-switcher ordering in ChatScreen: created desc,
// name asc as the tie-break (e.g. for legacy entries with created==0).
val threads =
listOf(
ChannelInfo(chatId = "t_2", name = "Capital of Romania", kind = "thread", parentChatId = "default", created = 1787232736.0),
ChannelInfo(chatId = "t_1", name = "Capital of France", kind = "thread", parentChatId = "default", created = 1787232221.0),
ChannelInfo(
chatId = "t_9",
name = "Test file operations",
kind = "thread",
parentChatId = "default",
created = 1787422924.0,
),
ChannelInfo(chatId = "t_0", name = "Legacy b", kind = "thread", parentChatId = "default"),
ChannelInfo(chatId = "t_0a", name = "Legacy a", kind = "thread", parentChatId = "default"),
)
val ordered =
threads.sortedWith(compareByDescending<ChannelInfo> { it.created }.thenBy { it.name.lowercase() })
assertEquals(listOf("t_9", "t_2", "t_1", "t_0a", "t_0"), ordered.map { it.chatId })
}
}
@@ -2,9 +2,9 @@ package iris.util
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MarkdownTest {
@Test
fun stripsLeadingNewlines() {
assertEquals("Hello", "\n\nHello".prepareForMarkdown())
@@ -61,4 +61,90 @@ class MarkdownTest {
val md = "**Title:** x\n**Model:** y"
assertEquals("**Title:** x \n**Model:** y ", md.preserveNewlinesAsHardBreaks())
}
}
@Test
fun streamingTransformSkipsLastLineOnly() {
// Identical to the static transform except the (still-incomplete)
// last line gets no trailing hard-break spaces.
assertEquals("a \nb", "a\nb".preserveNewlinesAsHardBreaksStreaming())
assertEquals("a \n\nb", "a\n\nb".preserveNewlinesAsHardBreaksStreaming())
assertEquals("a", "a".preserveNewlinesAsHardBreaksStreaming())
}
@Test
fun streamingTransformLeavesFencedCodeBlocksUntouched() {
val md = "```kotlin\nval a = 1\nval b = 2\n```"
assertEquals(md, md.preserveNewlinesAsHardBreaksStreaming())
}
@Test
fun streamingTransformIsPrefixPreserving() {
// The core invariant the incremental renderer relies on: for every
// prefix p of s, transform(p) is a prefix of transform(s).
val docs =
listOf(
"a\nb",
"a\n\nb\nc",
"**bold** and `code`",
"```kotlin\nval a = 1\n```\nthen text",
"~~~\nx\n~~~\ny",
"| a | b |\n| - | - |\n| 1 | 2 |",
"- item\n- item2",
"line with trailing spaces \nnext",
"",
"\n\n \n",
)
for (s in docs) {
val whole = s.preserveNewlinesAsHardBreaksStreaming()
for (i in 0..s.length) {
val p = s.take(i)
val tp = p.preserveNewlinesAsHardBreaksStreaming()
assertTrue(
whole.startsWith(tp),
"prefix <$tp> not a prefix of <$whole> (doc=<$s>)",
)
}
}
}
@Test
fun appendChunkReturnsSuffixOnExtension() {
assertEquals(" world", appendChunk("Hello", "Hello world"))
assertEquals("", appendChunk("abc", "abc"))
assertEquals("abc", appendChunk("", "abc"))
}
@Test
fun appendChunkReturnsNullOnRewrite() {
assertEquals(null, appendChunk("Hello", "Hi"))
assertEquals(null, appendChunk("Hello world", "Hello"))
assertEquals(null, appendChunk("a ", "ab")) // hard-break spaces shift
}
@Test
fun revealStepRevealsAtLeastOneChar() {
assertEquals(1, revealStep(2, charsPerSecond = 1.0, tickSeconds = 0.05))
assertEquals(3, revealStep(100, charsPerSecond = 60.0, tickSeconds = 0.05))
assertEquals(0, revealStep(0, charsPerSecond = 60.0, tickSeconds = 0.05))
}
@Test
fun revealStepCatchesUpWhenBacklogIsLarge() {
// Backlog beyond ~2 s of reveal time (120 * 2 = 240) jumps at once.
assertEquals(500, revealStep(500, charsPerSecond = 120.0, tickSeconds = 0.05))
assertEquals(241, revealStep(241, charsPerSecond = 120.0, tickSeconds = 0.05))
// At exactly 2 s of backlog it still steps normally (6 = 120 * 0.05).
assertEquals(6, revealStep(240, charsPerSecond = 120.0, tickSeconds = 0.05))
}
@Test
fun streamCharsPerSecondMapsSmoothnessRange() {
// 0.2 → 300 chars/s (fast), 0.8 → 75 chars/s (slow); out-of-range
// values are clamped to the ends.
assertEquals(300.0, streamCharsPerSecond(0.2f), 0.001)
assertEquals(150.0, streamCharsPerSecond(0.4f), 0.001)
assertEquals(75.0, streamCharsPerSecond(0.8f), 0.001)
assertEquals(300.0, streamCharsPerSecond(0.05f), 0.001)
assertEquals(75.0, streamCharsPerSecond(2.0f), 0.001)
}
}
@@ -5,6 +5,7 @@ import iris.protocol.IrisJson
import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme
import iris.util.STREAM_SMOOTHNESS_DEFAULT
import kotlinx.serialization.Serializable
import java.io.File
import java.security.SecureRandom
@@ -64,6 +65,7 @@ class DesktopSecureStore : SecureStore {
val threadsEnabled: Boolean = false,
val toolDetail: String = "truncated",
val streamingEnabled: Boolean = true,
val streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
val reasoningAutoCollapse: Boolean = true,
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
@@ -222,6 +224,13 @@ class DesktopSecureStore : SecureStore {
save(d.copy(streamingEnabled = value))
}
override var streamSmoothness: Float
get() = load().streamSmoothness
set(value) {
val d = load()
save(d.copy(streamSmoothness = value))
}
override var reasoningAutoCollapse: Boolean
get() = load().reasoningAutoCollapse
set(value) {
+10 -1
View File
@@ -40,7 +40,8 @@ Pairing succeeded.
```json
{"type":"hello.ack","payload":{
"server_caps":{"streaming":true,"reasoning":true,"tools":true,"media":true,
"search":true,"push":"fcm","pickers":true},
"search":true,"push":"fcm","pickers":true,
"app_version":"0.1.2"},
"sync_cursor":1042,
"last_pushed_cursor":1040,
"device_token":"9f2c…(64 hex)",
@@ -48,6 +49,14 @@ Pairing succeeded.
}}
```
`server_caps.app_version` is the gateway plugin's release version (the
repo-root `VERSION` file, `gateway-plugin/version.py`). The app shows it in
Settings → About (next to its own version) and hints when the app and
gateway versions differ.
The app reports its own version on the SSE open via the
`X-Iris-App-Version` header (stored in the device registry's `caps` JSON,
visible in `~/.hermes/.../devices.db`).
`last_pushed_cursor` is the highest outbox cursor already delivered to THIS
device via the push backend (0 = never). The app skips system notifications
for sync-replayed frames with `cursor <= last_pushed_cursor` — they already
+46 -1
View File
@@ -12,11 +12,13 @@ produced by the gateway and rendered by the app.
**Gateway side.** The agent's `stream_delta_callback` feeds a
`GatewayStreamConsumer` (`gateway/stream_consumer.py:156`). The consumer
accumulates text and, at intervals / thresholds, calls:
- `adapter.send(chat_id, text)` — first time a bubble is created.
- `adapter.edit_message(chat_id, message_id, text)` — subsequent updates
(each carries the **full** accumulated text).
**Adapter → frames.**
- First `send()` of a turn segment → `message.start {message_id, role}`.
- Each `edit_message()` → `message.update {message_id, text}` (full text).
- Segment/turn finalization → `message.stop {message_id, final_text, reasoning?,
@@ -27,7 +29,44 @@ replace the bubble text (cheap: it's a full snapshot). On `message.stop`,
finalize (attach reasoning/model/tokens footer, stop the cursor). Auto-scroll
while the user is at the bottom.
**Smooth streaming (app-side rendering).** Re-parsing + re-laying-out the
whole bubble on every update made streamed text unreadable (raw-markdown
flashes, constant reflow). `MarkdownText` therefore renders streaming bubbles
incrementally:
- **Reveal (typewriter):** the latest full snapshot is revealed at a steady
rate instead of jumping per gateway update. The rate is user-adjustable:
Settings → "Streaming speed" (`streamSmoothness`, 0.2–0.8 s between visible
updates, lower = faster; `streamCharsPerSecond` maps it to chars/s:
`60 / smoothness`, so 0.2 → 300 chars/s, 0.8 → 75 chars/s). Applied on the
fly; a backlog exceeding ~2 s of reveal time is revealed at once (fast
model / reconnect catch-up).
- **Wait for the reveal:** the streaming renderer stays active until the
reveal catches up, even after `message.stop` — a fast model that dumps the
whole text in one or two frames still plays out the typewriter instead of
jumping to the full message. Only then does the bubble switch to the static
renderer (which also enables the HTML artifact card).
- **Incremental parse:** the revealed prefix is fed as append chunks
(`appendChunk`) into the library's `StreamingMarkdownState`
(`rememberStreamingMarkdownState`, mikepenz 0.44.0) — an append-only parser
that re-parses only the unstable tail. Settled blocks keep AST identity, so
Compose never re-lays them out; only the tail re-renders per tick. A
non-extension snapshot (rewrite) recreates the parser state and re-seeds it.
- **Prefix-preserving transform:** `preserveNewlinesAsHardBreaksStreaming()`
is like `preserveNewlinesAsHardBreaks()` but skips the still-incomplete last
line, so the transform of a prefix is always a prefix of the transform of
the whole (required for pure-append diffs). The static path keeps the
original transform plus `retainState = true` (last formatted output stays
visible during re-parses — no raw flash).
- **Cursor:** the ▉ is appended to the last text leaf by the annotator (not to
the parse input, which would break the append diff).
The gateway cadence (`edit_interval` / `buffer_threshold`, default 0.8 s /
24 chars) is unchanged — smoothing happens entirely client-side, so it works
with any gateway and per device.
**Streaming on/off.** Two levels:
- **Gateway side:** hermes `display.platforms.iris.streaming` (default
follows global). When off, the app just gets one final `message` frame.
- **App side (per device):** Settings → "Streaming" toggle (default on). When
@@ -45,11 +84,13 @@ reference screenshot's "Reasoning:" panel with a copy button).
**Gateway side.** hermes prepends reasoning to the final response when
`show_reasoning` is enabled (`gateway/run.py:20089`). The format is stable and
chosen by `reasoning_style` (`gateway/display_config.py:37`):
- `code` (default): `💭 **Reasoning:**\n```\n<reasoning>\n```\n\n<response>`
- `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>`
- `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style)
**Plugin config.** Set for the `iris` platform:
```yaml
display:
platforms:
@@ -59,12 +100,14 @@ display:
```
**Adapter split.** In `send()`, detect the `code`-style prefix and split:
```
prefix = "💭 **Reasoning:**\n```\n"
# find the closing "\n```\n\n" after the prefix
reasoning = text[len(prefix):close_idx]
body = text[close_idx + len("\n```\n\n"):]
```
Emit `message {reasoning: <reasoning>, text: <body>, …}`. If no prefix is found
(reasoning off / no reasoning), emit `message {text: …}` with no `reasoning`.
@@ -89,6 +132,7 @@ gateway progress queue → `send_progress_messages` (`gateway/run.py:4603`) →
**Adapter → frames.** The adapter classifies tool activity (via turn-state +
line format) and emits **structured** frames — not pre-formatted strings:
- `tool.start {index, name, preview, args}` — a tool call began.
- `tool.progress {index, name, note}` — in-progress update (optional).
- `tool.end {index, name, ok, duration, output_preview}` — completed.
@@ -99,6 +143,7 @@ short tail; full tool output is **not** streamed (it lives in agent history and
is reachable via search).
**App side — the verbosity setting** (Settings → "Tool detail"):
- **Everything** — show tool name, full args (collapsible), and output preview.
- **Truncated** (default) — show `emoji name: "short preview"` one-liner,
collapsible to expand.
@@ -147,4 +192,4 @@ srv → message.start {message_id:m3}
srv → message.update {m3, "The repo has…"}
srv → message.stop {m3, final_text:"…", reasoning:"…", model:"…", tokens:42}
srv → typing {on:false}
```
```
+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
+3 -3
View File
@@ -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 <IRIS_TOKEN> \
python tests/ws_probe.py --token <IRIS_TOKEN> \
--send "list the files and summarize"
# prints: hello.ack, typing, message.start, message.update…, tool.start, tool.end,
# commentary, message.stop {reasoning,…}, …
+1 -1
View File
@@ -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
+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.
+2 -2
View File
@@ -21,7 +21,7 @@
"hello.ack": {
"description": "Pairing succeeded.",
"payload": {
"server_caps": { "type": "object", "properties": { "streaming": {"type":"boolean"}, "reasoning": {"type":"boolean"}, "tools": {"type":"boolean"}, "media": {"type":"boolean"}, "search": {"type":"boolean"}, "push": {"type":"string","enum":["fcm","ntfy","none"]}, "push_ntfy_server": {"type":"string","description":"ntfy server URL for the app's listener; empty string when the backend is not ntfy."}, "pickers": {"type":"boolean"} } },
"server_caps": { "type": "object", "properties": { "streaming": {"type":"boolean"}, "reasoning": {"type":"boolean"}, "tools": {"type":"boolean"}, "media": {"type":"boolean"}, "search": {"type":"boolean"}, "push": {"type":"string","enum":["fcm","ntfy","none"]}, "push_ntfy_server": {"type":"string","description":"ntfy server URL for the app's listener; empty string when the backend is not ntfy."}, "pickers": {"type":"boolean"}, "app_version": {"type":"string","description":"Release version of the gateway plugin (repo-root VERSION file); the app shows it in Settings and hints on app/gateway mismatch."} } },
"sync_cursor": { "type": "integer" },
"last_pushed_cursor": { "type": "integer", "description": "Highest outbox cursor already delivered to THIS device via the push backend (0 = never). The app skips system notifications for sync-replayed frames at/below it (dedupe, docs/08 §8.7)." },
"device_token": { "type": "string", "description": "Per-device token minted at pairing (docs/09 §9.3). The app stores it and presents it INSTEAD of the shared IRIS_TOKEN from then on; the gateway can revoke it per device. Empty when the gateway didn't issue one (legacy)." },
@@ -97,7 +97,7 @@
},
"definitions": {
"kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] },
"channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"}, "auto": {"type":"boolean","description":"Optional; true on channel.created for a gateway-minted auto-thread."}, "favorite": {"type":"boolean","description":"Optional; cosmetic favorite flag (sorts to the top of the list)."}, "icon": {"type":["string","null"],"description":"Optional; cosmetic icon, a base64-encoded image (PNG/JPEG). Absent/null = auto-generated letter avatar."}, "color": {"type":["string","null"],"description":"Optional; cosmetic avatar color override (#RRGGBB). Absent/null = auto-generated name-hash color."}, "automation": {"type":"boolean","description":"Optional; true when the channel is an automation channel (read-only for the user; only receives gateway-originated output such as cron jobs and webhooks). The app hides the composer and the gateway rejects message.send into it. Never set on the default channel."} } },
"channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"}, "created": {"type":"number","description":"Optional; unix timestamp (seconds) when the channel/thread was created. The app orders threads newest-first in the topic switcher."}, "auto": {"type":"boolean","description":"Optional; true on channel.created for a gateway-minted auto-thread."}, "favorite": {"type":"boolean","description":"Optional; cosmetic favorite flag (sorts to the top of the list)."}, "icon": {"type":["string","null"],"description":"Optional; cosmetic icon, a base64-encoded image (PNG/JPEG). Absent/null = auto-generated letter avatar."}, "color": {"type":["string","null"],"description":"Optional; cosmetic avatar color override (#RRGGBB). Absent/null = auto-generated name-hash color."}, "automation": {"type":"boolean","description":"Optional; true when the channel is an automation channel (read-only for the user; only receives gateway-originated output such as cron jobs and webhooks). The app hides the composer and the gateway rejects message.send into it. Never set on the default channel."} } },
"media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"}, "message_id": {"type":"string","description":"Optional; set on media.offer to associate the offer with the assistant message it belongs to."} } },
"runtime": { "type": "object", "description": "Structured runtime-metadata footer (app-controlled display). The gateway ALWAYS sends it on final assistant messages; whether/what is shown is a per-app setting (Settings -> Runtime footer), NOT a hermes config. All keys optional; absent when the data is unavailable (e.g. local models have no cost).", "properties": { "model": {"type":"string","description":"Bare model id, vendor prefix dropped (gpt-5.4)."}, "context_pct": {"type":"integer","description":"Last-call context occupancy, 0-100."}, "cwd": {"type":"string","description":"Home-relative working dir (~)."}, "latency": {"type":"number","description":"Wall-clock turn duration, seconds."}, "cost": {"type":"number","description":"Turn cost, USD."} } }
},
+4
View File
@@ -135,6 +135,7 @@ from .setup import (
validate_config,
)
from .tool_frames import ToolProgressHandlers
from .version import plugin_version
logger = logging.getLogger(__name__)
@@ -778,6 +779,9 @@ class IrisAdapter(
self._push.server_url if isinstance(self._push, NtfyBackend) else ""
),
"pickers": True, # picker.choice / picker.select (slash-command menus)
# Release version from the repo-root VERSION file (version.py);
# the app shows it in Settings and hints on app/gateway mismatch.
"app_version": plugin_version(),
}
+104 -14
View File
@@ -45,6 +45,7 @@ import contextlib
import json
import logging
import queue
import socket
import ssl
import threading
import time
@@ -199,7 +200,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,
@@ -216,7 +221,14 @@ class HttpServer:
if self._adapter.http_cert and self._adapter.http_key:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key)
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
# The handshake runs in the per-connection thread with a
# hard timeout (see _ThreadingHTTPD.process_request).
# Wrapping the *listening* socket here instead would make
# serve_forever's accept() block inside do_handshake() on
# a half-open connection (TCP established, client gone
# mid-handshake), wedging ALL new device connections until
# the gateway is restarted.
httpd.set_tls(ctx)
except Exception as e:
logger.warning("iris: HTTP server disabled (bind %s:%s failed: %s)", host, port, e)
self._release_lock()
@@ -241,17 +253,35 @@ class HttpServer:
s.q.put_nowait(_STOP)
httpd = self._httpd
self._httpd = None
if httpd is not None:
# shutdown() must be called from a thread other than the one
# running serve_forever(); we are on the asyncio loop thread.
with contextlib.suppress(Exception):
httpd.shutdown()
with contextlib.suppress(Exception):
httpd.server_close()
t = self._thread
self._thread = None
if t is not None and t is not threading.current_thread():
t.join(timeout=5.0)
if httpd is not None or t is not None:
# shutdown() blocks until the serve_forever loop exits and
# server_close() may join handler threads — both must run on a
# worker thread (never the asyncio loop thread) with a hard
# timeout, or a wedged server would freeze the whole gateway.
# The threads are daemons: if the bounded wait expires they die
# with the process and there is nothing left to do.
loop = asyncio.get_running_loop()
def _stop_httpd() -> None:
if httpd is not None:
with contextlib.suppress(Exception):
httpd.shutdown()
with contextlib.suppress(Exception):
httpd.server_close()
if t is not None and t is not threading.current_thread():
t.join(timeout=5.0)
try:
await asyncio.wait_for(
loop.run_in_executor(None, _stop_httpd),
timeout=10.0,
)
except Exception:
logger.warning(
"iris: HTTP server teardown did not finish in time; abandoning daemon threads"
)
self._release_lock()
def _release_lock(self) -> None:
@@ -490,11 +520,20 @@ class HttpServer:
device_name = (handler.headers.get("X-Iris-Device-Name") or "").strip()[:120]
fcm_token = handler.headers.get("X-Iris-Fcm-Token") or None
ntfy_topic = handler.headers.get("X-Iris-Ntfy-Topic") or None
# App release version (the repo-root VERSION baked into the build);
# stored in the device registry's caps JSON so `hermes` can see which
# app version each device runs (old-version awareness). A missing
# header (old app build) must not wipe a previously stored version,
# so merge over the existing caps instead of replacing them.
app_version = (handler.headers.get("X-Iris-App-Version") or "").strip()[:40]
try:
existing_caps = dict(self._devices.get(device_id) or {}).get("caps") or {}
if app_version:
existing_caps["app_version"] = app_version
self._devices.upsert(
device_id,
device_name or device_id,
None,
existing_caps or None,
fcm_token,
ntfy_topic,
)
@@ -523,7 +562,12 @@ class HttpServer:
# lifecycle is the primary "is the device connected?" signal
# for debugging flaky links — a gap here is invisible at the
# gateway's default log level.
logger.info("iris: SSE stream opened: %s (cursor=%d)", device_id, cursor)
logger.info(
"iris: SSE stream opened: %s (cursor=%d, app_version=%s)",
device_id,
cursor,
app_version or "?",
)
# 1. Catch-up from the outbox (id = cursor; the envelope also
# carries the cursor for the app's push dedupe).
max_cursor = cursor
@@ -765,14 +809,60 @@ class HttpServer:
class _ThreadingHTTPD(ThreadingHTTPServer):
"""One thread per connection (fine at single-user scale); daemon
threads so a stuck handler can't block process exit."""
threads so a stuck handler can't block process exit.
With TLS enabled (``set_tls``) the handshake runs in the
per-connection thread under a hard timeout — never in the
``serve_forever`` accept loop. ``ssl.SSLSocket.accept()`` would
otherwise block that loop inside ``do_handshake()`` on a half-open
connection (TCP established but the client vanished mid-handshake,
e.g. a phone losing its network/VPN), and the gateway would stop
accepting any new device connections until it is restarted.
"""
daemon_threads = True
allow_reuse_address = True
# A client that completes TCP but never finishes the TLS handshake
# must not hold the connection open indefinitely.
HANDSHAKE_TIMEOUT_S = 10.0
def __init__(self, addr: tuple[str, int], http_server: HttpServer):
super().__init__(addr, _Handler)
self.http_server = http_server
self._tls_ctx: ssl.SSLContext | None = None
def set_tls(self, ctx: ssl.SSLContext) -> None:
self._tls_ctx = ctx
def process_request( # noqa: A003 # type: ignore[override]
self, request: socket.socket, client_address: Any
) -> None:
"""Spawn the handler thread; with TLS, the handshake happens in
that thread first, under ``HANDSHAKE_TIMEOUT_S`` (see class
docstring). A failed/timed-out handshake just closes the socket —
the accept loop is never blocked by it."""
if self._tls_ctx is None:
super().process_request(request, client_address)
return
tls_ctx = self._tls_ctx
def _handshake_then_handle() -> None:
try:
request.settimeout(self.HANDSHAKE_TIMEOUT_S)
# wrap_socket() performs the handshake (default
# do_handshake_on_connect=True); restore blocking mode for
# the request handler afterwards.
tls_sock = tls_ctx.wrap_socket(request, server_side=True)
tls_sock.settimeout(None)
except OSError as e: # ssl.SSLError, timeout, reset, ...
with contextlib.suppress(OSError):
request.close()
logger.debug("iris http: TLS handshake failed (%s): %s", client_address, e)
return
super(_ThreadingHTTPD, self).process_request(tls_sock, client_address)
threading.Thread(target=_handshake_then_handle, name="iris-tls", daemon=True).start()
class _Handler(BaseHTTPRequestHandler):
+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
+2
View File
@@ -559,6 +559,8 @@ def _channel_payload(entry: dict[str, Any]) -> dict[str, Any]:
}
if entry.get("parent_chat_id") is not None:
payload["parent_chat_id"] = entry["parent_chat_id"]
if entry.get("created"):
payload["created"] = entry["created"]
if entry.get("is_default"):
payload["is_default"] = True
if entry.get("archived"):
+5 -5
View File
@@ -475,15 +475,15 @@ 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)")
token = get_env_value("IRIS_TOKEN") or ""
if not token:
generated = generate_token()
save_env_value("IRIS_TOKEN", generated)
print_success(f"Generated pairing token: {generated}")
token = generate_token()
save_env_value("IRIS_TOKEN", token)
print_success(f"Generated pairing token: {token}")
print_warning("Keep this secret -- the app presents it on connect.")
else:
print_info("Existing IRIS_TOKEN found (not shown).")
@@ -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")
+67
View File
@@ -0,0 +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). 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 _read_version_file(path: Path) -> str:
try:
return path.read_text().strip()
except OSError:
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
@@ -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 <IRIS_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.
automated, the device-notification / gateway-kill leg is manual.
+113 -55
View File
@@ -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
File diff suppressed because it is too large. Load diff
@@ -29,11 +29,14 @@ import asyncio
import base64
import contextlib
import importlib.util
import ipaddress
import json
import os
import socket
import ssl
import sys
import time
from http.client import HTTPConnection
from http.client import HTTPConnection, HTTPSConnection
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -59,14 +62,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 <repo>/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 +198,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 +228,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 +365,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 +381,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 +685,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 +793,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 +814,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,14 +826,147 @@ 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"
# ── TLS: a half-open connection must not wedge the accept loop ─────────────
#
# Regression (ARIA journal 2026-09-11 / 2026-09-23): the listening socket
# used to be wrapped in a server-side ssl.SSLSocket, so serve_forever's
# accept() ran the TLS handshake inline. A client that completed TCP but
# vanished mid-handshake (a phone losing its network/VPN while traveling)
# blocked do_handshake() forever: the gateway stopped accepting ANY new
# device connections (the app could not reconnect), and on the next restart
# httpd.shutdown() froze the whole event loop until the shutdown watchdog
# killed the process.
def _make_self_signed_cert(tmp_path: Path) -> tuple[Path, Path] | None:
"""Self-signed cert + key for the TLS tests; None when
``cryptography`` is unavailable (the tests then skip)."""
try:
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
except ImportError:
return None
import datetime
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "iris-test")])
now = datetime.datetime.now(datetime.timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(days=1))
.not_valid_after(now + datetime.timedelta(days=1))
.add_extension(
x509.SubjectAlternativeName(
[
x509.DNSName("localhost"),
x509.IPAddress(ipaddress.ip_address("127.0.0.1")),
]
),
critical=False,
)
.sign(key, hashes.SHA256())
)
cert_path = tmp_path / "iris-test.crt"
key_path = tmp_path / "iris-test.key"
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
key_path.write_bytes(
key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
)
)
return cert_path, key_path
@pytest_asyncio.fixture
async def gw_tls(adapter, tmp_path, monkeypatch):
"""Connected adapter with the HTTP leg TLS-enabled; the handshake
timeout is shortened so the half-open connection cleans itself up
quickly."""
paths = _make_self_signed_cert(tmp_path)
if paths is None:
pytest.skip("cryptography not available; TLS wedge test skipped")
cert_path, key_path = paths
plugin = _load_plugin()
monkeypatch.setattr(
plugin.http_server._ThreadingHTTPD, "HANDSHAKE_TIMEOUT_S", 0.5, raising=False
)
adapter.http_cert = str(cert_path)
adapter.http_key = str(key_path)
await adapter.connect()
try:
yield adapter
finally:
await adapter.disconnect()
def _tls_health(port: int) -> int:
"""GET /v1/health over a fresh TLS connection; returns the status."""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
conn = HTTPSConnection("127.0.0.1", port, timeout=5.0, context=ctx)
conn.request("GET", "/v1/health")
resp = conn.getresponse()
status = resp.status
resp.read()
conn.close()
return status
@pytest.mark.asyncio
async def test_half_open_tls_connection_does_not_wedge_accept_loop(gw_tls):
"""A client that completes TCP but never finishes the TLS handshake
must not stop the server from accepting new connections (see section
comment for the incident)."""
port = http_port(gw_tls)
# 1) Half-open connection: TCP established, then silence — the
# phone-loses-its-VPN scenario (the ClientHello never arrives).
wedge = socket.create_connection(("127.0.0.1", port), timeout=5.0)
try:
# 2) While the half-open connection sits un-handshaked, a fresh,
# well-formed TLS connection must still be accepted promptly.
deadline = time.monotonic() + 10.0
status = None
while time.monotonic() < deadline:
try:
status = await asyncio.to_thread(_tls_health, port)
break
except OSError:
await asyncio.sleep(0.2)
assert status == 200, f"health over TLS failed (status={status})"
# 3) Teardown must stay bounded with the half-open connection still
# open: stop() used to block the event loop on httpd.shutdown()
# until the shutdown watchdog killed the process.
t0 = time.monotonic()
await gw_tls._http_server.stop()
assert time.monotonic() - t0 < 15.0
finally:
with contextlib.suppress(OSError):
wedge.close()
# ── Helpers ─────────────────────────────────────────────────────────────────
@@ -8,7 +8,7 @@ while building the Kotlin client.
Usage::
hermes gateway & # with the iris plugin
python gateway-plugin/tests/ws_probe.py --token <IRIS_TOKEN> \
python tests/ws_probe.py --token <IRIS_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.