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

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

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

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

No files matched your search

+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
- name: Run android gateway tests - name: Run android gateway tests
run: | 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 cd hermes-agent
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py scripts/run_tests.sh tests/gateway/test_android.py
+8 -8
View File
@@ -3,10 +3,8 @@ name: Release
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: # The release version comes from the repo-root VERSION file (the single
description: "Release version (e.g. 0.2.0)" # source of truth) — bump it in a commit, then dispatch this workflow.
required: true
type: string
changelog: changelog:
description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks." description: "Release notes (markdown, shown on the release page). Single-line field — use literal \\n for line breaks."
required: false required: false
@@ -38,7 +36,7 @@ jobs:
- name: Run android gateway tests - name: Run android gateway tests
run: | 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 cd hermes-agent
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py scripts/run_tests.sh tests/gateway/test_android.py
@@ -133,7 +131,8 @@ jobs:
- name: Build APK + AAB - name: Build APK + AAB
run: | 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 cd app
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
# APK for direct sideloading, AAB for Play Store uploads. # 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). # for it (jpackage picks the native type: msi on Windows, dmg on macOS).
- name: Build desktop app-image + deb - name: Build desktop app-image + deb
run: | 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 cd app
# Self-contained app image (JRE bundled via jlink). # Self-contained app image (JRE bundled via jlink).
./gradlew :desktopApp:jpackage -PappVersion="$VERSION" ./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
@@ -177,7 +177,7 @@ jobs:
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}" SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}" REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}" 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. # 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') CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH" | sed 's/\\n/\n/g')
TAG="v$VERSION" TAG="v$VERSION"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"ignore": [ "ignore": [
"gateway-plugin/tests/test_android.py" "tests/test_android.py"
], ],
"rules": { "rules": {
"unchecked-throwing-call-python": { "unchecked-throwing-call-python": {
+6
View File
@@ -13,3 +13,9 @@ repos:
language: system language: system
pass_filenames: false 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 ## 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. - `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). - **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). - 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`. - `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). - `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`. - `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 ## Commands
- `hermes` is **not on PATH**: use `hermes-agent/.venv/bin/hermes` (venv from `cd hermes-agent && uv sync`). - `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`. - 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). - 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). - 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). - 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`. - 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 gateway-plugin/tests/e2e.py`. - E2E driver (gateway must be running; it never starts/stops it): `hermes-agent/.venv/bin/python tests/e2e.py`.
## Environment / pairing quirks ## Environment / pairing quirks
@@ -34,7 +39,7 @@
## Testing quirks ## 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. - 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: 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. - 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.
+53 -25
View File
@@ -7,10 +7,10 @@ Everything needed for the Gitea workflows (CI + manual release). Items marked
## 1. DONE — no action needed ## 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 `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). 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. so the scanner doesn't flag the vendored mirror.
--- ---
@@ -56,7 +56,7 @@ jobs:
- name: Run android gateway tests - name: Run android gateway tests
run: | 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 cd hermes-agent
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py scripts/run_tests.sh tests/gateway/test_android.py
@@ -100,11 +100,13 @@ jobs:
## 3. NEW FILE: `.gitea/workflows/release.yml` ## 3. NEW FILE: `.gitea/workflows/release.yml`
Manual trigger: **repo → Actions → Release → Run workflow**, enter a The release version is the repo-root **`VERSION` file** (single source of
`version` (e.g. `0.2.0`) and a `changelog`. It runs the same tests as CI, truth — "everything from here on out is vX.Y.Z" = bump `VERSION` and
builds a signed Android APK + AAB and the Linux desktop packages (jpackage, commit). Manual trigger: **repo → Actions → Release → Run workflow**,
JRE bundled), then creates the Gitea release `v<version>` with all artifacts optionally with a `changelog`. It runs the same tests as CI, builds a signed
as download attachments. 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 Note: builds + release creation happen in ONE job because Gitea/act_runner
does not implement the GitHub artifacts API (`upload-artifact@v4+` fails does not implement the GitHub artifacts API (`upload-artifact@v4+` fails
@@ -116,12 +118,10 @@ name: Release
on: on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: # The release version comes from the repo-root VERSION file (the single
description: "Release version (e.g. 0.2.0)" # source of truth) — bump it in a commit, then dispatch this workflow.
required: true
type: string
changelog: 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 required: false
type: string type: string
@@ -151,7 +151,7 @@ jobs:
- name: Run android gateway tests - name: Run android gateway tests
run: | 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 cd hermes-agent
IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \ IRIS_PLUGIN_DIR="$GITHUB_WORKSPACE/gateway-plugin" \
scripts/run_tests.sh tests/gateway/test_android.py scripts/run_tests.sh tests/gateway/test_android.py
@@ -246,7 +246,8 @@ jobs:
- name: Build APK + AAB - name: Build APK + AAB
run: | 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 cd app
if [ -n "$ANDROID_KEYSTORE_FILE" ]; then if [ -n "$ANDROID_KEYSTORE_FILE" ]; then
# APK for direct sideloading, AAB for Play Store uploads. # 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). # for it (jpackage picks the native type: msi on Windows, dmg on macOS).
- name: Build desktop app-image + deb - name: Build desktop app-image + deb
run: | 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 cd app
# Self-contained app image (JRE bundled via jlink). # Self-contained app image (JRE bundled via jlink).
./gradlew :desktopApp:jpackage -PappVersion="$VERSION" ./gradlew :desktopApp:jpackage -PappVersion="$VERSION"
@@ -290,20 +292,39 @@ jobs:
SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}" SERVER="${GITEA_SERVER_URL:-$GITHUB_SERVER_URL}"
REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}" REPO="${GITEA_REPOSITORY:-$GITHUB_REPOSITORY}"
TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}" TOKEN="${RELEASE_TOKEN:-$GITHUB_TOKEN}"
VERSION=$(jq -r '.inputs.version' "$GITHUB_EVENT_PATH") VERSION=$(cat "$GITHUB_WORKSPACE/VERSION")
CHANGELOG=$(jq -r '.inputs.changelog // ""' "$GITHUB_EVENT_PATH") # 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" TAG="v$VERSION"
API="$SERVER/api/v1/repos/$REPO" API="$SERVER/api/v1/repos/$REPO"
AUTH="Authorization: token $TOKEN" AUTH="Authorization: token $TOKEN"
# Re-run safety: drop a previous release (and its tag) for this version. # curl wrapper: on HTTP >= 400, print the response body (Gitea's error
OLD_ID=$(curl -sf -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') # message) before failing — plain `curl -f` hides it (exit 22).
if [ -n "$OLD_ID" ]; then api() {
curl -sf -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null 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 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
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. # 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" \ "$API/releases" \
-d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \ -d "$(jq -n --arg tag "$TAG" --arg title "Iris $VERSION" --arg body "$CHANGELOG" \
'{tag_name:$tag, title:$title, body:$body}')" \ '{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 for f in "$GITHUB_WORKSPACE"/iris-android-v* "$GITHUB_WORKSPACE"/iris-desktop-*; do
[ -f "$f" ] || continue [ -f "$f" ] || continue
echo "Uploading $(basename "$f")" echo "Uploading $(basename "$f")"
curl -sf -X POST -H "$AUTH" -F "attachment=@$f" \ # Forgejo-style API: release assets live under /assets, not /attachments.
"$API/releases/$RELEASE_ID/attachments" > /dev/null api -X POST -H "$AUTH" -F "attachment=@$f" \
"$API/releases/$RELEASE_ID/assets" > /dev/null
done done
echo "Done: $SERVER/$REPO/releases/tag/$TAG" echo "Done: $SERVER/$REPO/releases/tag/$TAG"
```
--- ---
## 4. EDITS to existing Gradle files ## 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` ### 4a. `app/androidApp/build.gradle.kts`
**Change 1** — in `defaultConfig`, replace: **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 ## Features
- **Native Hermes-Gateway integration** — your hermes → gateway → Iris app - **Native Hermes-Gateway integration** — your hermes → gateway → Iris app
- **Absolute Privacy!** — everything stays on your own infrastructure - **Absolute Privacy!** — chat stays on your own infrastructure
(push: ntfy by default; FCM is opt-in and routes push metadata via Google — (push: ntfy by default, **but the default ntfy server is the public
see [Push notifications](#push-notifications)) `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 - **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 `max_upload_bytes` (see [Media](docs/07-media.md) §7.7); all limits are set
on the gateway side (hermes), not in the app 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 Push wakes a backgrounded/offline device; on reconnect the app syncs the
outbox, so nothing is lost. outbox, so nothing is lost.
- **ntfy (default)** — push metadata stays on your own infrastructure - **ntfy (default)** — the backend for truly private communication.
(self-hosted ntfy recommended). This is the backend for truly private ⚠️ **By default it uses the public `https://ntfy.sh` cloud service** — push
communication. 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 - **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard/reliable, but FCM push
metadata (notification title, device token) is routed through **Google's metadata (notification title, device token) is routed through **Google's
servers**. If you want truly private communication, use ntfy instead. 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` - Python (gateway plugin): `cd hermes-agent && scripts/run_tests.sh tests/gateway/test_android.py`
(never bare `pytest` — hermes's runner sandboxes `HERMES_HOME`). (never bare `pytest` — hermes's runner sandboxes `HERMES_HOME`).
- Kotlin: `cd app && ./gradlew :shared:testDebugUnitTest` - Kotlin: `cd app && ./gradlew :shared:testDebugUnitTest`
- Live check (gateway must be running): `gateway-plugin/tests/ws_probe.py` and - Live check (gateway must be running): `tests/ws_probe.py` and
`gateway-plugin/tests/e2e.py` — see [`gateway-plugin/tests/README.md`](gateway-plugin/tests/README.md). `tests/e2e.py` — see [`tests/README.md`](tests/README.md).
4. **Keep the protocol in sync.** `gateway-plugin/protocol.py`, 4. **Keep the protocol in sync.** `gateway-plugin/protocol.py`,
`app/shared/.../protocol/Protocol.kt`, and `docs/protocol/frames.schema.json` `app/shared/.../protocol/Protocol.kt`, and `docs/protocol/frames.schema.json`
must always agree. 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 // Play Store requires an incrementing versionCode per upload; CI can
// pass -PappVersionCode=<n>. Local builds keep the default. // pass -PappVersionCode=<n>. Local builds keep the default.
versionCode = (project.findProperty("appVersionCode")?.toString()?.toIntOrNull()) ?: 1 versionCode = (project.findProperty("appVersionCode")?.toString()?.toIntOrNull()) ?: 1
// CI passes -PappVersion=<version> (release workflow); local builds // The repo-root VERSION file is the single source of truth (bump it
// keep the default. // to cut a release); CI can still override with -PappVersion.
versionName = (project.findProperty("appVersion") as? String) ?: "0.1.0" versionName =
(project.findProperty("appVersion") as? String)
?: project
.file("../../VERSION")
.takeIf { it.exists() }
?.readText()
?.trim()
?: "0.1.0"
} }
buildTypes { buildTypes {
+11 -3
View File
@@ -9,9 +9,17 @@ plugins {
val composeVersion = "1.11.1" val composeVersion = "1.11.1"
val os = OperatingSystem.current() val os = OperatingSystem.current()
val arch = System.getProperty("os.arch") ?: "amd64" val arch = System.getProperty("os.arch") ?: "amd64"
// CI passes -PappVersion=<version> (release workflow); local builds keep the // The repo-root VERSION file is the single source of truth (bump it to cut
// default. jpackage requires a plain semver (no leading "v"). // a release); CI can still override with -PappVersion. jpackage requires a
val appVersion = (project.findProperty("appVersion") as? String) ?: "0.1.0" // 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 = val desktopTarget =
when { when {
os.isMacOsX -> if (arch == "aarch64") "macos-arm64" else "macos-x64" 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 cameraxVersion = "1.5.1"
val mlKitVersion = "16.1.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 { kotlin {
android { android {
namespace = "iris.shared" namespace = "iris.shared"
@@ -53,6 +95,11 @@ kotlin {
} }
sourceSets { 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 // Both targets are JVM-based (androidTarget + jvm("desktop")), so
// shared JVM code (File I/O, SHA-256, media cache) lives in jvmMain. // shared JVM code (File I/O, SHA-256, media cache) lives in jvmMain.
val jvmMain by creating { dependsOn(commonMain.get()) } 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.Backdrop
import iris.ui.theme.BackgroundMode import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme import iris.ui.theme.UserTheme
import iris.util.STREAM_SMOOTHNESS_DEFAULT
import java.util.UUID import java.util.UUID
/** /**
@@ -134,6 +135,10 @@ class AndroidSecureStore(
get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true) get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true)
set(value) = prefs.edit().putBoolean(KEY_STREAMING_ENABLED, value).apply() 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 override var reasoningAutoCollapse: Boolean
get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true) get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true)
set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply() 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_THREADS_ENABLED = "threads_enabled"
const val KEY_TOOL_DETAIL = "tool_detail" const val KEY_TOOL_DETAIL = "tool_detail"
const val KEY_STREAMING_ENABLED = "streaming_enabled" 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_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse"
const val KEY_USER_BUBBLE_COLOR = "user_bubble_color" const val KEY_USER_BUBBLE_COLOR = "user_bubble_color"
const val KEY_AGENT_BUBBLE_COLOR = "agent_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). */ /** UI setting: stream assistant replies live (token by token). */
var streamingEnabled: Boolean 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. */ /** UI setting: auto-collapse long reasoning blocks in the chat view. */
var reasoningAutoCollapse: Boolean var reasoningAutoCollapse: Boolean
@@ -1,5 +1,6 @@
package iris.net package iris.net
import iris.AppVersion
import iris.media.Sha256 import iris.media.Sha256
import iris.media.isValidMediaId import iris.media.isValidMediaId
import iris.protocol.ErrorPayload import iris.protocol.ErrorPayload
@@ -135,6 +136,9 @@ class HttpGateway(
deviceName?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Device-Name", it) } deviceName?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Device-Name", it) }
fcmToken()?.takeIf { !it.isNullOrBlank() }?.let { b.add("X-Iris-Fcm-Token", 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) } 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() 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", val push: String = "fcm",
@SerialName("push_ntfy_server") val pushNtfyServer: String = "", @SerialName("push_ntfy_server") val pushNtfyServer: String = "",
val pickers: Boolean = false, val pickers: Boolean = false,
/** Release version of the gateway plugin (repo-root VERSION file). */
@SerialName("app_version") val appVersion: String = "",
) )
@Serializable @Serializable
@@ -150,6 +152,9 @@ data class ChannelInfo(
@SerialName("is_default") val isDefault: Boolean = false, @SerialName("is_default") val isDefault: Boolean = false,
@SerialName("parent_chat_id") val parentChatId: String? = null, @SerialName("parent_chat_id") val parentChatId: String? = null,
val archived: Boolean = false, 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); /** Gateway minted this thread for an incoming message (auto-threading);
* the name is a derived title, upgraded by the LLM via channel.renamed. */ * the name is a derived title, upgraded by the LLM via channel.renamed. */
val auto: Boolean = false, val auto: Boolean = false,
@@ -80,6 +80,8 @@ import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme import iris.ui.theme.UserTheme
import iris.util.IrisLog import iris.util.IrisLog
import iris.util.STREAM_SMOOTHNESS_MAX
import iris.util.STREAM_SMOOTHNESS_MIN
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -93,6 +95,8 @@ import kotlinx.coroutines.launch
import java.util.Collections import java.util.Collections
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.TimeMark
import kotlin.time.TimeSource
/** /**
* App-level controller (M3): owns the GatewayClient + ChatStore + ChannelStore, * App-level controller (M3): owns the GatewayClient + ChatStore + ChannelStore,
@@ -156,6 +160,15 @@ class IrisController(
private val _gatewayStatus = MutableStateFlow<String?>(null) private val _gatewayStatus = MutableStateFlow<String?>(null)
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow() 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 ────────────────────────────────────────────── // ── M8: unread indicator ──────────────────────────────────────────────
/** True while the current lane's newest content sits at the bottom of the /** True while the current lane's newest content sits at the bottom of the
@@ -177,13 +190,34 @@ class IrisController(
_foreground.value = fg _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 /** 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 * 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 * current lane, the app is focused, and the newest content is at the
* bottom of the viewport). */ * bottom of the viewport). [messageId] dedupes redeliveries of the same
private fun noteIncomingAssistantMessage(lane: String) { * 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 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 /** M8: the user is now viewing the current lane's newest content — clear
@@ -267,6 +301,21 @@ class IrisController(
chat.streamingEnabled = _streamingEnabled.value 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") ─────────────────── // ── Reasoning auto-collapse (Settings → "Reasoning") ───────────────────
// Persisted. When on, long reasoning blocks start collapsed (short ones // Persisted. When on, long reasoning blocks start collapsed (short ones
// stay expanded); when off, all reasoning blocks start expanded. // 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. */ /** Max simultaneous banners; persistent ones are exempt from the cap. */
private const val MAX_BANNERS = 5 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_MIN = 0.8f
const val FONT_SCALE_MAX = 1.5f const val FONT_SCALE_MAX = 1.5f
@@ -497,6 +551,13 @@ class IrisController(
) { ) {
if (isAppForeground()) return if (isAppForeground()) return
if (text.isBlank()) 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 id = chatId ?: "default"
val chatName = channels.byId(id)?.name val chatName = channels.byId(id)?.name
postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId) postSystemNotification(id, chatName, chatName ?: "Iris", preview(text), threadId)
@@ -621,7 +682,7 @@ class IrisController(
// content — count it as unread unless the // content — count it as unread unless the
// user is reading this lane right now. // user is reading this lane right now.
frame.chatId?.let { cid -> frame.chatId?.let { cid ->
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId)) noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId), it.messageId)
} }
if (!alreadyConsumed && !isPushedReplay(frame)) { if (!alreadyConsumed && !isPushedReplay(frame)) {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText) notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
@@ -634,7 +695,7 @@ class IrisController(
if (it.role == ROLE_ASSISTANT) { if (it.role == ROLE_ASSISTANT) {
// M8: a finalized (non-streaming) reply. // M8: a finalized (non-streaming) reply.
frame.chatId?.let { cid -> frame.chatId?.let { cid ->
noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId)) noteIncomingAssistantMessage(chat.laneKey(cid, frame.threadId), it.messageId)
} }
if (!alreadyConsumed && !isPushedReplay(frame)) { if (!alreadyConsumed && !isPushedReplay(frame)) {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text) notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
@@ -761,6 +822,15 @@ class IrisController(
// sync replay) must not re-show the banner. // sync replay) must not re-show the banner.
if (!alreadyConsumed) { if (!alreadyConsumed) {
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId) 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 // M5: the connection is live but the app is backgrounded — the
// in-app banner is invisible, so mirror to a system // in-app banner is invisible, so mirror to a system
// notification (the push backend only fires when // notification (the push backend only fires when
@@ -872,6 +942,11 @@ class IrisController(
// just close the in-flight turn's dangling tool cards / // just close the in-flight turn's dangling tool cards /
// streaming bubble (nothing spins forever). // streaming bubble (nothing spins forever).
chat.finalizeInterrupted() 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). * refreshes (skipped on a plain reconnect via historyLoaded).
*/ */
private fun onConnectedLane(connected: GatewayClient.State.Connected) { private fun onConnectedLane(connected: GatewayClient.State.Connected) {
_gatewayVersion.value = connected.caps.appVersion
// Never wipe the directory with an empty list: the long-poll restore // Never wipe the directory with an empty list: the long-poll restore
// path carries no channels when there was no prior SSE hello (lastAck // path carries no channels when there was no prior SSE hello (lastAck
// null), and the cached directory is still valid then. // 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.IconButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.LinkInteractionListener import androidx.compose.ui.text.LinkInteractionListener
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle 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.Markdown
import com.mikepenz.markdown.m3.markdownColor import com.mikepenz.markdown.m3.markdownColor
import com.mikepenz.markdown.m3.markdownTypography import com.mikepenz.markdown.m3.markdownTypography
import com.mikepenz.markdown.model.StreamingMarkdownState
import com.mikepenz.markdown.model.markdownAnnotator import com.mikepenz.markdown.model.markdownAnnotator
import com.mikepenz.markdown.model.rememberMarkdownState import com.mikepenz.markdown.model.rememberMarkdownState
import com.mikepenz.markdown.model.rememberStreamingMarkdownState
import com.mikepenz.markdown.utils.getUnescapedTextInNode import com.mikepenz.markdown.utils.getUnescapedTextInNode
import dev.snipme.highlights.Highlights import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.SyntaxThemes import dev.snipme.highlights.model.SyntaxThemes
import iris.ui.theme.IrisColors 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.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.intellij.markdown.MarkdownElementTypes 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 * 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 * syntax highlighting (```json → JSON, ```kotlin → Kotlin, …). [text] is the
* raw markdown; [color] and [fontSize] match the surrounding bubble so the * raw markdown; [color] and [fontSize] match the surrounding bubble so the
* rendered text blends in. * 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 @Composable
fun MarkdownText( fun MarkdownText(
@@ -64,11 +87,30 @@ fun MarkdownText(
// plain highlighted code — the artifact card (and its runnable preview) // plain highlighted code — the artifact card (and its runnable preview)
// only appears once the message is complete. // only appears once the message is complete.
isStreaming: Boolean = false, 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 // The app is always dark-themed, so force the dark highlight palette
// (isSystemInDarkTheme() is unreliable on desktop). // (isSystemInDarkTheme() is unreliable on desktop).
val highlights = remember { val highlights =
remember {
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true)) Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
} }
val base = TextStyle(color = color, fontSize = fontSize) val base = TextStyle(color = color, fontSize = fontSize)
@@ -80,7 +122,8 @@ fun MarkdownText(
// Brief green flash shown on the chip right after a copy, so the tap is // Brief green flash shown on the chip right after a copy, so the tap is
// visible feedback (the clipboard write itself is silent). // visible feedback (the clipboard write itself is silent).
val copiedCodeSpanStyle = 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() .toSpanStyle()
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -92,9 +135,11 @@ fun MarkdownText(
// Re-render it without the padding, and wrap it in a link so tapping the // 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 // inline code copies it to the clipboard (Telegram-style). The
// LinkAnnotation carries the click listener; the URL is never opened. // LinkAnnotation carries the click listener; the URL is never opened.
val annotator = markdownAnnotator( val annotator =
markdownAnnotator(
annotate = { content, child -> annotate = { content, child ->
if (child.type == MarkdownElementTypes.CODE_SPAN) { when {
child.type == MarkdownElementTypes.CODE_SPAN -> {
val children = child.children val children = child.children
// Drop the surrounding backtick tokens (present as first/last child). // 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 inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
@@ -106,13 +151,15 @@ fun MarkdownText(
url = "iris:copy-code", url = "iris:copy-code",
// Keep every interaction state identical to the chip so // Keep every interaction state identical to the chip so
// hover/press never restyles the inline code. // hover/press never restyles the inline code.
styles = TextLinkStyles( styles =
TextLinkStyles(
style = spanStyle, style = spanStyle,
focusedStyle = spanStyle, focusedStyle = spanStyle,
hoveredStyle = spanStyle, hoveredStyle = spanStyle,
pressedStyle = spanStyle, pressedStyle = spanStyle,
), ),
linkInteractionListener = LinkInteractionListener { linkInteractionListener =
LinkInteractionListener {
clipboard.setText(AnnotatedString(code)) clipboard.setText(AnnotatedString(code))
copiedCode.value = code copiedCode.value = code
scope.launch { scope.launch {
@@ -126,23 +173,36 @@ fun MarkdownText(
} }
pop() pop()
true true
} else { }
// 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 false
} }
}
}, },
) )
Markdown( val colors =
markdownState = state, markdownColor(
modifier = modifier,
annotator = annotator,
colors = markdownColor(
text = color, text = color,
codeBackground = Color.Black.copy(alpha = 0.8f), codeBackground = Color.Black.copy(alpha = 0.8f),
inlineCodeBackground = inlineCodeBackground, inlineCodeBackground = inlineCodeBackground,
dividerColor = IrisColors.divider, dividerColor = IrisColors.divider,
tableBackground = Color.White.copy(alpha = 0.03f), tableBackground = Color.White.copy(alpha = 0.03f),
), )
typography = markdownTypography( val typography =
markdownTypography(
h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold), h1 = base.copy(fontSize = fontSize * 1.3f, fontWeight = FontWeight.Bold),
h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold), h2 = base.copy(fontSize = fontSize * 1.2f, fontWeight = FontWeight.Bold),
h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold), h3 = base.copy(fontSize = fontSize * 1.1f, fontWeight = FontWeight.Bold),
@@ -157,15 +217,17 @@ fun MarkdownText(
ordered = base, ordered = base,
bullet = base, bullet = base,
list = base, list = base,
textLink = TextLinkStyles( textLink =
TextLinkStyles(
style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(), style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(),
), ),
table = base.copy(fontSize = fontSize * 0.95f), table = base.copy(fontSize = fontSize * 0.95f),
), )
components = markdownComponents( val components =
markdownComponents(
codeFence = { model -> codeFence = { model ->
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style -> 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) HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else { } else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights) CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
@@ -174,7 +236,7 @@ fun MarkdownText(
}, },
codeBlock = { model -> codeBlock = { model ->
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style -> 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) HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else { } else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights) CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
@@ -184,16 +246,128 @@ fun MarkdownText(
// The core default checkbox renders literal "[x]"/"[ ]" text; use the // The core default checkbox renders literal "[x]"/"[ ]" text; use the
// Material 3 checkbox instead. // Material 3 checkbox instead.
checkbox = { 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)
}, },
), )
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 -> loading = { m ->
// While (re)parsing — e.g. on each streaming update — show the raw // First parse of a (long) message: show the text so the bubble
// text so the bubble never goes blank between updates. // never goes blank.
Text(text, modifier = m, color = color, fontSize = fontSize) 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 * Renders a highlighted code block with a copy button in the top-right corner
@@ -213,7 +387,8 @@ internal fun CodeBlockWithCopy(
if (code.isNotBlank()) { if (code.isNotBlank()) {
IconButton( IconButton(
onClick = { clipboard.setText(AnnotatedString(code)) }, onClick = { clipboard.setText(AnnotatedString(code)) },
modifier = Modifier modifier =
Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
// MarkdownHighlightedCode insets its background by 8dp top; // MarkdownHighlightedCode insets its background by 8dp top;
// match that so the button sits inside the block, not above it. // match that so the button sits inside the block, not above it.
@@ -143,12 +143,11 @@ import iris.ui.theme.IrisColors
import iris.ui.theme.LocalUserTheme import iris.ui.theme.LocalUserTheme
import iris.ui.theme.avatarColor import iris.ui.theme.avatarColor
import iris.ui.theme.contrastText import iris.ui.theme.contrastText
import iris.util.STREAM_SMOOTHNESS_DEFAULT
import iris.util.formatDayLabel import iris.util.formatDayLabel
import iris.util.formatTime import iris.util.formatTime
import iris.util.fuzzyScore import iris.util.fuzzyScore
import iris.util.localDayKey import iris.util.localDayKey
import iris.util.prepareForMarkdown
import iris.util.preserveNewlinesAsHardBreaks
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale import java.util.Locale
@@ -171,11 +170,17 @@ fun ChatScreen(controller: IrisController) {
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState() val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState() val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState() val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
val streamSmoothness by controller.streamSmoothness.collectAsState()
val channels by controller.channels.channels.collectAsState() val channels by controller.channels.channels.collectAsState()
val (currentChatId, currentThreadId) = controller.chat.parseLane(currentLane) val (currentChatId, currentThreadId) = controller.chat.parseLane(currentLane)
val currentChannel = channels.firstOrNull { it.chatId == currentChatId } 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 // M8: unread counts. Per-lane from the store; per-channel aggregated
// (flat lane + all threads) for the drawer/rail badges. // (flat lane + all threads) for the drawer/rail badges.
@@ -712,6 +717,7 @@ fun ChatScreen(controller: IrisController) {
}, },
runtimeFooterEnabled = runtimeFooterEnabled, runtimeFooterEnabled = runtimeFooterEnabled,
runtimeFooterFields = runtimeFooterFields, runtimeFooterFields = runtimeFooterFields,
streamSmoothness = streamSmoothness,
) )
} }
} }
@@ -2521,6 +2527,7 @@ private fun MessageBubble(
onSelect: (() -> Unit)? = null, onSelect: (() -> Unit)? = null,
runtimeFooterEnabled: Boolean = false, runtimeFooterEnabled: Boolean = false,
runtimeFooterFields: List<String> = emptyList(), runtimeFooterFields: List<String> = emptyList(),
streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
) { ) {
val isUser = msg.role == ROLE_USER val isUser = msg.role == ROLE_USER
val isCommentary = msg.isCommentary val isCommentary = msg.isCommentary
@@ -2594,9 +2601,7 @@ private fun MessageBubble(
// in replies. // in replies.
if (msg.text.isNotBlank() || msg.streaming) { if (msg.text.isNotBlank() || msg.streaming) {
MarkdownText( MarkdownText(
text = text = msg.text,
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
if (msg.streaming) " ▉" else "",
color = textColor, color = textColor,
fontSize = 15.sp, fontSize = 15.sp,
isStreaming = msg.streaming, isStreaming = msg.streaming,
@@ -2641,20 +2646,18 @@ private fun MessageBubble(
} else { } else {
if (msg.text.isNotBlank() || msg.streaming) { if (msg.text.isNotBlank() || msg.streaming) {
// M8: render the agent's reply as markdown (bold / italic / // M8: render the agent's reply as markdown (bold / italic /
// underscore, tables, highlighted code blocks). Leading // underscore, tables, highlighted code blocks). The
// newlines are stripped so the text hugs the top of the // transform (leading-newline strip, hard breaks) and the ▉
// bubble; single newlines become hard breaks (models write // streaming cursor live inside MarkdownText; while
// status lines and wrapped text expecting a break per line, // streaming the text is revealed at a steady rate and
// same as user input); the ▉ cursor is kept while streaming. // parsed incrementally (docs/05 §5.1).
val displayText =
msg.text.prepareForMarkdown().preserveNewlinesAsHardBreaks() +
if (msg.streaming) " ▉" else ""
MarkdownText( MarkdownText(
text = displayText, text = msg.text,
color = textColor, color = textColor,
fontSize = if (isCommentary) 13.sp else 15.sp, fontSize = if (isCommentary) 13.sp else 15.sp,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
isStreaming = msg.streaming, 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.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import iris.AppVersion
import iris.net.ReleaseCheck
import iris.platform.ImageFilePicker import iris.platform.ImageFilePicker
import iris.platform.decodeImageBytes import iris.platform.decodeImageBytes
import iris.platform.loadScaledImage import iris.platform.loadScaledImage
@@ -54,6 +56,8 @@ import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode import iris.ui.theme.BackgroundMode
import iris.ui.theme.IrisColors import iris.ui.theme.IrisColors
import iris.ui.theme.LocalUserTheme import iris.ui.theme.LocalUserTheme
import iris.util.STREAM_SMOOTHNESS_MAX
import iris.util.STREAM_SMOOTHNESS_MIN
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
@@ -71,15 +75,24 @@ fun SettingsScreen(
) { ) {
val threadsEnabled by controller.threadsEnabled.collectAsState() val threadsEnabled by controller.threadsEnabled.collectAsState()
val streamingEnabled by controller.streamingEnabled.collectAsState() val streamingEnabled by controller.streamingEnabled.collectAsState()
val streamSmoothness by controller.streamSmoothness.collectAsState()
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState() val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState() val runtimeFooterEnabled by controller.runtimeFooterEnabled.collectAsState()
val runtimeFooterFields by controller.runtimeFooterFields.collectAsState() val runtimeFooterFields by controller.runtimeFooterFields.collectAsState()
val toolDetail by controller.toolDetail.collectAsState() val toolDetail by controller.toolDetail.collectAsState()
val fontSizeScale by controller.fontSizeScale.collectAsState() val fontSizeScale by controller.fontSizeScale.collectAsState()
val gatewayVersion by controller.gatewayVersion.collectAsState()
val theme = LocalUserTheme.current val theme = LocalUserTheme.current
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) } var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
var showForgetConfirm by remember { mutableStateOf(false) } 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)) { Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
Column( Column(
modifier = modifier =
@@ -142,6 +155,27 @@ fun SettingsScreen(
onCheckedChange = { controller.toggleStreaming() }, 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 { SettingsCard {
Row( Row(
@@ -410,6 +444,19 @@ fun SettingsScreen(
Text("Forget pairing", fontSize = 12.sp) 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) { 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). */ /** Settings row container (panel card). */
@Composable @Composable
private fun SettingsCard(content: @Composable () -> Unit) { private fun SettingsCard(content: @Composable () -> Unit) {
@@ -25,16 +25,107 @@ fun String.prepareForMarkdown(): String {
fun String.preserveNewlinesAsHardBreaks(): String { fun String.preserveNewlinesAsHardBreaks(): String {
val lines = split("\n") val lines = split("\n")
var inCodeBlock = false var inCodeBlock = false
val out = lines.map { line -> val out =
lines.map { line ->
val trimmed = line.trimStart() val trimmed = line.trimStart()
when { when {
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> { trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
inCodeBlock = !inCodeBlock inCodeBlock = !inCodeBlock
line line
} }
inCodeBlock || line.isBlank() -> line
else -> line + " " inCodeBlock || line.isBlank() -> {
line
}
else -> {
line + " "
}
} }
} }
return out.joinToString("\n") 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.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MarkdownTest { class MarkdownTest {
@Test @Test
fun stripsLeadingNewlines() { fun stripsLeadingNewlines() {
assertEquals("Hello", "\n\nHello".prepareForMarkdown()) assertEquals("Hello", "\n\nHello".prepareForMarkdown())
@@ -61,4 +61,90 @@ class MarkdownTest {
val md = "**Title:** x\n**Model:** y" val md = "**Title:** x\n**Model:** y"
assertEquals("**Title:** x \n**Model:** y ", md.preserveNewlinesAsHardBreaks()) 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.Backdrop
import iris.ui.theme.BackgroundMode import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme import iris.ui.theme.UserTheme
import iris.util.STREAM_SMOOTHNESS_DEFAULT
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.io.File import java.io.File
import java.security.SecureRandom import java.security.SecureRandom
@@ -64,6 +65,7 @@ class DesktopSecureStore : SecureStore {
val threadsEnabled: Boolean = false, val threadsEnabled: Boolean = false,
val toolDetail: String = "truncated", val toolDetail: String = "truncated",
val streamingEnabled: Boolean = true, val streamingEnabled: Boolean = true,
val streamSmoothness: Float = STREAM_SMOOTHNESS_DEFAULT,
val reasoningAutoCollapse: Boolean = true, val reasoningAutoCollapse: Boolean = true,
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE, val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE, val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
@@ -222,6 +224,13 @@ class DesktopSecureStore : SecureStore {
save(d.copy(streamingEnabled = value)) 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 override var reasoningAutoCollapse: Boolean
get() = load().reasoningAutoCollapse get() = load().reasoningAutoCollapse
set(value) { set(value) {
+10 -1
View File
@@ -40,7 +40,8 @@ Pairing succeeded.
```json ```json
{"type":"hello.ack","payload":{ {"type":"hello.ack","payload":{
"server_caps":{"streaming":true,"reasoning":true,"tools":true,"media":true, "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, "sync_cursor":1042,
"last_pushed_cursor":1040, "last_pushed_cursor":1040,
"device_token":"9f2c…(64 hex)", "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 `last_pushed_cursor` is the highest outbox cursor already delivered to THIS
device via the push backend (0 = never). The app skips system notifications device via the push backend (0 = never). The app skips system notifications
for sync-replayed frames with `cursor <= last_pushed_cursor` — they already for sync-replayed frames with `cursor <= last_pushed_cursor` — they already
+45
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 **Gateway side.** The agent's `stream_delta_callback` feeds a
`GatewayStreamConsumer` (`gateway/stream_consumer.py:156`). The consumer `GatewayStreamConsumer` (`gateway/stream_consumer.py:156`). The consumer
accumulates text and, at intervals / thresholds, calls: accumulates text and, at intervals / thresholds, calls:
- `adapter.send(chat_id, text)` — first time a bubble is created. - `adapter.send(chat_id, text)` — first time a bubble is created.
- `adapter.edit_message(chat_id, message_id, text)` — subsequent updates - `adapter.edit_message(chat_id, message_id, text)` — subsequent updates
(each carries the **full** accumulated text). (each carries the **full** accumulated text).
**Adapter → frames.** **Adapter → frames.**
- First `send()` of a turn segment → `message.start {message_id, role}`. - First `send()` of a turn segment → `message.start {message_id, role}`.
- Each `edit_message()` → `message.update {message_id, text}` (full text). - Each `edit_message()` → `message.update {message_id, text}` (full text).
- Segment/turn finalization → `message.stop {message_id, final_text, reasoning?, - 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 finalize (attach reasoning/model/tokens footer, stop the cursor). Auto-scroll
while the user is at the bottom. 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: **Streaming on/off.** Two levels:
- **Gateway side:** hermes `display.platforms.iris.streaming` (default - **Gateway side:** hermes `display.platforms.iris.streaming` (default
follows global). When off, the app just gets one final `message` frame. follows global). When off, the app just gets one final `message` frame.
- **App side (per device):** Settings → "Streaming" toggle (default on). When - **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 **Gateway side.** hermes prepends reasoning to the final response when
`show_reasoning` is enabled (`gateway/run.py:20089`). The format is stable and `show_reasoning` is enabled (`gateway/run.py:20089`). The format is stable and
chosen by `reasoning_style` (`gateway/display_config.py:37`): chosen by `reasoning_style` (`gateway/display_config.py:37`):
- `code` (default): `💭 **Reasoning:**\n```\n<reasoning>\n```\n\n<response>` - `code` (default): `💭 **Reasoning:**\n```\n<reasoning>\n```\n\n<response>`
- `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>` - `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>`
- `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style) - `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style)
**Plugin config.** Set for the `iris` platform: **Plugin config.** Set for the `iris` platform:
```yaml ```yaml
display: display:
platforms: platforms:
@@ -59,12 +100,14 @@ display:
``` ```
**Adapter split.** In `send()`, detect the `code`-style prefix and split: **Adapter split.** In `send()`, detect the `code`-style prefix and split:
``` ```
prefix = "💭 **Reasoning:**\n```\n" prefix = "💭 **Reasoning:**\n```\n"
# find the closing "\n```\n\n" after the prefix # find the closing "\n```\n\n" after the prefix
reasoning = text[len(prefix):close_idx] reasoning = text[len(prefix):close_idx]
body = text[close_idx + len("\n```\n\n"):] body = text[close_idx + len("\n```\n\n"):]
``` ```
Emit `message {reasoning: <reasoning>, text: <body>, …}`. If no prefix is found Emit `message {reasoning: <reasoning>, text: <body>, …}`. If no prefix is found
(reasoning off / no reasoning), emit `message {text: …}` with no `reasoning`. (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 + **Adapter → frames.** The adapter classifies tool activity (via turn-state +
line format) and emits **structured** frames — not pre-formatted strings: line format) and emits **structured** frames — not pre-formatted strings:
- `tool.start {index, name, preview, args}` — a tool call began. - `tool.start {index, name, preview, args}` — a tool call began.
- `tool.progress {index, name, note}` — in-progress update (optional). - `tool.progress {index, name, note}` — in-progress update (optional).
- `tool.end {index, name, ok, duration, output_preview}` — completed. - `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). is reachable via search).
**App side — the verbosity setting** (Settings → "Tool detail"): **App side — the verbosity setting** (Settings → "Tool detail"):
- **Everything** — show tool name, full args (collapsible), and output preview. - **Everything** — show tool name, full args (collapsible), and output preview.
- **Truncated** (default) — show `emoji name: "short preview"` one-liner, - **Truncated** (default) — show `emoji name: "short preview"` one-liner,
collapsible to expand. collapsible to expand.
+5 -2
View File
@@ -3,8 +3,11 @@
The gateway can't reach a sleeping phone directly. Push goes through a cloud The gateway can't reach a sleeping phone directly. Push goes through a cloud
relay. **Decision: ntfy default, FCM optional** (`IRIS_PUSH_BACKEND`). relay. **Decision: ntfy default, FCM optional** (`IRIS_PUSH_BACKEND`).
Privacy: FCM push metadata (notification title, device token) is routed Privacy: FCM push metadata (notification title, device token) is routed
through Google's servers — for truly private communication use ntfy through Google's servers. ntfy is the private option — **but note the default
(self-hosted), which keeps everything on your own infrastructure. `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 ## 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 ## 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 into the hermes `tests/gateway/test_android.py` pattern when running under
hermes's suite). hermes's suite).
- **Run with hermes's hermetic runner** (never bare `pytest`): - **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) ## 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 **real running gateway** and drives a turn, printing every frame. This is how we
**empirically confirm** the exact frame shapes (especially tool-progress vs **empirically confirm** the exact frame shapes (especially tool-progress vs
commentary classification and the reasoning prefix) before/while building the commentary classification and the reasoning prefix) before/while building the
@@ -56,7 +56,7 @@ Kotlin client.
```bash ```bash
hermes gateway & # with the iris plugin 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" --send "list the files and summarize"
# prints: hello.ack, typing, message.start, message.update…, tool.start, tool.end, # prints: hello.ack, typing, message.start, message.update…, tool.start, tool.end,
# commentary, message.stop {reasoning,…}, … # 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 auth → 401, magic-byte reclassification; `GET /v1/media/{id}` happy path
(bytes + content-type), unknown id → 404, denied path → 404. (bytes + content-type), unknown id → 404, denied path → 404.
- **Probe:** `ws_probe.py` gains an `--http` mode (health, post, SSE read with - **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). (v2: upload round-trip via `POST /v1/media`, exit 23 on rejection).
- **Kotlin** (`:shared` commonTest): SSE parser (multi-line data, comments, - **Kotlin** (`:shared` commonTest): SSE parser (multi-line data, comments,
`Last-Event-ID` bookkeeping); transport state machine transitions (fake `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. outbox.
- **ntfy (default)** — the phone generates its own topic automatically; the - **ntfy (default)** — the phone generates its own topic automatically; the
gateway publishes to it. Set `NTFY_SERVER_URL` to a **self-hosted ntfy** gateway publishes to it. ⚠️ **The default server is the public
for reliability (the public `ntfy.sh` SSE endpoint is flaky). Push metadata `https://ntfy.sh` cloud service** — push metadata (topic, notification
stays on your own infrastructure — this is the private option. 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 - **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard and reliable, but push
metadata (notification title, device token) is routed through **Google's metadata (notification title, device token) is routed through **Google's
servers**. Needs a Firebase project + `google-services.json` in the app 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: **Absolute Privacy!** — everything stays on your own infrastructure:
- Your gateway, your machine, your data. No cloud middleman for chat. - Your gateway, your machine, your data. No cloud middleman for chat.
- **Push notifications:** ntfy by default — push metadata stays on your own - **Push notifications:** ntfy by default. Note: out of the box it uses the
(self-hosted) ntfy server. 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 - **FCM is opt-in** (`IRIS_PUSH_BACKEND=fcm`): standard and reliable, but FCM
push metadata (notification title, device token) is routed through push metadata (notification title, device token) is routed through
**Google's servers**. If you want truly private communication, use ntfy **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) ## Privacy note (for the "Data safety" section / FAQ)
Iris talks directly to your own hermes gateway over a private, token-authenticated 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 connection. By default, push notifications use ntfy — out of the box via the
that push metadata never leaves your infrastructure. If you explicitly enable public ntfy.sh service (push metadata such as the topic and notification title
FCM, push metadata (notification title, device token) is sent via Google's FCM passes through ntfy.sh's servers); self-host ntfy (one env var) so that push
servers; chat content itself is not sent to Google — FCM only carries a short metadata never leaves your infrastructure. If you explicitly enable FCM, push
preview, and full content is fetched from your gateway over the authenticated 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 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": { "hello.ack": {
"description": "Pairing succeeded.", "description": "Pairing succeeded.",
"payload": { "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" }, "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)." }, "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)." }, "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": { "definitions": {
"kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] }, "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."} } }, "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."} } } "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, validate_config,
) )
from .tool_frames import ToolProgressHandlers from .tool_frames import ToolProgressHandlers
from .version import plugin_version
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -778,6 +779,9 @@ class IrisAdapter(
self._push.server_url if isinstance(self._push, NtfyBackend) else "" self._push.server_url if isinstance(self._push, NtfyBackend) else ""
), ),
"pickers": True, # picker.choice / picker.select (slash-command menus) "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(),
} }
+21 -3
View File
@@ -199,7 +199,11 @@ class HttpServer:
from gateway.status import acquire_scoped_lock from gateway.status import acquire_scoped_lock
lock_key = f"http:{host}:{port}" 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( logger.warning(
"iris: HTTP port %s:%s in use by another profile; server disabled", "iris: HTTP port %s:%s in use by another profile; server disabled",
host, host,
@@ -490,11 +494,20 @@ class HttpServer:
device_name = (handler.headers.get("X-Iris-Device-Name") or "").strip()[:120] device_name = (handler.headers.get("X-Iris-Device-Name") or "").strip()[:120]
fcm_token = handler.headers.get("X-Iris-Fcm-Token") or None fcm_token = handler.headers.get("X-Iris-Fcm-Token") or None
ntfy_topic = handler.headers.get("X-Iris-Ntfy-Topic") 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: 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( self._devices.upsert(
device_id, device_id,
device_name or device_id, device_name or device_id,
None, existing_caps or None,
fcm_token, fcm_token,
ntfy_topic, ntfy_topic,
) )
@@ -523,7 +536,12 @@ class HttpServer:
# lifecycle is the primary "is the device connected?" signal # lifecycle is the primary "is the device connected?" signal
# for debugging flaky links — a gap here is invisible at the # for debugging flaky links — a gap here is invisible at the
# gateway's default log level. # 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 # 1. Catch-up from the outbox (id = cursor; the envelope also
# carries the cursor for the app's push dedupe). # carries the cursor for the app's push dedupe).
max_cursor = cursor max_cursor = cursor
+5 -1
View File
@@ -1,7 +1,11 @@
name: iris-platform name: iris-platform
label: Iris label: Iris
kind: platform 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: > description: >
Native Android / Desktop client gateway adapter for Hermes Agent. Native Android / Desktop client gateway adapter for Hermes Agent.
Runs an HTTP server (optional TLS) inside the gateway; the app connects 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: if entry.get("parent_chat_id") is not None:
payload["parent_chat_id"] = entry["parent_chat_id"] payload["parent_chat_id"] = entry["parent_chat_id"]
if entry.get("created"):
payload["created"] = entry["created"]
if entry.get("is_default"): if entry.get("is_default"):
payload["is_default"] = True payload["is_default"] = True
if entry.get("archived"): 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 from hermes_cli.config import get_env_value, save_env_value
except Exception: 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 return
print_info("📱 Android / Desktop (Iris x Hermes)") print_info("📱 Android / Desktop (Iris x Hermes)")
token = get_env_value("IRIS_TOKEN") or "" token = get_env_value("IRIS_TOKEN") or ""
if not token: if not token:
generated = generate_token() token = generate_token()
save_env_value("IRIS_TOKEN", generated) save_env_value("IRIS_TOKEN", token)
print_success(f"Generated pairing token: {generated}") print_success(f"Generated pairing token: {token}")
print_warning("Keep this secret -- the app presents it on connect.") print_warning("Keep this secret -- the app presents it on connect.")
else: else:
print_info("Existing IRIS_TOKEN found (not shown).") 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). # call args (it decides how much to show via Settings → Tool detail).
_ensure_verbose_tool_progress() _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") 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):: 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 drives a turn, printing every frame. Run with the hermes venv python
(needs `websockets`); the gateway must already be up:: (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" --token <IRIS_TOKEN> --send "hello"
Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`, 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 / `hermes` CLI for cron) as subprocesses. Prints PASS / PARTIAL / SKIP /
FAIL per scenario plus a summary table; exits 0 if no FAIL, 1 otherwise:: 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 tests/e2e.py
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7 hermes-agent/.venv/bin/python 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 --url http://host:8791
The token is read from `$IRIS_TOKEN`, else `hermes-agent/.env`, else The token is read from `$IRIS_TOKEN`, else `hermes-agent/.env`, else
`~/.hermes/.env`. The gateway must already be running (the driver never `~/.hermes/.env`. The gateway must already be running (the driver never
+110 -52
View File
@@ -7,9 +7,9 @@ summary table. Exit 0 if no FAIL, 1 otherwise.
Usage:: Usage::
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py hermes-agent/.venv/bin/python tests/e2e.py
hermes-agent/.venv/bin/python gateway-plugin/tests/e2e.py --skip 3,5,7 hermes-agent/.venv/bin/python 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 --url http://host:8791
The token is read from $IRIS_TOKEN, else hermes-agent/.env, else The token is read from $IRIS_TOKEN, else hermes-agent/.env, else
~/.hermes/.env. The gateway must already be running (this driver never ~/.hermes/.env. The gateway must already be running (this driver never
@@ -36,7 +36,7 @@ from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
HERE = Path(__file__).resolve().parent HERE = Path(__file__).resolve().parent
REPO = HERE.parent.parent REPO = HERE.parent
PY = REPO / "hermes-agent" / ".venv" / "bin" / "python" PY = REPO / "hermes-agent" / ".venv" / "bin" / "python"
PROBE = HERE / "ws_probe.py" PROBE = HERE / "ws_probe.py"
HERMES = REPO / "hermes-agent" / ".venv" / "bin" / "hermes" HERMES = REPO / "hermes-agent" / ".venv" / "bin" / "hermes"
@@ -49,6 +49,7 @@ PASS, PARTIAL, SKIP, FAIL = "PASS", "PARTIAL", "SKIP", "FAIL"
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def find_token(cli_token: str) -> str: def find_token(cli_token: str) -> str:
if cli_token: if cli_token:
return 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)) raw = b"".join(b"\x00" + bytes(color) * size for _ in range(size))
def chunk(tag: bytes, data: bytes) -> bytes: def chunk(tag: bytes, data: bytes) -> bytes:
return (struct.pack(">I", len(data)) + tag + data return (
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)) 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) ihdr = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)
path.write_bytes( path.write_bytes(
@@ -122,6 +127,7 @@ def sweep_leftovers(env, url, token) -> None:
# Scenarios (docs/13-testing.md §13.4) # Scenarios (docs/13-testing.md §13.4)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def s1_pair(env, url, token): def s1_pair(env, url, token):
rc, _, _ = run_probe(env, url, "definitely-wrong-token", "--authfail", "--send", "") rc, _, _ = run_probe(env, url, "definitely-wrong-token", "--authfail", "--send", "")
if rc != 0: if rc != 0:
@@ -134,8 +140,9 @@ def s1_pair(env, url, token):
def s2_text(env, url, token): def s2_text(env, url, token):
prompt = "Write a short poem about the ocean, at least 8 lines" prompt = "Write a short poem about the ocean, at least 8 lines"
rc, _, _ = run_probe(env, url, token, "--send", prompt, rc, _, _ = run_probe(
"--assert-turn", "--timeout", "120") env, url, token, "--send", prompt, "--assert-turn", "--timeout", "120"
)
if rc == 0: if rc == 0:
return PASS, "message.start -> >=1 message.update -> message.stop" return PASS, "message.start -> >=1 message.update -> message.stop"
if rc == 10: if rc == 10:
@@ -145,8 +152,9 @@ def s2_text(env, url, token):
def s3_reasoning(env, url, token): def s3_reasoning(env, url, token):
prompt = "Work out step by step: what is 17 * 23? Show your reasoning." prompt = "Work out step by step: what is 17 * 23? Show your reasoning."
rc, _, _ = run_probe(env, url, token, "--send", prompt, rc, _, _ = run_probe(
"--assert-reasoning", "--timeout", "120") env, url, token, "--send", prompt, "--assert-reasoning", "--timeout", "120"
)
if rc == 0: if rc == 0:
return PASS, "final message.stop carries non-empty reasoning" return PASS, "final message.stop carries non-empty reasoning"
if rc == 11: if rc == 11:
@@ -155,10 +163,13 @@ def s3_reasoning(env, url, token):
def s4_tools(env, url, token): def s4_tools(env, url, token):
prompt = ("List the files in your current working directory using your " prompt = (
"shell tool, then tell me how many there are") "List the files in your current working directory using your "
rc, _, _ = run_probe(env, url, token, "--send", prompt, "shell tool, then tell me how many there are"
"--assert-tools", "--timeout", "150") )
rc, _, _ = run_probe(
env, url, token, "--send", prompt, "--assert-tools", "--timeout", "150"
)
if rc == 0: if rc == 0:
return PASS, "tool.start with a matching tool.end" return PASS, "tool.start with a matching tool.end"
if rc == 12: if rc == 12:
@@ -167,12 +178,15 @@ def s4_tools(env, url, token):
def s5_commentary(env, url, token): def s5_commentary(env, url, token):
prompt = ("Research task: (1) use your shell tool to list the top-level " prompt = (
"Research task: (1) use your shell tool to list the top-level "
"directories in /tmp, (2) report your findings so far, " "directories in /tmp, (2) report your findings so far, "
"(3) use your shell tool to count files in /tmp, " "(3) use your shell tool to count files in /tmp, "
"(4) report those findings too, (5) give a final summary of both") "(4) report those findings too, (5) give a final summary of both"
rc, _, _ = run_probe(env, url, token, "--send", prompt, )
"--assert-commentary", "--timeout", "150") rc, _, _ = run_probe(
env, url, token, "--send", prompt, "--assert-commentary", "--timeout", "150"
)
if rc == 0: if rc == 0:
return PASS, "commentary frame observed" return PASS, "commentary frame observed"
if rc == 13: if rc == 13:
@@ -206,9 +220,15 @@ def s7_cron(env, url, token):
job_name = f"e2e-cron-{uuid.uuid4().hex[:6]}" job_name = f"e2e-cron-{uuid.uuid4().hex[:6]}"
deliver = f"iris:{chat_id}" deliver = f"iris:{chat_id}"
rc, out, err = run_hermes( rc, out, err = run_hermes(
env, "cron", "create", "1m", env,
"cron",
"create",
"1m",
"Reply with exactly: e2e cron delivery OK", "Reply with exactly: e2e cron delivery OK",
"--deliver", deliver, "--name", job_name, "--deliver",
deliver,
"--name",
job_name,
) )
job_id = None job_id = None
if rc == 0: if rc == 0:
@@ -217,8 +237,9 @@ def s7_cron(env, url, token):
try: try:
if rc != 0: if rc != 0:
return SKIP, f"hermes cron create failed: {(err or out).strip()[:120]}" return SKIP, f"hermes cron create failed: {(err or out).strip()[:120]}"
rc, out, _ = run_probe(env, url, token, "--watch", chat_id, rc, out, _ = run_probe(
"--timeout", "330", timeout=400) env, url, token, "--watch", chat_id, "--timeout", "330", timeout=400
)
if rc == 0: if rc == 0:
return PASS, f"one-shot cron job fired; message landed in {chat_id}" 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})" return FAIL, f"no message in {chat_id} within 330s (probe rc={rc})"
@@ -228,8 +249,9 @@ def s7_cron(env, url, token):
else: else:
# create succeeded but the id was not parseable: find by name. # create succeeded but the id was not parseable: find by name.
_, list_out, _ = run_hermes(env, "cron", "list") _, list_out, _ = run_hermes(env, "cron", "list")
m = re.search(r"(\S+) \[active\]\s*\n\s*Name:\s+" + re.escape(job_name), m = re.search(
list_out) r"(\S+) \[active\]\s*\n\s*Name:\s+" + re.escape(job_name), list_out
)
if m: if m:
run_hermes(env, "cron", "remove", m.group(1)) run_hermes(env, "cron", "remove", m.group(1))
run_probe(env, url, token, "--channel-delete", chat_id) 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): def s8_search(env, url, token):
marker = f"e2emarker{uuid.uuid4().hex[:8]}" marker = f"e2emarker{uuid.uuid4().hex[:8]}"
rc, _, _ = run_probe(env, url, token, "--send", rc, _, _ = run_probe(
f"Remember this marker phrase: {marker}. " env,
"Just acknowledge it briefly.", url,
"--timeout", "120") token,
"--send",
f"Remember this marker phrase: {marker}. Just acknowledge it briefly.",
"--timeout",
"120",
)
if rc != 0: if rc != 0:
return FAIL, f"setup message failed (rc={rc})" return FAIL, f"setup message failed (rc={rc})"
rc, _, _ = run_probe(env, url, token, "--send", "", "--search", marker) 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") png = Path(f"/tmp/e2e_in_{uuid.uuid4().hex[:6]}.png")
write_png(png, (30, 120, 220)) write_png(png, (30, 120, 220))
try: try:
rc, _, _ = run_probe(env, url, token, "--upload", str(png), rc, _, _ = run_probe(
"--send", "describe this image briefly", env,
"--timeout", "120") url,
token,
"--upload",
str(png),
"--send",
"describe this image briefly",
"--timeout",
"120",
)
if rc == 0: if rc == 0:
return PASS, "upload + vision reply (final message)" return PASS, "upload + vision reply (final message)"
if rc == 8: if rc == 8:
@@ -270,11 +305,14 @@ def s9_media_in(env, url, token):
def s10_media_out(env, url, token): def s10_media_out(env, url, token):
prompt = ("Create a 100x100 orange square PNG in /tmp with your tools. " prompt = (
"Create a 100x100 orange square PNG in /tmp with your tools. "
"In your final reply, include the MEDIA:/absolute/path tag for " "In your final reply, include the MEDIA:/absolute/path tag for "
"that file so it is delivered to me.") "that file so it is delivered to me."
rc, out, _ = run_probe(env, url, token, "--send", prompt, )
"--pull-offer", "--timeout", "150") rc, out, _ = run_probe(
env, url, token, "--send", prompt, "--pull-offer", "--timeout", "150"
)
m = re.search(r"== pulled (\d+) bytes", out) m = re.search(r"== pulled (\d+) bytes", out)
if rc == 0 and m and int(m.group(1)) > 0: if rc == 0 and m and int(m.group(1)) > 0:
return PASS, f"media.offer pulled ({m.group(1)} bytes)" 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): def s11_push(env, url, token):
rc, out, _ = run_probe(env, url, token, "--fcm-token", "test-token-123", rc, out, _ = run_probe(
"--fcm-reg", "--send", "") env, url, token, "--fcm-token", "test-token-123", "--fcm-reg", "--send", ""
)
if rc != 0: if rc != 0:
return FAIL, f"probe rc={rc}" return FAIL, f"probe rc={rc}"
if "<- error" in out: if "<- error" in out:
return FAIL, "error frame after fcm.register" return FAIL, "error frame after fcm.register"
return PARTIAL, ("fcm.register accepted (no error frame); " return PARTIAL, (
"device-notification leg is manual") "fcm.register accepted (no error frame); device-notification leg is manual"
)
def s12_sync(env, url, token): def s12_sync(env, url, token):
rc, out, _ = run_probe(env, url, token, "--sync", "0") rc, out, _ = run_probe(env, url, token, "--sync", "0")
if rc == 0 and "sync done" in out: if rc == 0 and "sync done" in out:
return PARTIAL, ("sync replay + sync.done verified; " return PARTIAL, (
"gateway-kill/restart leg is manual") "sync replay + sync.done verified; gateway-kill/restart leg is manual"
)
if rc == 8: if rc == 8:
return FAIL, "sync failed" return FAIL, "sync failed"
return FAIL, f"probe rc={rc}" 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" scheme = "https" if u.scheme in ("wss", "https") else "http"
http_port = u.port or int(os.getenv("IRIS_HTTP_PORT", "8791")) 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}" 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, rc, out, _ = run_probe(
"--send", "Reply with exactly: e2e http fallback OK", env,
"--timeout", "120") url,
token,
"--http",
"--http-url",
http_url,
"--send",
"Reply with exactly: e2e http fallback OK",
"--timeout",
"120",
)
if rc == 0: if rc == 0:
m = re.search(r"== user echo in ([\d.]+)s", out) m = re.search(r"== user echo in ([\d.]+)s", out)
echo = float(m.group(1)) if m else None echo = float(m.group(1)) if m else None
if echo is not None and echo > 1.5: if echo is not None and echo > 1.5:
return FAIL, f"user echo took {echo:.2f}s (> 1.5 s)" return FAIL, f"user echo took {echo:.2f}s (> 1.5 s)"
return PASS, ("health + POST /v1/frame + SSE turn complete" return PASS, (
+ (f"; user echo in {echo:.2f}s" if echo is not None else "")) "health + POST /v1/frame + SSE turn complete"
+ (f"; user echo in {echo:.2f}s" if echo is not None else "")
)
if rc == 20: if rc == 20:
return FAIL, "health check failed (HTTP leg not running?)" return FAIL, "health check failed (HTTP leg not running?)"
if rc == 21: 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("--url", default=os.getenv("IRIS_HTTP_URL", DEFAULT_URL))
p.add_argument("--token", default="") p.add_argument("--token", default="")
p.add_argument("--skip", default="", p.add_argument(
help="comma-separated scenario numbers to skip (e.g. 3,5,7)") "--skip",
default="",
help="comma-separated scenario numbers to skip (e.g. 3,5,7)",
)
args = p.parse_args() args = p.parse_args()
token = find_token(args.token) token = find_token(args.token)
@@ -391,10 +446,13 @@ def main() -> int:
for num, name, status, reason in results: for num, name, status, reason in results:
print(f"{num:<3} {name:<18} {status:<8} {reason}") print(f"{num:<3} {name:<18} {status:<8} {reason}")
print("-" * 78) print("-" * 78)
counts = {s: sum(1 for r in results if r[2] == s) counts = {
for s in (PASS, PARTIAL, SKIP, FAIL)} 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]}") 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 return 1 if counts[FAIL] else 0
File diff suppressed because it is too large. Load diff
@@ -59,14 +59,17 @@ def _plugin_dir() -> Path:
env = os.environ.get("IRIS_PLUGIN_DIR") env = os.environ.get("IRIS_PLUGIN_DIR")
if env: if env:
return Path(env) return Path(env)
# Works from either copy of this file: gateway-plugin/tests/ (canonical, # Works from either copy of this file: tests/ (canonical, repo root is
# plugin dir is parents[1]) or the hermes-agent/tests/gateway/ mirror # parents[1]) or the hermes-agent/tests/gateway/ mirror (repo root is
# (repo root is parents[3]). # parents[3]). The plugin always lives in <repo>/gateway-plugin.
here = Path(__file__).resolve() 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(): if (candidate / "protocol.py").is_file():
return candidate return candidate
return here.parents[1] return here.parents[1] / "gateway-plugin"
def _load_plugin(): def _load_plugin():
@@ -192,7 +195,9 @@ def _frame_json(frame: dict) -> dict:
return {"v": 1, **frame} 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.""" """Parse raw SSE lines into ``[(event, id, data), ...]`` + comment count."""
events: list[tuple[str | None, str | None, str]] = [] events: list[tuple[str | None, str | None, str]] = []
comments = 0 comments = 0
@@ -220,7 +225,9 @@ def _parse_sse(lines: list[str]) -> tuple[list[tuple[str | None, str | None, str
return events, comments 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 """Open an SSE connection (blocking); returns the HTTPResponse (read
lines via ``_sse_read_lines``; close with ``resp.close()``).""" lines via ``_sse_read_lines``; close with ``resp.close()``)."""
conn = HTTPConnection("127.0.0.1", port, timeout=30) conn = HTTPConnection("127.0.0.1", port, timeout=30)
@@ -355,8 +362,12 @@ async def test_post_wrong_content_type_400(gw):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_post_oversize_body_413(gw): async def test_post_oversize_body_413(gw):
big = json.dumps(_frame_json({"type": "ping", "payload": {"pad": "x" * (1024 * 1024 + 1)}})) big = json.dumps(
status, _ = await asyncio.to_thread(_request, http_port(gw), "POST", "/v1/frame", body=big) _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 assert status == 413
@@ -367,7 +378,12 @@ async def test_post_empty_message_400(gw):
_post_frame, _post_frame,
http_port(gw), http_port(gw),
_frame_json( _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 assert status == 400
@@ -666,7 +682,9 @@ def _upload(
"X-Iris-Media-Ref": media_ref, "X-Iris-Media-Ref": media_ref,
"X-Iris-Media-Kind": kind, "X-Iris-Media-Kind": kind,
"X-Iris-Media-Filename": filename, "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( status, payload = _request(
port, port,
@@ -772,7 +790,9 @@ async def test_media_pull_ok(gw):
str(img), "image", "image/png", "http_pull_test.png", len(PNG_1X1) str(img), "image", "image/png", "http_pull_test.png", len(PNG_1X1)
) )
port = http_port(gw) 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 status == 200
assert payload == PNG_1X1 assert payload == PNG_1X1
conn = HTTPConnection("127.0.0.1", port, timeout=10) conn = HTTPConnection("127.0.0.1", port, timeout=10)
@@ -791,7 +811,9 @@ async def test_media_pull_ok(gw):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_media_pull_unknown_404(gw): async def test_media_pull_unknown_404(gw):
port = http_port(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) body = json.loads(payload)
assert status == 404 assert status == 404
assert body["payload"]["code"] == "not_found" assert body["payload"]["code"] == "not_found"
@@ -801,9 +823,13 @@ async def test_media_pull_unknown_404(gw):
async def test_media_pull_denied_path_404(gw): async def test_media_pull_denied_path_404(gw):
"""Known id, but the path fails delivery validation (denylist) — same """Known id, but the path fails delivery validation (denylist) — same
re-check at pull time as the WS path.""" 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) 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) body = json.loads(payload)
assert status == 404 assert status == 404
assert body["payload"]["code"] == "not_found" assert body["payload"]["code"] == "not_found"
@@ -8,7 +8,7 @@ while building the Kotlin client.
Usage:: Usage::
hermes gateway & # with the iris plugin 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" --send "hello"
Options: Options:
@@ -136,9 +136,7 @@ def _print_frame(raw):
f"preview={str(payload.get('preview'))[:80]!r}" f"preview={str(payload.get('preview'))[:80]!r}"
) )
elif ftype == "tool.progress": elif ftype == "tool.progress":
extra = ( extra = f" idx={payload.get('index')} name={payload.get('name')!r} note={payload.get('note')!r}"
f" idx={payload.get('index')} name={payload.get('name')!r} note={payload.get('note')!r}"
)
elif ftype == "tool.end": elif ftype == "tool.end":
extra = ( extra = (
f" idx={payload.get('index')} name={payload.get('name')!r} " f" idx={payload.get('index')} name={payload.get('name')!r} "
@@ -147,7 +145,9 @@ def _print_frame(raw):
elif ftype == "commentary": elif ftype == "commentary":
extra = f" id={payload.get('message_id')} text={(payload.get('text') or '')[:120]!r}" extra = f" id={payload.get('message_id')} text={(payload.get('text') or '')[:120]!r}"
elif ftype == "hello.ack": 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": elif ftype == "error":
extra = f" code={payload.get('code')} msg={payload.get('message')!r}" extra = f" code={payload.get('code')} msg={payload.get('message')!r}"
elif ftype == "typing": 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: if "message.start" in events and "message.stop" in events:
i_start = events.index("message.start") i_start = events.index("message.start")
i_stop = events.index("message.stop") 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 ok = True
break break
results.append( 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: if args.assert_reasoning:
reasoning = st.final_stop_reasoning or st.final_message_reasoning reasoning = st.final_stop_reasoning or st.final_message_reasoning
results.append( 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: if args.assert_tools:
ok = bool(st.tool_starts) and bool(st.tool_starts & st.tool_ends) 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")) results.append((12, ok, "assert-tools: no tool.start with a matching tool.end"))
if args.assert_commentary: 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 args.assert_read_receipt:
if st.read_receipt is None: if st.read_receipt is None:
print("== SKIP: no read.receipt frame (M7 frame not live on this gateway)") print("== SKIP: no read.receipt frame (M7 frame not live on this gateway)")
elif not st.read_receipt: elif not st.read_receipt:
results.append( 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 args.assert_status:
if not st.status_seen: if not st.status_seen:
print("== SKIP: no status frame (M7 frame not live on this gateway)") print("== SKIP: no status frame (M7 frame not live on this gateway)")
elif st.status_empty: 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 return results
@@ -391,7 +411,12 @@ def run_http(args, base: str) -> int:
host, host,
port, port,
headers, 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", "channel.created",
30, 30,
) )
@@ -477,7 +502,12 @@ def run_http(args, base: str) -> int:
host, host,
port, port,
headers, 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", "fcm.registered",
30, 30,
) )
@@ -513,7 +543,10 @@ def run_http(args, base: str) -> int:
if data is not None and data.get("chat_id") == args.watch: if data is not None and data.get("chat_id") == args.watch:
ftype = data.get("type") ftype = data.get("type")
payload = data.get("payload") or {} 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( print(
f"== message landed in {args.watch}: {str(payload.get('text'))[:120]!r}" 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 got_final = True
if ftype == "message.stop": if ftype == "message.stop":
seen_final_frame = True 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 got_final = True
cur_data = [] cur_data = []
return got_final return got_final
@@ -673,7 +710,9 @@ def main() -> int:
p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}") p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}")
p.add_argument("--send", default="hello") p.add_argument("--send", default="hello")
p.add_argument( 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( p.add_argument(
"--pull-offer", "--pull-offer",
@@ -686,14 +725,18 @@ def main() -> int:
default=None, default=None,
help="M5: send sync {cursor} after pairing, print replay, exit", 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( p.add_argument(
"--fcm-reg", "--fcm-reg",
action="store_true", action="store_true",
help="M5: send fcm.register after pairing (uses --fcm-token)", help="M5: send fcm.register after pairing (uses --fcm-token)",
) )
p.add_argument("--timeout", type=float, default=120.0) 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( p.add_argument(
"--assert-turn", "--assert-turn",
action="store_true", action="store_true",
@@ -705,9 +748,13 @@ def main() -> int:
help="assert the final message.stop carries non-empty reasoning", help="assert the final message.stop carries non-empty reasoning",
) )
p.add_argument( 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( p.add_argument(
"--assert-read-receipt", "--assert-read-receipt",
action="store_true", action="store_true",
@@ -719,10 +766,15 @@ def main() -> int:
help="assert a status frame is received (SKIP if absent; M7)", help="assert a status frame is received (SKIP if absent; M7)",
) )
p.add_argument( 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( 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( p.add_argument(
"--chat-id", "--chat-id",
@@ -730,12 +782,20 @@ def main() -> int:
help="chat_id for --scope chat (default default)", help="chat_id for --scope chat (default default)",
) )
p.add_argument( 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( 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( p.add_argument(
"--offer-grace", "--offer-grace",
@@ -766,7 +826,9 @@ def main() -> int:
if not args.token and not args.authfail: if not args.token and not args.authfail:
p.error("--token (or $IRIS_TOKEN) is required") p.error("--token (or $IRIS_TOKEN) is required")
if args.assert_read_receipt and not args.send: 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 # 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 # --url (legacy ws(s)://host:8790/ws -> http(s)://host:8791) unless
# --http-url is given. # --http-url is given.