Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8657e6afc6 | ||
|
|
5b78e1566f | ||
|
|
0f5b5a16ab | ||
|
|
ea375fd88c | ||
|
|
b065d1783b | ||
|
|
fb980d12b4 | ||
|
|
f3f1b37221 | ||
|
|
6f339330c5 | ||
|
|
573291fc1e | ||
|
|
83a67f6fb1 |
No files matched your search
+65
-65
@@ -1,79 +1,79 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
gateway:
|
gateway:
|
||||||
name: Gateway plugin tests
|
name: Gateway plugin tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
run: curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|
||||||
# The gateway tests run inside the hermes-agent test harness, which is
|
# The gateway tests run inside the hermes-agent test harness, which is
|
||||||
# git-ignored in this repo (read-only research reference). CI clones the
|
# git-ignored in this repo (read-only research reference). CI clones the
|
||||||
# upstream repo at a pinned commit and drops in the vendored test copy.
|
# upstream repo at a pinned commit and drops in the vendored test copy.
|
||||||
# Bump the pinned SHA when you update the local hermes-agent checkout.
|
# Bump the pinned SHA when you update the local hermes-agent checkout.
|
||||||
- name: Clone hermes-agent (pinned)
|
- name: Clone hermes-agent (pinned)
|
||||||
run: |
|
run: |
|
||||||
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
|
git clone https://github.com/NousResearch/hermes-agent.git hermes-agent
|
||||||
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
|
git -C hermes-agent fetch --depth 1 origin 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||||
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
|
git -C hermes-agent checkout 31f62d76af068abde3c699f91190e8ded07fd05b
|
||||||
|
|
||||||
- name: Sync venv
|
- name: Sync venv
|
||||||
run: |
|
run: |
|
||||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||||
cd hermes-agent
|
cd hermes-agent
|
||||||
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
|
# pytest lives in the `dev` extra — a plain `uv sync` leaves the
|
||||||
# venv without it and run_tests.sh refuses to run.
|
# venv without it and run_tests.sh refuses to run.
|
||||||
uv sync --extra dev
|
uv sync --extra dev
|
||||||
|
|
||||||
- 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
|
||||||
|
|
||||||
kotlin:
|
kotlin:
|
||||||
name: Kotlin tests (android host + desktop)
|
name: Kotlin tests (android host + desktop)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
- uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: "21"
|
java-version: "21"
|
||||||
|
|
||||||
# gradle.properties pins org.gradle.java.home to a local JDK path;
|
# gradle.properties pins org.gradle.java.home to a local JDK path;
|
||||||
# strip it so CI uses the JDK installed by setup-java.
|
# strip it so CI uses the JDK installed by setup-java.
|
||||||
- name: Strip local JDK pin
|
- name: Strip local JDK pin
|
||||||
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
run: sed -i '/^org\.gradle\.java\.home/d' app/gradle.properties
|
||||||
|
|
||||||
- name: Install Android SDK
|
- name: Install Android SDK
|
||||||
run: |
|
run: |
|
||||||
export ANDROID_HOME="$HOME/android-sdk"
|
export ANDROID_HOME="$HOME/android-sdk"
|
||||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||||
curl -fsSL -o /tmp/ct.zip \
|
curl -fsSL -o /tmp/ct.zip \
|
||||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||||
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
unzip -q /tmp/ct.zip -d "$ANDROID_HOME/cmdline-tools"
|
||||||
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest"
|
||||||
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
# Finite input from a file: `yes | sdkmanager` dies with SIGPIPE
|
||||||
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
# (exit 141) under Gitea's `bash -e -o pipefail` once sdkmanager
|
||||||
# exits before `yes` is done writing.
|
# exits before `yes` is done writing.
|
||||||
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
for i in $(seq 100); do echo y; done > /tmp/sdk_licenses_yes.txt
|
||||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses < /tmp/sdk_licenses_yes.txt > /dev/null
|
||||||
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
echo "ANDROID_HOME=$ANDROID_HOME" >> "$GITHUB_ENV"
|
||||||
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
echo "sdk.dir=$ANDROID_HOME" > app/local.properties
|
||||||
|
|
||||||
# Host-side tests only (no device needed). AGP auto-downloads the
|
# Host-side tests only (no device needed). AGP auto-downloads the
|
||||||
# missing SDK platforms (licenses accepted above).
|
# missing SDK platforms (licenses accepted above).
|
||||||
- name: Run host tests
|
- name: Run host tests
|
||||||
working-directory: app
|
working-directory: app
|
||||||
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
run: ./gradlew :shared:testAndroidHostTest :shared:desktopTest
|
||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
+52
-24
@@ -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).
|
||||||
|
api() {
|
||||||
|
local code body
|
||||||
|
body=$(mktemp)
|
||||||
|
code=$(curl -s -o "$body" -w '%{http_code}' "$@") || { cat "$body"; rm -f "$body"; return 1; }
|
||||||
|
if [ "${code:0:1}" != "2" ]; then
|
||||||
|
echo "API error $code: $(cat "$body")" >&2
|
||||||
|
rm -f "$body"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
cat "$body"
|
||||||
|
rm -f "$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Re-run safety: drop a previous release AND its tag for this version.
|
||||||
|
# (Gitea's DELETE /releases/:id does NOT remove the tag; a leftover tag
|
||||||
|
# makes the POST below fail with 409.)
|
||||||
|
OLD_ID=$(api -H "$AUTH" "$API/releases/tags/$TAG" | jq -r '.id // empty') || true
|
||||||
if [ -n "$OLD_ID" ]; then
|
if [ -n "$OLD_ID" ]; then
|
||||||
curl -sf -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
|
api -X DELETE -H "$AUTH" "$API/releases/$OLD_ID" > /dev/null
|
||||||
fi
|
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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,13 +87,32 @@ 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 =
|
||||||
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
|
remember {
|
||||||
}
|
Highlights.Builder().theme(SyntaxThemes.default(darkMode = true))
|
||||||
|
}
|
||||||
val base = TextStyle(color = color, fontSize = fontSize)
|
val base = TextStyle(color = color, fontSize = fontSize)
|
||||||
val inlineCodeBackground = Color.White.copy(alpha = 0.08f)
|
val inlineCodeBackground = Color.White.copy(alpha = 0.08f)
|
||||||
// Inline-code span style (mirrors the library's codeSpanStyle): the
|
// Inline-code span style (mirrors the library's codeSpanStyle): the
|
||||||
@@ -80,7 +122,8 @@ fun MarkdownText(
|
|||||||
// Brief green flash shown on the chip right after a copy, so the tap is
|
// 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,57 +135,74 @@ 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 =
|
||||||
annotate = { content, child ->
|
markdownAnnotator(
|
||||||
if (child.type == MarkdownElementTypes.CODE_SPAN) {
|
annotate = { content, child ->
|
||||||
val children = child.children
|
when {
|
||||||
// Drop the surrounding backtick tokens (present as first/last child).
|
child.type == MarkdownElementTypes.CODE_SPAN -> {
|
||||||
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
|
val children = child.children
|
||||||
val code = inner.joinToString("") { it.getUnescapedTextInNode(content) }
|
// Drop the surrounding backtick tokens (present as first/last child).
|
||||||
val spanStyle = if (code == copiedCode.value) copiedCodeSpanStyle else codeSpanStyle
|
val inner = if (children.size >= 3) children.subList(1, children.size - 1) else children
|
||||||
pushStyle(spanStyle)
|
val code = inner.joinToString("") { it.getUnescapedTextInNode(content) }
|
||||||
withLink(
|
val spanStyle = if (code == copiedCode.value) copiedCodeSpanStyle else codeSpanStyle
|
||||||
LinkAnnotation.Url(
|
pushStyle(spanStyle)
|
||||||
url = "iris:copy-code",
|
withLink(
|
||||||
// Keep every interaction state identical to the chip so
|
LinkAnnotation.Url(
|
||||||
// hover/press never restyles the inline code.
|
url = "iris:copy-code",
|
||||||
styles = TextLinkStyles(
|
// Keep every interaction state identical to the chip so
|
||||||
style = spanStyle,
|
// hover/press never restyles the inline code.
|
||||||
focusedStyle = spanStyle,
|
styles =
|
||||||
hoveredStyle = spanStyle,
|
TextLinkStyles(
|
||||||
pressedStyle = spanStyle,
|
style = spanStyle,
|
||||||
),
|
focusedStyle = spanStyle,
|
||||||
linkInteractionListener = LinkInteractionListener {
|
hoveredStyle = spanStyle,
|
||||||
clipboard.setText(AnnotatedString(code))
|
pressedStyle = spanStyle,
|
||||||
copiedCode.value = code
|
),
|
||||||
scope.launch {
|
linkInteractionListener =
|
||||||
delay(600)
|
LinkInteractionListener {
|
||||||
copiedCode.value = null
|
clipboard.setText(AnnotatedString(code))
|
||||||
}
|
copiedCode.value = code
|
||||||
},
|
scope.launch {
|
||||||
),
|
delay(600)
|
||||||
) {
|
copiedCode.value = null
|
||||||
append(code)
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
append(code)
|
||||||
|
}
|
||||||
|
pop()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Streaming cursor: append ▉ to the last text leaf of the
|
||||||
|
// document so the cursor sits at the end of the visible text.
|
||||||
|
// The tail is re-annotated on every reveal tick, so the cursor
|
||||||
|
// follows the text (and vanishes once the message is final).
|
||||||
|
useStreaming &&
|
||||||
|
child.type == MarkdownTokenTypes.TEXT &&
|
||||||
|
content.length - child.endOffset <= 1 -> {
|
||||||
|
append(child.getUnescapedTextInNode(content))
|
||||||
|
append(" ▉")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pop()
|
},
|
||||||
true
|
)
|
||||||
} else {
|
val colors =
|
||||||
false
|
markdownColor(
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Markdown(
|
|
||||||
markdownState = state,
|
|
||||||
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 =
|
||||||
style = base.copy(textDecoration = TextDecoration.Underline).toSpanStyle(),
|
TextLinkStyles(
|
||||||
),
|
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,17 +246,129 @@ 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)
|
||||||
},
|
},
|
||||||
),
|
)
|
||||||
loading = { m ->
|
if (useStreaming) {
|
||||||
// While (re)parsing — e.g. on each streaming update — show the raw
|
if (pipeline.displayed.isEmpty()) {
|
||||||
// text so the bubble never goes blank between updates.
|
// No text revealed yet (message.start, first tick pending): show
|
||||||
Text(text, modifier = m, color = color, fontSize = fontSize)
|
// just the cursor so the bubble is visible immediately.
|
||||||
},
|
Text("▉", modifier = modifier, color = color, fontSize = fontSize)
|
||||||
)
|
} else {
|
||||||
|
Markdown(
|
||||||
|
streamingMarkdownState = pipeline.state,
|
||||||
|
modifier = modifier,
|
||||||
|
annotator = annotator,
|
||||||
|
colors = colors,
|
||||||
|
typography = typography,
|
||||||
|
components = components,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// retainState: keep the last formatted output visible while the new
|
||||||
|
// text re-parses (async) — no raw-markdown flash between updates.
|
||||||
|
val state = rememberMarkdownState(displayText, retainState = true)
|
||||||
|
Markdown(
|
||||||
|
markdownState = state,
|
||||||
|
modifier = modifier,
|
||||||
|
annotator = annotator,
|
||||||
|
colors = colors,
|
||||||
|
typography = typography,
|
||||||
|
components = components,
|
||||||
|
loading = { m ->
|
||||||
|
// First parse of a (long) message: show the text so the bubble
|
||||||
|
// never goes blank.
|
||||||
|
Text(displayText, modifier = m, color = color, fontSize = fontSize)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incremental streaming pipeline (docs/05 §5.1). [text] is the latest FULL
|
||||||
|
* snapshot from the gateway; it is revealed at a steady rate controlled by
|
||||||
|
* [smoothness] (typewriter effect), and the revealed prefix is fed into an
|
||||||
|
* append-only [StreamingMarkdownState]. Settled blocks are parsed once and
|
||||||
|
* keep their AST identity, so Compose never re-lays them out — only the
|
||||||
|
* unstable tail re-renders per tick.
|
||||||
|
*
|
||||||
|
* [active] is false for history messages (never streamed): the reveal is
|
||||||
|
* skipped and [StreamingPipeline.done] is true immediately. [streaming]
|
||||||
|
* indicates the message is still receiving updates; once the reveal catches
|
||||||
|
* up and the message is final, the reveal loop stops.
|
||||||
|
*
|
||||||
|
* Returns the parser state, the currently revealed text (read inside the
|
||||||
|
* composition so reveal ticks recompose the caller), and whether the reveal
|
||||||
|
* has caught up with the target.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberStreamingMarkdownPipeline(
|
||||||
|
text: String,
|
||||||
|
smoothness: Float,
|
||||||
|
active: Boolean,
|
||||||
|
streaming: Boolean,
|
||||||
|
): StreamingPipeline {
|
||||||
|
// Prefix-preserving transform (see preserveNewlinesAsHardBreaksStreaming):
|
||||||
|
// a prefix of the revealed text always maps to a prefix of the target, so
|
||||||
|
// the diff between consecutive reveals is a pure append.
|
||||||
|
val target = remember(text) { text.prepareForMarkdown().preserveNewlinesAsHardBreaksStreaming() }
|
||||||
|
val displayed = remember { mutableStateOf("") }
|
||||||
|
val latestTarget = rememberUpdatedState(target)
|
||||||
|
val latestSmoothness = rememberUpdatedState(smoothness)
|
||||||
|
val latestStreaming = rememberUpdatedState(streaming)
|
||||||
|
// Bumped when the target is rewritten (not extended): the parser state is
|
||||||
|
// recreated via key() below and re-seeded with the full text.
|
||||||
|
val generation = remember { mutableIntStateOf(0) }
|
||||||
|
val state = key(generation.value) { rememberStreamingMarkdownState() }
|
||||||
|
LaunchedEffect(state, active) {
|
||||||
|
if (!active) {
|
||||||
|
// Never streamed (history message): reveal everything at once so
|
||||||
|
// the caller falls back to the static renderer immediately.
|
||||||
|
displayed.value = latestTarget.value
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
var last = ""
|
||||||
|
while (true) {
|
||||||
|
delay(STREAM_REVEAL_TICK_MS)
|
||||||
|
val t = latestTarget.value
|
||||||
|
val cur = displayed.value
|
||||||
|
if (cur == t) {
|
||||||
|
// Reveal complete: keep ticking only while the message is
|
||||||
|
// still streaming (more text may arrive); otherwise stop so
|
||||||
|
// finished bubbles don't burn battery.
|
||||||
|
if (!latestStreaming.value) return@LaunchedEffect
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val step =
|
||||||
|
revealStep(
|
||||||
|
remaining = t.length - cur.length,
|
||||||
|
charsPerSecond = streamCharsPerSecond(latestSmoothness.value),
|
||||||
|
tickSeconds = STREAM_REVEAL_TICK_MS / 1000.0,
|
||||||
|
)
|
||||||
|
val next = t.take(cur.length + step)
|
||||||
|
displayed.value = next
|
||||||
|
val chunk = appendChunk(last, next)
|
||||||
|
if (chunk == null) {
|
||||||
|
// Rewritten, not extended: recreate the parser state. The
|
||||||
|
// effect restarts (keyed on [state]) and re-seeds it.
|
||||||
|
generation.value++
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
if (chunk.isNotEmpty()) state.append(chunk)
|
||||||
|
last = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return StreamingPipeline(state, displayed.value, displayed.value == target)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of the streaming reveal pipeline. */
|
||||||
|
private data class StreamingPipeline(
|
||||||
|
val state: StreamingMarkdownState,
|
||||||
|
val displayed: String,
|
||||||
|
val done: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a highlighted code block with a copy button in the top-right corner
|
* Renders a highlighted code block with a copy button in the top-right corner
|
||||||
* (Telegram-style). Tapping the button copies the whole block to the clipboard.
|
* (Telegram-style). Tapping the button copies the whole block to the clipboard.
|
||||||
@@ -213,12 +387,13 @@ internal fun CodeBlockWithCopy(
|
|||||||
if (code.isNotBlank()) {
|
if (code.isNotBlank()) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { clipboard.setText(AnnotatedString(code)) },
|
onClick = { clipboard.setText(AnnotatedString(code)) },
|
||||||
modifier = Modifier
|
modifier =
|
||||||
.align(Alignment.TopEnd)
|
Modifier
|
||||||
// MarkdownHighlightedCode insets its background by 8dp top;
|
.align(Alignment.TopEnd)
|
||||||
// match that so the button sits inside the block, not above it.
|
// MarkdownHighlightedCode insets its background by 8dp top;
|
||||||
.padding(top = 8.dp, end = 4.dp)
|
// match that so the button sits inside the block, not above it.
|
||||||
.size(28.dp),
|
.padding(top = 8.dp, end = 4.dp)
|
||||||
|
.size(28.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.ContentCopy,
|
imageVector = Icons.Filled.ContentCopy,
|
||||||
|
|||||||
@@ -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 =
|
||||||
val trimmed = line.trimStart()
|
lines.map { line ->
|
||||||
when {
|
val trimmed = line.trimStart()
|
||||||
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
|
when {
|
||||||
inCodeBlock = !inCodeBlock
|
trimmed.startsWith("```") || trimmed.startsWith("~~~") -> {
|
||||||
line
|
inCodeBlock = !inCodeBlock
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,…}, …
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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."} } }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
||||||
Executable
+41
@@ -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
|
||||||
@@ -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 = (
|
||||||
"directories in /tmp, (2) report your findings so far, "
|
"Research task: (1) use your shell tool to list the top-level "
|
||||||
"(3) use your shell tool to count files in /tmp, "
|
"directories in /tmp, (2) report your findings so far, "
|
||||||
"(4) report those findings too, (5) give a final summary of both")
|
"(3) use your shell tool to count files in /tmp, "
|
||||||
rc, _, _ = run_probe(env, url, token, "--send", prompt,
|
"(4) report those findings too, (5) give a final summary of both"
|
||||||
"--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 = (
|
||||||
"In your final reply, include the MEDIA:/absolute/path tag for "
|
"Create a 100x100 orange square PNG in /tmp with your tools. "
|
||||||
"that file so it is delivered to me.")
|
"In your final reply, include the MEDIA:/absolute/path tag for "
|
||||||
rc, out, _ = run_probe(env, url, token, "--send", prompt,
|
"that file so it is delivered to me."
|
||||||
"--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.
|
||||||
Reference in new issue
Block a user