Compare commits

...
Author SHA1 Message Date
Bailey Dixon 545d238fd2 Merge pull request #311 from Codename-11/dev
release: Android 1.7.0 and Server 1.6.0
2026-08-06 21:38:49 -04:00
Bailey Dixon 9b36a34e2a release(android): android-v1.7.0 2026-08-06 21:01:01 -04:00
Bailey Dixon 7c35a51aed release(server): server-v1.6.0 2026-08-06 21:00:52 -04:00
Bailey Dixon 474147cb62 test(server): align session TTL route expectations 2026-08-06 20:59:59 -04:00
Bailey Dixon 0bd246586a chore: backmerge released main into dev 2026-08-06 20:16:54 -04:00
Bailey Dixon fc6aaffc11 fix(android): keep restored chats bottom-pinned 2026-08-05 22:08:55 -04:00
Bailey Dixon 197b23f344 feat: resolve Codex effort levels dynamically 2026-08-05 21:28:43 -04:00
Bailey Dixon 0862b4c346 Merge branch 'fix/android-provider-effort-levels' into dev 2026-08-05 21:09:51 -04:00
Bailey Dixon 9c7bc46c7c feat(android): explain reasoning effort compatibility 2026-08-05 21:09:37 -04:00
Bailey Dixon 9554eab757 Merge fix/android-provider-effort-levels into dev 2026-08-05 20:38:32 -04:00
Bailey Dixon 3cacc7d6d6 Merge dev into fix/android-provider-effort-levels
# Conflicts:
#	DEVLOG.md
#	app/src/main/kotlin/com/hermesandroid/relay/viewmodel/ChatViewModel.kt
2026-08-05 20:37:14 -04:00
Bailey Dixon 311888a9fb fix(android): settle chat viewport and pet grounding 2026-08-05 20:35:22 -04:00
Bailey Dixon 96dfe472ef feat: resolve reasoning efforts through relay 2026-08-05 20:34:56 -04:00
Bailey Dixon 7d6a0215ba fix(android): stabilize chat drawer interactions 2026-08-05 19:55:55 -04:00
Bailey Dixon 51ec55e08c fix(android): restore developer settings imports 2026-08-05 19:23:31 -04:00
Bailey Dixon 4edcd18398 Merge fix/standard-voice-speech into dev
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/viewmodel/VoiceViewModel.kt
2026-08-05 19:19:27 -04:00
Bailey Dixon 3f50a94d03 Merge fix/developer-options-data into dev
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/data/FeatureFlags.kt
#	app/src/main/kotlin/com/hermesandroid/relay/ui/components/ActiveConnectionSections.kt
#	app/src/main/kotlin/com/hermesandroid/relay/ui/screens/DeveloperSettingsScreen.kt
#	app/src/test/kotlin/com/hermesandroid/relay/data/FeatureFlagsTest.kt
#	docs/localization-status.json
2026-08-05 19:18:59 -04:00
Bailey Dixon 94d2231a80 Merge chore/backmerge-main-after-android-1.6.0 into dev 2026-08-05 19:17:30 -04:00
Bailey Dixon 5ac798d5b3 Merge feature/local-dev-work-20260805 into dev 2026-08-05 19:17:17 -04:00
Bailey Dixon ebe1a8ebc4 chore(android): streamline local dev loop 2026-08-05 19:16:36 -04:00
Bailey Dixon fc3a1c169b fix(android): stabilize pet and model sync feedback 2026-08-05 19:08:27 -04:00
Bailey Dixon 6eb2c8bd9e fix(android): smooth active session glow loop 2026-08-05 18:53:31 -04:00
Bailey Dixon cbe323cf6d Merge fix/android-chat-tail-settle into dev 2026-08-05 18:35:50 -04:00
Bailey Dixon 5401e8f274 fix(android): keep completed chat tail stable 2026-08-05 17:58:09 -04:00
Bailey Dixon 0b62afa6c3 Merge feature/android-session-activity-ui into dev 2026-08-05 16:54:50 -04:00
Bailey Dixon 02801dda70 feat(android): surface active session states 2026-08-05 16:52:56 -04:00
Bailey Dixon 39011d5600 Merge pull request #307 from Codename-11/fix/android-focus-pointer-input
fix(android): restore focus voice controls
2026-08-05 16:21:32 -04:00
Bailey Dixon e50c644d09 fix(android): restore focus voice controls 2026-08-05 14:30:20 -04:00
Bailey Dixon 3ff4e7cf8e Merge pull request #304 from Codename-11/fix/android-actionable-diagnostics
fix(android): make connection diagnostics actionable
2026-08-05 10:52:31 -04:00
Bailey Dixon 683d9712d8 fix(android): make connection diagnostics actionable 2026-08-05 10:36:56 -04:00
Bailey Dixon 7ae4a3987e Merge pull request #301 from Codename-11/feature/android-reliability-support
feat(android): unify local reliability reporting
2026-08-04 21:44:52 -04:00
Bailey Dixon 9dcb280eff feat(android): unify local reliability reporting 2026-08-04 21:32:18 -04:00
Bailey Dixon a90067292a Merge pull request #300 from Codename-11/fix/android-chat-render-identity
fix(android): stabilize chat identity and issue labeling
2026-08-04 21:26:06 -04:00
Bailey Dixon bbea5d1b73 Merge pull request #297 from Codename-11/dev
fix(release): repair Server 1.5.1 metadata
2026-08-03 22:25:29 -04:00
Bailey Dixon c3ff201ecc Merge pull request #294 from Codename-11/dev
release: server-v1.5.1 and android-v1.6.1
2026-08-03 22:20:48 -04:00
Bailey Dixon 9fdb11ae94 chore(android): script foreground service demo capture 2026-08-02 20:56:31 -04:00
Bailey Dixon fd35c9b2b6 docs(android): document Play foreground services 2026-08-02 19:47:52 -04:00
Bailey Dixon 9dc625ddd0 chore: backmerge Android 1.6.0 release 2026-08-02 19:46:48 -04:00
Bailey Dixon e5b25ab650 Merge pull request #284 from Codename-11/dev
release(android): android-v1.6.0
2026-08-02 19:28:01 -04:00
Bailey Dixon 0e6d64f987 fix(android): restore standard voice narration 2026-07-26 08:42:04 -04:00
Bailey Dixon e3bc816624 fix(android): repair developer options and data actions 2026-07-25 16:29:36 -04:00
104 changed files with 7625 additions and 1053 deletions
+13
View File
@@ -191,6 +191,19 @@ jobs:
# app/build/outputs/bundle/sideloadRelease/hermes-relay-<version>-sideload-release.aab
run: ./gradlew bundleRelease assembleRelease
# The Play AAB carries its mapping for Play Console deobfuscation, but
# sideload issue reports need the exact mapping from this immutable build.
# Keep both variants as a workflow artifact (not a public release asset).
- name: Retain R8 mappings for retrace
uses: actions/upload-artifact@v7
with:
name: android-r8-mappings-${{ needs.validate.outputs.version }}-${{ github.sha }}
path: |
app/build/outputs/mapping/googlePlayRelease/mapping.txt
app/build/outputs/mapping/sideloadRelease/mapping.txt
if-no-files-found: error
retention-days: 90
- name: Scan release DEX for unsupported collection APIs
run: |
python3 scripts/check-android-collection-apis.py \
+23 -1
View File
@@ -8,10 +8,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Fixed
- **Android chat and Voice keep one render identity through recovery.** Checkpoint restore, streamed callbacks, server-ID adoption, and replay now resolve the same owned transcript row before publication, preventing recurring Compose duplicate-key crashes.
- **Issue area labels require maintainer review.** The unreliable keyword-based auto-labeling workflow no longer assigns ownership from ambiguous issue text.
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
## [Android 1.7.0] - 2026-08-06
### Added
- **Android exposes provider-aware reasoning controls.** The effort drawer consumes exact upstream or optional Relay capability metadata for each provider/model identity, while unmodified or older Hermes installations retain a fail-soft standard fallback including `max` and `ultra`.
- **Android support information is local, redacted, and reviewable.** Fatal crashes and handled failures share a bounded on-device record, Diagnostics can copy or share the exact reviewed text, and nothing is uploaded automatically.
### Fixed
- **Android chat chrome follows its active interaction state.** Opening the session drawer dismisses the composer keyboard, refreshed sessions keep their newest row visible, and floating pets wait for measured chat terrain, sit flush on supported rails, and treat the complete scroll-to-bottom control as forbidden space.
- **Android pets and optional model discovery initialize quietly.** Floating companions wait for a measured overlay before taking their home position, and background API model-inventory failures retain actionable local diagnostics without interrupting chat with a generic notice.
- **Android chat and Voice stay precisely bottom-pinned through replies, restores, and layout changes.** The active tail keeps its stable live renderer until another row takes ownership, restored sessions follow late composer and message measurement without overriding a reader, and bottom-owned transcripts settle to the exact list boundary after replies and keyboard animations instead of leaving a small hidden remainder.
- **Android Focus voice controls remain responsive.** The modal click-through guard now sits behind the voice UI instead of consuming pointer events from the mic, close, expansion, and panel controls.
- **Android diagnostics explain what failed and what to try next.** Relay, route, WebSocket, and API checks distinguish the saved route from the redacted request they actually attempted, name the operation, and provide targeted guidance for connection, DNS, timeout, TLS, authentication, rate-limit, and server failures.
- **Android chat and Voice keep one render identity through recovery.** Checkpoint restore, streamed callbacks, server-ID adoption, and replay now resolve the same owned transcript row before publication, preventing recurring Compose duplicate-key crashes.
- **Android crash reports retain actionable release context.** Reports identify the Android surface, avoid exposing hosts and credentials, migrate earlier local crash records, and release automation retains exact Play and sideload R8 mappings for retrace.
## [1.6.0] - 2026-08-06
### Added
- **Relay supplies exact provider/model reasoning capabilities when providers expose them.** The bounded, profile-aware overlay resolves dynamic catalogs for OpenAI Codex, Copilot, LM Studio, and Ollama Cloud, keeps provider credentials on the host, and leaves unknown or unavailable catalogs on the advisory fallback.
## [Android 1.6.1] - 2026-08-03
### Fixed
+32 -7
View File
@@ -17,17 +17,41 @@ That's it — no extra setup or credentials required for a debug build.
Helper scripts for common development tasks:
```bash
scripts/dev.bat build # Build debug APK
scripts/dev.bat build # Build the sideload debug APK
scripts/dev.bat compile # Compile sideload Kotlin only
scripts/dev.bat test-one "com.hermesandroid.relay.SomeTest" # Run one test class
scripts/dev.bat install-fast # Build arm64 only + install + launch
scripts/dev.bat release # Build signed release APK
scripts/dev.bat bundle # Build release AAB for Google Play
scripts/dev.bat run # Build + install + launch + logcat
scripts/dev.bat test # Run unit tests
scripts/dev.bat run # Build sideload + install + launch + logcat
scripts/dev.bat test # Run sideload debug unit tests
scripts/dev.bat version # Show current version
scripts/dev.bat relay # Start relay server (dev, no TLS)
```
Linux/macOS equivalent lives at `scripts/dev.sh`.
### Fast Android iteration
Gradle's daemon, local build cache, configuration cache, and parallel task
execution are enabled for repeat local builds. Keep the same Gradle JVM
configuration between invocations and do not add `--no-daemon` to normal dev
commands; a different heap or Java home starts a separate daemon and discards
the warm-process benefit.
Use the narrowest command that proves the change:
1. `scripts/dev.bat compile` for a Kotlin compile check.
2. `scripts/dev.bat test-one "<fully-qualified-class-or-pattern>"` for a focused regression.
3. `scripts/dev.bat install-fast` when the result must run on the connected
arm64 phone. This passes `-Phermes.devAbi=arm64-v8a`, avoiding the x86,
x86_64, and armeabi-v7a native libraries in the local APK.
4. `scripts/dev.bat prepush` before pushing Android work.
`install-fast` is intentionally phone-specific. Use `install` for a universal
sideload debug APK or when the target ABI is not arm64. Release builds remain
universal and are unaffected unless `-Phermes.devAbi` is explicitly supplied.
## Repository Structure
```
@@ -51,12 +75,12 @@ The legacy `relay_server/` directory is a thin compatibility shim around `plugin
| Component | Stack |
|-----------|-------|
| **Android App** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
| **Android App** | Kotlin 2.4, Jetpack Compose, Material 3, OkHttp |
| **Relay Server** | Python 3.11+, aiohttp |
| **Serialization** | kotlinx.serialization |
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 |
| **Build** | AGP 9.3.1, Gradle 9.6.1, JVM toolchain 17 |
| **CI/CD** | GitHub Actions (lint, build, test, signed APK artifacts) |
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
| **Min SDK** | 26 (Android 8.0) / Target SDK 36 |
## Running the Relay Locally
@@ -174,7 +198,8 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
CI in one cached Gradle invocation. Run it before pushing Android PR updates
to catch common hosted failures without waiting for another full Actions
cycle; hosted CI remains the exhaustive all-variant gate.
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
- **Focused Android unit test:** `scripts/dev.bat test-one "<fully-qualified-class-or-pattern>"`
- **Android unit tests:** `scripts/dev.bat test` (runs the sideload debug JUnit + MockK + Compose suite)
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
+133
View File
@@ -1,5 +1,118 @@
# Hermes-Relay — Dev Log
## 2026-08-05 — Restored chat bottom ownership and effort fallback clarity
Opening an existing Android session now retains exact bottom ownership through
late, non-streaming layout changes. Composer capability controls, status rows,
and restored message content can finish measuring after history first reaches
the footer; a session-scoped geometry observer corrects those changes without
using a fixed delay. New-message following remains governed by the smooth
auto-scroll setting, while a real drag, IME ownership, and the Voice dock keep
their existing anchors.
The advisory effort drawer now states that Hermes does not advertise exact
levels for the selected model before explaining why standard options are shown.
The wording is consistent across all shipped Android locales.
## 2026-08-05 — Provider-aware reasoning effort discovery
The optional Relay plugin now exposes a bearer-protected, profile-aware model
capability overlay without requiring changes to upstream Hermes. Android merges
that overlay with the standard `model.options` inventory using exact provider
and model identities, while older or unpaired Relay installations continue with
the canonical advisory fallback.
Dynamic LM Studio, Ollama Cloud, and Copilot discovery is bounded by a shared
network limiter, cached by profile, endpoint, model, and credential fingerprint,
and fenced across refresh generations. Neither credentials nor internal cache
scope are returned to clients. Composer controls, Agent Passport, session
creation, and asynchronous server reconciliation share the same capability
resolver so a displayed effort cannot silently differ from the value sent.
## 2026-08-05 — Chat drawer and companion terrain ownership
The Chat screen now clears composer focus when the session drawer commits to
opening, dismissing the IME without continuously clearing focus from drawer
search or rename fields. Drawer refreshes override keyed list anchoring only
when the leading session identity changes, keeping the newest row visible after
activity-based reordering.
Floating companions wait for Chat's measured composer rail before publishing
their first roaming position. Their collision footprint contains both the
pointer target and rendered sprite, and the complete scroll-to-bottom control
envelope is an obstacle rather than a landing perch. Supported rails add no
visual lift, and the floating-only renderer aligns each frame's opaque bottom
edge to its canvas baseline so transparent atlas padding cannot make pets hover;
centered previews and message avatars remain unchanged.
## 2026-08-05 — Measured pet placement and passive model sync
The floating pet now remains unpublished until the app-level overlay has a
positive measured viewport. Its initial home coordinate is therefore derived
from the real safe bounds instead of the zero-size pre-measure bounds that
collapsed to the top-left corner.
API provider inventory remains an optional background catalog on Gateway-led
connections. A timeout, refusal, or unavailable optional route no longer emits
a global chat notice during initialization, reconnection, or connection-sheet
refresh. The failure is retained as a contextual warning in local Diagnostics,
including the operation, endpoint role, redacted stack trace, preserved network
cause, and targeted troubleshooting guidance. Cached and Gateway-owned model
options remain unchanged.
## 2026-08-05 — Stable chat-tail completion
Chat and Voice now treat the active streamed reply as the owner of its live
renderer until a different row becomes the conversation tail. Stream
completion retains the existing Compose subtree and list anchor; the full
Markdown renderer is deferred until the row is no longer active or the session
is revisited.
The last-in-group timestamp occupies its final geometry from the first
streaming frame and is only revealed at completion. Measured positive growth
during an active stream continues to follow the bottom without replacing the
logical anchor. Once completion layout stabilizes, a bottom-owned transcript
settles to the exact LazyColumn boundary; proximity slop is reserved for
retaining follow intent during motion and cannot define the final position. The
visible footer supplies the exact remaining distance so rounding or adjacent
layout changes cannot leave a residual forward range.
IME expansion participates in that same viewport owner. A transcript already
at the bottom advances by the measured viewport-height loss throughout the
keyboard animation, then settles exactly after inset updates stop on both open
and close. A transcript being read above the bottom preserves its existing
anchor, and a real drag cancels keyboard follow immediately. Host-side coverage
verifies renderer ownership, unchanged bubble height, exact footer settling,
keyboard arming, viewport loss, completion/IME settlement ownership, and
history-reading behavior.
## 2026-08-05 — Focus voice input boundary repair
The Focus voice presentation remains modal without installing a consuming
pointer handler on the full overlay ancestor. Its click-through guard is now a
behind-content sibling: empty-space gestures cannot reach the chat or drawer,
while the mic, close, expand/collapse, and panel controls receive their full
pointer sequence.
Host-side Compose coverage injects real touch events instead of invoking
semantic click actions. It verifies both child callback delivery and the modal
background boundary so the two requirements cannot regress independently.
## 2026-08-05 — Actionable Android connection diagnostics
Android diagnostic entries now separate the configured route from the exact
request operation and path used to test it. Relay health checks identify the
HTTP `/health` probe that precedes a WebSocket connection, route selection
records its Dashboard, API, or Relay probe, and WebSocket and API checks name
their handshake or authentication stage.
Known network and HTTP failure classes attach a bounded next step for refused
listeners, DNS, routing, timeouts, TLS, credentials, rate limits, missing
routes, and server failures. The activity list, status timeline, detail dialog,
copy text, and GitHub issue prefill all carry the same context. Public issue
text preserves protocol and request paths while redacting hosts, credentials,
queries, and user information.
## 2026-08-04 — Android transcript identity ownership
ChatHandler now owns one render identity for every published transcript row.
@@ -15,6 +128,26 @@ sequences across restore, replay, deltas, thinking, and usage updates. Voice's
temporary transcript row now occupies an auxiliary key namespace disjoint from
real message rows.
## 2026-08-04 — Android reliability and support foundation
Android fatal capture and centrally classified handled failures now converge on
a versioned, allowlisted reliability record. Reports are redacted before local
persistence, capped at 20 records with 14-day retention, written atomically,
and correlated only with random app/report identifiers. Expected cancellation
and permission denial remain non-reportable. The pre-existing one-file crash
format migrates locally on first launch.
Crash recovery leads with the recovery outcome and no-upload guarantee, then
requires an explicit review before copy, share, or GitHub actions. Diagnostics
adds an offline support-information review using the same exact redacted text.
Android issue prefills now request the Android area while repository-wide issue
ownership remains maintainer-reviewed, and the release workflow retains both
variant R8 mappings for deterministic retrace.
The architecture audit defers an ANR watchdog, richer allowlisted breadcrumbs,
hashed product correlation, and OOM emergency writing until their lifecycle,
privacy, and false-positive behavior can be validated on devices.
## 2026-08-02 — Android Russian localization
Android now ships complete Russian catalogs for the main and sideload builds.
+10 -5
View File
@@ -1,17 +1,22 @@
# Hermes-Relay-Server v__VERSION__
**Release Date:** August 3, 2026
**Release Date:** August 6, 2026
This patch makes intentional re-pairing repair the existing device record instead of accumulating duplicate Relay sessions.
This patch adds an optional Relay capability overlay so clients can present the reasoning effort levels supported by an exact provider and model without changing upstream model selection or chat behavior.
Standard chat, session history, and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
## What's changed
### Fixed
### Added
- **Re-pairing replaces stale credentials for the same device.** After the host approves a new pair, Relay revokes older sessions and refresh credentials that belong to that device before issuing the replacement.
- **Existing and unrelated sessions remain operator-controlled.** The Dashboard and `/relay revoke <token-prefix>` continue to provide explicit cleanup without treating optional Relay pairing as a requirement.
- **Profile-aware reasoning capability resolution.** `POST /relay/model-capabilities` accepts exact provider/model pairs and returns ordered effort choices with explicit exactness and source metadata.
- **Bounded live discovery for local and authenticated providers.** Relay can query OpenAI Codex, GitHub Copilot, LM Studio, and Ollama Cloud capability surfaces with short timeouts, concurrency limits, isolated caches, and an explicit refresh option.
- **Capability advertisement.** `/relay/info` now advertises `model_reasoning_capabilities_v1` so clients can detect the optional overlay before using it.
### Security
- **Provider credentials remain host-local.** Remote requests require a paired session with the chat grant, and responses never serialize provider credentials.
## Install / update
+9 -6
View File
@@ -267,11 +267,14 @@ Already installed? The same recipe is auto-loaded as a Hermes skill — invoke `
```bash
# Android: open the repo root in Android Studio, wait for Gradle sync, Run (Shift+F10).
scripts/dev.bat build # Build debug APK
scripts/dev.bat build # Build sideload debug APK
scripts/dev.bat compile # Compile sideload Kotlin only
scripts/dev.bat test-one "com.hermesandroid.relay.SomeTest" # Focused unit test
scripts/dev.bat install-fast # arm64 phone build + install + launch
scripts/dev.bat release # Build signed release APK
scripts/dev.bat bundle # Build release AAB for Google Play
scripts/dev.bat run # Build + install + launch + logcat
scripts/dev.bat test # Run unit tests
scripts/dev.bat run # Build sideload + install + launch + logcat
scripts/dev.bat test # Run sideload debug unit tests
scripts/dev.bat version # Show current version
scripts/dev.bat relay # Start the relay server (dev, no TLS)
```
@@ -280,13 +283,13 @@ scripts/dev.bat relay # Start the relay server (dev, no TLS)
| Component | Stack |
|-----------|-------|
| **Android app** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
| **Android app** | Kotlin 2.4, Jetpack Compose, Material 3, OkHttp |
| **Hermes-Relay CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
| **Server / plugin** | Python 3.11+, aiohttp |
| **Serialization** | kotlinx.serialization (Android) |
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (CLI) |
| **Build** | AGP 9.3.1, Gradle 9.6.1, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (CLI) |
| **CI/CD** | GitHub Actions — lint, build, test, APK artifact, CLI binaries per platform |
| **Min SDK** | 26 (Android 8.0) · Target SDK 35 |
| **Min SDK** | 26 (Android 8.0) · Target SDK 36 |
<details>
<summary><b>Repository structure</b></summary>
+13 -1
View File
@@ -494,6 +494,14 @@ the new app version and a higher `appVersionCode`.
in `app/build.gradle.kts`. Never rename the sideload APK — the
in-app update checker matches assets by `.apk` + `sideload` in the
name, and user-docs verify steps cite the filename.
The release workflow also retains
`app/build/outputs/mapping/{googlePlayRelease,sideloadRelease}/mapping.txt`
for 90 days in the `android-r8-mappings-<version>-<sha>` workflow
artifact. It is intentionally not a GitHub Release asset. To symbolicate an
in-app or sideload report, download the artifact for the exact version/SHA and
run Android's retrace tool with the matching flavor mapping:
`retrace <mapping.txt> <obfuscated-trace.txt>`. Play reports can additionally
use the mapping bundled into the uploaded AAB through Play Console.
- `app/src/main/assets/whats_new.txt` — in-app "What's New" content
shown in the settings/about screen. Update with the version number
and a brief feature summary. Gets stale silently if forgotten
@@ -509,7 +517,11 @@ the new app version and a higher `appVersionCode`.
the version reference and the "Release Notes" section that gets
pasted into the Play Console "What's new" field. Keep the Play
"What's new" within **500 characters** and framed around the
release's themes, not a feature dump.
release's themes, not a feature dump. Compare its **Foreground service
permissions** section with the merged `googlePlayRelease` manifest and
complete Play Console declarations for every declared service type before
approval; the Publisher API can upload a draft and still reject promotion
when an App content declaration is missing.
#### Scrub for public distribution
+33 -13
View File
@@ -1,10 +1,10 @@
# Hermes-Relay-Android v1.6.1
# Hermes-Relay-Android v1.7.0
**Release Date:** August 3, 2026
**Release Date:** August 6, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.6.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.7.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,19 +12,39 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This patch clarifies optional Relay recovery, restores the full session drawer, and fixes several chat and Voice regressions.
This patch makes model reasoning controls provider-aware, keeps active and
restored conversations stable, and adds private, actionable support information.
## Added
- Reasoning effort choices now follow the selected provider and model when an
exact list is available. Standard choices remain available as an advisory
fallback on unmodified Hermes or without the optional Relay capability overlay.
- The session drawer can search sessions and shows which conversations are
working or waiting for input.
- Diagnostics now provides a locally redacted support report that can be
reviewed before copy, share, or opening GitHub. Nothing uploads automatically.
## Fixed
- Optional Relay failures stay within Relay-only surfaces, use consistent status labels, and only request re-pairing when the stored Relay credential actually needs it.
- Foreground recovery reconnects immediately after ordinary backoff, while retained credentials are described as stored details rather than an active in-memory session.
- Session history loads its 200-row drawer window through upstream-compatible 100-row pages instead of failing with HTTP 422.
- Selecting text remains stable when a streamed response changes from live text to rendered Markdown.
- Manual Voice recording waits for barge-in microphone teardown and gives a useful recovery message when the microphone is unavailable.
- New-chat coaching yields while Voice owns the composer, so it no longer covers the expanding Voice drawer.
- Restored and completed conversations remain at the exact bottom through late
message, composer, keyboard, and status-row layout changes without overriding
someone who intentionally scrolls up.
- Chat recovery keeps one stable rendered row through checkpoint restore,
streaming callbacks, server reconciliation, and replay, preventing recurring
duplicate-row crashes in Chat and Voice.
- Focus Voice controls receive taps again while the modal background continues
to block interaction with the chat behind it.
- Connection diagnostics identify the attempted route and operation, redact host
details, and give targeted guidance for DNS, timeout, TLS, authentication,
rate-limit, missing-route, and server failures.
- The session drawer dismisses the composer keyboard when opening, preserves the
newest row during refresh, and keeps floating pets on measured safe terrain.
## Install / Verify
- App version: **1.6.1** (versionCode **38**).
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
- Same-device Relay re-pair replacement requires the optional Server 1.5.1 plugin; Dashboard and `/relay revoke <token-prefix>` remain available for explicit cleanup.
- App version: **1.7.0** (versionCode **39**).
- Standard Chat, model selection, and Vanilla Hermes voice continue to work
against unmodified upstream Hermes.
- Relay capability discovery is optional and fail-soft; unavailable or older
Relay installations retain the standard reasoning choices.
+16
View File
@@ -24,6 +24,22 @@ multi-session protocol before treating `lifecycle=session` as an isolation claim
---
## Split fast Android unit tests from resource and screenshot tests
The quick-loop commands now narrow execution to the sideload debug variant and
support one-class filtering, but all `:app` unit tests still share one Android
test variant. That variant includes merged Android resources, gives every test
worker a 2 GiB heap, runs on JDK 21, and enables Roborazzi recording because a
small subset of Robolectric/screenshot tests requires those settings.
Create a separate resource/screenshot test lane so pure state, parser, routing,
and formatting tests can run as ordinary JVM tests without Android resource
packaging. Keep golden-image recording explicit rather than applying it to all
unit tests, preserve a CI task that runs both lanes, and benchmark cold plus
warm focused-test latency before adopting the split.
---
## Verify Android native dashboard sign-in on device
Android now selects Custom Tab + PKCE for HTTPS gateways that advertise
+20
View File
@@ -7,6 +7,15 @@ plugins {
alias(libs.plugins.play.publisher)
}
val supportedHermesDevAbis = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
val hermesDevAbi = providers.gradleProperty("hermes.devAbi").orNull
hermesDevAbi?.let { requestedAbi ->
require(requestedAbi in supportedHermesDevAbis) {
"Unsupported hermes.devAbi '$requestedAbi'. Expected one of: " +
supportedHermesDevAbis.sorted().joinToString()
}
}
// Rename output artifacts to include the app version. AGP respects
// `archivesName` for both APK (assemble*) and AAB (bundle*) outputs, so
// this single line produces `hermes-relay-<version>-<flavor>-<buildType>`
@@ -43,6 +52,17 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// Optional local-only fast path for device iteration. Native voice/VAD
// dependencies make the universal sideload APK very large, while a
// connected phone needs only its own ABI. Release and normal debug
// builds remain universal unless the developer explicitly supplies
// -Phermes.devAbi=<abi>.
hermesDevAbi?.let { requestedAbi ->
ndk {
abiFilters += requestedAbi
}
}
// Feature flags — DEV_MODE enables all experimental features in debug builds
buildConfigField("boolean", "DEV_MODE", "false")
}
@@ -1 +1 @@
Optional Relay recovery now stays in the right surfaces with clear status labels and immediate foreground retry. Session history again loads its full window through upstream-compatible paging. Streamed text selection, Voice microphone handoff, and the expanding Voice drawer are also more reliable.
Smarter reasoning controls now match the selected provider and model, with standard choices when exact levels are unavailable. The session drawer shows working and needs-input chats. Restored and completed conversations stay bottom-pinned without overriding scrollback. Redacted local support information and clearer connection diagnostics make problems easier to resolve. Focus Voice controls and recovered chat rows are more reliable.
+6 -4
View File
@@ -101,8 +101,8 @@
<!-- Protects user-started active turns automatically; the optional
"Persistent connection" setting extends the same foreground
protection to idle/background connectivity (and relay-paired
device features). In main so BOTH flavors ship it. specialUse
needs a Play Console foreground-service declaration at submission. -->
device features). In main so BOTH flavors ship it. Every Play
foreground-service type needs its matching App content declaration. -->
<service
android:name=".network.upstream.GatewayKeepAliveService"
android:exported="false"
@@ -113,7 +113,8 @@
</service>
<!-- Experimental, explicitly user-started on-device wake-word listener.
Audio remains local and the service is never boot/restart started. -->
Audio remains local and the service is never boot/restart started.
The Play build's microphone type needs an App content declaration. -->
<service
android:name=".wake.WakeWordForegroundService"
android:exported="false"
@@ -122,7 +123,8 @@
<!-- User-started protection for voice capture from the system overlay.
The service does not own AudioRecord; it keeps foreground-only
microphone app-ops available while Hermes is behind another app. -->
microphone app-ops available while Hermes is behind another app.
Include this use case in the Play microphone declaration. -->
<service
android:name=".voice.VoiceOverlayForegroundService"
android:exported="false"
+29
View File
@@ -1,5 +1,34 @@
{
"versions": [
{
"version": "1.7.0",
"title": "Smarter controls, steadier sessions",
"date": "2026-08-06",
"sections": [
{
"header": "Model controls fit the model",
"bullets": [
"Reasoning effort choices follow the selected provider and model when an exact supported list is available.",
"Unmodified Hermes and setups without the optional Relay capability overlay keep a fail-soft standard choice list."
]
},
{
"header": "Active chats stay easy to follow",
"bullets": [
"The searchable session drawer shows which conversations are working or waiting for input.",
"Restored and completed chats remain bottom-pinned through late layout changes without overriding intentional scrollback.",
"Chat and Voice keep stable rows through recovery, and Focus Voice controls receive taps normally."
]
},
{
"header": "Support stays private and useful",
"bullets": [
"Review locally redacted support information before choosing to copy, share, or open GitHub; nothing uploads automatically.",
"Connection diagnostics identify the failed operation and offer targeted guidance without exposing hosts or credentials."
]
}
]
},
{
"version": "1.6.1",
"title": "Clearer recovery, steadier chat",
+6 -5
View File
@@ -1,6 +1,7 @@
v1.6.1 - Clearer recovery, steadier chat
v1.7.0 - Smarter controls, steadier sessions
* Keep optional Relay recovery scoped to Relay surfaces with clear status labels.
* Restore the 200-session drawer through upstream-compatible paging.
* Stabilize streamed text selection and Voice microphone handoff.
* Keep new-chat coaching clear of the expanding Voice drawer.
* Match reasoning levels to the selected provider and model, with a standard fallback.
* See working and needs-input activity in the searchable session drawer.
* Keep restored and completed chats bottom-pinned without overriding scrollback.
* Review redacted local support information and actionable connection guidance.
* Restore Focus Voice controls and stabilize recovered chat rows.
@@ -0,0 +1,7 @@
package com.hermesandroid.relay.data
/** Live activity surfaced beside a session without conflating it with selection. */
enum class SessionActivityState {
Working,
NeedsInput,
}
@@ -3,6 +3,8 @@ package com.hermesandroid.relay.diagnostics
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import com.hermesandroid.relay.reliability.ReliabilityCenter
import com.hermesandroid.relay.reliability.ReliabilityRedactor
enum class DiagnosticCategory(val label: String) {
Api("API"),
@@ -25,7 +27,16 @@ data class DiagnosticLogEntry(
val severity: DiagnosticSeverity,
val title: String,
val detail: String? = null,
/** Human-readable action that produced this event, not merely its subsystem. */
val operation: String? = null,
val endpointRole: String? = null,
/** User/configuration-facing route before protocol/path normalization. */
val configuredUrl: String? = null,
/** Exact sanitized URL attempted on the wire, including the diagnostic path. */
val requestUrl: String? = null,
/** Concrete next troubleshooting step for failures with a known interpretation. */
val suggestion: String? = null,
/** Legacy single-URL field retained for diagnostics that have not needed split context. */
val url: String? = null,
val elapsedMs: Long? = null,
/**
@@ -34,7 +45,11 @@ data class DiagnosticLogEntry(
* the detail view shows this. Null for non-error / manually-recorded entries.
*/
val stacktrace: String? = null,
)
) {
/** Best route for mode inference and compact list rendering. */
val primaryUrl: String?
get() = configuredUrl ?: requestUrl ?: url
}
/**
* Current health of a single subsystem on the Diagnostics status timeline.
@@ -81,19 +96,29 @@ object DiagnosticsLog {
severity: DiagnosticSeverity = DiagnosticSeverity.Info,
title: String,
detail: String? = null,
operation: String? = null,
endpointRole: String? = null,
configuredUrl: String? = null,
requestUrl: String? = null,
suggestion: String? = null,
url: String? = null,
elapsedMs: Long? = null,
stacktrace: String? = null,
) {
val safeConfiguredUrl = sanitizeUrl(configuredUrl)
val safeRequestUrl = sanitizeUrl(requestUrl)
val entry = DiagnosticLogEntry(
timestampMs = System.currentTimeMillis(),
category = category,
severity = severity,
title = clean(title) ?: title.take(MAX_TEXT_LENGTH),
detail = clean(detail),
operation = clean(operation),
endpointRole = clean(endpointRole),
url = sanitizeUrl(url),
configuredUrl = safeConfiguredUrl,
requestUrl = safeRequestUrl,
suggestion = clean(suggestion),
url = if (safeConfiguredUrl == null && safeRequestUrl == null) sanitizeUrl(url) else null,
elapsedMs = elapsedMs,
stacktrace = redactTrace(stacktrace),
)
@@ -121,20 +146,40 @@ object DiagnosticsLog {
title: String,
detail: String? = null,
throwable: Throwable? = null,
operation: String? = null,
endpointRole: String? = null,
configuredUrl: String? = null,
requestUrl: String? = null,
suggestion: String? = null,
url: String? = null,
elapsedMs: Long? = null,
reliabilityContext: String? = null,
) {
record(
category = category,
severity = DiagnosticSeverity.Error,
title = title,
detail = detail ?: throwable?.message,
operation = operation,
endpointRole = endpointRole,
configuredUrl = configuredUrl,
requestUrl = requestUrl,
suggestion = suggestion,
url = url,
elapsedMs = elapsedMs,
stacktrace = throwable?.let { stackTraceText(it) },
)
if (throwable != null) {
runCatching {
ReliabilityCenter.recordHandled(
title = title,
detail = detail ?: throwable.message,
throwable = throwable,
context = reliabilityContext,
routeRole = endpointRole,
)
}
}
}
private fun stackTraceText(t: Throwable): String =
@@ -167,10 +212,8 @@ object DiagnosticsLog {
val prefix = noQuery.substring(0, schemeEnd + 3)
val rest = noQuery.substring(schemeEnd + 3)
val slash = rest.indexOf('/').let { if (it < 0) rest.length else it }
val authority = rest.substring(0, slash)
val path = rest.substring(slash)
val safeAuthority = authority.substringAfterLast('@')
prefix + safeAuthority + path
prefix + "[host]" + path
} else {
noQuery
}
@@ -197,7 +240,7 @@ object DiagnosticsLog {
*/
private fun redactTrace(value: String?): String? {
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
val redacted = redact(trimmed)
val redacted = ReliabilityRedactor.redact(trimmed, MAX_TRACE_LENGTH)
return if (redacted.length > MAX_TRACE_LENGTH) {
redacted.take(MAX_TRACE_LENGTH) + "\n… (truncated)"
} else {
@@ -205,8 +248,5 @@ object DiagnosticsLog {
}
}
private fun redact(value: String): String =
value.replace(Regex("""(?i)(bearer|token|api[_-]?key|session[_-]?token)\s*[:=]\s*\S+""")) {
"${it.groupValues[1]}=[hidden]"
}
private fun redact(value: String): String = ReliabilityRedactor.redact(value, MAX_TRACE_LENGTH)
}
@@ -0,0 +1,42 @@
package com.hermesandroid.relay.diagnostics
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
/**
* Maps network failure classes to narrow, truthful next steps.
*
* These messages are diagnostic guidance, not recovery behavior: callers still
* own retries, routing, and authentication. Walking the cause chain preserves
* useful classification when OkHttp or a coroutine boundary wraps the socket
* exception in a higher-level failure.
*/
object NetworkDiagnosticGuidance {
fun forThrowable(throwable: Throwable, target: String): String? {
val causes = generateSequence(throwable as Throwable?) { it.cause }.take(12).toList()
return when {
causes.any { it is ConnectException } ->
"Verify $target is running and listening on the configured host and port."
causes.any { it is UnknownHostException } ->
"Verify the configured hostname resolves from this device."
causes.any { it is NoRouteToHostException } ->
"Verify the device has a network path to the configured host."
causes.any { it is SocketTimeoutException } ->
"Check network routing or firewall rules between this device and $target."
causes.any { it is SSLException } ->
"Verify the TLS scheme, certificate, and trust configuration for $target."
else -> null
}
}
fun forHttpStatus(statusCode: Int, target: String): String? = when (statusCode) {
401, 403 -> "Verify the configured $target credentials or pair the device again."
404 -> "Verify this URL points to the expected $target route and version."
429 -> "Wait for the server's backoff period before retrying."
in 500..599 -> "Check the $target server logs for the failing request."
else -> null
}
}
@@ -15,6 +15,7 @@ import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import com.hermesandroid.relay.network.relay.models.Envelope
import com.hermesandroid.relay.network.shared.EndpointResolver
import com.hermesandroid.relay.network.shared.EndpointSurface
@@ -441,7 +442,9 @@ class ConnectionManager(
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.conn_diag_socket_blocked) ?: "Relay socket blocked",
detail = "ws:// is disabled",
url = url,
operation = "Open Relay WebSocket",
configuredUrl = url,
suggestion = "Use wss:// or explicitly allow plain ws:// for a trusted LAN or VPN.",
)
return
}
@@ -452,7 +455,9 @@ class ConnectionManager(
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.conn_diag_url_invalid) ?: "Relay socket URL invalid",
detail = "URL must start with ws:// or wss://",
url = url,
operation = "Open Relay WebSocket",
configuredUrl = url,
suggestion = "Edit or re-pair the Relay route with a ws:// or wss:// URL.",
)
return
}
@@ -488,14 +493,18 @@ class ConnectionManager(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.conn_diag_opening_insecure) ?: "Opening insecure relay socket",
url = normalized,
operation = "Open Relay WebSocket",
configuredUrl = url,
requestUrl = normalized,
)
} else {
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = context?.getString(R.string.conn_diag_opening_socket) ?: "Opening relay socket",
url = normalized,
operation = "Open Relay WebSocket",
configuredUrl = url,
requestUrl = normalized,
)
}
@@ -985,7 +994,9 @@ class ConnectionManager(
severity = DiagnosticSeverity.Error,
title = "Invalid relay URL",
detail = "The relay address could not be parsed; re-pair to refresh it.",
url = url,
operation = "Build Relay WebSocket request",
configuredUrl = url,
suggestion = "Edit or re-pair the Relay route to replace the invalid address.",
)
authenticated = false
_connectionState.value = ConnectionState.Disconnected
@@ -1018,7 +1029,8 @@ class ConnectionManager(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Info,
title = context?.getString(R.string.conn_diag_connected) ?: "Relay socket connected",
url = url,
operation = "Relay WebSocket handshake",
requestUrl = url,
)
// TOFU: record the peer cert fingerprint if we don't have one
@@ -1079,7 +1091,9 @@ class ConnectionManager(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.conn_diag_closed) ?: "Relay socket closed",
detail = "code=$code reason=$reason",
url = url,
operation = "Relay WebSocket session",
requestUrl = url,
suggestion = if (code == 1000) null else "Check the Relay server logs for the matching close code and reason.",
)
authenticated = false
_connectionState.value = ConnectionState.Disconnected
@@ -1102,7 +1116,11 @@ class ConnectionManager(
t.message,
code?.let { "HTTP $it" },
).joinToString(": "),
url = url,
operation = "Relay WebSocket handshake",
requestUrl = url,
suggestion = code?.let {
NetworkDiagnosticGuidance.forHttpStatus(it, "Relay")
} ?: NetworkDiagnosticGuidance.forThrowable(t, "Relay"),
)
lastUpgradeResponseCode = code
if (response == null) {
@@ -7,10 +7,12 @@ import com.hermesandroid.relay.auth.PairedDeviceInfo
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
@@ -59,6 +61,10 @@ class RelayHttpClient(
companion object {
private const val TAG = "RelayHttpClient"
const val MAX_MODEL_CAPABILITY_ROWS = 64
private const val MAX_MODEL_CAPABILITY_PROVIDER_CHARS = 128
private const val MAX_MODEL_CAPABILITY_MODEL_CHARS = 512
private const val MAX_MODEL_CAPABILITY_PROFILE_CHARS = 128
private val sessionsJson = Json {
ignoreUnknownKeys = true
isLenient = true
@@ -710,6 +716,37 @@ class RelayHttpClient(
@SerialName("age_seconds") val ageSeconds: Int? = null,
)
@Serializable
data class ModelCapabilityRequestRow(
val provider: String,
val model: String,
)
@Serializable
data class ModelCapabilitiesRequest(
@SerialName("schema_version") val schemaVersion: Int = 1,
val profile: String? = null,
val refresh: Boolean = false,
val models: List<ModelCapabilityRequestRow>,
)
@Serializable
data class ModelCapabilityRow(
val provider: String,
val model: String,
val reasoning: Boolean? = null,
@SerialName("reasoning_efforts") val reasoningEfforts: List<String> = emptyList(),
@SerialName("reasoning_efforts_exact") val reasoningEffortsExact: Boolean = false,
val source: String = "",
)
@Serializable
data class ModelCapabilitiesResponse(
@SerialName("schema_version") val schemaVersion: Int = 1,
@SerialName("contract_version") val contractVersion: String = "",
val capabilities: List<ModelCapabilityRow> = emptyList(),
)
/** Fetch the installed plugin/protocol/profile capability contract. */
suspend fun fetchRelayInfo(): Result<RelayInfo?> = withContext(Dispatchers.IO) {
val relayUrl = relayUrlProvider()?.trim().orEmpty()
@@ -743,6 +780,64 @@ class RelayHttpClient(
}
}
/** Optional provider/model reasoning overlay; 404 and no pairing are fail-soft. */
suspend fun fetchModelCapabilities(
models: List<ModelCapabilityRequestRow>,
profile: String? = null,
refresh: Boolean = false,
): Result<ModelCapabilitiesResponse?> = withContext(Dispatchers.IO) {
val boundedModels = models
.asSequence()
.map {
ModelCapabilityRequestRow(
it.provider.trim().take(MAX_MODEL_CAPABILITY_PROVIDER_CHARS),
it.model.trim().take(MAX_MODEL_CAPABILITY_MODEL_CHARS),
)
}
.filter { it.provider.isNotEmpty() && it.model.isNotEmpty() }
.distinct()
.take(MAX_MODEL_CAPABILITY_ROWS)
.toList()
if (boundedModels.isEmpty()) return@withContext Result.success(null)
val relayUrl = relayUrlProvider()?.trim().orEmpty()
val token = sessionTokenProvider()
if (relayUrl.isEmpty() || token.isNullOrBlank()) return@withContext Result.success(null)
val base = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val url = runCatching { "$base/relay/model-capabilities".toHttpUrl() }.getOrElse {
return@withContext Result.success(null)
}
val payload = ModelCapabilitiesRequest(
profile = profile?.trim()?.take(MAX_MODEL_CAPABILITY_PROFILE_CHARS)
?.takeIf { it.isNotEmpty() },
refresh = refresh,
models = boundedModels,
)
val request = Request.Builder()
.url(url)
.post(sessionsJson.encodeToString(payload).toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer $token")
.header("Accept", "application/json")
.build()
try {
okHttpClient.newBuilder().callTimeout(4, java.util.concurrent.TimeUnit.SECONDS).build()
.newCall(request).execute().use { response ->
if (response.code == 404) return@withContext Result.success(null)
if (!response.isSuccessful) return@withContext Result.failure(IOException("HTTP ${response.code}"))
val body = response.body?.string().orEmpty()
val parsed = body.takeIf { it.isNotBlank() }?.let {
sessionsJson.decodeFromString(ModelCapabilitiesResponse.serializer(), it)
}
if (parsed?.schemaVersion != 1) Result.success(null) else Result.success(parsed)
}
} catch (e: Exception) {
Log.w(TAG, "fetchModelCapabilities failed: ${e.message}")
Result.failure(e)
}
}
/**
* Ask the relay whether a newer plugin release is available — it compares its
* installed version against the latest `plugin-v*` GitHub release (cached an
@@ -1139,6 +1234,7 @@ class RelayHttpClient(
logSuccess: Boolean = true,
): Result<RelayHealth> = withContext(Dispatchers.IO) {
val trimmed = relayUrl.trim()
val operation = "Relay health probe before WebSocket connection"
if (trimmed.isEmpty()) {
return@withContext Result.failure(
IllegalArgumentException("Relay URL is empty")
@@ -1159,7 +1255,9 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.http_diag_url_invalid) ?: "Relay URL invalid",
detail = e.message,
url = relayUrl,
operation = operation,
configuredUrl = relayUrl,
suggestion = "Enter a Relay URL beginning with ws:// or wss://.",
)
return@withContext Result.failure(
IOException("Invalid relay URL: ${e.message}")
@@ -1180,6 +1278,7 @@ class RelayHttpClient(
.get()
.header("Accept", "application/json")
.build()
val requestUrl = request.url.toString()
try {
fastClient.newCall(request).execute().use { response ->
@@ -1189,8 +1288,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "HTTP ${response.code}",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forHttpStatus(response.code, "Relay"),
)
return@withContext Result.failure(
IOException("Relay responded HTTP ${response.code}")
@@ -1203,8 +1305,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "Empty response",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = "Verify this route points to a Hermes-Relay server and inspect its logs.",
)
return@withContext Result.failure(
IOException("Relay returned an empty response")
@@ -1220,8 +1325,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "Non-JSON response",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = "Verify this route points to a Hermes-Relay server rather than another HTTP service.",
)
return@withContext Result.failure(
IOException("Relay returned non-JSON: ${e.message ?: "parse error"}")
@@ -1234,8 +1342,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "status=${status ?: "missing"}",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = "Check the Relay service health and server logs.",
)
return@withContext Result.failure(
IOException("Relay reports status=${status ?: "missing"} (expected 'ok')")
@@ -1248,8 +1359,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = "Missing version field",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = "Verify this route points to a current Hermes-Relay server.",
)
return@withContext Result.failure(
IOException("Response doesn't look like a hermes-relay — missing 'version' field")
@@ -1265,7 +1379,9 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Info,
title = context?.getString(R.string.http_diag_health_ok) ?: "Relay health ok",
detail = "version=$version clients=$clients sessions=$sessions",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
)
}
@@ -1278,8 +1394,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_timeout) ?: "Relay health timeout",
detail = "No HTTP response in 3s",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
)
Result.failure(IOException("Relay is not responding (3s timeout)"))
} catch (e: java.net.ConnectException) {
@@ -1289,8 +1408,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.http_diag_conn_refused) ?: "Relay connection refused",
detail = e.message,
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
)
Result.failure(IOException("Connection refused — is the relay running on this URL?"))
} catch (e: IOException) {
@@ -1300,8 +1422,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = e.message ?: "Network error",
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
)
Result.failure(IOException("Network error: ${e.message ?: "unreachable"}"))
} catch (e: Exception) {
@@ -1311,8 +1436,11 @@ class RelayHttpClient(
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
detail = e.message ?: e.javaClass.simpleName,
url = httpBase,
operation = operation,
configuredUrl = trimmed,
requestUrl = requestUrl,
elapsedMs = System.currentTimeMillis() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
)
Result.failure(e)
}
@@ -9,6 +9,7 @@ import com.hermesandroid.relay.data.routeAuthority
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
@@ -353,6 +354,10 @@ class EndpointResolver(
surface: EndpointSurface,
): Boolean {
val startedAtMs = clock()
val operation = when (surface) {
EndpointSurface.Standard -> "Dashboard or API route health probe"
EndpointSurface.Relay -> "Relay route health probe"
}
val target = probeTarget(candidate, surface)
val url = target?.requestUrl?.toHttpUrlOrNull()
?: run {
@@ -362,8 +367,10 @@ class EndpointResolver(
severity = DiagnosticSeverity.Error,
title = context?.getString(R.string.endpoint_diag_probe_invalid) ?: "Endpoint probe invalid",
detail = "No valid Dashboard, API, or Relay URL",
operation = operation,
endpointRole = candidate.role,
url = candidate.primaryRouteUrl(),
configuredUrl = candidate.primaryRouteUrl(),
suggestion = "Edit or re-pair this route so it contains a valid service URL.",
)
recordOutcome(candidate, surface, reachable = false, detail = "Invalid route URL")
return false
@@ -397,9 +404,16 @@ class EndpointResolver(
severity = if (ok) DiagnosticSeverity.Info else DiagnosticSeverity.Warning,
title = probeTitle,
detail = if (ok) null else "HTTP ${resp.code}",
operation = operation,
endpointRole = candidate.role,
url = target.baseUrl,
configuredUrl = target.baseUrl,
requestUrl = target.requestUrl,
elapsedMs = clock() - startedAtMs,
suggestion = if (ok) {
null
} else {
NetworkDiagnosticGuidance.forHttpStatus(resp.code, surface.diagnosticTarget())
},
)
recordOutcome(
candidate,
@@ -415,9 +429,12 @@ class EndpointResolver(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
operation = operation,
endpointRole = candidate.role,
url = target.baseUrl,
configuredUrl = target.baseUrl,
requestUrl = target.requestUrl,
elapsedMs = clock() - startedAtMs,
suggestion = "Check network routing or firewall rules between this device and ${surface.diagnosticTarget()}.",
)
recordOutcome(candidate, surface, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
false
@@ -428,9 +445,12 @@ class EndpointResolver(
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
operation = operation,
endpointRole = candidate.role,
url = target.baseUrl,
configuredUrl = target.baseUrl,
requestUrl = target.requestUrl,
elapsedMs = clock() - startedAtMs,
suggestion = "Check network routing or firewall rules between this device and ${surface.diagnosticTarget()}.",
)
recordOutcome(candidate, surface, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
false
@@ -441,10 +461,13 @@ class EndpointResolver(
category = DiagnosticCategory.Endpoint,
severity = DiagnosticSeverity.Warning,
title = context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed",
detail = e.javaClass.simpleName,
detail = humanProbeFailure(e),
operation = operation,
endpointRole = candidate.role,
url = target.baseUrl,
configuredUrl = target.baseUrl,
requestUrl = target.requestUrl,
elapsedMs = clock() - startedAtMs,
suggestion = NetworkDiagnosticGuidance.forThrowable(e, surface.diagnosticTarget()),
)
recordOutcome(candidate, surface, reachable = false, detail = humanProbeFailure(e))
false
@@ -521,6 +544,11 @@ class EndpointResolver(
else -> e.javaClass.simpleName
}
private fun EndpointSurface.diagnosticTarget(): String = when (this) {
EndpointSurface.Standard -> "Dashboard or API server"
EndpointSurface.Relay -> "Relay"
}
/**
* Mark [candidate] unreachable without re-probing. Called from
* `ConnectionManager`'s `NetworkCallback.onLost` so the next resolve()
@@ -260,6 +260,9 @@ class GatewayChatClient(
private val _serverProvider = MutableStateFlow<String?>(null)
val serverProvider: StateFlow<String?> = _serverProvider.asStateFlow()
private val _serverModelIdentity = MutableStateFlow<GatewayModelIdentity?>(null)
val serverModelIdentity: StateFlow<GatewayModelIdentity?> = _serverModelIdentity.asStateFlow()
/**
* Active reasoning EFFORT from `session.info` (string; "" when reasoning is
* disabled). The reasoning DISPLAY mode is NOT on session.info — it stays a
@@ -269,6 +272,10 @@ class GatewayChatClient(
private val _serverReasoningEffort = MutableStateFlow<String?>(null)
val serverReasoningEffort: StateFlow<String?> = _serverReasoningEffort.asStateFlow()
private val _serverReasoningIdentity = MutableStateFlow<GatewayReasoningIdentity?>(null)
val serverReasoningIdentity: StateFlow<GatewayReasoningIdentity?> =
_serverReasoningIdentity.asStateFlow()
/**
* Server-reported credential warning (upstream `session.info.credential_warning`)
* — present ONLY when the active provider's key is missing/invalid, absent
@@ -1421,20 +1428,7 @@ class GatewayChatClient(
return rpc("model.options", params).map { result ->
val providers = (result["providers"] as? JsonArray).orEmpty().mapNotNull { el ->
val obj = el as? JsonObject ?: return@mapNotNull null
val slug = obj.stringField("slug") ?: return@mapNotNull null
GatewayModelProvider(
name = obj.stringField("name") ?: slug,
slug = slug,
models = (obj["models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
warning = obj.stringField("warning"),
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull() ?: 0,
)
parseGatewayModelProvider(obj)
}
GatewayModelOptions(
providers = providers,
@@ -1784,12 +1778,23 @@ class GatewayChatClient(
_serverPersonality.value =
(info.stringField("personality") ?: "").ifBlank { "none" }
}
info.stringField("model")?.takeIf { it.isNotBlank() }?.let { _serverModel.value = it }
info.stringField("provider")?.takeIf { it.isNotBlank() }?.let { _serverProvider.value = it }
val model = info.stringField("model")?.takeIf { it.isNotBlank() }
val provider = info.stringField("provider")?.takeIf { it.isNotBlank() }
model?.let { _serverModel.value = it }
provider?.let { _serverProvider.value = it }
if (model != null && provider != null) {
_serverModelIdentity.value = GatewayModelIdentity(model = model, provider = provider)
}
// reasoning effort: ignore "" (reasoning disabled) so it can't clobber
// the chip; display mode is config.get-only, not here.
info.stringField("reasoning_effort")?.takeIf { it.isNotBlank() }
?.let { _serverReasoningEffort.value = it }
val reasoningEffort = info.stringField("reasoning_effort")?.takeIf { it.isNotBlank() }
reasoningEffort?.let { _serverReasoningEffort.value = it }
if (model != null && provider != null && reasoningEffort != null) {
_serverReasoningIdentity.value = GatewayReasoningIdentity(
identity = GatewayModelIdentity(model = model, provider = provider),
effort = reasoningEffort,
)
}
// credential_warning: present only when the provider key is missing/
// invalid. ABSENT means healthy — clear to null so it self-resolves.
_serverCredentialWarning.value =
@@ -1,6 +1,11 @@
package com.hermesandroid.relay.network.upstream
import com.hermesandroid.relay.network.upstream.models.UsageInfo
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
/**
* Shared types for the Gateway chat transport — upstream hermes-agent's
@@ -347,8 +352,45 @@ data class GatewayModelProvider(
val unavailableModels: List<String> = emptyList(),
val freeTier: Boolean = false,
val totalModels: Int = 0,
/** Per-model capability rows keyed by the exact model id. */
val capabilities: Map<String, GatewayModelCapabilities> = emptyMap(),
)
/** Shared tolerant parser for the gateway RPC and API-server REST twins. */
internal fun parseGatewayModelProvider(obj: JsonObject): GatewayModelProvider? {
val slug = (obj["slug"] as? JsonPrimitive)?.contentOrNull
?.trim()?.takeIf { it.isNotEmpty() } ?: return null
val capabilities = (obj["capabilities"] as? JsonObject).orEmpty().mapNotNull { (model, raw) ->
val row = raw as? JsonObject ?: return@mapNotNull null
val effortsElement = row["reasoning_efforts"]
val efforts = if (effortsElement is JsonArray) {
effortsElement.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
} else {
null
}
model to GatewayModelCapabilities(
reasoning = (row["reasoning"] as? JsonPrimitive)?.booleanOrNull,
reasoningEfforts = efforts,
reasoningEffortsExact =
(row["reasoning_efforts_exact"] as? JsonPrimitive)?.booleanOrNull,
)
}.toMap()
return GatewayModelProvider(
name = (obj["name"] as? JsonPrimitive)?.contentOrNull ?: slug,
slug = slug,
models = (obj["models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
warning = (obj["warning"] as? JsonPrimitive)?.contentOrNull,
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull() ?: 0,
capabilities = capabilities,
)
}
data class GatewayMoaReference(
val index: Int?,
val count: Int?,
@@ -364,6 +406,15 @@ data class GatewayModelOptions(
val currentProvider: String,
)
/** Coherent model identity from a single `session.info` payload. */
data class GatewayModelIdentity(val model: String, val provider: String)
/** Model identity and effort observed together in one `session.info` payload. */
data class GatewayReasoningIdentity(
val identity: GatewayModelIdentity,
val effort: String,
)
/** Reject provider catalogs that completed after a profile/context switch. */
internal fun isCurrentModelOptionsResponse(
requestGeneration: Long,
@@ -215,22 +215,7 @@ internal fun parseApiProviderModelOptionsBody(
val rows = root["providers"] as? JsonArray ?: return null
val providers = rows.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val slug = (obj["slug"] as? JsonPrimitive)?.contentOrNull
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
GatewayModelProvider(
name = (obj["name"] as? JsonPrimitive)?.contentOrNull ?: slug,
slug = slug,
models = (obj["models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
warning = (obj["warning"] as? JsonPrimitive)?.contentOrNull,
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull
?.toIntOrNull() ?: 0,
)
parseGatewayModelProvider(obj)
}
return ApiProviderModelOptions(
providers = providers,
@@ -254,7 +239,8 @@ enum class ApiModelRoutingErrorCode {
class ApiModelRoutingException(
val code: ApiModelRoutingErrorCode,
message: String,
) : IOException(message)
cause: Throwable? = null,
) : IOException(message, cause)
sealed interface ApiModelSelectionAck {
data object ServerDefault : ApiModelSelectionAck
@@ -815,6 +801,7 @@ class HermesApiClient(
ApiModelRoutingException(
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
"Model inventory could not be loaded.",
e,
)
},
)
@@ -0,0 +1,102 @@
package com.hermesandroid.relay.network.upstream
/** Canonical reasoning-effort values accepted by upstream Hermes. */
object ReasoningEfforts {
const val DEFAULT = "medium"
val canonical: List<String> =
listOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
fun normalize(value: String?): String {
val normalized = value?.trim()?.lowercase().orEmpty()
return normalized.takeIf { it in canonical } ?: DEFAULT
}
}
/** Provider/model capability row advertised by upstream `model.options`. */
data class GatewayModelCapabilities(
/** Legacy capability flag. Null means the server did not advertise support either way. */
val reasoning: Boolean? = null,
/** Exact selectable values on newer servers. Null means use the canonical compatibility list. */
val reasoningEfforts: List<String>? = null,
/** Explicit false means the advertised list is advisory rather than selectable. */
val reasoningEffortsExact: Boolean? = null,
)
data class ReasoningEffortAvailability(
val supported: Boolean?,
val choices: List<String>,
val exact: Boolean,
) {
fun accepts(effort: String): Boolean = supported != false && (!exact || effort in choices)
}
/** Exact provider/model identity to which a confirmed effort belongs. */
data class ReasoningEffortIdentity(val provider: String, val model: String)
/**
* Resolve the active provider/model's advertised reasoning contract.
*
* Older servers expose either no capability entry or only `reasoning: true`;
* those receive the full canonical compatibility list. An explicit false
* disables the control. A `reasoning_efforts` array is authoritative.
*/
fun resolveReasoningEffortAvailability(
providers: List<GatewayModelProvider>,
provider: String?,
model: String?,
relayCapabilities: Map<ReasoningEffortIdentity, GatewayModelCapabilities> = emptyMap(),
): ReasoningEffortAvailability {
val normalizedProvider = provider?.trim().orEmpty()
val normalizedModel = model?.trim().orEmpty()
if (normalizedProvider.isEmpty() || normalizedModel.isEmpty()) {
return ReasoningEffortAvailability(
supported = null,
choices = ReasoningEfforts.canonical,
exact = false,
)
}
val providerRow = providers.firstOrNull {
it.slug.equals(normalizedProvider, ignoreCase = true)
}
val upstream = providerRow?.capabilities?.get(normalizedModel)
?: providerRow?.capabilities?.entries?.firstOrNull {
it.key.equals(normalizedModel, ignoreCase = true)
}?.value
val identity = ReasoningEffortIdentity(
provider = normalizedProvider.lowercase(),
model = normalizedModel,
)
val relay = relayCapabilities[identity]
fun exactAvailability(capabilities: GatewayModelCapabilities): ReasoningEffortAvailability {
val choices = capabilities.reasoningEfforts.orEmpty()
.map { it.trim().lowercase() }
.filter { it in ReasoningEfforts.canonical }
.distinct()
return ReasoningEffortAvailability(
supported = capabilities.reasoning ?: choices.isNotEmpty(),
choices = choices,
exact = true,
)
}
// Contract precedence: authoritative upstream, authoritative Relay overlay,
// explicit upstream suppression, then compatibility fallback.
if (upstream?.reasoningEfforts != null && upstream.reasoningEffortsExact == true) {
return exactAvailability(upstream)
}
if (relay?.reasoningEfforts != null && relay.reasoningEffortsExact == true) {
return exactAvailability(relay)
}
if (upstream?.reasoning == false) {
return ReasoningEffortAvailability(supported = false, choices = emptyList(), exact = false)
}
return ReasoningEffortAvailability(
supported = upstream?.reasoning,
choices = ReasoningEfforts.canonical,
exact = false,
)
}
@@ -0,0 +1,123 @@
package com.hermesandroid.relay.reliability
import android.content.Context
import android.os.Build
import com.hermesandroid.relay.BuildConfig
import java.io.PrintWriter
import java.io.StringWriter
import java.time.Instant
import java.util.concurrent.Executors
/**
* Android boundary for the local reliability store. Nothing in this object has
* a network path; writes stay in app-private storage until a user explicitly
* reviews and shares text through the UI.
*/
object ReliabilityCenter {
private val writer = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "hermes-reliability-writer").apply { isDaemon = true }
}
private val appSessionId = ReliabilityReport.newId("app")
@Volatile
private var store: ReliabilityStore? = null
fun initialize(context: Context) {
if (store != null) return
synchronized(this) {
if (store == null) {
store = ReliabilityStore(
java.io.File(context.applicationContext.filesDir, "reliability/reports-v1.json"),
)
}
}
}
fun recordFatal(
context: Context,
throwable: Throwable,
threadName: String,
timeIso: String = Instant.now().toString(),
): ReliabilityReport {
initialize(context)
val summary = buildString {
append(throwable.javaClass.simpleName.ifBlank { "Unexpected crash" })
throwable.message?.takeIf { it.isNotBlank() }?.let { append(": ").append(it) }
}
val report = ReliabilityReport(
reportId = ReliabilityReport.newId(),
appSessionId = appSessionId,
timeIso = timeIso,
kind = ReliabilityKind.FatalCrash,
owner = ReliabilityOwner.Android,
severity = ReliabilitySeverity.Fatal,
summary = summary,
recovery = "The app restarted. Work already running on Hermes may still be active.",
reportRecommended = true,
technicalDetail = "Thread: $threadName\n${stackTraceText(throwable)}",
environment = environment(),
pendingReview = true,
)
// Fatal capture must complete before the platform terminates the process.
store?.append(report)
return report
}
fun recordHandled(
title: String,
detail: String?,
throwable: Throwable,
context: String?,
routeRole: String? = null,
) {
val target = store ?: return
val classification = ReliabilityClassifier.classify(throwable, context)
if (!classification.shouldPersist) return
val report = ReliabilityReport(
reportId = ReliabilityReport.newId(),
appSessionId = appSessionId,
timeIso = Instant.now().toString(),
kind = classification.kind,
owner = classification.owner,
severity = ReliabilitySeverity.Error,
summary = title,
recovery = detail ?: "The failure was handled; retry or review Diagnostics if it continues.",
reportRecommended = classification.reportRecommended,
technicalDetail = stackTraceText(throwable),
routeRole = routeRole,
environment = environment(),
)
writer.execute { runCatching { target.append(report) } }
}
fun reports(context: Context): List<ReliabilityReport> {
initialize(context)
return store?.readAll().orEmpty()
}
fun pendingCrash(context: Context): ReliabilityReport? =
reports(context).lastOrNull { it.kind == ReliabilityKind.FatalCrash && it.pendingReview }
fun markReviewed(context: Context, reportId: String) {
initialize(context)
store?.markReviewed(reportId)
}
fun import(context: Context, report: ReliabilityReport) {
initialize(context)
store?.append(report)
}
fun environment(): ReliabilityEnvironment = ReliabilityEnvironment(
versionName = BuildConfig.VERSION_NAME,
versionCode = BuildConfig.VERSION_CODE,
flavor = BuildConfig.FLAVOR,
manufacturer = Build.MANUFACTURER.orEmpty().ifBlank { "?" },
model = Build.MODEL.orEmpty().ifBlank { "?" },
androidRelease = Build.VERSION.RELEASE.orEmpty().ifBlank { "?" },
sdkInt = Build.VERSION.SDK_INT,
)
private fun stackTraceText(throwable: Throwable): String =
StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
}
@@ -0,0 +1,361 @@
package com.hermesandroid.relay.reliability
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.time.Instant
import java.util.UUID
import java.util.concurrent.CancellationException
const val RELIABILITY_SCHEMA_VERSION = 1
@Serializable
enum class ReliabilityKind {
FatalCrash,
AnrSignal,
RecoverableProductError,
Connectivity,
Authentication,
RateLimit,
ServiceUnavailable,
ExpectedCancellation,
UserDenial,
}
@Serializable
enum class ReliabilityOwner(val label: String) {
Android("Android"),
Dashboard("Dashboard"),
Api("API"),
Relay("Relay"),
UpstreamGateway("Upstream Gateway"),
Voice("Voice"),
Unknown("Unknown"),
}
@Serializable
enum class ReliabilitySeverity { Info, Warning, Error, Fatal }
@Serializable
data class ReliabilityEnvironment(
val versionName: String,
val versionCode: Int,
val flavor: String,
val manufacturer: String,
val model: String,
val androidRelease: String,
val sdkInt: Int,
)
/**
* Allowlisted local reliability record. There are deliberately no fields for
* prompts, messages, profile names, product session IDs, URLs, media, or paths.
*/
@Serializable
data class ReliabilityReport(
val schemaVersion: Int = RELIABILITY_SCHEMA_VERSION,
val reportId: String,
val appSessionId: String,
val timeIso: String,
val kind: ReliabilityKind,
val owner: ReliabilityOwner,
val severity: ReliabilitySeverity,
val summary: String,
val recovery: String,
val reportRecommended: Boolean,
val technicalDetail: String? = null,
val routeRole: String? = null,
val environment: ReliabilityEnvironment,
val pendingReview: Boolean = false,
) {
fun shortTitle(): String = summary.lineSequence().firstOrNull().orEmpty().ifBlank {
kind.name
}.take(90)
fun versionLine(): String =
"${environment.versionName} (code ${environment.versionCode}) ${environment.flavor}"
fun environmentBlock(): String = buildString {
appendLine("- Hermes-Relay version/tag: ${environment.versionName} (code ${environment.versionCode})")
appendLine(
"- Install surface: " +
if (environment.flavor.equals("sideload", ignoreCase = true)) "sideload APK" else "Google Play",
)
appendLine(
"- Android device and OS: ${environment.manufacturer} ${environment.model} — " +
"Android ${environment.androidRelease} (SDK ${environment.sdkInt})",
)
append("- Connection mode: ${routeRole ?: "unknown"}")
}
/** Exact local review/copy/share payload. Redaction is repeated for legacy defense in depth. */
fun toPlainText(): String = ReliabilityRedactor.redact(
buildString {
appendLine("Hermes-Relay support information")
appendLine("Report: $reportId")
appendLine("Session: $appSessionId")
appendLine("Time: $timeIso")
appendLine("Type: ${kind.name}")
appendLine("Owner: ${owner.label}")
appendLine("App: ${versionLine()}")
appendLine(
"Device: ${environment.manufacturer} ${environment.model} — " +
"Android ${environment.androidRelease} (SDK ${environment.sdkInt})",
)
routeRole?.let { appendLine("Route: $it") }
appendLine()
appendLine("What happened: $summary")
appendLine("Recovery: $recovery")
technicalDetail?.let {
appendLine()
appendLine("Technical detail (redacted)")
append(it)
}
},
)
companion object {
fun newId(prefix: String = "rpt"): String =
"$prefix-${UUID.randomUUID().toString().replace("-", "").take(16)}"
}
}
/** Old `files/crash/last-crash.json` shape, retained only for one-way migration. */
@Serializable
data class LegacyCrashSnapshot(
val timeIso: String,
val versionName: String,
val versionCode: Int,
val flavor: String,
val manufacturer: String,
val model: String,
val androidRelease: String,
val sdkInt: Int,
val threadName: String,
val exceptionSummary: String,
val stackTrace: String,
)
fun migrateLegacyCrash(
old: LegacyCrashSnapshot,
reportId: String = ReliabilityReport.newId(),
appSessionId: String = ReliabilityReport.newId("legacy"),
): ReliabilityReport = ReliabilityReport(
reportId = reportId,
appSessionId = appSessionId,
timeIso = runCatching { Instant.parse(old.timeIso).toString() }.getOrDefault(old.timeIso),
kind = ReliabilityKind.FatalCrash,
owner = ReliabilityOwner.Android,
severity = ReliabilitySeverity.Fatal,
summary = ReliabilityRedactor.redact(old.exceptionSummary, 240),
recovery = "The app restarted. Work already running on Hermes may still be active.",
reportRecommended = true,
technicalDetail = ReliabilityRedactor.redact("Thread: ${old.threadName}\n${old.stackTrace}"),
environment = ReliabilityEnvironment(
old.versionName, old.versionCode, old.flavor, old.manufacturer, old.model,
old.androidRelease, old.sdkInt,
),
pendingReview = true,
)
data class ReliabilityClassification(
val kind: ReliabilityKind,
val owner: ReliabilityOwner,
val reportRecommended: Boolean,
val shouldPersist: Boolean,
)
object ReliabilityClassifier {
fun classify(throwable: Throwable, context: String? = null): ReliabilityClassification {
val message = throwable.message.orEmpty().lowercase()
val owner = ownerForContext(context)
return when {
throwable is CancellationException -> ReliabilityClassification(
ReliabilityKind.ExpectedCancellation, owner, reportRecommended = false, shouldPersist = false,
)
throwable is SecurityException && ("denied" in message || "permission" in message) ->
ReliabilityClassification(
ReliabilityKind.UserDenial, ReliabilityOwner.Android,
reportRecommended = false, shouldPersist = false,
)
"429" in message || "rate limit" in message || "too many requests" in message ->
ReliabilityClassification(
ReliabilityKind.RateLimit, owner, reportRecommended = false, shouldPersist = true,
)
"401" in message || "403" in message || "unauthorized" in message || "forbidden" in message ->
ReliabilityClassification(
ReliabilityKind.Authentication, owner, reportRecommended = false, shouldPersist = true,
)
throwable is java.net.UnknownHostException ||
throwable is java.net.ConnectException ||
throwable is java.net.SocketTimeoutException ||
"timeout" in message -> ReliabilityClassification(
ReliabilityKind.Connectivity, owner, reportRecommended = false, shouldPersist = true,
)
"503" in message || "service unavailable" in message || "gateway_draining" in message ->
ReliabilityClassification(
ReliabilityKind.ServiceUnavailable, owner,
reportRecommended = false, shouldPersist = true,
)
else -> ReliabilityClassification(
ReliabilityKind.RecoverableProductError, owner,
reportRecommended = true, shouldPersist = true,
)
}
}
fun ownerForContext(context: String?): ReliabilityOwner = when (context?.lowercase()) {
"dashboard", "manage", "dashboard_auth" -> ReliabilityOwner.Dashboard
"gateway", "gateway_chat", "upstream_gateway" -> ReliabilityOwner.UpstreamGateway
"transcribe", "synthesize", "voice_config", "record", "voice" -> ReliabilityOwner.Voice
"pair", "save_and_test", "media_fetch", "relay" -> ReliabilityOwner.Relay
"send_message", "load_sessions", "create_session", "api" -> ReliabilityOwner.Api
"android", "permission", "ui" -> ReliabilityOwner.Android
else -> ReliabilityOwner.Unknown
}
}
/** Local, deterministic redaction. It runs before persistence and again before export. */
object ReliabilityRedactor {
const val MAX_TECHNICAL_LENGTH = 8_000
private const val HIDDEN = "[hidden]"
private val secretAssignment = Regex(
"""(?i)\b(authorization|bearer|cookie|set-cookie|token|api[_-]?key|session[_-]?token|pairing[_-]?code|password|secret|oauth[_-]?code)\s*[:=]\s*((?:Bearer\s+)?[^\s,;]+)""",
)
private val sensitiveHeader = Regex("""(?im)^\s*(authorization|cookie|set-cookie)\s*:\s*.+$""")
private val standaloneBearer = Regex("""(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+""")
private val sensitivePayload = Regex(
"""(?i)\b(prompt|message|content|transcript|reasoning|tool[_-]?(args|result)|profile[_-]?name)\s*[:=]\s*([^\r\n]+)""",
)
private val url = Regex("""(?i)\b(?:https?|wss?)://[^\s)\]}>,]+""")
private val ipv4 = Regex("""(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?(?![\w.])""")
private val uuid = Regex("""(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b""")
private val email = Regex("""(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b""")
private val namedHost = Regex("""(?i)\b(host|hostname)\s*[:=]\s*[^\s,;]+""")
private val unresolvedHost = Regex("""(?i)(?:resolve|resolved|host)\s+[\"']([^\"']+)[\"']""")
private val windowsPath = Regex("""(?i)\b[A-Z]:\\(?:[^\s\\]+\\)+[^\s]+""")
private val unixPrivatePath = Regex("""(?i)(?:/home/|/Users/|/data/user/\d+/|/sdcard/)[^\s]+""")
fun redact(value: String, maxLength: Int = MAX_TECHNICAL_LENGTH): String {
var result = value
result = sensitiveHeader.replace(result) { "${it.groupValues[1]}: $HIDDEN" }
result = secretAssignment.replace(result) { "${it.groupValues[1]}=$HIDDEN" }
result = standaloneBearer.replace(result, "Bearer $HIDDEN")
result = sensitivePayload.replace(result) { "${it.groupValues[1]}=$HIDDEN" }
result = url.replace(result, "[url hidden]")
result = ipv4.replace(result, "[host hidden]")
result = uuid.replace(result, "[id hidden]")
result = email.replace(result, "[email hidden]")
result = namedHost.replace(result) { "${it.groupValues[1]}=$HIDDEN" }
result = unresolvedHost.replace(result, "host \"$HIDDEN\"")
result = windowsPath.replace(result, "[path hidden]")
result = unixPrivatePath.replace(result, "[path hidden]")
return if (result.length > maxLength) {
result.take(maxLength) + "\n… (truncated)"
} else {
result
}
}
}
@Serializable
private data class ReliabilityEnvelope(
val schemaVersion: Int = RELIABILITY_SCHEMA_VERSION,
val reports: List<ReliabilityReport> = emptyList(),
)
/** Pure file store so retention, bounds, and migration behavior are JVM-testable. */
class ReliabilityStore(
private val file: File,
private val maxReports: Int = 20,
private val retentionDays: Long = 14,
) {
private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }
private val lock = Any()
fun readAll(now: Instant = Instant.now()): List<ReliabilityReport> = synchronized(lock) {
val decoded = decode()
val retained = prune(decoded, now)
if (retained != decoded) write(retained)
retained
}
fun append(report: ReliabilityReport, now: Instant = Instant.now()) = synchronized(lock) {
write(prune(decode() + sanitize(report), now))
}
fun markReviewed(reportId: String, now: Instant = Instant.now()) = synchronized(lock) {
write(
prune(
decode().map { if (it.reportId == reportId) it.copy(pendingReview = false) else it },
now,
),
)
}
private fun sanitize(report: ReliabilityReport): ReliabilityReport = report.copy(
summary = ReliabilityRedactor.redact(report.summary, 240),
recovery = ReliabilityRedactor.redact(report.recovery, 240),
technicalDetail = report.technicalDetail?.let(ReliabilityRedactor::redact),
routeRole = report.routeRole?.let { ReliabilityRedactor.redact(it, 40) },
)
private fun prune(reports: List<ReliabilityReport>, now: Instant): List<ReliabilityReport> {
val cutoff = now.minusSeconds(retentionDays * 24 * 60 * 60)
return reports
.distinctBy { it.reportId }
.filter { report -> runCatching { Instant.parse(report.timeIso) >= cutoff }.getOrDefault(true) }
.sortedBy { it.timeIso }
.takeLast(maxReports.coerceAtLeast(1))
}
private fun decode(): List<ReliabilityReport> = runCatching {
if (!file.isFile) return emptyList()
json.decodeFromString<ReliabilityEnvelope>(file.readText()).reports
}.getOrDefault(emptyList())
private fun write(reports: List<ReliabilityReport>) {
file.parentFile?.mkdirs()
val temp = File(file.parentFile, "${file.name}.tmp")
temp.writeText(json.encodeToString(ReliabilityEnvelope(reports = reports)))
runCatching {
java.nio.file.Files.move(
temp.toPath(),
file.toPath(),
java.nio.file.StandardCopyOption.ATOMIC_MOVE,
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
)
}.recoverCatching {
java.nio.file.Files.move(
temp.toPath(),
file.toPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
)
}.getOrThrow()
}
}
object SupportBundleBuilder {
const val MAX_REPORTS = 10
fun build(reports: List<ReliabilityReport>): String {
val selected = reports.sortedByDescending { it.timeIso }.take(MAX_REPORTS)
return ReliabilityRedactor.redact(
buildString {
appendLine("Hermes-Relay support bundle")
appendLine("Local-only export · review before sharing")
appendLine("Reports: ${selected.size}")
selected.forEachIndexed { index, report ->
appendLine()
appendLine("===== Report ${index + 1} =====")
append(report.toPlainText())
appendLine()
}
},
maxLength = 64_000,
)
}
}
@@ -167,11 +167,12 @@ fun ChatInputBar(
onModelOptionSelected: (ChatInputPickerOption) -> Unit = {},
onModelPickerClick: (() -> Unit)? = null,
effortControl: ChatInputPickerControl? = null,
onEffortOptionSelected: (ChatInputPickerOption) -> Unit = {},
onEffortPickerClick: (() -> Unit)? = null,
topContent: (@Composable () -> Unit)? = null,
topContentVisible: Boolean = topContent != null,
suppressVoiceTrailing: Boolean = false,
modifier: Modifier = Modifier,
surfaceModifier: Modifier = Modifier,
enabled: Boolean = true,
) {
// Keep the last caption around so the AnimatedVisibility exit doesn't
@@ -255,7 +256,8 @@ fun ChatInputBar(
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
.padding(horizontal = 8.dp, vertical = 6.dp)
.then(surfaceModifier),
) {
Column {
if (topContent != null) {
@@ -397,8 +399,9 @@ fun ChatInputBar(
if (effortControl != null) {
ChatInputPickerChip(
control = effortControl,
onSelect = onEffortOptionSelected,
onSelect = {},
modifier = Modifier.widthIn(max = 104.dp),
onClickOverride = onEffortPickerClick,
)
}
@@ -707,12 +707,14 @@ fun AgentInfoSheet(
val personalityNames by chatViewModel.personalityNames.collectAsState()
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
val selectedModel by chatViewModel.selectedModelOverride.collectAsState()
val selectedProvider by chatViewModel.selectedProviderOverride.collectAsState()
val serverModel by chatViewModel.serverModelName.collectAsState()
val gatewayModel by chatViewModel.gatewayCurrentModel.collectAsState()
val gatewayProvider by chatViewModel.gatewayCurrentProvider.collectAsState()
val modelProviders by chatViewModel.modelProviders.collectAsState()
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState()
val reasoningCapabilityRevision by chatViewModel.reasoningCapabilityRevision.collectAsState()
val selectedReasoning by chatViewModel.selectedReasoningEffort.collectAsState()
val approvalMode by chatViewModel.approvalMode.collectAsState()
val approvalCapability by chatViewModel.approvalModeCapability.collectAsState()
@@ -742,6 +744,18 @@ fun AgentInfoSheet(
connectionViewModel.connectionStore.activeConnectionId.collectAsState()
val authState by connectionViewModel.authState.collectAsState()
val reasoningAvailability = remember(
selectedModel,
selectedProvider,
gatewayModel,
gatewayProvider,
modelProviders,
apiModelOptions,
reasoningCapabilityRevision,
) {
chatViewModel.reasoningEffortAvailability()
}
var selectedTab by remember { mutableStateOf(0) }
var activePicker by remember { mutableStateOf<AgentPassportPicker?>(null) }
var showIdentityEditor by remember { mutableStateOf(false) }
@@ -1014,15 +1028,21 @@ fun AgentInfoSheet(
enabled = !isStreaming,
onClick = { activePicker = AgentPassportPicker.Model },
)
PassportDivider()
PassportConfigRow(
icon = Icons.Filled.Psychology,
title = stringResource(R.string.chat_select_reasoning_effort),
value = reasoningLabel(selectedReasoning),
expanded = false,
enabled = gatewayControlsAvailable && !isStreaming,
onClick = { activePicker = AgentPassportPicker.Reasoning },
)
if (
reasoningAvailability.supported != false &&
reasoningAvailability.choices.isNotEmpty()
) {
PassportDivider()
PassportConfigRow(
icon = Icons.Filled.Psychology,
title = stringResource(R.string.chat_select_reasoning_effort),
value = selectedReasoning?.let { reasoningEffortLabel(it) }
?: serverDefaultLabel,
expanded = false,
enabled = gatewayControlsAvailable && !isStreaming,
onClick = { activePicker = AgentPassportPicker.Reasoning },
)
}
}
}
@@ -1120,7 +1140,7 @@ fun AgentInfoSheet(
sessionProfileName = effectiveSessionProfileName,
modelLabel = sessionModelLabel,
providerLabel = sessionProviderLabel,
reasoningLabel = selectedReasoning?.let(::reasoningLabel)
reasoningLabel = selectedReasoning?.let { reasoningEffortLabel(it) }
?: stringResource(R.string.conn_info_server_default),
approvalMode = approvalMode,
approvalCapability = approvalCapability,
@@ -1232,29 +1252,38 @@ fun AgentInfoSheet(
},
onDismiss = { activePicker = null },
)
AgentPassportPicker.Reasoning -> OptionPickerSheet(
title = stringResource(R.string.chat_select_reasoning_effort),
options = listOf(
"none" to stringResource(R.string.chat_reasoning_none),
"minimal" to stringResource(R.string.chat_reasoning_minimal),
"low" to stringResource(R.string.chat_reasoning_low),
"medium" to stringResource(R.string.chat_reasoning_medium),
"high" to stringResource(R.string.chat_reasoning_high),
"xhigh" to "XHigh",
).map { (value, label) ->
AgentPassportPicker.Reasoning -> {
val reasoningOptions = reasoningAvailability.choices.map { value ->
ChatInputPickerOption(
label = label,
label = reasoningEffortLabel(value),
value = value,
selected = selectedReasoning == value,
enabled = gatewayControlsAvailable && !isStreaming,
enabled = reasoningAvailability.supported != false &&
gatewayControlsAvailable && !isStreaming,
)
},
onSelect = { option ->
option.value?.let(chatViewModel::selectReasoningEffort)
activePicker = null
},
onDismiss = { activePicker = null },
)
}
OptionPickerSheet(
title = stringResource(R.string.chat_select_reasoning_effort),
subtitle = when {
reasoningAvailability.exact &&
selectedReasoning != null &&
selectedReasoning !in reasoningAvailability.choices -> stringResource(
R.string.reasoning_effort_current_outside_supported,
reasoningEffortLabel(selectedReasoning),
reasoningOptions.joinToString { it.label },
)
!reasoningAvailability.exact ->
stringResource(R.string.reasoning_effort_standard_levels_notice)
else -> null
},
options = reasoningOptions,
onSelect = { option ->
option.value?.let(chatViewModel::selectReasoningEffort)
activePicker = null
},
onDismiss = { activePicker = null },
)
}
null -> Unit
}
@@ -2094,9 +2123,6 @@ private fun compactTokenCount(value: Int): String = when {
else -> value.toString()
}
private fun reasoningLabel(value: String?): String =
value?.replaceFirstChar { it.uppercase() } ?: "Medium"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LegacyAgentInfoSheet(
@@ -46,7 +46,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.hermesandroid.relay.R
import com.hermesandroid.relay.util.CrashReport
import com.hermesandroid.relay.reliability.ReliabilityReport
import com.hermesandroid.relay.util.CrashReporter
import com.hermesandroid.relay.util.IssueReport
import kotlinx.coroutines.Dispatchers
@@ -57,16 +57,16 @@ import kotlinx.coroutines.withContext
* crashed). Render it inside the app theme so the dialog picks up Material
* colors — see RelayApp.
*
* Peeks the report on first composition (does NOT delete on read) and clears it
* only when the user acknowledges it (Dismiss/Report). A report the user merely
* Peeks the report on first composition (does not mark it reviewed on read) and
* acknowledges it only on Dismiss/Report. A report the user merely
* glanced at — or never reached because the app was backgrounded — therefore
* survives relaunches instead of being lost after one view; once acknowledged
* it's deleted and won't reappear.
* survives relaunches instead of being lost after one view. Once acknowledged,
* it remains in bounded Diagnostics history but does not interrupt startup again.
*/
@Composable
fun CrashReportGate() {
val context = LocalContext.current
var report by remember { mutableStateOf<CrashReport?>(null) }
var report by remember { mutableStateOf<ReliabilityReport?>(null) }
var checked by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
@@ -79,19 +79,26 @@ fun CrashReportGate() {
CrashReportDialog(
report = pending,
onDismiss = {
// Acknowledged (Dismiss/Report) → delete so it won't reappear.
// Acknowledged (Dismiss/Report) → retain as reviewed history without showing again.
// Copy does NOT route through here, so the report stays available
// across relaunches until the user actually dismisses or reports it.
CrashReporter.clearPending(context)
CrashReporter.clearPending(context, pending.reportId)
report = null
},
)
}
@Composable
private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
private fun CrashReportDialog(report: ReliabilityReport, onDismiss: () -> Unit) {
val context = LocalContext.current
val reportText = remember(report) { report.toPlainText() }
var showDetails by remember(report.reportId) { mutableStateOf(false) }
val copiedMessage = stringResource(R.string.crash_toast_copied)
val noShareMessage = stringResource(R.string.crash_toast_no_share)
val shareTitle = stringResource(R.string.crash_share_title)
val reportSubject = stringResource(R.string.crash_share_subject, report.shortTitle())
val openedMessage = stringResource(R.string.crash_toast_opened)
val noBrowserMessage = stringResource(R.string.crash_toast_no_browser)
Dialog(
onDismissRequest = onDismiss,
@@ -126,25 +133,34 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 300.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
) {
SelectionContainer {
Text(
text = reportText,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
Text(
text = stringResource(R.string.crash_privacy),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
if (showDetails) {
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 300.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
) {
SelectionContainer {
Text(
text = reportText,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
}
@@ -157,45 +173,45 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, reportText)
toast(context, "Crash report copied")
},
) { Text(stringResource(R.string.common_copy)) }
if (!showDetails) {
Button(onClick = { showDetails = true }) {
Text(stringResource(R.string.crash_review))
}
} else {
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, reportText)
toast(context, copiedMessage)
},
) { Text(stringResource(R.string.common_copy)) }
// Universal, GitHub-free path: hand the full report to the
// system share sheet (email, chat apps, notes, Drive…). The
// user picks the destination, so nothing leaves the device
// until they choose to send it — same privacy posture as Copy.
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
"Hermes-Relay crash report — ${report.shortTitle()}",
reportText,
chooserTitle = "Share crash report",
)
if (!shared) {
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
reportSubject,
reportText,
chooserTitle = shareTitle,
)
if (!shared) {
IssueReport.copyToClipboard(context, reportText)
toast(context, noShareMessage)
}
onDismiss()
},
) { Text(stringResource(R.string.common_share)) }
Button(
onClick = {
IssueReport.copyToClipboard(context, reportText)
toast(context, "Report copied — no app found to share to")
}
onDismiss()
},
) { Text(stringResource(R.string.common_share)) }
Button(
onClick = {
// Copy the FULL report first; the URL only carries the
// head of the trace, so the user can paste the rest.
IssueReport.copyToClipboard(context, reportText)
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
toast(
context,
if (opened) "Full report copied — paste into the issue if it's truncated"
else "Report copied — no browser found to open GitHub",
)
onDismiss()
},
) { Text(stringResource(R.string.common_report)) }
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
toast(context, if (opened) openedMessage else noBrowserMessage)
onDismiss()
},
) { Text(stringResource(R.string.common_report)) }
}
}
}
}
@@ -106,9 +106,27 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
MetaRow("When", DateFormat.format("yyyy-MM-dd HH:mm:ss", entry.timestampMs).toString())
MetaRow("Severity", severityName)
MetaRow("Category", entry.category.label)
entry.operation?.let { MetaRow("Operation", it) }
entry.endpointRole?.let { MetaRow("Route", it) }
entry.url?.let { MetaRow("URL", it) }
entry.configuredUrl?.let { MetaRow("Configured URL", it) }
entry.requestUrl?.let { MetaRow("Request", it) }
if (entry.configuredUrl == null && entry.requestUrl == null) {
entry.url?.let { MetaRow("URL", it) }
}
entry.elapsedMs?.let { MetaRow("Elapsed", "${it}ms") }
entry.suggestion?.let { suggestion ->
Spacer(Modifier.height(10.dp))
Text(
text = "Suggested next step",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = suggestion,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(Modifier.height(14.dp))
val body = entry.stacktrace ?: entry.detail
@@ -269,9 +287,15 @@ private fun DiagnosticLogEntry.toPlainText(): String = buildString {
appendLine("Severity: ${severity.name}")
appendLine("Time: ${DateFormat.format("yyyy-MM-dd HH:mm:ss", timestampMs)}")
appendLine("App: ${BuildConfig.VERSION_NAME} (code ${BuildConfig.VERSION_CODE}) ${BuildConfig.FLAVOR}")
operation?.let { appendLine("Operation: $it") }
endpointRole?.let { appendLine("Route: $it") }
url?.let { appendLine("URL: $it") }
configuredUrl?.let { appendLine("Configured URL: $it") }
requestUrl?.let { appendLine("Request: $it") }
if (configuredUrl == null && requestUrl == null) {
url?.let { appendLine("URL: $it") }
}
elapsedMs?.let { appendLine("Elapsed: ${it}ms") }
suggestion?.let { appendLine("Suggested next step: $it") }
detail?.let {
appendLine()
appendLine("Detail:")
@@ -218,8 +218,10 @@ private fun severityColor(severity: DiagnosticSeverity): Color = when (severity)
private fun DiagnosticLogEntry.detailLine(): String? {
val pieces = listOfNotNull(
detail,
suggestion,
operation,
endpointRole?.let { "route=$it" },
url,
primaryUrl,
elapsedMs?.let { "${it}ms" },
)
return pieces.joinToString(" - ").takeIf { it.isNotBlank() }
@@ -18,6 +18,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
@@ -54,6 +55,7 @@ import com.hermesandroid.relay.data.PetBehaviorPreferences
import com.hermesandroid.relay.data.PetTemperament
import com.hermesandroid.relay.ui.components.avatar.AgentAvatar
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
import com.hermesandroid.relay.ui.components.avatar.LocalPetGroundOpaqueBottom
import com.hermesandroid.relay.ui.components.avatar.PetLocomotion
import com.hermesandroid.relay.ui.components.pet.LocalPetSafeAreaRegistry
import com.hermesandroid.relay.ui.components.pet.PetLayoutDirection
@@ -106,6 +108,7 @@ import kotlin.math.roundToInt
import kotlin.math.sqrt
internal const val FLOATING_PET_COMPACT_HEIGHT_DP = 700
internal const val FLOATING_PET_SUPPORTED_RAIL_CLEARANCE_DP = 0f
internal const val CHAT_PET_WALK_REGION = "chat-composer-perch"
internal const val CHAT_PET_MESSAGE_PERCH_PREFIX = "chat-message-perch:"
internal const val CHAT_PET_ASSISTANT_MESSAGE_PERCH_PREFIX = "${CHAT_PET_MESSAGE_PERCH_PREFIX}assistant:"
@@ -220,6 +223,18 @@ internal fun floatingPetAcceptsPointerInput(
surfaceScrolling: Boolean,
): Boolean = positioned && !surfaceScrolling
/** The overlay's pre-measure zero size is not a valid coordinate space. */
internal fun shouldInitializeFloatingPet(
positioned: Boolean,
viewportWidth: Int,
viewportHeight: Int,
terrainReady: Boolean = true,
): Boolean = !positioned && viewportWidth > 0 && viewportHeight > 0 && terrainReady
/** Collision geometry must contain both the pointer target and rendered art. */
internal fun floatingPetCollisionSizePx(targetSizePx: Float, visualSizePx: Float): Float =
maxOf(targetSizePx, visualSizePx)
internal fun floatingPetRoamDelayMs(
hasMoved: Boolean,
roamIntervalMs: Long = PET_ROAM_REPEAT_DELAY_MS,
@@ -490,12 +505,17 @@ fun FloatingPetCompanion(
val visualSize = dimensions.visualSizeDp.dp
val targetSizePx = with(density) { targetSize.toPx() }
val visualSizePx = with(density) { visualSize.toPx() }
val collisionSizePx = floatingPetCollisionSizePx(targetSizePx, visualSizePx)
val collisionSize = with(density) { collisionSizePx.toDp() }
val heldLiftPx = with(density) { 6.dp.toPx() }
val scrollReactionLiftPx = with(density) { PET_SCROLL_REACTION_LIFT_DP.dp.toPx() }
val maximumDirectHopPx = with(density) { PET_MAX_DIRECT_HOP_DP.dp.toPx() }
val maximumMessageHopPx = with(density) { PET_MAX_CLEAR_MESSAGE_HOP_DP.dp.toPx() }
val safeMarginPx = with(density) { 12.dp.toPx() }
val perchClearancePx = with(density) { 6.dp.toPx() }
// A supported collision box sits directly on its measured rail. Obstacle
// clearance remains part of [footprint]; adding it again here makes the
// visible pet hover above the surface.
val perchClearancePx = with(density) { FLOATING_PET_SUPPORTED_RAIL_CLEARANCE_DP.dp.toPx() }
val topClearancePx = with(density) { 76.dp.toPx() }
val bottomClearancePx = with(density) { (if (compact) 84.dp else 104.dp).toPx() }
val radius = targetSizePx / 2f
@@ -514,8 +534,8 @@ fun FloatingPetCompanion(
PetSafeBounds(left, top, right, bottom)
}
val registry = LocalPetSafeAreaRegistry.current
val footprint = remember(targetSizePx, safeMarginPx) {
PetFootprint(targetSizePx, targetSizePx, safeMarginPx / 2f)
val footprint = remember(collisionSizePx, safeMarginPx) {
PetFootprint(collisionSizePx, collisionSizePx, safeMarginPx / 2f)
}
// Bubble-edge hops are visual traversal, not persistent placement. Keep
// the larger accessible target for controls and viewport containment while
@@ -1583,8 +1603,26 @@ fun FloatingPetCompanion(
return true
}
LaunchedEffect(pet.id, homePoint) {
if (!positioned) {
// Chat roaming owns a measured composer rail. Publishing before that rail
// exists lets initialization race route registration and can strand the
// pet on transient fallback geometry until direct manipulation.
val initialTerrainReady = route != "chat" || !roamingEnabled || !roamingAllowed ||
composerRails.isNotEmpty()
LaunchedEffect(
pet.id,
homePoint,
viewportWidth,
viewportHeight,
initialTerrainReady,
) {
if (
shouldInitializeFloatingPet(
positioned = positioned,
viewportWidth = viewportWidth,
viewportHeight = viewportHeight,
terrainReady = initialTerrainReady,
)
) {
x.snapTo(homePoint.x)
y.snapTo(homePoint.y)
positioned = true
@@ -2211,11 +2249,14 @@ fun FloatingPetCompanion(
.offset {
val displayed = draggedPoint ?: PetPoint(x.value, y.value)
IntOffset(
(displayed.x - targetSizePx / 2f).roundToInt(),
(displayed.y - targetSizePx / 2f).roundToInt(),
(displayed.x - collisionSizePx / 2f).roundToInt(),
(displayed.y - collisionSizePx / 2f).roundToInt(),
)
}
.size(targetSize)
// The host, route footprint, and visible art share one center.
// A smaller pointer-only host shifts bottom-aligned art upward
// whenever the authored visual is larger than its touch target.
.size(collisionSize)
.alpha(if (positioned) 1f else 0f)
.graphicsLayer {
val scale = 1f + heldProgress * 0.10f
@@ -2354,22 +2395,26 @@ fun FloatingPetCompanion(
),
)
key(pet.id) {
pet.Render(
state = state.copy(
petLocomotion = presentedPetLocomotion(
dragging = dragging,
dropping = pendingDrop != null,
agentState = state.state,
movement = locomotion,
CompositionLocalProvider(LocalPetGroundOpaqueBottom provides true) {
pet.Render(
state = state.copy(
petLocomotion = presentedPetLocomotion(
dragging = dragging,
dropping = pendingDrop != null,
agentState = state.state,
movement = locomotion,
),
paused = shouldPauseFloatingPet(
alreadyPaused = state.paused || !accessibleMotion.osAnimations ||
accessibleMotion.touchExploration,
animationEnabled = animationEnabled,
),
),
paused = shouldPauseFloatingPet(
alreadyPaused = state.paused || !accessibleMotion.osAnimations ||
accessibleMotion.touchExploration,
animationEnabled = animationEnabled,
),
),
modifier = Modifier.size(visualSize),
)
modifier = Modifier
.align(Alignment.BottomCenter)
.size(visualSize),
)
}
}
DropdownMenu(
@@ -58,6 +58,7 @@ import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
@@ -748,16 +749,21 @@ fun MessageBubble(
// burst of fragments doesn't stack three near-touching time labels.
// Grouping breaks on a >5min gap (ChatScreen), so every pause still
// surfaces its own time. Alpha floored at 0.6 for 11sp contrast.
// Keep the live bubble's footer structurally quiet. Showing a
// timestamp while text is still growing makes it chase every
// token and exaggerates any single-frame layout lag. Reveal it
// once the message settles into its final layout.
if (isLastInGroup && !message.isStreaming) {
// Reserve the footer from the first streaming frame so
// completion is a color-only transition and cannot resize the
// row. Hide the reserved timestamp from accessibility until it
// becomes visible.
if (isLastInGroup) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = timeFormat.format(Date(message.timestamp)),
style = MaterialTheme.typography.labelSmall,
color = textColor.copy(alpha = 0.6f)
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
modifier = if (message.isStreaming) {
Modifier.clearAndSetSemantics { }
} else {
Modifier
},
)
}
@@ -0,0 +1,20 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.network.upstream.ReasoningEfforts
/** One localized label source shared by the composer and Agent Passport. */
@Composable
fun reasoningEffortLabel(value: String?): String = when (ReasoningEfforts.normalize(value)) {
"none" -> stringResource(R.string.chat_reasoning_none)
"minimal" -> stringResource(R.string.chat_reasoning_minimal)
"low" -> stringResource(R.string.chat_reasoning_low)
"medium" -> stringResource(R.string.chat_reasoning_medium)
"high" -> stringResource(R.string.chat_reasoning_high)
"xhigh" -> stringResource(R.string.chat_reasoning_xhigh)
"max" -> stringResource(R.string.chat_reasoning_max)
"ultra" -> stringResource(R.string.chat_reasoning_ultra)
else -> error("ReasoningEfforts.normalize returned a non-canonical value")
}
@@ -1,7 +1,15 @@
package com.hermesandroid.relay.ui.components
import android.content.Context
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.SweepGradient
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -57,13 +65,23 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.ui.theme.RelayRefresh
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
import java.text.SimpleDateFormat
@@ -77,6 +95,8 @@ private enum class SessionDrawerFilter {
Archive,
}
internal const val SESSION_DRAWER_LIST_TAG = "session-drawer-list"
@Composable
fun SessionDrawerContent(
sessions: List<ChatSession>,
@@ -85,6 +105,8 @@ fun SessionDrawerContent(
scopeSubtitle: String? = null,
isLoading: Boolean = false,
isOpen: Boolean = true,
activityStates: Map<String, SessionActivityState> = emptyMap(),
animationEnabled: Boolean = true,
autoTitlesSupported: Boolean = true,
onRefresh: (() -> Unit)? = null,
onNewChat: () -> Unit,
@@ -115,11 +137,11 @@ fun SessionDrawerContent(
var sourceFilterOpen by remember { mutableStateOf(false) }
var deleteDialogSession by remember { mutableStateOf<ChatSession?>(null) }
var query by remember { mutableStateOf("") }
var searchExpanded by remember { mutableStateOf(false) }
var filter by remember { mutableStateOf(SessionDrawerFilter.All) }
var pinnedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
var archivedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
val listState = rememberLazyListState()
var scrollToTopPending by remember { mutableStateOf(false) }
val trimmedQuery = query.trim()
// Threads affordance shows when the capability is active OR there's already at least one
// agent Thread (source=phone) in the list. If the filter is on Threads but they've
@@ -174,27 +196,12 @@ fun SessionDrawerContent(
.toList()
val topVisibleSessionId = visibleSessions.firstOrNull()?.sessionId
LaunchedEffect(isOpen) {
scrollToTopPending = isOpen
if (isOpen && visibleSessions.isNotEmpty()) {
listState.scrollToItem(0)
scrollToTopPending = false
}
}
LaunchedEffect(filter, trimmedQuery) {
if (isOpen && visibleSessions.isNotEmpty()) {
listState.scrollToItem(0)
scrollToTopPending = false
} else if (isOpen) {
scrollToTopPending = true
}
}
LaunchedEffect(isOpen, topVisibleSessionId, visibleSessions.size) {
if (isOpen && scrollToTopPending && visibleSessions.isNotEmpty()) {
listState.scrollToItem(0)
scrollToTopPending = false
LaunchedEffect(isOpen, activeFilter, trimmedQuery, topVisibleSessionId) {
if (isOpen && topVisibleSessionId != null) {
// A drawer-open refresh can reorder rows after the initial scroll.
// Override LazyColumn's normal key anchoring so the new leading
// session is visible instead of being retained above the viewport.
listState.requestScrollToItem(0)
}
}
@@ -298,6 +305,21 @@ fun SessionDrawerContent(
)
}
}
IconButton(
onClick = { searchExpanded = !searchExpanded },
modifier = Modifier.size(36.dp),
) {
Icon(
Icons.Filled.Search,
contentDescription = stringResource(R.string.drawer_search_sessions),
tint = if (searchExpanded || query.isNotBlank()) {
RelayRefresh.Relay
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(20.dp),
)
}
// Manual re-pull: the server titles a session asynchronously after
// the first turn (and never pushes a rename), so a refresh is the
// way to pick up a title the auto-reconcile window missed.
@@ -335,17 +357,19 @@ fun SessionDrawerContent(
Text(stringResource(R.string.drawer_new_chat))
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null)
},
placeholder = { Text(stringResource(R.string.drawer_search_placeholder)) },
)
if (searchExpanded || query.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null)
},
placeholder = { Text(stringResource(R.string.drawer_search_placeholder)) },
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
@@ -462,11 +486,16 @@ fun SessionDrawerContent(
)
}
} else {
LazyColumn(state = listState) {
LazyColumn(
state = listState,
modifier = Modifier.testTag(SESSION_DRAWER_LIST_TAG),
) {
items(visibleSessions, key = { it.sessionId }) { session ->
SessionItem(
session = session,
isActive = session.sessionId == currentSessionId,
activityState = activityStates[session.sessionId],
animationEnabled = animationEnabled,
pinned = session.sessionId in pinnedSessionIds,
archived = session.sessionId in archivedSessionIds,
onClick = { onSelectSession(session.sessionId) },
@@ -590,6 +619,8 @@ fun SessionDrawerContent(
private fun SessionItem(
session: ChatSession,
isActive: Boolean,
activityState: SessionActivityState?,
animationEnabled: Boolean,
pinned: Boolean,
archived: Boolean,
onClick: () -> Unit,
@@ -603,6 +634,12 @@ private fun SessionItem(
val locale = LocalLocale.current.platformLocale
val context = LocalContext.current
val untitledLabel = stringResource(R.string.drawer_untitled)
val activityLabel = when (activityState) {
SessionActivityState.Working -> stringResource(R.string.drawer_activity_working)
SessionActivityState.NeedsInput -> stringResource(R.string.drawer_activity_needs_input)
null -> null
}
val motion = rememberAccessibleMotionState()
val backgroundColor = if (isActive) {
MaterialTheme.colorScheme.secondaryContainer
} else {
@@ -613,6 +650,13 @@ private fun SessionItem(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor)
.sessionActivityBorder(
state = activityState,
animated = animationEnabled && motion.osAnimations && !motion.touchExploration,
)
.semantics {
activityLabel?.let { stateDescription = it }
}
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
@@ -637,6 +681,17 @@ private fun SessionItem(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (activityState != null && activityLabel != null) {
SessionActivityIndicator(activityState, activityLabel)
}
if (pinned) {
Icon(
Icons.Filled.Star,
contentDescription = null,
tint = RelayRefresh.Amber,
modifier = Modifier.size(12.dp),
)
}
// Agent Thread tag — the clean spool + "Thread", so a source=phone
// conversation reads as its own lane in the unified session list (ADR 12).
if (isThreadSource(session.source)) {
@@ -687,18 +742,6 @@ private fun SessionItem(
}
}
IconButton(
onClick = onTogglePinned,
modifier = Modifier.size(36.dp),
) {
Icon(
Icons.Filled.Star,
contentDescription = if (pinned) stringResource(R.string.drawer_unpin_session) else stringResource(R.string.drawer_pin_session),
tint = if (pinned) RelayRefresh.Amber else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(19.dp),
)
}
Box {
IconButton(
onClick = { menuOpen = true },
@@ -715,6 +758,32 @@ private fun SessionItem(
expanded = menuOpen,
onDismissRequest = { menuOpen = false },
) {
DropdownMenuItem(
text = {
Text(
if (pinned) {
stringResource(R.string.drawer_unpin_session)
} else {
stringResource(R.string.drawer_pin_session)
}
)
},
leadingIcon = {
Icon(
Icons.Filled.Star,
contentDescription = null,
tint = if (pinned) {
RelayRefresh.Amber
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
},
onClick = {
menuOpen = false
onTogglePinned()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.chat_copy_session_id)) },
leadingIcon = {
@@ -777,6 +846,147 @@ private fun SessionItem(
}
}
@Composable
private fun SessionActivityIndicator(state: SessionActivityState, label: String) {
val color = when (state) {
SessionActivityState.Working -> RelayRefresh.Relay
SessionActivityState.NeedsInput -> RelayRefresh.Amber
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Box(
modifier = Modifier
.size(7.dp)
.clip(RoundedCornerShape(50))
.background(color),
)
Text(
text = label,
style = relayMetadataStyle(),
color = color,
maxLines = 1,
)
}
}
@Composable
private fun Modifier.sessionActivityBorder(
state: SessionActivityState?,
animated: Boolean,
): Modifier {
if (state == null) return this
val color = when (state) {
SessionActivityState.Working -> RelayRefresh.Relay
SessionActivityState.NeedsInput -> RelayRefresh.Amber
}
val shouldRotate = animated && state == SessionActivityState.Working
val phase = if (shouldRotate) {
val transition = rememberInfiniteTransition(label = "session-activity")
transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 2_230, easing = LinearEasing),
),
label = "session-activity-glow",
)
} else {
null
}
return drawWithCache {
val coreWidth = 1.25.dp.toPx()
val coreInset = coreWidth / 2f
val haloWidth = 5.dp.toPx()
val haloInset = haloWidth / 2f
val radius = 12.dp.toPx()
// Matching transparent endpoints make phase 0 and 1 identical, so the
// full shader rotation loops without the dash-pattern reset of the old ring.
val animatedShader = SweepGradient(
size.width / 2f,
size.height / 2f,
intArrayOf(
color.copy(alpha = 0f).toArgb(),
color.copy(alpha = 0f).toArgb(),
color.copy(alpha = 0.16f).toArgb(),
color.copy(alpha = 0.72f).toArgb(),
color.toArgb(),
color.copy(alpha = 0.34f).toArgb(),
color.copy(alpha = 0f).toArgb(),
),
floatArrayOf(0f, 0.52f, 0.66f, 0.78f, 0.84f, 0.92f, 1f),
)
val shaderMatrix = Matrix()
val haloPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = haloWidth
alpha = 88
shader = animatedShader
}
val middlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 2.75.dp.toPx()
alpha = 150
shader = animatedShader
}
val corePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = coreWidth
shader = animatedShader
}
val haloRect = RectF(
haloInset,
haloInset,
size.width - haloInset,
size.height - haloInset,
)
val middleInset = middlePaint.strokeWidth / 2f
val middleRect = RectF(
middleInset,
middleInset,
size.width - middleInset,
size.height - middleInset,
)
val coreRect = RectF(
coreInset,
coreInset,
size.width - coreInset,
size.height - coreInset,
)
onDrawWithContent {
drawContent()
if (phase != null) {
shaderMatrix.setRotate(
phase.value * 360f - 90f,
size.width / 2f,
size.height / 2f,
)
animatedShader.setLocalMatrix(shaderMatrix)
drawContext.canvas.nativeCanvas.apply {
drawRoundRect(haloRect, radius, radius, haloPaint)
drawRoundRect(middleRect, radius, radius, middlePaint)
drawRoundRect(coreRect, radius, radius, corePaint)
}
} else {
drawRoundRect(
brush = Brush.linearGradient(
listOf(color.copy(alpha = 0.72f), color.copy(alpha = 0.28f))
),
topLeft = androidx.compose.ui.geometry.Offset(coreInset, coreInset),
size = androidx.compose.ui.geometry.Size(
width = size.width - coreWidth,
height = size.height - coreWidth,
),
cornerRadius = CornerRadius(radius),
style = Stroke(width = coreWidth),
)
}
}
}
}
private fun sessionTimestampText(session: ChatSession, locale: Locale, context: Context): String? {
val timestamp = session.activityTimestamp
if (timestamp <= 0L) return null
@@ -0,0 +1,130 @@
package com.hermesandroid.relay.ui.components
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.hermesandroid.relay.R
import com.hermesandroid.relay.reliability.ReliabilityReport
import com.hermesandroid.relay.reliability.SupportBundleBuilder
import com.hermesandroid.relay.util.IssueReport
data class SupportReviewState(
val reportCount: Int,
val text: String,
val shareEnabled: Boolean,
)
internal fun buildSupportReviewState(reports: List<ReliabilityReport>): SupportReviewState =
SupportReviewState(
reportCount = reports.takeLast(SupportBundleBuilder.MAX_REPORTS).size,
text = SupportBundleBuilder.build(reports),
shareEnabled = reports.isNotEmpty(),
)
/** Exact review surface for the local text handed to clipboard/share. */
@Composable
fun SupportBundleDialog(state: SupportReviewState, onDismiss: () -> Unit) {
val context = LocalContext.current
val copied = stringResource(R.string.support_bundle_copied)
val noShare = stringResource(R.string.support_bundle_no_share)
val chooser = stringResource(R.string.support_bundle_share_title)
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier = Modifier.fillMaxWidth(0.94f),
shape = RoundedCornerShape(24.dp),
tonalElevation = 6.dp,
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(stringResource(R.string.support_bundle_title), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(6.dp))
Text(
stringResource(R.string.support_bundle_privacy, state.reportCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 160.dp, max = 420.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
RoundedCornerShape(12.dp),
),
) {
SelectionContainer {
Text(
text = state.text,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
modifier = Modifier.verticalScroll(rememberScrollState()).padding(12.dp),
)
}
}
Spacer(Modifier.height(18.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
OutlinedButton(
enabled = state.shareEnabled,
onClick = {
IssueReport.copyToClipboard(context, state.text)
Toast.makeText(context, copied, Toast.LENGTH_LONG).show()
},
) { Text(stringResource(R.string.common_copy)) }
Button(
enabled = state.shareEnabled,
onClick = {
if (!IssueReport.share(
context,
subject = chooser,
text = state.text,
chooserTitle = chooser,
)
) {
IssueReport.copyToClipboard(context, state.text)
Toast.makeText(context, noShare, Toast.LENGTH_LONG).show()
}
},
) { Text(stringResource(R.string.common_share)) }
}
}
}
}
}
@@ -194,27 +194,29 @@ fun VoiceModeOverlay(
modifier = modifier
.fillMaxSize()
.background(if (focusMode) surface.copy(alpha = 0.96f) else Color.Transparent)
// Click-through fix: in focus mode the overlay is a true modal.
// Consume any pointer event a child (mic button, transcript,
// chips, pill) didn't handle so stray taps/swipes don't fall
// through to the chat + session drawer behind it. Children run on
// the same Main pass leaf-first, so this only catches the gaps.
// In Conversation the overlay is intentionally transparent and the
// chat stays interactive, so no scrim is installed.
.then(
if (focusMode) {
Modifier.pointerInput(Unit) {
) {
if (focusMode) {
// Focus is modal, but its click-through guard must be a sibling
// behind the controls. A consuming pointerInput on the root is an
// ancestor of every button and cancels their gesture detectors on
// later pointer passes, leaving ripples without firing callbacks.
// This scrim wins hit-testing only where no foreground control did,
// so blank-space gestures stay off the chat underneath while mic,
// close, pill, and expanded-panel controls remain interactive.
Box(
Modifier
.fillMaxSize()
.testTag("voiceFocusInputScrim")
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent().changes.forEach { it.consume() }
}
}
}
} else {
Modifier
}
},
)
) {
}
if (focusMode) {
VoiceSessionPill(
uiState = uiState,
@@ -143,3 +143,11 @@ val LocalPetPlaybackSpeed = staticCompositionLocalOf { 1f }
* on; the sphere ignores it.
*/
val LocalPetStabilize = staticCompositionLocalOf { true }
/**
* Grounds decoded pet art on the bottom of its render canvas. The floating
* companion enables this at its host boundary so transparent atlas padding
* cannot make a supported pet appear to hover. Pickers, previews, and message
* avatars retain their centered rendering.
*/
val LocalPetGroundOpaqueBottom = staticCompositionLocalOf { false }
@@ -214,10 +214,10 @@ class PetAvatar(
private val decodedSheets = LinkedHashMap<String, DecodedSheet>(4, 0.75f, true)
private val decodedClips = LinkedHashMap<PetDecodeKey, PetFrames>(8, 0.75f, true)
private fun decode(clip: PetClip, stabilize: Boolean): PetFrames? {
val key = PetDecodeKey(clip, stabilize)
private fun decode(clip: PetClip, stabilize: Boolean, groundOpaqueBottom: Boolean): PetFrames? {
val key = PetDecodeKey(clip, stabilize, groundOpaqueBottom)
synchronized(decodedClips) { decodedClips[key] }?.let { return it }
val decoded = decodeClip(clip, stabilize, decodedSheets) ?: return null
val decoded = decodeClip(clip, stabilize, groundOpaqueBottom, decodedSheets) ?: return null
synchronized(decodedClips) {
decodedClips[key] = decoded
while (decodedClips.size > PET_DECODED_CLIP_CACHE_ENTRIES) {
@@ -363,18 +363,21 @@ class PetAvatar(
// character that floats/jumps cell-to-cell). Global Appearance toggle;
// flipping it selects a separately cached decode.
val stabilize = LocalPetStabilize.current
val groundOpaqueBottom = LocalPetGroundOpaqueBottom.current
// Decode the active clip off the main thread. Keep the last complete
// visual while a new state is loading so greet/done/locomotion swaps do
// not expose the Canvas as a blank frame.
var displayedClip by remember(id, stabilize) {
var displayedClip by remember(id, stabilize, groundOpaqueBottom) {
mutableStateOf<DisplayedPetClip?>(null)
}
LaunchedEffect(id, clip, stabilize, mirrorHorizontally) {
LaunchedEffect(id, clip, stabilize, groundOpaqueBottom, mirrorHorizontally) {
val decoded = if (clip == null) {
null
} else {
withContext(Dispatchers.IO) { runCatching { decode(clip, stabilize) }.getOrNull() }
withContext(Dispatchers.IO) {
runCatching { decode(clip, stabilize, groundOpaqueBottom) }.getOrNull()
}
?.let { DisplayedPetClip(it, mirrorHorizontally) }
}
displayedClip = retainPetFrameDuringDecode(
@@ -484,6 +487,7 @@ private data class DisplayedPetClip(
private data class PetDecodeKey(
val clip: PetClip,
val stabilize: Boolean,
val groundOpaqueBottom: Boolean,
)
/** One decoded atlas per selected pet; all of its row clips share these pixels. */
@@ -492,6 +496,7 @@ private data class DecodedSheet(val bitmap: Bitmap, val image: ImageBitmap)
private fun decodeClip(
clip: PetClip,
stabilize: Boolean,
groundOpaqueBottom: Boolean,
decodedSheets: MutableMap<String, DecodedSheet>,
): PetFrames? = when (clip) {
is FrameSequenceClip -> {
@@ -530,7 +535,13 @@ private fun decodeClip(
frameHeight = bitmaps.first().height,
frameCount = bitmaps.size,
fps = clip.fps,
centerOffsets = if (stabilize) bitmaps.map { bitmapRecenter(it) } else null,
centerOffsets = if (stabilize || groundOpaqueBottom) {
bitmaps.map { bitmap ->
bitmapContentAlignment(bitmap, stabilize, groundOpaqueBottom)
}
} else {
null
},
)
}
}
@@ -583,8 +594,17 @@ private fun decodeClip(
frameCount = count,
fps = clip.fps,
startFrame = clip.startFrame,
centerOffsets = if (stabilize) {
sheetRecenter(bmp, cols, clip.frameWidth, clip.frameHeight, clip.startFrame, count)
centerOffsets = if (stabilize || groundOpaqueBottom) {
sheetContentAlignment(
bmp,
cols,
clip.frameWidth,
clip.frameHeight,
clip.startFrame,
count,
stabilize,
groundOpaqueBottom,
)
} else {
null
},
@@ -593,33 +613,65 @@ private fun decodeClip(
}
}
/** Per-cell recenter offsets for a sprite sheet (see [contentRecenter]). */
private fun sheetRecenter(
/** Per-cell alignment offsets for a sprite sheet (see [contentAlignment]). */
private fun sheetContentAlignment(
bmp: Bitmap,
cols: Int,
fw: Int,
fh: Int,
startFrame: Int,
count: Int,
stabilize: Boolean,
groundOpaqueBottom: Boolean,
): List<IntOffset> {
val buf = IntArray(fw * fh)
return (0 until count).map { i ->
val cell = startFrame + i
contentRecenter(bmp, (cell % cols) * fw, (cell / cols) * fh, fw, fh, buf)
contentAlignment(
bmp,
(cell % cols) * fw,
(cell / cols) * fh,
fw,
fh,
buf,
stabilize,
groundOpaqueBottom,
)
}
}
/** Recenter offset for one standalone frame bitmap. */
private fun bitmapRecenter(bmp: Bitmap): IntOffset =
contentRecenter(bmp, 0, 0, bmp.width, bmp.height, IntArray(bmp.width * bmp.height))
/** Content alignment offset for one standalone frame bitmap. */
private fun bitmapContentAlignment(
bmp: Bitmap,
stabilize: Boolean,
groundOpaqueBottom: Boolean,
): IntOffset = contentAlignment(
bmp,
0,
0,
bmp.width,
bmp.height,
IntArray(bmp.width * bmp.height),
stabilize,
groundOpaqueBottom,
)
/**
* Scan the [w]×[h] region at ([x0],[y0]) of [bmp] for opaque pixels and return the
* offset (source px) that moves their bounding-box center to the region center —
* cancelling per-frame positional drift. [buf] (size ≥ [w]×[h]) is reused scratch.
* Returns [IntOffset.Zero] for a fully-transparent region.
* offset (source px) that horizontally stabilizes the content and, when asked,
* grounds its opaque bottom. [buf] (size ≥ [w]×[h]) is reused scratch. Returns
* [IntOffset.Zero] for a fully-transparent region.
*/
private fun contentRecenter(bmp: Bitmap, x0: Int, y0: Int, w: Int, h: Int, buf: IntArray): IntOffset {
private fun contentAlignment(
bmp: Bitmap,
x0: Int,
y0: Int,
w: Int,
h: Int,
buf: IntArray,
stabilize: Boolean,
groundOpaqueBottom: Boolean,
): IntOffset {
bmp.getPixels(buf, 0, w, x0, y0, w, h)
var minX = w
var minY = h
@@ -638,7 +690,44 @@ private fun contentRecenter(bmp: Bitmap, x0: Int, y0: Int, w: Int, h: Int, buf:
}
}
if (maxX < 0) return IntOffset.Zero
return IntOffset(w / 2 - (minX + maxX) / 2, h / 2 - (minY + maxY) / 2)
return petContentAlignmentOffset(
width = w,
height = h,
minX = minX,
minY = minY,
maxX = maxX,
maxY = maxY,
stabilize = stabilize,
groundOpaqueBottom = groundOpaqueBottom,
)
}
/** Pure alignment policy shared by atlas and standalone frames. */
internal fun petContentAlignmentOffset(
width: Int,
height: Int,
minX: Int,
minY: Int,
maxX: Int,
maxY: Int,
stabilize: Boolean,
groundOpaqueBottom: Boolean,
): IntOffset {
if (
width <= 0 || height <= 0 || minX < 0 || minY < 0 ||
maxX < minX || maxY < minY || maxX >= width || maxY >= height
) {
return IntOffset.Zero
}
val x = if (stabilize) width / 2 - (minX + maxX) / 2 else 0
val y = if (groundOpaqueBottom) {
height - 1 - maxY
} else if (stabilize) {
height / 2 - (minY + maxY) / 2
} else {
0
}
return IntOffset(x, y)
}
/** Draw frame [index] of [f], contain-fit + centered, scaled by [bounce]. */
@@ -87,6 +87,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
@@ -122,9 +123,11 @@ import com.hermesandroid.relay.ui.theme.radialNavyBackground
import com.hermesandroid.relay.network.upstream.ApiModelOption
import com.hermesandroid.relay.network.upstream.ChatMode
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.upstream.ReasoningEfforts
import com.hermesandroid.relay.network.relay.RelayVoiceClient
import com.hermesandroid.relay.network.relay.RealtimeVoiceConfig
import com.hermesandroid.relay.network.relay.VoiceOutputConfig
import com.hermesandroid.relay.ui.components.reasoningEffortLabel
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
@@ -163,6 +166,7 @@ import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.HermesCardAction
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.VoicePresentationMode
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
import com.hermesandroid.relay.ui.components.AgentInfoSheet
@@ -178,6 +182,7 @@ import com.hermesandroid.relay.ui.components.ChatInputTrailing
import com.hermesandroid.relay.ui.components.CommandPalette
import com.hermesandroid.relay.ui.components.KeepScreenOnWhile
import com.hermesandroid.relay.ui.components.ModelPickerSheet
import com.hermesandroid.relay.ui.components.OptionPickerSheet
import com.hermesandroid.relay.ui.components.ConnectionStatusBadge
import com.hermesandroid.relay.ui.components.CommandRow
import com.hermesandroid.relay.ui.components.CompactToolCall
@@ -249,10 +254,24 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val DEFAULT_CHAR_LIMIT = 4096
private const val CHAT_SCROLL_TO_BOTTOM_PET_PERCH = "chat-scroll-to-bottom-perch"
private const val CHAT_SCROLL_TO_BOTTOM_PET_OBSTACLE = "chat-scroll-to-bottom-obstacle"
private val CHAT_PET_ROUTES = setOf("chat")
internal fun resolveSessionActivityStates(
background: Map<String, SessionActivityState>,
currentSessionId: String?,
isStreaming: Boolean,
needsInput: Boolean,
): Map<String, SessionActivityState> = background.toMutableMap().apply {
currentSessionId?.let { sessionId ->
when {
needsInput -> put(sessionId, SessionActivityState.NeedsInput)
isStreaming -> put(sessionId, SessionActivityState.Working)
else -> remove(sessionId)
}
}
}
internal fun resolveChatHeaderSubtitle(
isStreaming: Boolean,
statusText: String,
@@ -299,33 +318,131 @@ internal data class ChatScrollSnapshot(
val isStreaming: Boolean
)
internal fun ChatScrollSnapshot.isCompletionAfter(previous: ChatScrollSnapshot?): Boolean =
previous?.isStreaming == true &&
!isStreaming &&
previous.messageCount == messageCount &&
previous.lastMessageUiKey == lastMessageUiKey
internal fun releaseRetainedLiveTail(
internal fun retainedLiveTailAfterTransition(
retainedUiKey: String?,
completedUiKey: String?,
): String? = retainedUiKey?.takeUnless { it == completedUiKey }
internal fun tailEndScrollOffset(
tailSizePx: Int,
footerSizePx: Int,
viewportSizePx: Int,
): Int = (tailSizePx + footerSizePx - viewportSizePx).coerceAtLeast(0)
streamStarted: Boolean,
lastMessageUiKey: String?,
): String? = when {
streamStarted -> lastMessageUiKey
retainedUiKey != null && retainedUiKey != lastMessageUiKey -> null
else -> retainedUiKey
}
private class ChatTailTransitionRef(
var snapshot: ChatScrollSnapshot? = null,
)
private data class ChatTailLayoutSnapshot(
val uiKey: String?,
val measuredSizePx: Int?,
val shouldFollowGrowth: Boolean,
internal data class ChatViewportFollowSnapshot(
val totalItemsCount: Int,
val tailUiKey: String?,
val tailSizePx: Int?,
val viewportHeightPx: Int,
val visibleBottomDistancePx: Int?,
val followTailGrowth: Boolean,
val followViewportResize: Boolean,
)
internal fun shouldCorrectConversationBottomAfterLayout(
previous: ChatViewportFollowSnapshot?,
current: ChatViewportFollowSnapshot,
atExactBottom: Boolean,
userScrolledAway: Boolean,
userDragging: Boolean,
isStreaming: Boolean,
smoothAutoScroll: Boolean,
viewportFollowAllowed: Boolean,
): Boolean {
val old = previous ?: return false
if (
atExactBottom || userScrolledAway || userDragging || !viewportFollowAllowed ||
(isStreaming && !smoothAutoScroll)
) {
return false
}
if (
old.totalItemsCount != current.totalItemsCount ||
old.tailUiKey == null ||
old.tailUiKey != current.tailUiKey
) {
return false
}
return old.tailSizePx != current.tailSizePx ||
old.viewportHeightPx != current.viewportHeightPx ||
old.visibleBottomDistancePx != current.visibleBottomDistancePx
}
internal fun requiredBottomFollowScroll(
previous: ChatViewportFollowSnapshot?,
current: ChatViewportFollowSnapshot,
): Int {
if (!current.followTailGrowth && !current.followViewportResize) return 0
// When the footer is visible, its trailing edge is the authoritative
// distance to the exact bottom. This also consumes rounding and any small
// non-tail layout changes that a tail-height delta cannot represent.
current.visibleBottomDistancePx?.let { return it.coerceAtLeast(0) }
val previousSnapshot = previous ?: return 0
val tailGrowthPx = if (
current.followTailGrowth &&
previousSnapshot.tailUiKey == current.tailUiKey
) {
((current.tailSizePx ?: 0) - (previousSnapshot.tailSizePx ?: 0)).coerceAtLeast(0)
} else {
0
}
val viewportLossPx = if (current.followViewportResize) {
(previousSnapshot.viewportHeightPx - current.viewportHeightPx).coerceAtLeast(0)
} else {
0
}
return maxOf(tailGrowthPx, viewportLossPx)
}
internal fun ownedBottomFollowScroll(
previous: ChatViewportFollowSnapshot?,
current: ChatViewportFollowSnapshot,
): Int {
val sameTranscript = previous != null &&
previous.totalItemsCount == current.totalItemsCount &&
previous.tailUiKey == current.tailUiKey
// A structural/new-tail transition belongs to the existing streaming
// owner. Ordinary restore-layout following must never opt a reader into
// new-message auto-follow when smooth auto-scroll is disabled.
if (!sameTranscript && !current.followTailGrowth) return 0
return requiredBottomFollowScroll(previous, current)
}
internal fun shouldFollowImeAfterInsetChange(
wasFollowing: Boolean,
previousImeBottomPx: Int,
currentImeBottomPx: Int,
wasAtBottom: Boolean,
userDragging: Boolean,
): Boolean = when {
currentImeBottomPx == 0 || userDragging -> false
previousImeBottomPx == 0 -> wasAtBottom
else -> wasFollowing
}
internal fun shouldExactlySettleConversation(
autoFollowEnabled: Boolean,
userScrolledAway: Boolean,
userDragging: Boolean,
hasMessages: Boolean,
): Boolean = autoFollowEnabled && hasMessages && !userScrolledAway && !userDragging
internal fun shouldFollowConversationViewportResize(
userScrolledAway: Boolean,
userDragging: Boolean,
imeBottomPx: Int,
followImeResize: Boolean,
voiceDockAnchorTransitionActive: Boolean,
): Boolean = !userScrolledAway &&
!userDragging &&
!voiceDockAnchorTransitionActive &&
(followImeResize || imeBottomPx == 0)
private fun LazyListState.isAtConversationBottom(slopPx: Int): Boolean {
val layout = layoutInfo
if (layout.totalItemsCount == 0) return true
@@ -334,6 +451,14 @@ private fun LazyListState.isAtConversationBottom(slopPx: Int): Boolean {
(last.offset + last.size) - layout.viewportEndOffset <= slopPx
}
private fun LazyListState.visibleConversationBottomDistancePx(): Int? {
val layout = layoutInfo
val lastIndex = layout.totalItemsCount - 1
if (lastIndex < 0) return 0
val footer = layout.visibleItemsInfo.firstOrNull { it.index == lastIndex } ?: return null
return ((footer.offset + footer.size) - layout.viewportEndOffset).coerceAtLeast(0)
}
private suspend fun LazyListState.scrollToConversationBottom(
animated: Boolean,
slopPx: Int,
@@ -619,8 +744,24 @@ fun ChatScreen(
val chatMode by connectionViewModel.chatMode.collectAsState()
val error by chatViewModel.error.collectAsState()
val sessions by chatViewModel.sessions.collectAsState()
val backgroundSessionActivityStates by
chatViewModel.backgroundSessionActivityStates.collectAsState()
val serverAutoTitles by chatViewModel.serverAutoTitles.collectAsState()
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
val pendingAsk by chatViewModel.pendingAsk.collectAsState()
val sessionActivityStates = remember(
backgroundSessionActivityStates,
currentSessionId,
isStreaming,
pendingAsk,
) {
resolveSessionActivityStates(
background = backgroundSessionActivityStates,
currentSessionId = currentSessionId,
isStreaming = isStreaming,
needsInput = pendingAsk != null,
)
}
val backgroundProcesses by chatViewModel.backgroundProcesses.collectAsState()
val backgroundProcessesLoading by chatViewModel.backgroundProcessesLoading.collectAsState()
val stoppingProcessIds by chatViewModel.stoppingProcessIds.collectAsState()
@@ -645,8 +786,11 @@ fun ChatScreen(
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
val modelProviders by chatViewModel.modelProviders.collectAsState()
val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState()
val reasoningCapabilityRevision by chatViewModel.reasoningCapabilityRevision.collectAsState()
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
val selectedProviderOverride by chatViewModel.selectedProviderOverride.collectAsState()
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
val gatewayCurrentProvider by chatViewModel.gatewayCurrentProvider.collectAsState()
val gatewayProjectName by chatViewModel.gatewayProjectName.collectAsState()
val selectedReasoningEffort by chatViewModel.selectedReasoningEffort.collectAsState()
val showThinking by connectionViewModel.showThinking.collectAsState()
@@ -796,6 +940,7 @@ fun ChatScreen(
var inputText by remember { mutableStateOf("") }
var showCommandPalette by remember { mutableStateOf(false) }
var showModelSheet by remember { mutableStateOf(false) }
var showEffortSheet by remember { mutableStateOf(false) }
var showAgentInfo by remember { mutableStateOf(false) }
var showBackgroundProcesses by remember { mutableStateOf(false) }
@@ -1169,7 +1314,25 @@ fun ChatScreen(
var isUserDragging by remember(currentSessionId) { mutableStateOf(false) }
var programmaticBottomScroll by remember { mutableStateOf(false) }
var retainedLiveTailUiKey by remember(currentSessionId) { mutableStateOf<String?>(null) }
var completionSettlingUiKey by remember(currentSessionId) { mutableStateOf<String?>(null) }
val density = LocalDensity.current
val imeBottomPx = WindowInsets.ime.getBottom(density)
val latestImeBottomPx by rememberUpdatedState(imeBottomPx)
var previousImeBottomPx by remember(currentSessionId) { mutableStateOf(imeBottomPx) }
var followImeResize by remember(currentSessionId) { mutableStateOf(false) }
SideEffect {
followImeResize = shouldFollowImeAfterInsetChange(
wasFollowing = followImeResize,
previousImeBottomPx = previousImeBottomPx,
currentImeBottomPx = imeBottomPx,
// At the first non-zero IME inset, LazyColumn still exposes the
// pre-resize layout. Capture bottom ownership before the viewport
// starts losing height.
wasAtBottom = !userScrolledAway &&
listState.isAtConversationBottom(atBottomSlopPx),
userDragging = isUserDragging,
)
previousImeBottomPx = imeBottomPx
}
val currentUnreadSnapshot = remember(messages) { messages.toUnreadSnapshot() }
var lastReadSnapshot by remember(currentSessionId) {
mutableStateOf(currentUnreadSnapshot)
@@ -1194,7 +1357,9 @@ fun ChatScreen(
try {
listState.scrollToConversationBottom(
animated = animated,
slopPx = atBottomSlopPx,
// Slop preserves follow ownership during motion; an explicit
// settlement must reach the real LazyColumn boundary.
slopPx = 0,
)
userScrolledAway = false
} finally {
@@ -1216,6 +1381,7 @@ fun ChatScreen(
when (interaction) {
is DragInteraction.Start -> {
isUserDragging = true
followImeResize = false
}
is DragInteraction.Stop, is DragInteraction.Cancel -> {
isUserDragging = false
@@ -1225,6 +1391,7 @@ fun ChatScreen(
}
}
var voiceDockAnchorGuardReady by remember { mutableStateOf(false) }
var voiceDockAnchorTransitionActive by remember { mutableStateOf(false) }
LaunchedEffect(conversationVoiceDockVisible) {
if (!voiceDockAnchorGuardReady) {
voiceDockAnchorGuardReady = true
@@ -1238,14 +1405,19 @@ fun ChatScreen(
// every animation frame and appears to scroll when voice mode opens.
val anchorIndex = listState.firstVisibleItemIndex
val anchorOffset = listState.firstVisibleItemScrollOffset
var firstFrameNanos = 0L
var frameNanos: Long
do {
if (isUserDragging) return@LaunchedEffect
listState.requestScrollToItem(anchorIndex, anchorOffset)
frameNanos = withFrameNanos { it }
if (firstFrameNanos == 0L) firstFrameNanos = frameNanos
} while (frameNanos - firstFrameNanos < 300_000_000L)
voiceDockAnchorTransitionActive = true
try {
var firstFrameNanos = 0L
var frameNanos: Long
do {
if (isUserDragging) return@LaunchedEffect
listState.requestScrollToItem(anchorIndex, anchorOffset)
frameNanos = withFrameNanos { it }
if (firstFrameNanos == 0L) firstFrameNanos = frameNanos
} while (frameNanos - firstFrameNanos < 300_000_000L)
} finally {
voiceDockAnchorTransitionActive = false
}
}
// Reaching the bottom by any means (user, follow-pin, content shrank)
// always re-arms auto-follow.
@@ -1253,6 +1425,27 @@ fun ChatScreen(
if (isAtBottom) userScrolledAway = false
}
// IME insets arrive as an animation, not one layout. Wait until inset
// updates pause, then remove any rounding/late-measurement residue if the
// conversation still owns bottom-follow. This runs for both opening and
// closing without moving a transcript whose reader scrolled away.
var lastImeSettleTargetPx by remember(currentSessionId) { mutableStateOf(imeBottomPx) }
LaunchedEffect(imeBottomPx) {
if (imeBottomPx == lastImeSettleTargetPx) return@LaunchedEffect
lastImeSettleTargetPx = imeBottomPx
delay(96)
if (
shouldExactlySettleConversation(
autoFollowEnabled = true,
userScrolledAway = userScrolledAway,
userDragging = isUserDragging,
hasMessages = messages.isNotEmpty(),
)
) {
scrollConversationToBottom(animated = false)
}
}
// Scroll-to-bottom FAB visibility. The button means "you've scrolled up —
// tap to catch up", so it must NOT flash while we're auto-pinning to the
// bottom. Suppress it when:
@@ -1267,12 +1460,10 @@ fun ChatScreen(
derivedStateOf {
val retainingVisibleTail = retainedLiveTailUiKey != null &&
messages.lastOrNull()?.uiKey == retainedLiveTailUiKey
val settlingVisibleTail = completionSettlingUiKey != null &&
messages.lastOrNull()?.uiKey == completionSettlingUiKey
messages.isNotEmpty() &&
!isAtBottom &&
!programmaticBottomScroll &&
!((isStreaming || retainingVisibleTail || settlingVisibleTail) &&
!((isStreaming || retainingVisibleTail) &&
smoothAutoScroll &&
!userScrolledAway)
}
@@ -1422,6 +1613,22 @@ fun ChatScreen(
}
}
// The drawer and composer share this screen's focus owner. Clear the
// composer's input focus as soon as an open transition is committed so
// menu activation, accessibility activation, and edge swipes all dismiss
// the IME without leaving the obscured composer ready for hardware input.
// Observe the target rather than isOpen so the keyboard closes alongside
// the drawer animation, not after it settles.
LaunchedEffect(drawerState, focusManager) {
snapshotFlow { drawerState.targetValue }
.distinctUntilChanged()
.collect { target ->
if (target == DrawerValue.Open) {
focusManager.clearFocus(force = true)
}
}
}
// Opening the drawer re-syncs the list — so a session created on another
// device (or one whose optimistic row was dropped on a profile switch)
// shows up without a manual reload. Cheap dashboard read; the optimistic
@@ -1450,13 +1657,12 @@ fun ChatScreen(
)
val tailTransitionRef = remember(currentSessionId) { ChatTailTransitionRef() }
// New rows and streaming -> final Markdown are structural transitions.
// Anchor their trailing spacer in SideEffect so the request participates in
// the very next remeasure instead of correcting an already-drawn frame.
// A live tail owns its stable renderer until another row becomes the tail.
// Completion is deliberately not a structural transition: changing the
// renderer or list anchor at that boundary caused a visible scroll jump.
SideEffect {
val previous = tailTransitionRef.snapshot
val streamStarted = tailTransition.isStreaming && previous?.isStreaming != true
val completed = tailTransition.isCompletionAfter(previous)
val tailStructureChanged = tailTransition.lastMessageUiKey != null &&
(previous == null ||
previous.messageCount != tailTransition.messageCount ||
@@ -1467,21 +1673,17 @@ fun ChatScreen(
// transcript had previously been left above the bottom. Do not
// clear isUserDragging: a real finger keeps priority until release.
userScrolledAway = false
retainedLiveTailUiKey = tailTransition.lastMessageUiKey
} else if (completed) {
completionSettlingUiKey = tailTransition.lastMessageUiKey
} else if (
tailStructureChanged &&
retainedLiveTailUiKey != null &&
retainedLiveTailUiKey != tailTransition.lastMessageUiKey
) {
retainedLiveTailUiKey = null
}
retainedLiveTailUiKey = retainedLiveTailAfterTransition(
retainedUiKey = retainedLiveTailUiKey,
streamStarted = streamStarted,
lastMessageUiKey = tailTransition.lastMessageUiKey,
)
val shouldAnchor = smoothAutoScroll &&
!isUserDragging &&
(!userScrolledAway || streamStarted) &&
(streamStarted || completed || tailStructureChanged)
(streamStarted || tailStructureChanged)
if (shouldAnchor) {
listState.requestScrollToItem(tailTransition.messageCount + 1)
}
@@ -1489,123 +1691,17 @@ fun ChatScreen(
tailTransitionRef.snapshot = tailTransition
}
// Completion adds the timestamp/footer after the final token. Keep the
// stable live renderer, then consume any small remaining forward range for
// two settled frames. scrollBy preserves the current item anchor and is
// visually inert when already at the exact bottom; unlike scrollToItem it
// cannot align the top of a tall response with the viewport.
LaunchedEffect(
completionSettlingUiKey,
smoothAutoScroll,
userScrolledAway,
isUserDragging,
) {
val settlingKey = completionSettlingUiKey ?: return@LaunchedEffect
if (!smoothAutoScroll || userScrolledAway || isUserDragging) {
// Retention is only a completion-transition aid. Never leave the
// finalized tail on the plain streaming renderer just because the
// user disabled follow-scroll or is reading above the bottom.
retainedLiveTailUiKey = releaseRetainedLiveTail(
retainedUiKey = retainedLiveTailUiKey,
completedUiKey = settlingKey,
)
completionSettlingUiKey = null
return@LaunchedEffect
}
var settledFrames = 0
var previousMarkdownTailSize: Int? = null
var previousMarkdownFooterSize: Int? = null
val markdownWasAlreadyReleased = retainedLiveTailUiKey != settlingKey
repeat(60) completionFrame@{
withFrameNanos { }
if (messages.lastOrNull()?.uiKey != settlingKey) {
completionSettlingUiKey = null
return@LaunchedEffect
}
if (!markdownWasAlreadyReleased && retainedLiveTailUiKey == settlingKey) {
if (listState.canScrollForward) {
settledFrames = 0
val viewportHeight = listState.layoutInfo.viewportSize.height
if (viewportHeight > 0) {
listState.scroll(MutatePriority.Default) {
scrollBy(viewportHeight.toFloat())
}
}
return@completionFrame
}
settledFrames += 1
if (settledFrames < 2) return@completionFrame
retainedLiveTailUiKey = releaseRetainedLiveTail(
retainedUiKey = retainedLiveTailUiKey,
completedUiKey = settlingKey,
)
settledFrames = 0
return@completionFrame
}
// Once Markdown owns the row, position its measured trailing edge
// explicitly. `canScrollForward` is insufficient here: LazyColumn
// may preserve the leading edge of a tall item while reporting an
// otherwise valid item anchor. Repeating catches deferred parsing,
// highlighted code, and attachment measurement without competing
// with the ordinary streaming-growth coroutine.
val layout = listState.layoutInfo
val tailIndex = messages.size // header item + zero-based messages
val footerIndex = tailIndex + 1
val tailInfo = layout.visibleItemsInfo.firstOrNull { it.index == tailIndex }
val footerInfo = layout.visibleItemsInfo.firstOrNull { it.index == footerIndex }
if (tailInfo == null) {
listState.scrollToItem(tailIndex)
settledFrames = 0
return@completionFrame
}
val viewportHeight = layout.viewportSize.height
if (viewportHeight <= 0) return@completionFrame
val desiredOffset = tailEndScrollOffset(
tailSizePx = tailInfo.size,
footerSizePx = footerInfo?.size ?: 0,
viewportSizePx = viewportHeight,
)
if (desiredOffset == 0) {
listState.scrollToItem(footerIndex)
} else {
listState.scrollToItem(tailIndex, desiredOffset)
}
val footerSize = footerInfo?.size ?: 0
settledFrames = if (
previousMarkdownTailSize == tailInfo.size &&
previousMarkdownFooterSize == footerSize
) {
settledFrames + 1
} else {
0
}
previousMarkdownTailSize = tailInfo.size
previousMarkdownFooterSize = footerSize
if (settledFrames >= 12) {
completionSettlingUiKey = null
return@LaunchedEffect
}
}
retainedLiveTailUiKey = releaseRetainedLiveTail(
retainedUiKey = retainedLiveTailUiKey,
completedUiKey = settlingKey,
)
completionSettlingUiKey = null
}
// Ordinary streaming growth keeps the same row and Text node. Advance the
// existing scroll position by exactly the measured positive height delta;
// never replace the logical anchor with scrollToItem(). User input has a
// higher mutation priority and cancels this work naturally.
LaunchedEffect(listState, smoothAutoScroll, userScrolledAway, isUserDragging) {
if (!smoothAutoScroll || userScrolledAway || isUserDragging) return@LaunchedEffect
var previousLayout: ChatTailLayoutSnapshot? = null
// One owner follows every bottom-preserving viewport transition. Streaming
// growth advances by its measured delta, while any ordinary viewport loss
// (IME, late composer controls, status text, or top chrome hydration)
// advances by the lost height. This matters on restore: model and effort
// controls can finish resolving after history has already reached the
// footer. The voice dock's measured 300 ms anchor transition temporarily
// owns both follow paths; ordinary late layout correction resumes after it
// settles. A visible footer supplies the authoritative final distance. No
// transition replaces the logical item anchor.
LaunchedEffect(listState, currentSessionId, smoothAutoScroll) {
var previousLayout: ChatViewportFollowSnapshot? = null
snapshotFlow {
val tail = messages.lastOrNull()
val tailSize = tail?.uiKey?.let { uiKey ->
@@ -1613,39 +1709,80 @@ fun ChatScreen(
.firstOrNull { item -> item.key == uiKey }
?.size
}
ChatTailLayoutSnapshot(
uiKey = tail?.uiKey,
measuredSizePx = tailSize,
shouldFollowGrowth = tail?.isStreaming == true ||
(retainedLiveTailUiKey != null && tail?.uiKey == retainedLiveTailUiKey),
ChatViewportFollowSnapshot(
totalItemsCount = listState.layoutInfo.totalItemsCount,
tailUiKey = tail?.uiKey,
tailSizePx = tailSize,
viewportHeightPx = listState.layoutInfo.viewportSize.height,
visibleBottomDistancePx = listState.visibleConversationBottomDistancePx(),
followTailGrowth = !voiceDockAnchorTransitionActive &&
!isUserDragging &&
smoothAutoScroll &&
!userScrolledAway &&
(tail?.isStreaming == true ||
(retainedLiveTailUiKey != null && tail?.uiKey == retainedLiveTailUiKey)),
followViewportResize = shouldFollowConversationViewportResize(
userScrolledAway = userScrolledAway,
userDragging = isUserDragging,
imeBottomPx = latestImeBottomPx,
followImeResize = followImeResize,
voiceDockAnchorTransitionActive = voiceDockAnchorTransitionActive,
),
)
}
.distinctUntilChanged()
.collect { current ->
val previous = previousLayout
val scrollPx = ownedBottomFollowScroll(previous, current)
val correctLateLayout = shouldCorrectConversationBottomAfterLayout(
previous = previous,
current = current,
atExactBottom = listState.isAtConversationBottom(0),
userScrolledAway = userScrolledAway,
userDragging = isUserDragging,
isStreaming = messages.lastOrNull()?.isStreaming == true,
smoothAutoScroll = smoothAutoScroll,
viewportFollowAllowed = current.followViewportResize,
)
previousLayout = current
val previousSize = previous?.measuredSizePx ?: return@collect
val currentSize = current.measuredSizePx ?: return@collect
if (!current.shouldFollowGrowth || previous.uiKey != current.uiKey) return@collect
val growthPx = currentSize - previousSize
if (growthPx > 0) {
if (scrollPx > 0) {
listState.scroll(MutatePriority.Default) {
scrollBy(growthPx.toFloat())
scrollBy(scrollPx.toFloat())
}
} else if (correctLateLayout) {
val lastIndex = listState.layoutInfo.totalItemsCount - 1
if (lastIndex >= 0) {
programmaticBottomScroll = true
try {
listState.scrollToItem(lastIndex)
userScrolledAway = false
} finally {
programmaticBottomScroll = false
}
}
}
}
}
// Completion haptic only; scroll ownership remains with the transition and
// measured-growth paths above.
var observedActiveStream by remember { mutableStateOf(false) }
// Completion keeps the stable live renderer, then settles the owned
// transcript to the exact boundary after its final layout pass.
var observedActiveStream by remember(currentSessionId) { mutableStateOf(false) }
LaunchedEffect(isStreaming) {
if (isStreaming) {
observedActiveStream = true
} else if (observedActiveStream) {
observedActiveStream = false
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
if (
shouldExactlySettleConversation(
autoFollowEnabled = smoothAutoScroll,
userScrolledAway = userScrolledAway,
userDragging = isUserDragging,
hasMessages = messages.isNotEmpty(),
)
) {
scrollConversationToBottom(animated = false)
}
}
}
@@ -1730,6 +1867,8 @@ fun ChatScreen(
scopeSubtitle = drawerSubtitle,
isLoading = isLoadingSessions,
isOpen = drawerState.isOpen,
activityStates = sessionActivityStates,
animationEnabled = animationEnabled,
autoTitlesSupported = serverAutoTitles,
onRefresh = { chatViewModel.refreshSessions() },
onNewChat = {
@@ -2777,14 +2916,14 @@ fun ChatScreen(
) {
Box(
modifier = Modifier
// The visible FAB is narrower than the pet's
// footprint. Measure a transparent landing
// ledge around it so the pet can stand above
// the control without covering its touch area.
// The complete control envelope is forbidden
// terrain. Registering it as a perch invited
// the pet onto the button and let sibling
// composer routes treat it as walkable terrain.
.width(72.dp)
.height(48.dp)
.petPerchSurface(
key = CHAT_SCROLL_TO_BOTTOM_PET_PERCH,
.petObstacleSurface(
key = CHAT_SCROLL_TO_BOTTOM_PET_OBSTACLE,
routes = CHAT_PET_ROUTES,
),
contentAlignment = Alignment.Center,
@@ -2792,14 +2931,6 @@ fun ChatScreen(
SmallFloatingActionButton(
modifier = Modifier
.size(48.dp)
// The surrounding box is a landing ledge, but
// the real control remains forbidden space so
// composer and bubble routes cannot pass the
// pet's complete scaled footprint over it.
.petObstacleSurface(
key = CHAT_SCROLL_TO_BOTTOM_PET_OBSTACLE,
routes = CHAT_PET_ROUTES,
)
.semantics {
contentDescription = if (unreadMessageCount > 0) {
"Scroll to bottom, $unreadMessageCount unread " +
@@ -3228,22 +3359,37 @@ fun ChatScreen(
)
}
val normalizedEffort = normalizeReasoningEffortForInput(selectedReasoningEffort)
val effortLabels = mapOf(
"none" to stringResource(R.string.chat_reasoning_none),
"minimal" to stringResource(R.string.chat_reasoning_minimal),
"low" to stringResource(R.string.chat_reasoning_low),
"medium" to stringResource(R.string.chat_reasoning_medium),
"high" to stringResource(R.string.chat_reasoning_high),
"xhigh" to stringResource(R.string.chat_reasoning_high),
)
val effortPickerOptions = remember(normalizedEffort) {
CHAT_INPUT_REASONING_EFFORTS.map { effort ->
// Reads the same provider/model resolver used by session.create.
// Collected identity flows above keep this synchronous view reactive.
val effortAvailability = remember(
selectedModelOverride,
selectedProviderOverride,
gatewayCurrentModel,
gatewayCurrentProvider,
modelProviders,
apiModelOptions,
reasoningCapabilityRevision,
) {
chatViewModel.reasoningEffortAvailability()
}
val effortPickerOptions = effortAvailability.choices.map { effort ->
ChatInputPickerOption(
label = reasoningEffortChipLabel(effort, effortLabels),
label = reasoningEffortLabel(effort),
value = effort,
selected = effort == normalizedEffort,
selected = selectedReasoningEffort != null && effort == normalizedEffort,
)
}
}
val effortPickerSubtitle = when {
effortAvailability.exact &&
selectedReasoningEffort != null &&
normalizedEffort !in effortAvailability.choices -> stringResource(
R.string.reasoning_effort_current_outside_supported,
reasoningEffortLabel(normalizedEffort),
effortPickerOptions.joinToString { it.label },
)
!effortAvailability.exact ->
stringResource(R.string.reasoning_effort_standard_levels_notice)
else -> null
}
// Show the effort chip as soon as the gateway IS the transport or is
// still being probed (Unknown) — so it appears alongside the model
@@ -3254,9 +3400,14 @@ fun ChatScreen(
// shows the current effort but disabled. Hidden only when the gateway
// is definitively unreachable (SSE-only) — the agent sheet carries the
// disabled-with-reason version there.
val effortControl = if (chatGatewayAvailability != GatewayAvailability.Unreachable) {
val effortControl = if (
chatGatewayAvailability != GatewayAvailability.Unreachable &&
effortAvailability.supported != false &&
effortPickerOptions.isNotEmpty()
) {
ChatInputPickerControl(
value = reasoningEffortChipLabel(normalizedEffort, effortLabels),
value = selectedReasoningEffort?.let { reasoningEffortLabel(it) }
?: stringResource(R.string.conn_info_server_default),
contentDescription = stringResource(R.string.chat_select_reasoning_effort),
options = effortPickerOptions,
enabled = isGatewayTransport && chatReady && !isStreaming,
@@ -3364,9 +3515,7 @@ fun ChatScreen(
}
},
effortControl = effortControl,
onEffortOptionSelected = { option ->
option.value?.let { chatViewModel.selectReasoningEffort(it) }
},
onEffortPickerClick = { showEffortSheet = true },
topContent = {
ConversationVoiceDock(
uiState = voiceUiState,
@@ -3391,10 +3540,10 @@ fun ChatScreen(
},
topContentVisible = conversationVoiceDockVisible,
suppressVoiceTrailing = conversationVoiceDockVisible,
// Measure the existing composer as a Desktop-style ledge. The
// floating host stands on its top edge; no transcript space is
// reserved and the composer controls remain unobstructed.
modifier = Modifier.petPerchSurface(
// Measure the visible composer Surface as a Desktop-style
// ledge. Registering the outer input column includes its 6dp
// visual margin and makes a correctly grounded pet look raised.
surfaceModifier = Modifier.petPerchSurface(
key = CHAT_PET_WALK_REGION,
routes = CHAT_PET_ROUTES,
),
@@ -3418,6 +3567,20 @@ fun ChatScreen(
onDismiss = { showModelSheet = false },
)
}
if (showEffortSheet) {
OptionPickerSheet(
title = stringResource(R.string.chat_select_reasoning_effort),
subtitle = effortPickerSubtitle,
options = effortPickerOptions.map { option ->
option.copy(enabled = effortControl?.enabled == true)
},
onSelect = { option ->
showEffortSheet = false
option.value?.let(chatViewModel::selectReasoningEffort)
},
onDismiss = { showEffortSheet = false },
)
}
} // end Column
// Mic permission denied banner — title + body + Open Settings action.
@@ -4179,8 +4342,6 @@ private fun createCameraCaptureUri(context: android.content.Context): Uri {
)
}
private val CHAT_INPUT_REASONING_EFFORTS = listOf("none", "minimal", "low", "medium", "high", "xhigh")
internal fun shouldShowCleanViewHint(
hasMessages: Boolean,
ambientMode: Boolean,
@@ -4195,13 +4356,9 @@ private fun compactModelChipLabel(model: String?, defaultLabel: String): String
}
private fun normalizeReasoningEffortForInput(value: String?): String {
val normalized = value?.trim()?.lowercase().orEmpty()
return normalized.takeIf { it in CHAT_INPUT_REASONING_EFFORTS } ?: "medium"
return ReasoningEfforts.normalize(value)
}
private fun reasoningEffortChipLabel(value: String, labels: Map<String, String>): String =
labels[value] ?: labels["medium"] ?: value
private fun isSameDay(ts1: Long, ts2: Long): Boolean {
val d1 = java.time.Instant.ofEpochMilli(ts1).atZone(java.time.ZoneId.systemDefault()).toLocalDate()
val d2 = java.time.Instant.ofEpochMilli(ts2).atZone(java.time.ZoneId.systemDefault()).toLocalDate()
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -10,6 +11,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -24,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -42,6 +45,13 @@ import com.hermesandroid.relay.network.upstream.ServerCapabilities
import com.hermesandroid.relay.ui.components.DiagnosticDetailDialog
import com.hermesandroid.relay.ui.components.DiagnosticsLogPanel
import com.hermesandroid.relay.ui.components.StatusCheckTimeline
import com.hermesandroid.relay.ui.components.SupportBundleDialog
import com.hermesandroid.relay.ui.components.SupportReviewState
import com.hermesandroid.relay.ui.components.buildSupportReviewState
import com.hermesandroid.relay.reliability.ReliabilityCenter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
import com.hermesandroid.relay.viewmodel.ChatTransportPath
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
@@ -118,6 +128,8 @@ fun DiagnosticsScreen(
// Tapping a check backed by a concrete log entry opens its full detail.
var selectedEntry by remember { mutableStateOf<DiagnosticLogEntry?>(null) }
var supportReview by remember { mutableStateOf<SupportReviewState?>(null) }
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
@@ -232,6 +244,19 @@ fun DiagnosticsScreen(
},
)
OutlinedButton(
onClick = {
scope.launch {
supportReview = withContext(Dispatchers.IO) {
buildSupportReviewState(ReliabilityCenter.reports(context))
}
}
},
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.support_bundle_review))
}
Text(
text = stringResource(R.string.diag_recent_diagnostics),
style = MaterialTheme.typography.titleMedium,
@@ -256,6 +281,9 @@ fun DiagnosticsScreen(
selectedEntry?.let { entry ->
DiagnosticDetailDialog(entry = entry, onDismiss = { selectedEntry = null })
}
supportReview?.let { state ->
SupportBundleDialog(state = state, onDismiss = { supportReview = null })
}
}
// -----------------------------------------------------------------------------
@@ -295,7 +323,7 @@ internal fun buildStatusChecks(
it.category == category && it.severity == DiagnosticSeverity.Error
}
fun DiagnosticLogEntry.message(): String = detail ?: title
fun DiagnosticLogEntry.message(): String = suggestion ?: detail ?: title
val checks = mutableListOf<StatusCheck>()
@@ -1,81 +1,43 @@
package com.hermesandroid.relay.util
import android.content.Context
import android.os.Build
import android.os.Process
import android.util.Log
import com.hermesandroid.relay.BuildConfig
import kotlinx.serialization.Serializable
import com.hermesandroid.relay.reliability.ReliabilityCenter
import com.hermesandroid.relay.reliability.LegacyCrashSnapshot
import com.hermesandroid.relay.reliability.ReliabilityRedactor
import com.hermesandroid.relay.reliability.ReliabilityReport
import com.hermesandroid.relay.reliability.migrateLegacyCrash
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.io.PrintWriter
import java.io.StringWriter
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
import kotlin.system.exitProcess
/**
* Lightweight, privacy-respecting crash capture — no Firebase/Crashlytics, no
* network, no third-party SDK.
*
* On a fatal uncaught exception we persist a structured report to the app's
* private storage, then **re-raise to the platform's previous handler** so the
* system "app stopped" dialog still shows and Google Play Android vitals still
* records the crash. We only observe; we never swallow.
*
* On the next launch [CrashReportGate] reads the pending report and offers the
* user three GitHub-free-friendly actions (see [CrashReportDialog]): copy the
* full report, **share** it via the system sheet (email / chat / notes — the
* path for users without a GitHub account and for sideload installs Play vitals
* never sees), or open a pre-filled `bug_report.yml` issue. The GitHub path
* turns a one-star "it keeps crashing" review into an actionable issue with a
* stack trace attached; share/copy cover everyone else. Every outbound path is
* user-initiated — nothing is transmitted automatically.
*/
/** Local-only fatal capture. The platform handler still owns termination and Play vitals. */
object CrashReporter {
private const val TAG = "CrashReporter"
private const val DIR = "crash"
private const val FILE = "last-crash.json"
/**
* Cap the stack trace we inline into the GitHub URL. Browsers + GitHub
* truncate very long URLs, so we ship the head of the trace in the form and
* copy the *full* report to the clipboard for the user to paste if needed.
*/
private const val MAX_TRACE_FOR_URL = 3000
private val json = Json {
encodeDefaults = true
ignoreUnknownKeys = true
prettyPrint = false
}
private const val LEGACY_DIR = "crash"
private const val LEGACY_FILE = "last-crash.json"
private const val MAX_TRACE_FOR_URL = 3_000
private val json = Json { ignoreUnknownKeys = true }
@Volatile
private var installed = false
/**
* Install the process-wide uncaught-exception handler. Idempotent; call as
* early as possible in [com.hermesandroid.relay.HermesRelayApp.onCreate] so
* crashes during the rest of app init are captured too.
*/
fun install(context: Context) {
ReliabilityCenter.initialize(context)
migrateLegacy(context)
if (installed) return
installed = true
val appContext = context.applicationContext
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
persist(appContext, thread, throwable)
} catch (t: Throwable) {
// Never let the reporter itself worsen the crash.
Log.e(TAG, "Failed to persist crash report", t)
ReliabilityCenter.recordFatal(appContext, throwable, thread.name.orEmpty().ifBlank { "?" })
} catch (captureFailure: Throwable) {
// Never let the reporter worsen or replace the original crash.
Log.e(TAG, "Failed to persist local crash report", captureFailure)
}
// Re-raise so the platform behaves exactly as it would without us:
// system dialog + Play vitals collection.
if (previous != null) {
previous.uncaughtException(thread, throwable)
} else {
@@ -85,155 +47,70 @@ object CrashReporter {
}
}
private fun persist(context: Context, thread: Thread, throwable: Throwable) {
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
val report = CrashReport(
timeIso = isoNow(),
versionName = BuildConfig.VERSION_NAME,
versionCode = BuildConfig.VERSION_CODE,
flavor = BuildConfig.FLAVOR,
manufacturer = Build.MANUFACTURER.orEmpty().ifBlank { "?" },
model = Build.MODEL.orEmpty().ifBlank { "?" },
androidRelease = Build.VERSION.RELEASE.orEmpty().ifBlank { "?" },
sdkInt = Build.VERSION.SDK_INT,
threadName = thread.name.orEmpty().ifBlank { "?" },
exceptionSummary = "${throwable.javaClass.name}: ${throwable.message.orEmpty()}".trim(),
stackTrace = trace,
)
val dir = File(context.filesDir, DIR).apply { mkdirs() }
File(dir, FILE).writeText(json.encodeToString(report))
fun peekPending(context: Context): ReliabilityReport? {
migrateLegacy(context)
return ReliabilityCenter.pendingCrash(context)
}
/** Read the pending report without clearing it. */
fun peekPending(context: Context): CrashReport? = readFile(context)
/** Read the pending report and delete it (show-once semantics). */
fun consumePending(context: Context): CrashReport? {
val report = readFile(context)
clearPending(context)
return report
fun clearPending(context: Context, reportId: String? = null) {
val target = reportId ?: peekPending(context)?.reportId ?: return
ReliabilityCenter.markReviewed(context, target)
}
fun clearPending(context: Context) {
runCatching { reportFile(context).delete() }
}
private fun readFile(context: Context): CrashReport? = runCatching {
val file = reportFile(context)
if (!file.exists()) return null
json.decodeFromString<CrashReport>(file.readText())
}.getOrNull()
private fun reportFile(context: Context): File =
File(File(context.filesDir, DIR), FILE)
/**
* Build a pre-filled GitHub "new issue" URL.
*
* Uses the **stable** classic `title` + `body` + `labels` query params, NOT
* issue-form field-`id` prefilling (`template=...&<id>=...`). The latter is
* a GitHub public-preview feature and was observed to silently not apply
* (only `title` carried), which is unacceptable for a crash reporter that
* fires on devices we can't retry from. `blank_issues_enabled: true` in
* `.github/ISSUE_TEMPLATE/config.yml` guarantees `?body=` opens a prefilled
* issue. The body mirrors `bug_report.yml`'s sections in markdown so triage
* structure is preserved without depending on the preview path.
*/
fun buildGithubIssueUrl(report: CrashReport): String = IssueReport.buildGithubIssueUrl(
title = "[Bug]: Crash — ${report.shortTitle()}",
fun buildGithubIssueUrl(report: ReliabilityReport): String = IssueReport.buildGithubIssueUrl(
title = "[Bug]: Android crash — ${ReliabilityRedactor.redact(report.shortTitle(), 90)}",
bodyMarkdown = buildIssueBody(report),
labels = "bug",
labels = "bug,area:android",
)
private fun buildIssueBody(report: CrashReport): String {
val trace = report.stackTrace.let {
private fun buildIssueBody(report: ReliabilityReport): String {
val trace = report.technicalDetail.orEmpty().let {
if (it.length > MAX_TRACE_FOR_URL) {
it.take(MAX_TRACE_FOR_URL) + "\n… (truncated — full report copied to your clipboard)"
it.take(MAX_TRACE_FOR_URL) + "\n… (truncated — review/copy the local report for the remainder)"
} else {
it
}
}
return buildString {
appendLine(
"> ⚠️ Before submitting: remove any secrets, tokens, real hostnames/IPs, " +
"or personal data from the trace below.",
)
appendLine()
appendLine("### Affected area")
appendLine("Android app")
appendLine()
appendLine("### What happened?")
appendLine("The app closed unexpectedly. Auto-captured crash report below.")
appendLine()
appendLine("### Environment")
appendLine(report.environmentBlock())
appendLine()
appendLine("### Crash")
appendLine("```")
appendLine(trace)
appendLine("```")
appendLine()
append("<sub>Captured by the Hermes-Relay in-app crash reporter · ${report.timeIso}</sub>")
return ReliabilityRedactor.redact(
buildString {
appendLine("> No report was uploaded automatically. This is the locally reviewed, redacted copy.")
appendLine()
appendLine("### Affected area")
appendLine("Android app")
appendLine()
appendLine("### What happened?")
appendLine(report.summary)
appendLine()
appendLine("### Recovery")
appendLine(report.recovery)
appendLine()
appendLine("### Environment")
appendLine(report.environmentBlock())
appendLine("- Report ID: ${report.reportId}")
appendLine("- App session ID: ${report.appSessionId}")
appendLine()
appendLine("### Redacted technical detail")
appendLine("```")
appendLine(trace)
appendLine("```")
appendLine()
append("<sub>Captured locally by Hermes-Relay · ${report.timeIso}</sub>")
},
maxLength = 12_000,
)
}
/** Import the pre-v1 one-file crash format once, redacting before the new store sees it. */
private fun migrateLegacy(context: Context) {
val file = File(File(context.filesDir, LEGACY_DIR), LEGACY_FILE)
if (!file.isFile) return
runCatching {
val old = json.decodeFromString<LegacyCrashSnapshot>(file.readText())
val report = migrateLegacyCrash(old)
ReliabilityCenter.import(context, report)
file.delete()
}.onFailure {
Log.w(TAG, "Legacy crash report could not be migrated", it)
}
}
private fun isoNow(): String = runCatching {
OffsetDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS).toString()
}.getOrDefault(java.util.Date().toString())
}
/**
* Structured, serializable crash snapshot. Persisted as JSON between the
* crashing session and the next launch.
*/
@Serializable
data class CrashReport(
val timeIso: String,
val versionName: String,
val versionCode: Int,
val flavor: String,
val manufacturer: String,
val model: String,
val androidRelease: String,
val sdkInt: Int,
val threadName: String,
val exceptionSummary: String,
val stackTrace: String,
) {
fun deviceLine(): String = "$manufacturer $model"
fun androidLine(): String = "Android $androidRelease (SDK $sdkInt)"
fun versionLine(): String = "$versionName (code $versionCode) $flavor"
/** A short, human title for the GitHub issue — class name + trimmed message. */
fun shortTitle(): String {
val firstLine = exceptionSummary.lineSequence().firstOrNull().orEmpty()
val simpleClass = firstLine.substringBefore(':').substringAfterLast('.').ifBlank { "crash" }
val message = firstLine.substringAfter(':', "").trim()
return (if (message.isBlank()) simpleClass else "$simpleClass: $message").take(90)
}
/** Full, copy-paste-ready report shown in the dialog and copied to clipboard. */
fun toPlainText(): String = buildString {
appendLine("Hermes-Relay crash report")
appendLine("Time: $timeIso")
appendLine("App: ${versionLine()}")
appendLine("Device: ${deviceLine()}")
appendLine("Android: ${androidLine()}")
appendLine("Thread: $threadName")
appendLine()
append(stackTrace)
}
/** Matches the `environment` textarea default in `bug_report.yml`. */
fun environmentBlock(): String = buildString {
appendLine("- Hermes-Relay version/tag: $versionName (code $versionCode)")
appendLine(
"- Install surface: " +
if (flavor.equals("sideload", ignoreCase = true)) "sideload APK" else "Google Play",
)
appendLine("- Android device and OS: ${deviceLine()} — ${androidLine()}")
append("- Connection mode: LAN / Tailscale / public TLS / other")
}
}
@@ -28,6 +28,9 @@ object DiagnosticIssuePrefill {
private const val MAX_TRACE_FOR_URL = 3000
private const val DEFAULT_WHAT_HAPPENED = "Captured diagnostic from the in-app activity log."
private const val CONFIGURED_URL_MARKER = "[diagnostic-configured-url]"
private const val REQUEST_URL_MARKER = "[diagnostic-request-url]"
private const val LEGACY_URL_MARKER = "[diagnostic-url]"
/** `[Bug]:` for Error entries, `[Diagnostic]:` for Info/Warning. */
fun issueTitle(entry: DiagnosticLogEntry): String = when (entry.severity) {
@@ -36,12 +39,13 @@ object DiagnosticIssuePrefill {
}
/**
* `bug` for Error entries; `question` (an existing repo label) for
* Info/Warning so routine diagnostics don't pollute the bug queue.
* `bug,area:android` for Error entries; `question,area:android` for
* Info/Warning so routine diagnostics don't pollute the bug queue and all
* in-app reports reach the owning Android surface.
*/
fun issueLabels(entry: DiagnosticLogEntry): String = when (entry.severity) {
DiagnosticSeverity.Error -> "bug"
else -> "question"
DiagnosticSeverity.Error -> "bug,area:android"
else -> "question,area:android"
}
/**
@@ -52,7 +56,7 @@ object DiagnosticIssuePrefill {
*/
fun connectionMode(entry: DiagnosticLogEntry): String =
entry.endpointRole
?: entry.url?.let { Connection.inferRouteRole(it) }
?: entry.primaryUrl?.let { Connection.inferRouteRole(it) }
?: "unknown"
/**
@@ -76,7 +80,10 @@ object DiagnosticIssuePrefill {
val whatHappened = DiagnosticsLog.redactReportText(expectation)
?.takeIf { it.isNotBlank() }
?: DEFAULT_WHAT_HAPPENED
return buildString {
val configuredUrl = reportSafeUrl(entry.configuredUrl)
val requestUrl = reportSafeUrl(entry.requestUrl)
val legacyUrl = reportSafeUrl(entry.url)
val body = buildString {
appendLine(
"> ⚠️ Before submitting: remove any secrets, tokens, real hostnames/IPs, " +
"or personal data from the detail below.",
@@ -99,9 +106,15 @@ object DiagnosticIssuePrefill {
appendLine("- Title: ${entry.title}")
appendLine("- Category: ${entry.category.label}")
appendLine("- Severity: ${entry.severity.name}")
entry.operation?.let { appendLine("- Operation: $it") }
entry.endpointRole?.let { appendLine("- Route: $it") }
entry.url?.let { appendLine("- URL: $it") }
configuredUrl?.let { appendLine("- Configured URL: $CONFIGURED_URL_MARKER") }
requestUrl?.let { appendLine("- Request: $REQUEST_URL_MARKER") }
if (configuredUrl == null && requestUrl == null) {
legacyUrl?.let { appendLine("- URL: $LEGACY_URL_MARKER") }
}
entry.elapsedMs?.let { appendLine("- Elapsed: ${it}ms") }
entry.suggestion?.let { appendLine("- Suggested next step: $it") }
if (trace.isNotBlank()) {
appendLine()
appendLine("```")
@@ -111,5 +124,18 @@ object DiagnosticIssuePrefill {
appendLine()
append("<sub>Captured by the Hermes-Relay in-app diagnostics log</sub>")
}
return DiagnosticsLog.redactReportText(body).orEmpty()
.replace(CONFIGURED_URL_MARKER, configuredUrl.orEmpty())
.replace(REQUEST_URL_MARKER, requestUrl.orEmpty())
.replace(LEGACY_URL_MARKER, legacyUrl.orEmpty())
}
private fun reportSafeUrl(value: String?): String? {
val sanitized = DiagnosticsLog.sanitizeUrl(value) ?: return null
return if (sanitized.contains("://")) {
sanitized
} else {
DiagnosticsLog.redactReportText(sanitized)
}
}
}
@@ -4,6 +4,7 @@ import android.content.Context
import com.hermesandroid.relay.R
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
@@ -226,17 +227,31 @@ fun classifyError(t: Throwable?, context: String? = null, ctx: Context? = null):
// Record after classification so the clean title and the raw trace both
// reach the log. Defensive: never let logging turn a handled error fatal.
runCatching {
val category = categoryForContext(context)
DiagnosticsLog.recordError(
category = categoryForContext(context),
category = category,
title = human.title,
detail = human.body,
throwable = t,
operation = context?.diagnosticOperation(),
suggestion = NetworkDiagnosticGuidance.forThrowable(t, category.label),
reliabilityContext = context,
)
}
}
return human
}
private fun String.diagnosticOperation(): String =
trim()
.split('_', '-', ' ')
.filter { it.isNotBlank() }
.joinToString(" ") { word ->
word.replaceFirstChar { character ->
if (character.isLowerCase()) character.titlecase() else character.toString()
}
}
private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?): HumanError {
if (t == null) return nullFallback(context, ctx)
@@ -31,6 +31,7 @@ import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
import com.hermesandroid.relay.data.RealtimeTurnTrace
import com.hermesandroid.relay.data.SessionActivityState
import com.hermesandroid.relay.data.ToolCallEvent
import com.hermesandroid.relay.data.VoiceIntentTrace
import com.hermesandroid.relay.data.HermesCard
@@ -41,6 +42,7 @@ import com.hermesandroid.relay.data.HermesCardInput
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import com.hermesandroid.relay.network.upstream.ActiveTurnHandle
import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
import com.hermesandroid.relay.network.upstream.GatewayAsk
@@ -56,11 +58,16 @@ import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.network.upstream.GatewayEventMapper
import com.hermesandroid.relay.network.upstream.GatewayInboundTurnRegistration
import com.hermesandroid.relay.network.upstream.GatewayModelProvider
import com.hermesandroid.relay.network.upstream.GatewayModelCapabilities
import com.hermesandroid.relay.network.upstream.GatewayModelOptions
import com.hermesandroid.relay.network.upstream.GatewayProcess
import com.hermesandroid.relay.network.upstream.GatewayProcessCapability
import com.hermesandroid.relay.network.upstream.GatewayProcessEvent
import com.hermesandroid.relay.network.upstream.GatewaySessionModel
import com.hermesandroid.relay.network.upstream.ReasoningEffortAvailability
import com.hermesandroid.relay.network.upstream.ReasoningEffortIdentity
import com.hermesandroid.relay.network.upstream.ReasoningEfforts
import com.hermesandroid.relay.network.upstream.resolveReasoningEffortAvailability
import com.hermesandroid.relay.network.upstream.GatewayAttachment
import com.hermesandroid.relay.network.upstream.GatewayRpcException
import com.hermesandroid.relay.network.upstream.GatewayTurnCallbacks
@@ -125,6 +132,15 @@ import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
internal fun modelInventoryFailureNotice(
failure: Throwable,
userInitiated: Boolean,
): String? = if (userInitiated) {
"Couldn't refresh API model inventory: ${failure.message ?: "unknown error"}"
} else {
null
}
/**
* Absolute per-session context-window usage in tokens — the data behind the
* desktop-style "used / max" context bar. [fraction] is the fill 0..1.
@@ -199,6 +215,32 @@ class ChatViewModel : ViewModel() {
private data class TurnCheckpointKey(val contextKey: String, val sessionId: String)
private val backgroundTurnCheckpoints =
ConcurrentHashMap<TurnCheckpointKey, ChatTurnCheckpoint>()
private val backgroundNeedsInputKeys = ConcurrentHashMap.newKeySet<TurnCheckpointKey>()
private val _backgroundSessionActivityStates =
MutableStateFlow<Map<String, SessionActivityState>>(emptyMap())
val backgroundSessionActivityStates: StateFlow<Map<String, SessionActivityState>> =
_backgroundSessionActivityStates.asStateFlow()
private fun publishBackgroundSessionActivity() {
val contextKey = activeProfileContextKey
_backgroundSessionActivityStates.value = if (contextKey == null) {
emptyMap()
} else {
backgroundTurnCheckpoints.keys
.asSequence()
.filter { it.contextKey == contextKey }
.associate { key ->
key.sessionId to if (
key in backgroundNeedsInputKeys ||
backgroundPendingInteractions.containsKey(key)
) {
SessionActivityState.NeedsInput
} else {
SessionActivityState.Working
}
}
}
}
private fun TurnCheckpointKey.keepAliveKey(): String = "$contextKey::$sessionId"
@@ -526,6 +568,11 @@ class ChatViewModel : ViewModel() {
*/
private val _modelProviders = MutableStateFlow<List<GatewayModelProvider>>(emptyList())
val modelProviders: StateFlow<List<GatewayModelProvider>> = _modelProviders.asStateFlow()
private val relayReasoningCapabilities =
MutableStateFlow<Map<ReasoningEffortIdentity, GatewayModelCapabilities>>(emptyMap())
private val _reasoningCapabilityRevision = MutableStateFlow(0L)
val reasoningCapabilityRevision: StateFlow<Long> = _reasoningCapabilityRevision.asStateFlow()
private val relayCapabilityGeneration = AtomicLong(0L)
private val modelOptionsGeneration = java.util.concurrent.atomic.AtomicLong(0L)
private val modelOptionsByProfile = mutableMapOf<String, GatewayModelOptions>()
@@ -535,10 +582,16 @@ class ChatViewModel : ViewModel() {
return "$connectionProfile::$session"
}
private fun reasoningCapabilityContextKey(): String =
activeProfileContextKey ?: "__unbound__"
private fun activateModelOptionsProfile(profileKey: String) {
modelOptionsGeneration.incrementAndGet()
val cached = modelOptionsByProfile[profileKey]
_modelProviders.value = cached?.providers.orEmpty()
relayCapabilityGeneration.incrementAndGet()
relayReasoningCapabilities.value = emptyMap()
_reasoningCapabilityRevision.value += 1L
_gatewayCurrentModel.value = cached?.currentModel.orEmpty()
_gatewayCurrentProvider.value = cached?.currentProvider.orEmpty()
_apiModelOptions.value = emptyList()
@@ -571,6 +624,9 @@ class ChatViewModel : ViewModel() {
*/
private val _selectedReasoningEffort = MutableStateFlow<String?>(null)
val selectedReasoningEffort: StateFlow<String?> = _selectedReasoningEffort.asStateFlow()
/** Exact provider/model identity that confirmed the currently displayed effort. */
private var selectedReasoningEffortConfirmedIdentity: ReasoningEffortIdentity? = null
private val reasoningEffortRevision = AtomicLong(0)
private val _reasoningDisplay = MutableStateFlow<String?>(null)
@@ -653,6 +709,7 @@ class ChatViewModel : ViewModel() {
_modelProviders.value = it.providers
_gatewayCurrentModel.value = it.currentModel
_gatewayCurrentProvider.value = it.currentProvider
refreshRelayReasoningCapabilities(refresh = refresh)
android.util.Log.i(
"ChatViewModel",
"model.options${if (refresh) " refresh" else ""}: ${it.providers.size} providers, " +
@@ -676,10 +733,20 @@ class ChatViewModel : ViewModel() {
android.util.Log.i("ChatViewModel", "refreshReasoningSettings: no gateway client")
return
}
val revision = reasoningEffortRevision.get()
val identity = reasoningEffortIdentity()
viewModelScope.launch {
gateway.getReasoningSettings().fold(
onSuccess = {
if (!isCurrentReasoningResponse(
capturedRevision = revision,
currentRevision = reasoningEffortRevision.get(),
capturedIdentity = identity,
activeIdentity = reasoningEffortIdentity(),
)
) return@fold
_selectedReasoningEffort.value = normalizeReasoningEffort(it.effort)
selectedReasoningEffortConfirmedIdentity = identity
_reasoningDisplay.value = it.display
},
onFailure = {
@@ -797,9 +864,98 @@ class ChatViewModel : ViewModel() {
* model override is cleared.
*/
private val _selectedProviderOverride = MutableStateFlow<String?>(null)
val selectedProviderOverride: StateFlow<String?> = _selectedProviderOverride.asStateFlow()
private val apiSessionModelLocks = mutableMapOf<String, ApiModelSelectionAck.Locked>()
fun fetchModels() {
/** Active provider/model capability contract used by every reasoning control and send path. */
fun reasoningEffortAvailability(): ReasoningEffortAvailability {
val identity = reasoningEffortIdentity()
return resolveReasoningEffortAvailability(
providers = _modelProviders.value,
provider = identity?.provider,
model = identity?.model,
relayCapabilities = relayReasoningCapabilities.value,
)
}
private fun reasoningEffortIdentity(): ReasoningEffortIdentity? {
val selectedModel = _selectedModelOverride.value
val selectedProvider = _selectedProviderOverride.value
val aliasRoot = selectedModel?.let { selected ->
_apiModelOptions.value.firstOrNull { it.id == selected }?.root
}
val capabilityModel = aliasRoot ?: selectedModel ?: _gatewayCurrentModel.value
val capabilityProvider = selectedProvider ?: aliasRoot?.let { root ->
_modelProviders.value.singleOrNull { root in it.models }?.slug
} ?: if (selectedModel == null) {
_gatewayCurrentProvider.value
} else {
null
}
val provider = capabilityProvider?.trim()?.takeIf { it.isNotEmpty() } ?: return null
val model = capabilityModel.trim().takeIf { it.isNotEmpty() } ?: return null
return ReasoningEffortIdentity(provider = provider.lowercase(), model = model)
}
private fun refreshRelayReasoningCapabilities(
focus: ReasoningEffortIdentity? = reasoningEffortIdentity(),
refresh: Boolean = false,
) {
val relay = relayHttpClient ?: return
val pairs = buildList {
focus?.let(::add)
_modelProviders.value.forEach { provider ->
provider.models.forEach { model ->
add(ReasoningEffortIdentity(provider.slug.lowercase(), model))
}
}
}.distinct().take(RelayHttpClient.MAX_MODEL_CAPABILITY_ROWS)
if (pairs.isEmpty()) return
val generation = relayCapabilityGeneration.incrementAndGet()
val profileKey = reasoningCapabilityContextKey()
val profile = sessionProfileNameProvider()
viewModelScope.launch {
val result = relay.fetchModelCapabilities(
models = pairs.map {
RelayHttpClient.ModelCapabilityRequestRow(it.provider, it.model)
},
profile = profile,
refresh = refresh,
)
if (
relayHttpClient !== relay ||
!isCurrentReasoningCapabilityOverlay(
requestGeneration = generation,
currentGeneration = relayCapabilityGeneration.get(),
requestProfileKey = profileKey,
currentProfileKey = reasoningCapabilityContextKey(),
)
) return@launch
val response = result.getOrNull()
val requested = pairs.toSet()
val capabilities = response?.capabilities.orEmpty()
.asSequence()
.take(RelayHttpClient.MAX_MODEL_CAPABILITY_ROWS)
.mapNotNull { row ->
val identity = ReasoningEffortIdentity(
provider = row.provider.trim().lowercase(),
model = row.model.trim(),
)
if (identity !in requested) return@mapNotNull null
identity to GatewayModelCapabilities(
reasoning = row.reasoning,
reasoningEfforts = row.reasoningEfforts,
reasoningEffortsExact = row.reasoningEffortsExact,
)
}
.toMap()
relayReasoningCapabilities.value = capabilities
_reasoningCapabilityRevision.value += 1L
reconcilePendingReasoningEffortForModel()
}
}
fun fetchModels(userInitiated: Boolean = false) {
val client = apiClient ?: return
val generation = modelOptionsGeneration.incrementAndGet()
val profileKey = modelOptionsProfileKey()
@@ -832,15 +988,33 @@ class ChatViewModel : ViewModel() {
_apiModelOptions.value = aliases
_availableModels.value =
(options.providers.flatMap { it.models } + aliases.map { it.id }).distinct()
refreshRelayReasoningCapabilities()
} else {
val failure = providerResult.exceptionOrNull()
if (
failure !is ApiModelRoutingException ||
failure.code != ApiModelRoutingErrorCode.INVENTORY_UNSUPPORTED
) {
_transientNotice.tryEmit(
failure?.message ?: "Model inventory could not be loaded.",
val inventoryFailure = failure
?: ApiModelRoutingException(
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
"Model inventory could not be loaded.",
)
DiagnosticsLog.record(
category = DiagnosticCategory.Api,
severity = DiagnosticSeverity.Warning,
title = "Optional model inventory unavailable",
detail = inventoryFailure.message,
operation = "Load API model inventory",
endpointRole = "Optional API server",
suggestion = NetworkDiagnosticGuidance.forThrowable(
inventoryFailure,
"API server",
),
stacktrace = inventoryFailure.stackTraceToString(),
)
modelInventoryFailureNotice(inventoryFailure, userInitiated)
?.let(_transientNotice::tryEmit)
return@launch
}
// Confirmed older API servers expose only OpenAI-compatible aliases.
@@ -876,6 +1050,7 @@ class ChatViewModel : ViewModel() {
if (locked != null) {
_selectedModelOverride.value = locked.model
_selectedProviderOverride.value = locked.provider
transitionReasoningEffortIdentity()
_transientNotice.tryEmit(
"This session is locked to ${locked.model}. Start a new chat to use Server default.",
)
@@ -889,6 +1064,7 @@ class ChatViewModel : ViewModel() {
// model is cleared so the next new session falls back to the default.
_selectedProviderOverride.value =
provider?.takeIf { it.isNotBlank() && !model.isNullOrBlank() }
transitionReasoningEffortIdentity()
val gateway = gatewayClient
val handler = chatHandler
if (model.isNullOrBlank()) {
@@ -994,6 +1170,7 @@ class ChatViewModel : ViewModel() {
fun selectApiModel(modelId: String) {
_selectedModelOverride.value = modelId.trim().takeIf { it.isNotEmpty() }
_selectedProviderOverride.value = null
transitionReasoningEffortIdentity()
refreshActiveAgentName()
}
@@ -1011,6 +1188,10 @@ class ChatViewModel : ViewModel() {
*/
fun selectReasoningEffort(value: String) {
val normalized = normalizeReasoningEffort(value)
if (!reasoningEffortAvailability().accepts(normalized)) return
val revision = reasoningEffortRevision.incrementAndGet()
selectedReasoningEffortConfirmedIdentity = null
val identity = reasoningEffortIdentity()
_selectedReasoningEffort.value = normalized
val gateway = gatewayClient ?: return
val handler = chatHandler
@@ -1020,9 +1201,17 @@ class ChatViewModel : ViewModel() {
gateway.prewarm(handler.currentSessionId.value)
gateway.setReasoning(normalized).fold(
onSuccess = { result ->
if (!isCurrentReasoningResponse(
capturedRevision = revision,
currentRevision = reasoningEffortRevision.get(),
capturedIdentity = identity,
activeIdentity = reasoningEffortIdentity(),
)
) return@fold
_selectedReasoningEffort.value = normalizeReasoningEffort(
result.stringValue("value") ?: normalized,
)
selectedReasoningEffortConfirmedIdentity = identity
},
onFailure = { e ->
handler.addSystemNotice(
@@ -1034,6 +1223,27 @@ class ChatViewModel : ViewModel() {
}
}
private fun transitionReasoningEffortIdentity() {
reasoningEffortRevision.incrementAndGet()
selectedReasoningEffortConfirmedIdentity = null
reconcilePendingReasoningEffortForModel()
refreshRelayReasoningCapabilities()
}
private fun reconcilePendingReasoningEffortForModel() {
val identity = reasoningEffortIdentity()
val reconciled = reconcilePendingReasoningEffort(
value = _selectedReasoningEffort.value,
confirmedIdentity = selectedReasoningEffortConfirmedIdentity,
activeIdentity = identity,
availability = reasoningEffortAvailability(),
)
if (reconciled != _selectedReasoningEffort.value) {
reasoningEffortRevision.incrementAndGet()
_selectedReasoningEffort.value = reconciled
}
}
/**
* Toggle per-session approval bypass (YOLO). Session-scoped + ephemeral the
* way the desktop does it — never persists global auto-approve. Optimistic;
@@ -1203,6 +1413,8 @@ class ChatViewModel : ViewModel() {
// session.info confirm it on the first turn (see the dropped
// getReasoningSettings below).
_selectedReasoningEffort.value = null
reasoningEffortRevision.incrementAndGet()
selectedReasoningEffortConfirmedIdentity = null
_reasoningDisplay.value = null
// The personality overlay is per-profile. On SSE, leaving a stale pick
// here would inject the previous profile's overlay onto the new
@@ -1369,6 +1581,7 @@ class ChatViewModel : ViewModel() {
val model = _selectedModelOverride.value?.takeIf { it.isNotBlank() }
val provider = _selectedProviderOverride.value?.takeIf { it.isNotBlank() }
val effort = _selectedReasoningEffort.value?.takeIf { it.isNotBlank() }
?.takeIf { reasoningEffortAvailability().accepts(it) }
// Contract v4 distinguishes all three states: null omits the field
// and inherits the profile tier, true pins priority, and false pins
// normal. Do not filter false here or a user's explicit Fast-off
@@ -1411,15 +1624,17 @@ class ChatViewModel : ViewModel() {
if (key != null) {
backgroundPendingInteractions[key] =
BackgroundPendingInteraction(event.profile, event.ask)
backgroundNeedsInputKeys += key
ActiveTurnKeepAliveRegistry.setWaiting(key.keepAliveKey(), true)
publishBackgroundSessionActivity()
}
maybeNotifyInteraction(event.storedSessionId, event.ask, event.profile)
}
is GatewayBackgroundInteractionEvent.Resolved -> {
if (key != null) {
backgroundNeedsInputKeys -= key
backgroundPendingInteractions.computeIfPresent(key) { _, current ->
if (current.profile == event.profile &&
current.ask.kind == event.ask.kind &&
if (current.ask.kind == event.ask.kind &&
current.ask.requestId == event.ask.requestId
) {
null
@@ -1428,6 +1643,7 @@ class ChatViewModel : ViewModel() {
}
}
ActiveTurnKeepAliveRegistry.setWaiting(key.keepAliveKey(), false)
publishBackgroundSessionActivity()
}
cancelInteractionNotification(event.storedSessionId, event.ask, event.profile)
}
@@ -1449,6 +1665,8 @@ class ChatViewModel : ViewModel() {
// null = unknown (honest); refreshReasoningSettings/session.info
// re-seed it. Never a stale default that could ride session.create.
_selectedReasoningEffort.value = null
reasoningEffortRevision.incrementAndGet()
selectedReasoningEffortConfirmedIdentity = null
_reasoningDisplay.value = null
}
// Catalog fetch must never cold-open /api/ws — only fetch over an
@@ -1472,6 +1690,8 @@ class ChatViewModel : ViewModel() {
// null = unknown (honest); refreshReasoningSettings/session.info
// re-seed it. Never a stale default that could ride session.create.
_selectedReasoningEffort.value = null
reasoningEffortRevision.incrementAndGet()
selectedReasoningEffortConfirmedIdentity = null
_reasoningDisplay.value = null
}
}
@@ -1492,9 +1712,11 @@ class ChatViewModel : ViewModel() {
if (matching.isEmpty()) return
matching.forEach(backgroundTurnCheckpoints::remove)
matching.forEach { key ->
backgroundNeedsInputKeys -= key
backgroundPendingInteractions.remove(key)
ActiveTurnKeepAliveRegistry.release(key.keepAliveKey())
}
publishBackgroundSessionActivity()
chatTurnCheckpointStore?.let { store ->
viewModelScope.launch {
checkpointMutex.withLock {
@@ -2316,27 +2538,32 @@ class ChatViewModel : ViewModel() {
}
}
launch {
client.serverModel.collect { value ->
if (gatewayClient !== client || value.isNullOrBlank()) return@collect
if (_gatewayCurrentModel.value != value) _gatewayCurrentModel.value = value
}
}
launch {
client.serverProvider.collect { value ->
if (gatewayClient !== client || value.isNullOrBlank()) return@collect
if (_gatewayCurrentProvider.value != value) _gatewayCurrentProvider.value = value
}
}
launch {
client.serverReasoningEffort.collect { value ->
// Ignore blank (upstream "" = reasoning disabled — never
// clobber the chip). Compare on the normalized value, the
// same form the optimistic setter + config.get refresh store.
if (gatewayClient !== client || value.isNullOrBlank()) return@collect
val normalized = normalizeReasoningEffort(value)
if (_selectedReasoningEffort.value != normalized) {
_selectedReasoningEffort.value = normalized
client.serverModelIdentity.collect { identity ->
if (gatewayClient !== client || identity == null) return@collect
_gatewayCurrentModel.value = identity.model
_gatewayCurrentProvider.value = identity.provider
// A session.info identity is authoritative and coherent.
// Retaining a pending override after an acknowledgement or
// external Desktop/TUI switch can otherwise pair its stale
// model with the newly reported provider.
if (_selectedModelOverride.value != null) {
_selectedModelOverride.value = null
_selectedProviderOverride.value = null
}
transitionReasoningEffortIdentity()
}
}
launch {
client.serverReasoningIdentity.collect { state ->
if (gatewayClient !== client || state == null) return@collect
val activeIdentity = reasoningEffortIdentity() ?: return@collect
if (
!state.identity.provider.equals(activeIdentity.provider, ignoreCase = true) ||
state.identity.model != activeIdentity.model
) return@collect
reasoningEffortRevision.incrementAndGet()
selectedReasoningEffortConfirmedIdentity = activeIdentity
_selectedReasoningEffort.value = normalizeReasoningEffort(state.effort)
}
}
launch {
@@ -2696,6 +2923,7 @@ class ChatViewModel : ViewModel() {
}
ensureCheckpointObservers()
this.relayHttpClient = relayHttpClient
if (_modelProviders.value.isNotEmpty()) refreshRelayReasoningCapabilities()
this.mediaSettingsRepo = mediaSettingsRepo
this.mediaCacheWriter = mediaCacheWriter
@@ -2784,6 +3012,10 @@ class ChatViewModel : ViewModel() {
sessionRefreshJob?.cancel()
_isLoadingSessions.value = false
activeProfileContextKey = null
relayCapabilityGeneration.incrementAndGet()
relayReasoningCapabilities.value = emptyMap()
_reasoningCapabilityRevision.value += 1L
publishBackgroundSessionActivity()
_queuedMessages.value = emptyList()
_pendingAttachments.value = emptyList()
_steerableTurn.value = false
@@ -2800,6 +3032,8 @@ class ChatViewModel : ViewModel() {
// reset to unknown/default so neither a stale chip nor a stale
// SSE persona overlay carries into the new connection.
_selectedReasoningEffort.value = null
reasoningEffortRevision.incrementAndGet()
selectedReasoningEffortConfirmedIdentity = null
_reasoningDisplay.value = null
_selectedPersonality.value = "default"
pendingTruncateOrdinal = null
@@ -2835,6 +3069,8 @@ class ChatViewModel : ViewModel() {
) {
activateModelOptionsProfile(contextKey)
activeProfileContextKey = contextKey
refreshRelayReasoningCapabilities()
publishBackgroundSessionActivity()
activeTurnCheckpointSeed?.contextKey = contextKey
scheduleCheckpointWrite(immediate = true)
selectBackgroundProcessSession(sessionId, contextKey)
@@ -2852,6 +3088,8 @@ class ChatViewModel : ViewModel() {
_isLoadingSessions.value = false
activateModelOptionsProfile(contextKey)
activeProfileContextKey = contextKey
refreshRelayReasoningCapabilities()
publishBackgroundSessionActivity()
_queuedMessages.value = emptyList()
_pendingAttachments.value = emptyList()
_steerableTurn.value = false
@@ -2867,10 +3105,12 @@ class ChatViewModel : ViewModel() {
// SSE profile switch: reset the reasoning chip to unknown (a sessionless
// config.get reads the wrong profile's effort) and the personality to
// default so composeInjectedContext can't inject the previous profile's
// overlay onto this profile's first SSE turn. The serverReasoningEffort /
// serverPersonality collectors (gateway) and session.info reconcile after
// the first turn; on pure SSE the new profile's own SOUL carries instead.
// overlay onto this profile's first SSE turn. The identity-bound reasoning
// and personality collectors reconcile gateway session.info after the
// first turn; on pure SSE the new profile's own SOUL carries instead.
_selectedReasoningEffort.value = null
reasoningEffortRevision.incrementAndGet()
selectedReasoningEffortConfirmedIdentity = null
_reasoningDisplay.value = null
_selectedPersonality.value = "default"
// The agent display name was stamped above with the pre-reset persona —
@@ -2999,8 +3239,8 @@ class ChatViewModel : ViewModel() {
* groups refresh via [refreshModelOptions]; this covers [availableModels] used
* when no gateway model.options groups exist. Fetched once otherwise.
*/
fun refreshModels() {
fetchModels()
fun refreshModels(userInitiated: Boolean = false) {
fetchModels(userInitiated)
}
/** Clear server-owned catalogs before a different connection starts loading. */
@@ -3650,7 +3890,11 @@ class ChatViewModel : ViewModel() {
sessionId?.let { cancelInteractionNotification(it, pending.ask) }
}
val activeKey = activeTurnCheckpointKey()
if (activeKey != null) backgroundPendingInteractions.remove(activeKey)
if (activeKey != null) {
backgroundNeedsInputKeys -= activeKey
backgroundPendingInteractions.remove(activeKey)
publishBackgroundSessionActivity()
}
val now = restored?.receivedAt ?: System.currentTimeMillis()
val cardKey = restored?.cardKey ?: ask.requestId
?: "approval-${handler.currentSessionId.value ?: "session"}-$now"
@@ -4553,8 +4797,10 @@ class ChatViewModel : ViewModel() {
checkpointWriteJob = null
if (key != null) {
backgroundTurnCheckpoints.remove(key)
backgroundNeedsInputKeys -= key
backgroundPendingInteractions.remove(key)
ActiveTurnKeepAliveRegistry.release(key.keepAliveKey())
publishBackgroundSessionActivity()
}
val store = chatTurnCheckpointStore ?: return
viewModelScope.launch {
@@ -4589,6 +4835,8 @@ class ChatViewModel : ViewModel() {
if (checkpoint != null) {
val key = TurnCheckpointKey(checkpoint.contextKey, checkpoint.sessionId)
backgroundTurnCheckpoints[key] = checkpoint
if (checkpoint.pendingAsk != null) backgroundNeedsInputKeys += key
publishBackgroundSessionActivity()
chatTurnCheckpointStore?.let { store ->
viewModelScope.launch {
checkpointMutex.withLock { runCatching { store.write(checkpoint) } }
@@ -4669,6 +4917,8 @@ class ChatViewModel : ViewModel() {
runCatching { store.read(contextKey, sessionId) }.getOrNull()
}
?: return false
backgroundNeedsInputKeys -= key
publishBackgroundSessionActivity()
if (checkpoint.transport !in setOf("gateway", "sessions")) return false
if (activeStream != null) return true
if (streamRecovery != null) {
@@ -7813,6 +8063,8 @@ class ChatViewModel : ViewModel() {
gatewayClient?.setUnmatchedTurnCompleteListener(null)
gatewayClient?.setBackgroundInteractionListener(null)
backgroundPendingInteractions.clear()
backgroundNeedsInputKeys.clear()
publishBackgroundSessionActivity()
gatewayHistoryReconcileJob?.cancel()
gatewayHistoryReconcileJob = null
backgroundProcessSessionJob?.cancel()
@@ -8117,12 +8369,32 @@ private fun JsonObject.stringValue(key: String): String? =
private const val RECENT_PROMPTS_LIMIT = 15
private const val MAX_GATEWAY_COMMAND_DISPLAY_CHARS = 2_000
private const val DEFAULT_REASONING_EFFORT = "medium"
private val VALID_REASONING_EFFORTS = setOf("none", "minimal", "low", "medium", "high", "xhigh")
internal fun normalizeReasoningEffort(value: String?): String = ReasoningEfforts.normalize(value)
private fun normalizeReasoningEffort(value: String?): String {
val normalized = value?.trim()?.lowercase().orEmpty()
return normalized.takeIf { it in VALID_REASONING_EFFORTS } ?: DEFAULT_REASONING_EFFORT
internal fun isCurrentReasoningResponse(
capturedRevision: Long,
currentRevision: Long,
capturedIdentity: ReasoningEffortIdentity?,
activeIdentity: ReasoningEffortIdentity?,
): Boolean = capturedRevision == currentRevision && capturedIdentity == activeIdentity
internal fun isCurrentReasoningCapabilityOverlay(
requestGeneration: Long,
currentGeneration: Long,
requestProfileKey: String,
currentProfileKey: String,
): Boolean = requestGeneration == currentGeneration && requestProfileKey == currentProfileKey
internal fun reconcilePendingReasoningEffort(
value: String?,
confirmedIdentity: ReasoningEffortIdentity?,
activeIdentity: ReasoningEffortIdentity?,
availability: ReasoningEffortAvailability,
): String? = when {
value == null -> null
confirmedIdentity != null && confirmedIdentity == activeIdentity -> value
availability.accepts(value) -> value
else -> null
}
private fun String.compactWords(): String = replace(Regex("\\s+"), " ").trim()
@@ -5267,12 +5267,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
return@launch
}
val diagnosticApiUrl = effectiveApiServerUrlSnapshot()
_apiServerHealth.value = HealthStatus.Probing
DiagnosticsLog.record(
category = DiagnosticCategory.Api,
severity = DiagnosticSeverity.Info,
title = ctx.getString(R.string.conn_status_testing_standard),
url = effectiveApiServerUrlSnapshot(),
operation = "Hermes API health check",
configuredUrl = diagnosticApiUrl,
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/health",
)
val health = client.checkHealthDetailed()
@@ -5284,7 +5287,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
severity = DiagnosticSeverity.Error,
title = ctx.getString(R.string.conn_status_setup_health_failed),
detail = health.message,
url = effectiveApiServerUrlSnapshot(),
operation = "Hermes API health check",
configuredUrl = diagnosticApiUrl,
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/health",
)
onResult(
StandardApiSetupResult(
@@ -5365,7 +5370,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
severity = if (reachable) DiagnosticSeverity.Info else DiagnosticSeverity.Error,
title = if (reachable) ctx.getString(R.string.conn_status_hermes_ok) else ctx.getString(R.string.conn_status_auth_failed),
detail = message,
url = effectiveApiServerUrlSnapshot(),
operation = "Hermes API sessions authentication check",
configuredUrl = diagnosticApiUrl,
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/api/sessions",
)
onResult(
StandardApiSetupResult(
@@ -5587,12 +5594,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
return@launch
}
val diagnosticApiUrl = effectiveApiServerUrlSnapshot()
_apiServerHealth.value = HealthStatus.Probing
DiagnosticsLog.record(
category = DiagnosticCategory.Api,
severity = DiagnosticSeverity.Info,
title = ctx.getString(R.string.conn_status_testing_api),
url = effectiveApiServerUrlSnapshot(),
operation = "Hermes API health check",
configuredUrl = diagnosticApiUrl,
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/health",
)
val health = client.checkHealthDetailed()
if (health is com.hermesandroid.relay.network.upstream.HealthCheckResult.Unhealthy) {
@@ -5603,7 +5613,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
severity = DiagnosticSeverity.Error,
title = ctx.getString(R.string.conn_status_api_health_failed),
detail = health.message,
url = effectiveApiServerUrlSnapshot(),
operation = "Hermes API health check",
configuredUrl = diagnosticApiUrl,
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/health",
)
onResult(false, health.message)
return@launch
@@ -5624,7 +5636,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
severity = if (reachable) DiagnosticSeverity.Info else DiagnosticSeverity.Error,
title = if (reachable) ctx.getString(R.string.conn_status_api_test_ok) else ctx.getString(R.string.conn_status_api_test_failed),
detail = message,
url = effectiveApiServerUrlSnapshot(),
operation = "Hermes API sessions authentication check",
configuredUrl = diagnosticApiUrl,
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/api/sessions",
)
onResult(reachable, message)
}
+22 -1
View File
@@ -107,6 +107,8 @@
<string name="chat_placeholder_message">Mensagem…</string>
<string name="chat_edit_busy_snackbar">Não é possível editar agora — aguarde o turno atual terminar</string>
<string name="chat_select_reasoning_effort">Selecionar nível de raciocínio</string>
<string name="reasoning_effort_standard_levels_notice">O Hermes não informa níveis exatos de esforço para este modelo, então as opções padrão são exibidas.</string>
<string name="reasoning_effort_current_outside_supported">Sessão atual: %1$s (informado pelo Hermes). Novas seleções para este modelo estão limitadas a %2$s.</string>
<!-- Voice toasts -->
<string name="voice_toast_signin_route">A voz exige um login único pela rota %1$s — abra Gerenciar</string>
<string name="voice_toast_signin_default">A voz exige login no painel — abra Gerenciar para entrar</string>
@@ -806,6 +808,9 @@
<string name="drawer_refresh_sessions">Atualizar sessões</string>
<string name="drawer_new_chat">Novo Chat</string>
<string name="drawer_search_placeholder">Pesquisar sessões ou id...</string>
<string name="drawer_search_sessions">Pesquisar sessões</string>
<string name="drawer_activity_working">Em andamento</string>
<string name="drawer_activity_needs_input">Precisa de resposta</string>
<string name="drawer_new_thread">Nova Thread</string>
<string name="drawer_chats_not_named">Os chats não recebem nomes automáticos nesta conexão — use ⋮ → Renomear.</string>
<string name="drawer_loading_sessions">Carregando sessões…</string>
@@ -2518,7 +2523,14 @@
<string name="attachment_type_file">Arquivo</string>
<!-- CrashReportDialog -->
<string name="crash_title">O Hermes-Relay fechou inesperadamente</string>
<string name="crash_body">A última sessão falhou. Enviar este relatório ajuda a corrigir o problema mais rápido.</string>
<string name="crash_body">O Hermes-Relay reiniciou após um problema inesperado. Você pode continuar usando o app.</string>
<string name="crash_privacy">Nada foi enviado. Revise o relatório editado localmente antes de compartilhar.</string>
<string name="crash_review">Revisar relatório</string>
<string name="crash_share_title">Compartilhar relatório de falha</string>
<string name="crash_share_subject">Relatório de falha do Hermes-Relay — %1$s</string>
<string name="crash_toast_no_share">Relatório copiado — nenhum app encontrado para compartilhar</string>
<string name="crash_toast_opened">Relatório completo copiado — revise o problema no GitHub antes de enviar</string>
<string name="crash_toast_no_browser">Relatório copiado — nenhum navegador encontrado para abrir o GitHub</string>
<string name="crash_dismiss">Dispensar</string>
<string name="crash_copy">Copiar</string>
<string name="crash_share">Compartilhar</string>
@@ -2722,6 +2734,9 @@
<string name="chat_reasoning_low">Baixo</string>
<string name="chat_reasoning_medium">Médio</string>
<string name="chat_reasoning_high">Alto</string>
<string name="chat_reasoning_xhigh">Extra alto</string>
<string name="chat_reasoning_max">Máximo</string>
<string name="chat_reasoning_ultra">Ultra</string>
<string name="chat_date_today">Hoje</string>
<string name="chat_date_yesterday">Ontem</string>
<string name="chat_share_subject">Conversa do Hermes</string>
@@ -3679,4 +3694,10 @@
<string name="plugins_keep">Manter</string>
<string name="plugins_remove">Remover</string>
<string name="plugins_remove_confirm">Remover “%1$s”? Esta página do plugin não aparecerá mais nos dispositivos Android conectados.</string>
<string name="support_bundle_review">Revisar informações de suporte</string>
<string name="support_bundle_title">Informações de suporte</string>
<string name="support_bundle_privacy">Nada é enviado automaticamente. Revise a exportação local com até %1$d relatórios.</string>
<string name="support_bundle_copied">Informações de suporte copiadas</string>
<string name="support_bundle_no_share">Informações de suporte copiadas — nenhum app encontrado para compartilhar</string>
<string name="support_bundle_share_title">Compartilhar informações de suporte do Hermes-Relay</string>
</resources>
+22 -1
View File
@@ -118,6 +118,8 @@
<string name="chat_placeholder_message">输入消息…</string>
<string name="chat_edit_busy_snackbar">暂时无法编辑——请等当前这一轮结束</string>
<string name="chat_select_reasoning_effort">选择推理强度</string>
<string name="reasoning_effort_standard_levels_notice">Hermes 未公布此模型的确切推理强度级别,因此显示标准选项。</string>
<string name="reasoning_effort_current_outside_supported">当前会话:%1$s(由 Hermes 报告)。此模型的新选择仅限于 %2$s。</string>
<!-- 语音提示 -->
<string name="voice_toast_signin_route">语音需要在 %1$s 路由上登录一次——打开管理</string>
@@ -852,6 +854,9 @@
<string name="drawer_refresh_sessions">刷新会话</string>
<string name="drawer_new_chat">新对话</string>
<string name="drawer_search_placeholder">搜索会话或 ID…</string>
<string name="drawer_search_sessions">搜索会话</string>
<string name="drawer_activity_working">正在处理</string>
<string name="drawer_activity_needs_input">需要输入</string>
<string name="drawer_new_thread">新话题</string>
<string name="drawer_chats_not_named">此连接上的对话不会自动命名——使用 ⋮ → 重命名。</string>
<string name="drawer_loading_sessions">正在加载会话…</string>
@@ -2636,7 +2641,14 @@
<!-- CrashReportDialog -->
<string name="crash_title">Hermes-Relay 意外关闭</string>
<string name="crash_body">上次会话崩溃了。发送此报告有助于更快修复问题。</string>
<string name="crash_body">Hermes-Relay 在意外问题后已重新启动。你可以继续使用应用。</string>
<string name="crash_privacy">未发送任何内容。分享前请查看在本地脱敏的报告。</string>
<string name="crash_review">查看报告</string>
<string name="crash_share_title">分享崩溃报告</string>
<string name="crash_share_subject">Hermes-Relay 崩溃报告 — %1$s</string>
<string name="crash_toast_no_share">报告已复制 — 未找到可分享的应用</string>
<string name="crash_toast_opened">完整报告已复制 — 提交前请检查 GitHub 问题</string>
<string name="crash_toast_no_browser">报告已复制 — 未找到可打开 GitHub 的浏览器</string>
<string name="crash_dismiss">忽略</string>
<string name="crash_copy">复制</string>
<string name="crash_share">分享</string>
@@ -2853,6 +2865,9 @@
<string name="chat_reasoning_low">低</string>
<string name="chat_reasoning_medium">中</string>
<string name="chat_reasoning_high">高</string>
<string name="chat_reasoning_xhigh">特高</string>
<string name="chat_reasoning_max">最高</string>
<string name="chat_reasoning_ultra">超高</string>
<string name="chat_date_today">今天</string>
<string name="chat_date_yesterday">昨天</string>
<string name="chat_share_subject">Hermes 对话</string>
@@ -3767,4 +3782,10 @@
<string name="plugins_keep">保留</string>
<string name="plugins_remove">移除</string>
<string name="plugins_remove_confirm">要移除“%1$s”吗?此插件页面将不再显示在已连接的 Android 设备上。</string>
<string name="support_bundle_review">查看支持信息</string>
<string name="support_bundle_title">支持信息</string>
<string name="support_bundle_privacy">不会自动上传任何内容。请查看最多包含 %1$d 个报告的本地导出。</string>
<string name="support_bundle_copied">支持信息已复制</string>
<string name="support_bundle_no_share">支持信息已复制 — 未找到可分享的应用</string>
<string name="support_bundle_share_title">分享 Hermes-Relay 支持信息</string>
</resources>
+22 -1
View File
@@ -118,6 +118,8 @@
<string name="chat_placeholder_message">Nachricht…</string>
<string name="chat_edit_busy_snackbar">Bearbeiten derzeit nicht möglich — warte, bis der aktuelle Durchlauf beendet ist</string>
<string name="chat_select_reasoning_effort">Denkaufwand auswählen</string>
<string name="reasoning_effort_standard_levels_notice">Hermes gibt für dieses Modell keine genauen Aufwandsstufen an. Daher werden Standardoptionen angezeigt.</string>
<string name="reasoning_effort_current_outside_supported">Aktuelle Sitzung: %1$s (von Hermes gemeldet). Neue Auswahlen für dieses Modell sind auf %2$s beschränkt.</string>
<!-- Voice toasts -->
<string name="voice_toast_signin_route">Sprache erfordert eine einmalige Anmeldung über die Route %1$s — öffne Verwalten</string>
@@ -855,6 +857,9 @@
<string name="drawer_refresh_sessions">Sitzungen aktualisieren</string>
<string name="drawer_new_chat">Neuer Chat</string>
<string name="drawer_search_placeholder">Sitzungen oder ID suchen...</string>
<string name="drawer_search_sessions">Sitzungen durchsuchen</string>
<string name="drawer_activity_working">Wird bearbeitet</string>
<string name="drawer_activity_needs_input">Eingabe erforderlich</string>
<string name="drawer_new_thread">Neuer Thread</string>
<string name="drawer_chats_not_named">Chats werden bei dieser Verbindung nicht automatisch benannt — verwende ⋮ → Umbenennen.</string>
<string name="drawer_loading_sessions">Sitzungen werden geladen…</string>
@@ -2640,7 +2645,14 @@
<!-- CrashReportDialog -->
<string name="crash_title">Hermes-Relay wurde unerwartet beendet</string>
<string name="crash_body">Die letzte Sitzung ist abgestürzt. Dieser Bericht hilft, den Fehler schneller zu beheben.</string>
<string name="crash_body">Hermes-Relay wurde nach einem unerwarteten Problem neu gestartet. Sie können die App weiter verwenden.</string>
<string name="crash_privacy">Es wurde nichts gesendet. Prüfen Sie den lokal bereinigten Bericht vor dem Teilen.</string>
<string name="crash_review">Bericht prüfen</string>
<string name="crash_share_title">Absturzbericht teilen</string>
<string name="crash_share_subject">Hermes-Relay-Absturzbericht — %1$s</string>
<string name="crash_toast_no_share">Bericht kopiert — keine App zum Teilen gefunden</string>
<string name="crash_toast_opened">Vollständiger Bericht kopiert — GitHub-Problem vor dem Senden prüfen</string>
<string name="crash_toast_no_browser">Bericht kopiert — kein Browser für GitHub gefunden</string>
<string name="crash_dismiss">Schließen</string>
<string name="crash_copy">Kopieren</string>
<string name="crash_share">Teilen</string>
@@ -2857,6 +2869,9 @@
<string name="chat_reasoning_low">Niedrig</string>
<string name="chat_reasoning_medium">Mittel</string>
<string name="chat_reasoning_high">Hoch</string>
<string name="chat_reasoning_xhigh">Extra hoch</string>
<string name="chat_reasoning_max">Maximum</string>
<string name="chat_reasoning_ultra">Ultra</string>
<string name="chat_date_today">Heute</string>
<string name="chat_date_yesterday">Gestern</string>
<string name="chat_share_subject">Hermes-Unterhaltung</string>
@@ -3839,4 +3854,10 @@
<string name="plugins_keep">Behalten</string>
<string name="plugins_remove">Entfernen</string>
<string name="plugins_remove_confirm">„%1$s“ entfernen? Diese Plugin-Seite wird auf verbundenen Android-Geräten nicht mehr angezeigt.</string>
<string name="support_bundle_review">Supportinformationen prüfen</string>
<string name="support_bundle_title">Supportinformationen</string>
<string name="support_bundle_privacy">Nichts wird automatisch hochgeladen. Prüfen Sie den lokalen Export mit bis zu %1$d Berichten.</string>
<string name="support_bundle_copied">Supportinformationen kopiert</string>
<string name="support_bundle_no_share">Supportinformationen kopiert — keine App zum Teilen gefunden</string>
<string name="support_bundle_share_title">Hermes-Relay-Supportinformationen teilen</string>
</resources>
+22 -1
View File
@@ -101,6 +101,8 @@
<string name="chat_placeholder_message">Mensaje…</string>
<string name="chat_edit_busy_snackbar">No se puede editar en este momento: espera a que termine el turno actual</string>
<string name="chat_select_reasoning_effort">Seleccionar esfuerzo de razonamiento</string>
<string name="reasoning_effort_standard_levels_notice">Hermes no anuncia niveles de esfuerzo exactos para este modelo, por lo que se muestran las opciones estándar.</string>
<string name="reasoning_effort_current_outside_supported">Sesión actual: %1$s (informado por Hermes). Las nuevas selecciones para este modelo se limitan a %2$s.</string>
<string name="voice_toast_signin_route">Voice necesita un inicio de sesión único en la ruta %1$s: abra Administrar</string>
<string name="voice_toast_signin_default">Voice necesita iniciar sesión en el panel: abra Administrar para iniciar sesión</string>
<string name="voice_toast_unsupported">Esta compilación Hermes no tiene rutas de voz: actualice el agente hermes o empareje Relay</string>
@@ -770,6 +772,9 @@
<string name="drawer_refresh_sessions">Actualizar sesiones</string>
<string name="drawer_new_chat">Nuevo chat</string>
<string name="drawer_search_placeholder">Buscar sesiones o id...</string>
<string name="drawer_search_sessions">Buscar sesiones</string>
<string name="drawer_activity_working">En curso</string>
<string name="drawer_activity_needs_input">Requiere intervención</string>
<string name="drawer_new_thread">Nuevo hilo</string>
<string name="drawer_chats_not_named">Los chats no tienen nombre automático en esta conexión. Utiliza «→Renombrar».</string>
<string name="drawer_loading_sessions">Cargando sesiones…</string>
@@ -2393,7 +2398,14 @@
<string name="attachment_type_text">Texto</string>
<string name="attachment_type_file">Archivo</string>
<string name="crash_title">Hermes-Relay cerró inesperadamente</string>
<string name="crash_body">La última sesión fracasó. Enviar este informe ayuda a solucionarlo más rápido.</string>
<string name="crash_body">Hermes-Relay se reinició tras un problema inesperado. Puedes seguir usando la aplicación.</string>
<string name="crash_privacy">No se envió nada. Revisa el informe redactado localmente antes de compartirlo.</string>
<string name="crash_review">Revisar informe</string>
<string name="crash_share_title">Compartir informe de fallo</string>
<string name="crash_share_subject">Informe de fallo de Hermes-Relay — %1$s</string>
<string name="crash_toast_no_share">Informe copiado — no se encontró una aplicación para compartir</string>
<string name="crash_toast_opened">Informe completo copiado — revisa la incidencia de GitHub antes de enviarla</string>
<string name="crash_toast_no_browser">Informe copiado — no se encontró un navegador para GitHub</string>
<string name="crash_dismiss">Descartar</string>
<string name="crash_copy">Copiar</string>
<string name="crash_share">Compartir</string>
@@ -2584,6 +2596,9 @@
<string name="chat_reasoning_low">Bajo</string>
<string name="chat_reasoning_medium">Medio</string>
<string name="chat_reasoning_high">Alto</string>
<string name="chat_reasoning_xhigh">Muy alto</string>
<string name="chat_reasoning_max">Máximo</string>
<string name="chat_reasoning_ultra">Ultra</string>
<string name="chat_date_today">Hoy</string>
<string name="chat_date_yesterday">Ayer</string>
<string name="chat_share_subject">Conversación Hermes</string>
@@ -3524,4 +3539,10 @@
<string name="plugins_keep">Conservar</string>
<string name="plugins_remove">Eliminar</string>
<string name="plugins_remove_confirm">¿Eliminar «%1$s»? Esta página del plugin dejará de aparecer en los dispositivos Android conectados.</string>
<string name="support_bundle_review">Revisar información de soporte</string>
<string name="support_bundle_title">Información de soporte</string>
<string name="support_bundle_privacy">Nada se sube automáticamente. Revisa la exportación local con hasta %1$d informes.</string>
<string name="support_bundle_copied">Información de soporte copiada</string>
<string name="support_bundle_no_share">Información de soporte copiada — no se encontró una aplicación para compartir</string>
<string name="support_bundle_share_title">Compartir información de soporte de Hermes-Relay</string>
</resources>
+22 -1
View File
@@ -118,6 +118,8 @@
<string name="chat_placeholder_message">メッセージ…</string>
<string name="chat_edit_busy_snackbar">現在編集できません - 現在のターンが終了するまで待ちます</string>
<string name="chat_select_reasoning_effort">推論努力を選択する</string>
<string name="reasoning_effort_standard_levels_notice">Hermes はこのモデルの正確な推論レベルを公開していないため、標準オプションを表示しています。</string>
<string name="reasoning_effort_current_outside_supported">現在のセッション:%1$s(Hermes から報告)。このモデルで新たに選択できるのは %2$s です。</string>
<!-- Voice toasts -->
<string name="voice_toast_signin_route">Voice には %1$s ルートでの 1 回限りのサインインが必要です — [管理] を開きます</string>
@@ -868,6 +870,9 @@
<string name="drawer_refresh_sessions">セッションを更新する</string>
<string name="drawer_new_chat">新しいチャット</string>
<string name="drawer_search_placeholder">セッションまたは ID を検索...</string>
<string name="drawer_search_sessions">セッションを検索</string>
<string name="drawer_activity_working">処理中</string>
<string name="drawer_activity_needs_input">入力が必要</string>
<string name="drawer_new_thread">新しいスレッド</string>
<string name="drawer_chats_not_named">この接続ではチャットの名前は自動的に付けられません。「⋮」→「名前の変更」を使用してください。</string>
<string name="drawer_loading_sessions">セッションを読み込み中…</string>
@@ -2650,7 +2655,14 @@
<!-- CrashReportDialog -->
<string name="crash_title">Hermes-Relay が予期せず終了しました</string>
<string name="crash_body">最後のセッションがクラッシュしました。このレポートを送信すると、問題をより早く修正できます。</string>
<string name="crash_body">予期しない問題の後、Hermes-Relay が再起動しました。アプリは引き続き使用できます。</string>
<string name="crash_privacy">何も送信されていません。共有する前に端末内で編集されたレポートを確認してください。</string>
<string name="crash_review">レポートを確認</string>
<string name="crash_share_title">クラッシュレポートを共有</string>
<string name="crash_share_subject">Hermes-Relay クラッシュレポート — %1$s</string>
<string name="crash_toast_no_share">レポートをコピーしました — 共有できるアプリがありません</string>
<string name="crash_toast_opened">完全なレポートをコピーしました — 送信前に GitHub の内容を確認してください</string>
<string name="crash_toast_no_browser">レポートをコピーしました — GitHub を開けるブラウザがありません</string>
<string name="crash_dismiss">却下する</string>
<string name="crash_copy">コピー</string>
<string name="crash_share">共有</string>
@@ -2867,6 +2879,9 @@
<string name="chat_reasoning_low">低い</string>
<string name="chat_reasoning_medium">中くらい</string>
<string name="chat_reasoning_high">高い</string>
<string name="chat_reasoning_xhigh">非常に高い</string>
<string name="chat_reasoning_max">最大</string>
<string name="chat_reasoning_ultra">ウルトラ</string>
<string name="chat_date_today">今日</string>
<string name="chat_date_yesterday">昨日</string>
<string name="chat_share_subject">Hermes の会話</string>
@@ -3838,4 +3853,10 @@
<string name="plugins_keep">保持</string>
<string name="plugins_remove">削除</string>
<string name="plugins_remove_confirm">「%1$s」を削除しますか?接続された Android 端末にこのプラグインページは表示されなくなります。</string>
<string name="support_bundle_review">サポート情報を確認</string>
<string name="support_bundle_title">サポート情報</string>
<string name="support_bundle_privacy">自動アップロードはありません。最大 %1$d 件のレポートを含む端末内エクスポートを確認してください。</string>
<string name="support_bundle_copied">サポート情報をコピーしました</string>
<string name="support_bundle_no_share">サポート情報をコピーしました — 共有できるアプリがありません</string>
<string name="support_bundle_share_title">Hermes-Relay サポート情報を共有</string>
</resources>
+22 -1
View File
@@ -118,6 +118,8 @@
<string name="chat_placeholder_message">Сообщение…</string>
<string name="chat_edit_busy_snackbar">Нельзя редактировать сейчас — подождите, пока завершится текущий ход</string>
<string name="chat_select_reasoning_effort">Выберите усилия по рассуждению</string>
<string name="reasoning_effort_standard_levels_notice">Hermes не сообщает точные уровни усилий для этой модели, поэтому показаны стандартные варианты.</string>
<string name="reasoning_effort_current_outside_supported">Текущий сеанс: %1$s (по данным Hermes). Для этой модели новые варианты ограничены: %2$s.</string>
<string name="voice_toast_signin_route">Голосу требуется одноразовый вход на маршруте %1$s — откройте Управление</string>
<string name="voice_toast_signin_default">Голосу требуется вход в панель управления — откройте Управление для входа</string>
<string name="voice_toast_unsupported">Эта сборка Гермеса не имеет голосовых маршрутов — обновите Агента Гермеса или сопрягите плагин Relay</string>
@@ -878,6 +880,9 @@
<string name="drawer_refresh_sessions">Обновить сессии</string>
<string name="drawer_new_chat">Новый чат</string>
<string name="drawer_search_placeholder">Поиск сессий или id...</string>
<string name="drawer_search_sessions">Поиск сессий</string>
<string name="drawer_activity_working">Выполняется</string>
<string name="drawer_activity_needs_input">Требуется ввод</string>
<string name="drawer_new_thread">Новая ветка</string>
<string name="drawer_chats_not_named">Чаты не автоматически именуются на этом соединении — используйте ⋮ → Переименовать.</string>
<string name="drawer_loading_sessions">Загрузка сессий…</string>
@@ -2600,7 +2605,14 @@
<string name="attachment_type_text">Текст</string>
<string name="attachment_type_file">Файл</string>
<string name="crash_title">Hermes-Relay неожиданно закрылся</string>
<string name="crash_body">Последняя сессия завершилась аварийно. Отправка этого отчета поможет быстрее исправить проблему.</string>
<string name="crash_body">Hermes-Relay перезапустился после непредвиденной проблемы. Приложением можно продолжать пользоваться.</string>
<string name="crash_privacy">Ничего не отправлено. Перед отправкой проверьте локально обезличенный отчёт.</string>
<string name="crash_review">Проверить отчёт</string>
<string name="crash_share_title">Поделиться отчётом о сбое</string>
<string name="crash_share_subject">Отчёт о сбое Hermes-Relay — %1$s</string>
<string name="crash_toast_no_share">Отчёт скопирован — приложение для отправки не найдено</string>
<string name="crash_toast_opened">Полный отчёт скопирован — проверьте проблему GitHub перед отправкой</string>
<string name="crash_toast_no_browser">Отчёт скопирован — браузер для GitHub не найден</string>
<string name="crash_dismiss">Закрыть</string>
<string name="crash_copy">Скопировать</string>
<string name="crash_share">Поделиться</string>
@@ -2792,6 +2804,9 @@
<string name="chat_reasoning_low">Низкий</string>
<string name="chat_reasoning_medium">Средний</string>
<string name="chat_reasoning_high">Высокий</string>
<string name="chat_reasoning_xhigh">Очень высокий</string>
<string name="chat_reasoning_max">Максимальный</string>
<string name="chat_reasoning_ultra">Ультра</string>
<string name="chat_date_today">Сегодня</string>
<string name="chat_date_yesterday">Вчера</string>
<string name="chat_share_subject">Разговор с Гермес</string>
@@ -3560,4 +3575,10 @@
<string name="plugins_keep">Оставить</string>
<string name="plugins_remove">Удалить</string>
<string name="plugins_remove_confirm">Удалить «%1$s»? Эта страница плагина больше не будет отображаться на подключённых устройствах Android.</string>
<string name="support_bundle_review">Проверить сведения для поддержки</string>
<string name="support_bundle_title">Сведения для поддержки</string>
<string name="support_bundle_privacy">Ничего не загружается автоматически. Проверьте локальный экспорт с не более чем %1$d отчётами.</string>
<string name="support_bundle_copied">Сведения для поддержки скопированы</string>
<string name="support_bundle_no_share">Сведения для поддержки скопированы — приложение для отправки не найдено</string>
<string name="support_bundle_share_title">Поделиться сведениями поддержки Hermes-Relay</string>
</resources>
+22 -1
View File
@@ -118,6 +118,8 @@
<string name="chat_placeholder_message">Message…</string>
<string name="chat_edit_busy_snackbar">Can\'t edit right now — wait for the current turn to finish</string>
<string name="chat_select_reasoning_effort">Select reasoning effort</string>
<string name="reasoning_effort_standard_levels_notice">Hermes doesn\'t advertise exact effort levels for this model, so standard options are shown.</string>
<string name="reasoning_effort_current_outside_supported">Current session: %1$s (reported by Hermes). New selections for this model are limited to %2$s.</string>
<!-- Voice toasts -->
<string name="voice_toast_signin_route">Voice needs a one-time sign-in on the %1$s route — open Manage</string>
@@ -951,6 +953,9 @@
<string name="drawer_refresh_sessions">Refresh sessions</string>
<string name="drawer_new_chat">New Chat</string>
<string name="drawer_search_placeholder">Search sessions or id...</string>
<string name="drawer_search_sessions">Search sessions</string>
<string name="drawer_activity_working">Working</string>
<string name="drawer_activity_needs_input">Needs input</string>
<string name="drawer_new_thread">New Thread</string>
<string name="drawer_chats_not_named">Chats aren\'t auto-named on this connection — use ⋮ → Rename.</string>
<string name="drawer_loading_sessions">Loading sessions…</string>
@@ -2772,7 +2777,14 @@
<!-- CrashReportDialog -->
<string name="crash_title">Hermes-Relay closed unexpectedly</string>
<string name="crash_body">The last session crashed. Sending this report helps get it fixed faster.</string>
<string name="crash_body">Hermes-Relay restarted after an unexpected problem. You can continue using the app.</string>
<string name="crash_privacy">Nothing was sent. Review the locally redacted report before choosing copy, share, or GitHub.</string>
<string name="crash_review">Review report</string>
<string name="crash_share_title">Share crash report</string>
<string name="crash_share_subject">Hermes-Relay crash report — %1$s</string>
<string name="crash_toast_no_share">Report copied — no app found to share to</string>
<string name="crash_toast_opened">Full report copied — review the GitHub issue before submitting</string>
<string name="crash_toast_no_browser">Report copied — no browser found to open GitHub</string>
<string name="crash_dismiss">Dismiss</string>
<string name="crash_copy">Copy</string>
<string name="crash_share">Share</string>
@@ -2989,6 +3001,9 @@
<string name="chat_reasoning_low">Low</string>
<string name="chat_reasoning_medium">Medium</string>
<string name="chat_reasoning_high">High</string>
<string name="chat_reasoning_xhigh">Extra high</string>
<string name="chat_reasoning_max">Max</string>
<string name="chat_reasoning_ultra">Ultra</string>
<string name="chat_date_today">Today</string>
<string name="chat_date_yesterday">Yesterday</string>
<string name="chat_share_subject">Hermes conversation</string>
@@ -3845,4 +3860,10 @@
<string name="plugins_keep">Keep</string>
<string name="plugins_remove">Remove</string>
<string name="plugins_remove_confirm">Remove “%1$s”? This plugin page will no longer appear on connected Android devices.</string>
<string name="support_bundle_review">Review support information</string>
<string name="support_bundle_title">Support information</string>
<string name="support_bundle_privacy">Nothing is uploaded automatically. Review the exact local export below. It contains up to %1$d recent reports.</string>
<string name="support_bundle_copied">Support information copied</string>
<string name="support_bundle_no_share">Support information copied — no app found to share to</string>
<string name="support_bundle_share_title">Share Hermes-Relay support information</string>
</resources>
@@ -8,9 +8,9 @@ import org.junit.Test
class DiagnosticsLogTest {
@Test
fun sanitizeUrlDropsSecretsAndKeepsRoute() {
fun sanitizeUrlDropsSecretsAndHostWhileKeepingPath() {
assertEquals(
"https://relay.example.test:8767/health",
"https://[host]/health",
DiagnosticsLog.sanitizeUrl(
"https://user:secret@relay.example.test:8767/health?token=abc#frag",
),
@@ -0,0 +1,62 @@
package com.hermesandroid.relay.diagnostics
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLHandshakeException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NetworkDiagnosticGuidanceTest {
@Test
fun connectionRefusalPointsToTheOwningListener() {
val guidance = NetworkDiagnosticGuidance.forThrowable(
ConnectException("Failed to connect"),
target = "Relay",
)
assertEquals(
"Verify Relay is running and listening on the configured host and port.",
guidance,
)
}
@Test
fun wrappedNetworkFailuresUseTheRootCause() {
val guidance = NetworkDiagnosticGuidance.forThrowable(
IllegalStateException("probe failed", UnknownHostException("missing.example")),
target = "Dashboard",
)
assertTrue(guidance.orEmpty().contains("hostname"))
}
@Test
fun transportFailuresHaveDistinctNextSteps() {
assertTrue(
NetworkDiagnosticGuidance.forThrowable(SocketTimeoutException(), "API")
.orEmpty().contains("routing or firewall"),
)
assertTrue(
NetworkDiagnosticGuidance.forThrowable(NoRouteToHostException(), "API")
.orEmpty().contains("network path"),
)
assertTrue(
NetworkDiagnosticGuidance.forThrowable(SSLHandshakeException("bad cert"), "Relay")
.orEmpty().contains("TLS"),
)
}
@Test
fun httpStatusGuidanceIsSpecificAndUnknownSuccessHasNone() {
assertTrue(NetworkDiagnosticGuidance.forHttpStatus(401, "API").orEmpty().contains("credentials"))
assertTrue(NetworkDiagnosticGuidance.forHttpStatus(404, "Relay").orEmpty().contains("route"))
assertTrue(NetworkDiagnosticGuidance.forHttpStatus(429, "Relay").orEmpty().contains("backoff"))
assertTrue(NetworkDiagnosticGuidance.forHttpStatus(503, "API").orEmpty().contains("server logs"))
assertNull(NetworkDiagnosticGuidance.forHttpStatus(204, "API"))
}
}
@@ -0,0 +1,49 @@
package com.hermesandroid.relay.network.relay
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import java.net.ServerSocket
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class RelayHttpClientDiagnosticsTest {
@Before
fun setUp() {
DiagnosticsLog.clear()
}
@After
fun tearDown() {
DiagnosticsLog.clear()
}
@Test
fun refusedHealthProbeExplainsProtocolConversionAndOwningListener() = runTest {
val unusedPort = ServerSocket(0).use { it.localPort }
val configuredRelay = "ws://127.0.0.1:$unusedPort"
val client = RelayHttpClient(
okHttpClient = OkHttpClient(),
relayUrlProvider = { configuredRelay },
sessionTokenProvider = { null },
)
val result = client.probeHealth(configuredRelay)
assertTrue(result.isFailure)
val entry = DiagnosticsLog.recent(setOf(DiagnosticCategory.Relay)).first()
assertEquals("Relay health probe before WebSocket connection", entry.operation)
assertEquals("ws://[host]", entry.configuredUrl)
assertEquals("http://[host]/health", entry.requestUrl)
assertEquals(
"Verify Relay is running and listening on the configured host and port.",
entry.suggestion,
)
assertFalse(entry.detail.orEmpty().contains(unusedPort.toString()))
}
}
@@ -0,0 +1,95 @@
package com.hermesandroid.relay.network.relay
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
class RelayHttpClientModelCapabilitiesTest {
private lateinit var server: MockWebServer
@Before
fun setUp() {
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun postsBoundedProfileScopedPairsAndParsesExactOverlay() = runTest {
server.enqueue(
MockResponse().setResponseCode(200).setBody(
"""{
"schema_version":1,
"contract_version":"relay-reasoning-v1",
"capabilities":[{
"provider":"opencode",
"model":"deepseek-v3",
"reasoning":true,
"reasoning_efforts":["low","max"],
"reasoning_efforts_exact":true,
"source":"relay-provider-policy"
}]
}""".trimIndent(),
),
)
val client = client(token = "paired-token")
val rows = (0..130).map {
RelayHttpClient.ModelCapabilityRequestRow("provider-$it", "model-$it")
}
val response = client.fetchModelCapabilities(rows, profile = "research").getOrThrow()
val request = server.takeRequest()
val body = Json.parseToJsonElement(request.body.readUtf8()).jsonObject
assertEquals("/relay/model-capabilities", request.path)
assertEquals("Bearer paired-token", request.getHeader("Authorization"))
assertEquals("research", body.getValue("profile").jsonPrimitive.content)
assertEquals(
RelayHttpClient.MAX_MODEL_CAPABILITY_ROWS,
body.getValue("models").jsonArray.size,
)
assertEquals(listOf("low", "max"), response?.capabilities?.single()?.reasoningEfforts)
assertEquals(true, response?.capabilities?.single()?.reasoningEffortsExact)
}
@Test
fun olderRelay404IsOptional() = runTest {
server.enqueue(MockResponse().setResponseCode(404))
val response = client(token = "paired-token").fetchModelCapabilities(
listOf(RelayHttpClient.ModelCapabilityRequestRow("openai", "gpt-5.5")),
).getOrThrow()
assertNull(response)
}
@Test
fun vanillaUnpairedPathDoesNotCallRelay() = runTest {
val response = client(token = null).fetchModelCapabilities(
listOf(RelayHttpClient.ModelCapabilityRequestRow("openai", "gpt-5.5")),
).getOrThrow()
assertNull(response)
assertEquals(0, server.requestCount)
}
private fun client(token: String?) = RelayHttpClient(
okHttpClient = OkHttpClient(),
relayUrlProvider = { server.url("/").toString() },
sessionTokenProvider = { token },
)
}
@@ -4,6 +4,8 @@ import com.hermesandroid.relay.data.ApiEndpoint
import com.hermesandroid.relay.data.DashboardEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.RelayEndpoint
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
@@ -40,6 +42,7 @@ class EndpointResolverTest {
@Before
fun setUp() {
DiagnosticsLog.clear()
clockMillis.set(0L)
reachableServer = MockWebServer().apply {
dispatcher = healthDispatcher(statusCode = 200)
@@ -61,6 +64,7 @@ class EndpointResolverTest {
@After
fun tearDown() {
DiagnosticsLog.clear()
runCatching { reachableServer.shutdown() }
runCatching { secondReachableServer.shutdown() }
}
@@ -370,6 +374,11 @@ class EndpointResolverTest {
assertNotNull(request)
assertEquals("GET", request!!.method)
assertEquals("/api/status", request.path)
val diagnostic = DiagnosticsLog.recent(setOf(DiagnosticCategory.Endpoint))
.first { it.operation != null }
assertEquals("Dashboard or API route health probe", diagnostic.operation)
assertEquals("http://[host]", diagnostic.configuredUrl)
assertEquals("http://[host]/api/status", diagnostic.requestUrl)
}
@Test
@@ -289,6 +289,12 @@ class GatewayClientHarness(
"slug": "openai",
"name": "OpenAI",
"models": ["gpt-5.5"],
"capabilities": {
"gpt-5.5": {
"reasoning": true,
"reasoning_efforts": ["minimal", "medium", "ultra"]
}
},
"is_current": true,
"authenticated": true
}
@@ -858,6 +864,10 @@ class GatewayChatClientTest {
val normal = client.modelOptions().getOrThrow()
val normalParams = harness.awaitRpc("model.options")
assertEquals("gpt-5.5", normal.currentModel)
assertEquals(
listOf("minimal", "medium", "ultra"),
normal.providers.single().capabilities.getValue("gpt-5.5").reasoningEfforts,
)
assertFalse((normalParams["refresh"] as? JsonPrimitive)?.booleanOrNull == true)
val refreshed = client.modelOptions(refresh = true).getOrThrow()
@@ -1824,18 +1834,54 @@ class GatewayChatClientTest {
@Test
fun `reasoning settings fetch uses config get`() {
harness.reasoningEffort = "high"
harness.reasoningEffort = "ultra"
harness.reasoningDisplay = "show"
val result = runBlocking { client.getReasoningSettings() }
assertTrue(result.isSuccess)
assertEquals("high", result.getOrThrow().effort)
assertEquals("ultra", result.getOrThrow().effort)
assertEquals("show", result.getOrThrow().display)
val rpc = harness.awaitRpc("config.get")
assertEquals("reasoning", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `session info preserves coherent provider model and max effort`() {
val recorder = Recorder()
client.sendTurn("stored-1", "hi", null, recorder.callbacks) {
recorder.preflightFailures += it
}
val serverWs = harness.awaitServerSocket()
harness.awaitRpc("session.resume")
harness.awaitRpc("prompt.submit")
serverWs.send(
harness.eventFrame(
"session.info",
buildJsonObject {
put("model", "deepseek-v3")
put("provider", "opencode")
put("reasoning_effort", "max")
},
"live-resumed",
),
)
waitUntil { client.serverReasoningEffort.value == "max" }
assertEquals(
GatewayModelIdentity(model = "deepseek-v3", provider = "opencode"),
client.serverModelIdentity.value,
)
assertEquals(
GatewayReasoningIdentity(
identity = GatewayModelIdentity(model = "deepseek-v3", provider = "opencode"),
effort = "max",
),
client.serverReasoningIdentity.value,
)
}
@Test
fun `approval mode get and set use profile config without session yolo scope`() {
harness.approvalMode = "smart"
@@ -273,6 +273,13 @@ class HermesApiClientTest {
"authenticated": true,
"is_current": true,
"models": ["grok-4.3", "grok-4.2"],
"capabilities": {
"grok-4.3": {
"reasoning": true,
"reasoning_efforts": ["low", "high", "max"],
"reasoning_efforts_exact": true
}
},
"unavailable_models": ["grok-4.2"],
"free_tier": true,
"total_models": 2
@@ -294,6 +301,14 @@ class HermesApiClientTest {
assertEquals(listOf("grok-4.2"), parsed?.providers?.first()?.unavailableModels)
assertTrue(parsed?.providers?.first()?.authenticated == true)
assertFalse(parsed?.providers?.last()?.authenticated == true)
assertEquals(
listOf("low", "high", "max"),
parsed?.providers?.first()?.capabilities?.get("grok-4.3")?.reasoningEfforts,
)
assertEquals(
true,
parsed?.providers?.first()?.capabilities?.get("grok-4.3")?.reasoningEffortsExact,
)
}
@Test
@@ -0,0 +1,246 @@
package com.hermesandroid.relay.network.upstream
import com.hermesandroid.relay.viewmodel.reconcilePendingReasoningEffort
import com.hermesandroid.relay.viewmodel.isCurrentReasoningResponse
import com.hermesandroid.relay.viewmodel.isCurrentReasoningCapabilityOverlay
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ReasoningEffortSupportTest {
@Test
fun canonicalNormalizationPreservesMaxAndUltra() {
assertEquals(
listOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"),
ReasoningEfforts.canonical,
)
assertEquals("max", ReasoningEfforts.normalize(" MAX "))
assertEquals("ultra", ReasoningEfforts.normalize("Ultra"))
assertEquals("medium", ReasoningEfforts.normalize("future-value"))
}
@Test
fun exactProviderModelEffortsAreAuthoritative() {
val provider = provider(
slug = "opencode",
model = "deepseek-v3",
capabilities = GatewayModelCapabilities(
reasoning = true,
reasoningEfforts = listOf("low", "high", "max", "max", "unknown"),
reasoningEffortsExact = true,
),
)
val result = resolveReasoningEffortAvailability(
providers = listOf(provider),
provider = "opencode",
model = "deepseek-v3",
)
assertEquals(listOf("low", "high", "max"), result.choices)
assertTrue(result.exact)
assertTrue(result.accepts("max"))
assertFalse(result.accepts("ultra"))
}
@Test
fun explicitUpstreamReasoningFalseDisablesWithoutAnExactOverlay() {
val result = resolveReasoningEffortAvailability(
providers = listOf(
provider(
slug = "local",
model = "plain-model",
capabilities = GatewayModelCapabilities(
reasoning = false,
),
),
),
provider = "local",
model = "plain-model",
)
assertEquals(false, result.supported)
assertTrue(result.choices.isEmpty())
assertFalse(result.accepts("low"))
}
@Test
fun relayExactOverlayPrecedesUpstreamFalseButNotUpstreamExact() {
val identity = ReasoningEffortIdentity(provider = "opencode", model = "deepseek-v3")
val upstreamFalse = provider(
slug = identity.provider,
model = identity.model,
capabilities = GatewayModelCapabilities(reasoning = false),
)
val relayOverlay = mapOf(
identity to GatewayModelCapabilities(
reasoning = true,
reasoningEfforts = listOf("low", "max"),
reasoningEffortsExact = true,
),
)
val relayWins = resolveReasoningEffortAvailability(
providers = listOf(upstreamFalse),
provider = identity.provider,
model = identity.model,
relayCapabilities = relayOverlay,
)
assertEquals(listOf("low", "max"), relayWins.choices)
assertTrue(relayWins.exact)
val upstreamExact = upstreamFalse.copy(
capabilities = mapOf(
identity.model to GatewayModelCapabilities(
reasoning = true,
reasoningEfforts = listOf("high", "ultra"),
reasoningEffortsExact = true,
),
),
)
val upstreamWins = resolveReasoningEffortAvailability(
providers = listOf(upstreamExact),
provider = identity.provider,
model = identity.model,
relayCapabilities = relayOverlay,
)
assertEquals(listOf("high", "ultra"), upstreamWins.choices)
}
@Test
fun missingOrNonExactCapabilityFallsBackToCanonicalUnknownContract() {
val missing = resolveReasoningEffortAvailability(emptyList(), "openai", "gpt-5.5")
assertNull(missing.supported)
assertEquals(ReasoningEfforts.canonical, missing.choices)
val advisory = resolveReasoningEffortAvailability(
providers = listOf(
provider(
slug = "openai",
model = "gpt-5.5",
capabilities = GatewayModelCapabilities(
reasoningEfforts = listOf("high", "max"),
reasoningEffortsExact = false,
),
),
),
provider = "openai",
model = "gpt-5.5",
)
assertFalse(advisory.exact)
assertEquals(ReasoningEfforts.canonical, advisory.choices)
}
@Test
fun capabilityLookupNeverBorrowsSameModelFromAnotherProvider() {
val openCode = provider(
slug = "opencode",
model = "deepseek-v3",
capabilities = GatewayModelCapabilities(
reasoning = true,
reasoningEfforts = listOf("low", "max"),
reasoningEffortsExact = true,
),
)
val openRouter = provider(
slug = "openrouter",
model = "deepseek-v3",
capabilities = GatewayModelCapabilities(
reasoning = true,
reasoningEfforts = listOf("low", "ultra"),
reasoningEffortsExact = true,
),
)
val result = resolveReasoningEffortAvailability(
providers = listOf(openCode, openRouter),
provider = "openrouter",
model = "deepseek-v3",
)
assertEquals(listOf("low", "ultra"), result.choices)
val incompleteIdentity = resolveReasoningEffortAvailability(
providers = listOf(openCode, openRouter),
provider = null,
model = "deepseek-v3",
)
assertNull(incompleteIdentity.supported)
assertEquals(ReasoningEfforts.canonical, incompleteIdentity.choices)
}
@Test
fun modelChangeClearsOnlyIncompatiblePendingEffort() {
val availability = ReasoningEffortAvailability(
supported = true,
choices = listOf("low", "high", "max"),
exact = true,
)
val modelA = ReasoningEffortIdentity(provider = "openrouter", model = "model-a")
val modelB = ReasoningEffortIdentity(provider = "opencode", model = "model-b")
assertNull(
reconcilePendingReasoningEffort(
value = "ultra",
confirmedIdentity = null,
activeIdentity = modelB,
availability = availability,
),
)
assertFalse(
isCurrentReasoningResponse(
capturedRevision = 7L,
currentRevision = 8L,
capturedIdentity = modelA,
activeIdentity = modelB,
),
)
assertEquals(
"max",
reconcilePendingReasoningEffort(
value = "max",
confirmedIdentity = null,
activeIdentity = modelB,
availability = availability,
),
)
assertEquals(
"ultra",
reconcilePendingReasoningEffort(
value = "ultra",
confirmedIdentity = modelB,
activeIdentity = modelB,
availability = availability,
),
)
assertNull(
reconcilePendingReasoningEffort(
value = "ultra",
confirmedIdentity = modelA,
activeIdentity = modelB,
availability = availability,
),
)
}
@Test
fun relayOverlayResponseRequiresCurrentGenerationAndProfileContext() {
assertTrue(isCurrentReasoningCapabilityOverlay(4L, 4L, "conn::profile", "conn::profile"))
assertFalse(isCurrentReasoningCapabilityOverlay(3L, 4L, "conn::profile", "conn::profile"))
assertFalse(isCurrentReasoningCapabilityOverlay(4L, 4L, "conn::a", "conn::b"))
}
private fun provider(
slug: String,
model: String,
capabilities: GatewayModelCapabilities,
) = GatewayModelProvider(
name = slug,
slug = slug,
models = listOf(model),
isCurrent = false,
warning = null,
capabilities = mapOf(model to capabilities),
)
}
@@ -0,0 +1,171 @@
package com.hermesandroid.relay.reliability
import java.io.File
import java.net.SocketTimeoutException
import java.time.Instant
import java.util.concurrent.CancellationException
import java.net.URLDecoder
import com.hermesandroid.relay.util.CrashReporter
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ReliabilityReportTest {
private val environment = ReliabilityEnvironment(
versionName = "1.6.1",
versionCode = 38,
flavor = "sideload",
manufacturer = "Example",
model = "Phone",
androidRelease = "16",
sdkInt = 36,
)
@Test
fun redactorRemovesSecretsHostsIdentifiersPathsAndContent() {
val raw = """
authorization: Bearer-secret
token=abc123
https://private.example.test:8767/path?q=secret
host 192.168.1.4:8642
session 123e4567-e89b-12d3-a456-426614174000
C:\Users\person\private\file.txt
prompt=private words from a conversation
""".trimIndent()
val redacted = ReliabilityRedactor.redact(raw)
listOf(
"Bearer-secret", "abc123", "private.example.test", "192.168.1.4",
"123e4567-e89b-12d3-a456-426614174000", "person", "private words",
).forEach { assertFalse("leaked $it", redacted.contains(it)) }
assertTrue(redacted.contains("[url hidden]"))
assertTrue(redacted.contains("prompt=[hidden]"))
}
@Test
fun classifierSeparatesCancellationConnectivityAuthRateLimitAndProductErrors() {
assertEquals(
ReliabilityKind.ExpectedCancellation,
ReliabilityClassifier.classify(CancellationException(), "gateway").kind,
)
assertFalse(ReliabilityClassifier.classify(CancellationException(), "gateway").shouldPersist)
assertEquals(
ReliabilityKind.Connectivity,
ReliabilityClassifier.classify(SocketTimeoutException("slow"), "gateway").kind,
)
assertEquals(
ReliabilityOwner.UpstreamGateway,
ReliabilityClassifier.classify(SocketTimeoutException("slow"), "gateway").owner,
)
assertEquals(
ReliabilityKind.Authentication,
ReliabilityClassifier.classify(IllegalStateException("HTTP 401"), "dashboard").kind,
)
assertEquals(
ReliabilityKind.RateLimit,
ReliabilityClassifier.classify(IllegalStateException("HTTP 429"), "api").kind,
)
assertTrue(
ReliabilityClassifier.classify(IllegalArgumentException("duplicate key"), "ui")
.reportRecommended,
)
}
@Test
fun storeEnforcesAgeCountRedactionAndReviewedState() {
val dir = kotlin.io.path.createTempDirectory("reliability-store").toFile()
val store = ReliabilityStore(File(dir, "reports.json"), maxReports = 3, retentionDays = 14)
val now = Instant.parse("2026-08-04T12:00:00Z")
store.append(report("old", "2026-07-01T00:00:00Z"), now)
store.append(report("one", "2026-08-01T00:00:00Z"), now)
store.append(report("two", "2026-08-02T00:00:00Z"), now)
store.append(report("three", "2026-08-03T00:00:00Z"), now)
store.append(
report("four", "2026-08-04T00:00:00Z").copy(summary = "token=do-not-store"),
now,
)
val stored = store.readAll(now)
assertEquals(listOf("two", "three", "four"), stored.map { it.reportId })
assertFalse(stored.last().summary.contains("do-not-store"))
assertTrue(stored.last().pendingReview)
store.markReviewed("four", now)
assertFalse(store.readAll(now).last().pendingReview)
}
@Test
fun legacyMigrationIsVersionedPendingAndRedacted() {
val migrated = migrateLegacyCrash(
LegacyCrashSnapshot(
timeIso = "2026-08-03T15:12:26Z",
versionName = "1.6.0",
versionCode = 37,
flavor = "googlePlay",
manufacturer = "Example",
model = "Phone",
androidRelease = "17",
sdkInt = 37,
threadName = "main",
exceptionSummary = "Crash token=secret-value",
stackTrace = "at Example https://private.example.test/path",
),
reportId = "rpt-test",
appSessionId = "legacy-test",
)
assertEquals(RELIABILITY_SCHEMA_VERSION, migrated.schemaVersion)
assertEquals(ReliabilityKind.FatalCrash, migrated.kind)
assertTrue(migrated.pendingReview)
assertFalse(migrated.toPlainText().contains("secret-value"))
assertFalse(migrated.toPlainText().contains("private.example.test"))
}
@Test
fun supportBundleIsBoundedAndUsesExactRedactedReports() {
val reports = (1..12).map { index ->
report("r$index", "2026-08-${index.toString().padStart(2, '0')}T00:00:00Z")
.copy(technicalDetail = "token=secret-$index")
}
val bundle = SupportBundleBuilder.build(reports)
assertFalse(bundle.contains("secret-"))
assertFalse(bundle.contains("Report: r1\n"))
assertTrue(bundle.contains("Report: r12"))
assertEquals(10, Regex("===== Report ").findAll(bundle).count())
}
@Test
fun crashIssuePrefillTargetsAndroidAndContainsOnlyRedactedDetail() {
val report = report("rpt-prefill", "2026-08-04T00:00:00Z").copy(
summary = "Crash token=private-value",
technicalDetail = "at Example https://private.example.test/path",
)
val url = CrashReporter.buildGithubIssueUrl(report)
val decoded = URLDecoder.decode(url, "UTF-8")
assertTrue(decoded.contains("labels=bug,area:android"))
assertTrue(decoded.contains("### Affected area\nAndroid app"))
assertFalse(decoded.contains("private-value"))
assertFalse(decoded.contains("private.example.test"))
}
private fun report(id: String, time: String): ReliabilityReport = ReliabilityReport(
reportId = id,
appSessionId = "app-test",
timeIso = time,
kind = ReliabilityKind.FatalCrash,
owner = ReliabilityOwner.Android,
severity = ReliabilitySeverity.Fatal,
summary = "Unexpected problem",
recovery = "The app restarted",
reportRecommended = true,
technicalDetail = "java.lang.IllegalStateException",
environment = environment,
pendingReview = true,
)
}
@@ -19,6 +19,11 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class FloatingPetCompanionTest {
@Test
fun `supported floating pet rails add no visible vertical clearance`() {
assertEquals(0f, FLOATING_PET_SUPPORTED_RAIL_CLEARANCE_DP, 0f)
}
@Test
fun `terrain lookahead plans multiple levels before movement starts`() {
val composer = PetRoamingRail(
@@ -109,6 +114,35 @@ class FloatingPetCompanionTest {
assertFalse(floatingPetAcceptsPointerInput(positioned = false, surfaceScrolling = false))
}
@Test
fun `pet waits for a measured viewport before publishing its position`() {
assertFalse(shouldInitializeFloatingPet(false, viewportWidth = 0, viewportHeight = 0))
assertFalse(shouldInitializeFloatingPet(false, viewportWidth = 400, viewportHeight = 0))
assertFalse(shouldInitializeFloatingPet(false, viewportWidth = 0, viewportHeight = 800))
assertTrue(shouldInitializeFloatingPet(false, viewportWidth = 400, viewportHeight = 800))
assertFalse(shouldInitializeFloatingPet(true, viewportWidth = 400, viewportHeight = 800))
}
@Test
fun `chat pet waits for owner terrain before publishing its first position`() {
assertFalse(
shouldInitializeFloatingPet(
positioned = false,
viewportWidth = 400,
viewportHeight = 800,
terrainReady = false,
),
)
assertTrue(
shouldInitializeFloatingPet(
positioned = false,
viewportWidth = 400,
viewportHeight = 800,
terrainReady = true,
),
)
}
@Test
fun `roaming starts immediately then uses the normal repeat delay`() {
assertEquals(0L, floatingPetRoamDelayMs(hasMoved = false))
@@ -429,6 +463,16 @@ class FloatingPetCompanionTest {
assertEquals(60f, compactMaximum.visualSizeDp, 0.001f)
assertEquals(72f, compactMaximum.targetSizeDp, 0.001f)
assertEquals(FloatingPetDimensions(60f, 70f), floatingPetDimensions(false, Float.NaN))
listOf(
70f to 60f,
48f to 72f,
84f to 84f,
).forEach { (target, visual) ->
val collision = floatingPetCollisionSizePx(target, visual)
assertTrue(collision >= target)
assertTrue(collision >= visual)
}
}
@Test
@@ -0,0 +1,58 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.MessageRole
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h720dp-xhdpi")
class MessageBubbleCompletionLayoutTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `completion does not resize a retained live tail`() {
val streaming = mutableStateOf(true)
val message = ChatMessage(
id = "assistant-live",
role = MessageRole.ASSISTANT,
content = "A reply whose final text keeps the live renderer.",
timestamp = 1_700_000_000_000L,
)
compose.mainClock.autoAdvance = false
compose.setContent {
MaterialTheme {
MessageBubble(
message = message.copy(isStreaming = streaming.value),
retainStreamingLayout = true,
modifier = Modifier.testTag("retained-live-tail"),
)
}
}
compose.waitForIdle()
val streamingHeight = compose.onNodeWithTag("retained-live-tail")
.fetchSemanticsNode().boundsInRoot.height
compose.runOnIdle { streaming.value = false }
compose.mainClock.advanceTimeBy(1_000L)
compose.waitForIdle()
val completedHeight = compose.onNodeWithTag("retained-live-tail")
.fetchSemanticsNode().boundsInRoot.height
assertEquals(streamingHeight, completedHeight, 0.01f)
}
}
@@ -0,0 +1,72 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ChatSession
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h720dp-xhdpi")
class SessionDrawerTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `reordered leading session remains visible after drawer refresh`() {
var sessions by mutableStateOf(
List(15) { index ->
ChatSession(
sessionId = "session-$index",
title = "Session $index",
model = null,
lastActivityAt = 100L - index,
)
},
)
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = sessions,
currentSessionId = null,
isOpen = true,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithTag(SESSION_DRAWER_LIST_TAG)
.performScrollToNode(hasText("Session 14"))
compose.onNodeWithText("Session 14").assertIsDisplayed()
compose.runOnIdle {
sessions = sessions.map { session ->
if (session.sessionId == "session-10") {
session.copy(title = "Now latest", lastActivityAt = 1_000L)
} else {
session
}
}
}
compose.onNodeWithText("Now latest").assertIsDisplayed()
}
}
@@ -0,0 +1,43 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.reliability.ReliabilityEnvironment
import com.hermesandroid.relay.reliability.ReliabilityKind
import com.hermesandroid.relay.reliability.ReliabilityOwner
import com.hermesandroid.relay.reliability.ReliabilityReport
import com.hermesandroid.relay.reliability.ReliabilitySeverity
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SupportBundleDialogTest {
@Test
fun emptyStateCannotShare() {
val state = buildSupportReviewState(emptyList())
assertEquals(0, state.reportCount)
assertFalse(state.shareEnabled)
}
@Test
fun populatedStateShowsExactlyTheBundleThatCanBeShared() {
val report = ReliabilityReport(
reportId = "rpt-visible",
appSessionId = "app-visible",
timeIso = "2026-08-04T12:00:00Z",
kind = ReliabilityKind.RecoverableProductError,
owner = ReliabilityOwner.Voice,
severity = ReliabilitySeverity.Error,
summary = "Focus controls stopped responding",
recovery = "Voice closed and chat remained available",
reportRecommended = true,
environment = ReliabilityEnvironment("1.6.1", 38, "googlePlay", "Example", "Phone", "13", 33),
)
val state = buildSupportReviewState(listOf(report))
assertEquals(1, state.reportCount)
assertTrue(state.shareEnabled)
assertTrue(state.text.contains("Focus controls stopped responding"))
assertTrue(state.text.contains("Owner: Voice"))
}
}
@@ -0,0 +1,115 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.click
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.viewmodel.InteractionMode
import com.hermesandroid.relay.viewmodel.VoiceState
import com.hermesandroid.relay.viewmodel.VoiceUiState
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h720dp-xxhdpi")
class VoiceModeOverlayInteractionTest {
@get:Rule
val compose = createComposeRule()
@Test
fun focusMode_childControlsReceiveRealPointerClicks() {
var micTaps = 0
var dismissals = 0
val modeChanges = mutableListOf<InteractionMode>()
compose.mainClock.autoAdvance = false
compose.setContent {
MaterialTheme {
VoiceModeOverlay(
uiState = idleVoiceState(),
onMicTap = { micTaps += 1 },
onMicRelease = {},
onInterrupt = {},
onDismiss = { dismissals += 1 },
onModeChange = { modeChanges += it },
onClearError = {},
)
}
}
compose.onNodeWithContentDescription("Voice mic")
.performTouchInput { click() }
compose.onNodeWithContentDescription("Expand voice controls")
.performTouchInput { click() }
compose.mainClock.advanceTimeBy(500)
compose.onNodeWithText("Hold")
.performTouchInput { click() }
compose.onNodeWithContentDescription("Collapse voice controls")
.performTouchInput { click() }
compose.onNodeWithContentDescription("Exit voice mode")
.performTouchInput { click() }
compose.runOnIdle {
assertEquals(1, micTaps)
assertEquals(1, dismissals)
assertEquals(listOf(InteractionMode.HoldToTalk), modeChanges)
}
}
@Test
fun focusMode_emptySpaceDoesNotClickThroughToChat() {
var backgroundTaps = 0
compose.mainClock.autoAdvance = false
compose.setContent {
MaterialTheme {
Box(
Modifier
.fillMaxSize()
.testTag("backgroundChat")
.clickable { backgroundTaps += 1 },
)
VoiceModeOverlay(
uiState = idleVoiceState(),
onMicTap = {},
onMicRelease = {},
onInterrupt = {},
onDismiss = {},
onModeChange = {},
onClearError = {},
)
}
}
compose.onNodeWithTag(FOCUS_INPUT_SCRIM_TAG)
.performTouchInput { click(Offset(1f, center.y)) }
compose.runOnIdle { assertEquals(0, backgroundTaps) }
}
private fun idleVoiceState() = VoiceUiState(
voiceMode = true,
state = VoiceState.Idle,
interactionMode = InteractionMode.TapToTalk,
)
private companion object {
const val FOCUS_INPUT_SCRIM_TAG = "voiceFocusInputScrim"
}
}
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.components.avatar
import androidx.compose.ui.unit.IntOffset
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@@ -7,6 +8,53 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PetAvatarRuntimeTest {
@Test
fun `floating alignment removes transparent bottom padding while preserving horizontal stabilization`() {
assertEquals(
IntOffset(4, 11),
petContentAlignmentOffset(
width = 64,
height = 64,
minX = 13,
minY = 9,
maxX = 44,
maxY = 52,
stabilize = true,
groundOpaqueBottom = true,
),
)
}
@Test
fun `non-floating alignment retains centered preview behavior`() {
assertEquals(
IntOffset(4, 2),
petContentAlignmentOffset(
width = 64,
height = 64,
minX = 13,
minY = 9,
maxX = 44,
maxY = 52,
stabilize = true,
groundOpaqueBottom = false,
),
)
assertEquals(
IntOffset.Zero,
petContentAlignmentOffset(
width = 64,
height = 64,
minX = 13,
minY = 9,
maxX = 44,
maxY = 52,
stabilize = false,
groundOpaqueBottom = false,
),
)
}
@Test
fun `clip transition retains the previous complete visual until decode finishes`() {
assertEquals(
@@ -674,6 +674,36 @@ class PetRoamingGeometryTest {
assertEquals(180f, segments[1].top, 0f)
}
@Test
fun `scroll control envelope is excluded from every composer rail segment`() {
val outer = PetSafeBounds(0f, 0f, 400f, 400f)
val footprint = PetFootprint(width = 70f, height = 70f, clearance = 6f)
val composer = PetMeasuredPerch(
key = "chat-composer",
bounds = PetObstacle(0f, 260f, 400f, 340f),
)
val controlEnvelope = PetMeasuredObstacle(
key = "chat-scroll-to-bottom-obstacle",
bounds = PetObstacle(292f, 200f, 364f, 248f),
)
val segments = petPerchSegments(
perch = composer,
obstacles = listOf(controlEnvelope),
footprint = footprint,
outer = outer,
)
val expandedControl = controlEnvelope.bounds.expanded(
footprint.horizontalRadius,
footprint.verticalRadius,
)
assertTrue(segments.isNotEmpty())
segments.forEach { segment ->
assertFalse(segment.left < expandedControl.right && segment.right > expandedControl.left)
}
}
@Test
fun `sibling perch segments choose an above-control hop`() {
val safe = PetSafeBounds(20f, 20f, 280f, 220f)
@@ -1,103 +1,413 @@
package com.hermesandroid.relay.ui.screens
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatScrollSnapshotTest {
@Test
fun `completion releases only the retained live tail`() {
assertNull(releaseRetainedLiveTail("assistant-live", "assistant-live"))
fun `ordinary viewport resize follows an owned restored conversation`() {
assertEquals(
true,
shouldFollowConversationViewportResize(
userScrolledAway = false,
userDragging = false,
imeBottomPx = 0,
followImeResize = false,
voiceDockAnchorTransitionActive = false,
),
)
}
@Test
fun `voice dock transition preserves its leading row anchor`() {
assertEquals(
false,
shouldFollowConversationViewportResize(
userScrolledAway = false,
userDragging = false,
imeBottomPx = 0,
followImeResize = false,
voiceDockAnchorTransitionActive = true,
),
)
}
@Test
fun `ime resize follows only after bottom ownership was captured`() {
assertEquals(
false,
shouldFollowConversationViewportResize(
userScrolledAway = false,
userDragging = false,
imeBottomPx = 320,
followImeResize = false,
voiceDockAnchorTransitionActive = false,
),
)
assertEquals(
true,
shouldFollowConversationViewportResize(
userScrolledAway = false,
userDragging = false,
imeBottomPx = 320,
followImeResize = true,
voiceDockAnchorTransitionActive = false,
),
)
}
@Test
fun `same transcript late viewport or tail layout is corrected`() {
val previous = viewportSnapshot(
tailSizePx = 400,
viewportHeightPx = 1_000,
visibleBottomDistancePx = 0,
)
assertEquals(
true,
shouldCorrectConversationBottomAfterLayout(
previous = previous,
current = previous.copy(viewportHeightPx = 960, visibleBottomDistancePx = 40),
atExactBottom = false,
userScrolledAway = false,
userDragging = false,
isStreaming = false,
smoothAutoScroll = false,
viewportFollowAllowed = true,
),
)
assertEquals(
true,
shouldCorrectConversationBottomAfterLayout(
previous = previous,
current = previous.copy(tailSizePx = 460, visibleBottomDistancePx = null),
atExactBottom = false,
userScrolledAway = false,
userDragging = false,
isStreaming = false,
smoothAutoScroll = false,
viewportFollowAllowed = true,
),
)
}
@Test
fun `late layout correction preserves reader and new message ownership`() {
val previous = viewportSnapshot(visibleBottomDistancePx = 0)
val remeasured = previous.copy(viewportHeightPx = 960, visibleBottomDistancePx = 40)
fun correction(
current: ChatViewportFollowSnapshot = remeasured,
exact: Boolean = false,
away: Boolean = false,
dragging: Boolean = false,
) = shouldCorrectConversationBottomAfterLayout(
previous = previous,
current = current,
atExactBottom = exact,
userScrolledAway = away,
userDragging = dragging,
isStreaming = false,
smoothAutoScroll = true,
viewportFollowAllowed = true,
)
assertEquals(false, correction(exact = true))
assertEquals(false, correction(away = true))
assertEquals(false, correction(dragging = true))
assertEquals(
false,
correction(
current = remeasured.copy(
totalItemsCount = remeasured.totalItemsCount + 1,
tailUiKey = "new-tail",
),
),
)
}
@Test
fun `visible footer distance is the exact follow amount`() {
val previous = viewportSnapshot(tailSizePx = 400, visibleBottomDistancePx = 0)
val current = viewportSnapshot(tailSizePx = 432, visibleBottomDistancePx = 7)
assertEquals(7, requiredBottomFollowScroll(previous, current))
}
@Test
fun `viewport loss follows keyboard when footer leaves the viewport`() {
val previous = viewportSnapshot(viewportHeightPx = 1_000)
val current = viewportSnapshot(
viewportHeightPx = 680,
visibleBottomDistancePx = null,
followTailGrowth = false,
followViewportResize = true,
)
assertEquals(320, requiredBottomFollowScroll(previous, current))
}
@Test
fun `history reading never follows viewport or tail changes`() {
val previous = viewportSnapshot(tailSizePx = 400, viewportHeightPx = 1_000)
val current = viewportSnapshot(
tailSizePx = 520,
viewportHeightPx = 680,
visibleBottomDistancePx = 120,
followTailGrowth = false,
followViewportResize = false,
)
assertEquals(0, requiredBottomFollowScroll(previous, current))
}
@Test
fun `ordinary viewport ownership does not follow a new tail`() {
val previous = viewportSnapshot(visibleBottomDistancePx = 0)
val newTail = previous.copy(
totalItemsCount = previous.totalItemsCount + 1,
tailUiKey = "assistant-new",
visibleBottomDistancePx = 48,
followTailGrowth = false,
followViewportResize = true,
)
assertEquals(0, ownedBottomFollowScroll(previous, newTail))
assertEquals(
48,
ownedBottomFollowScroll(
previous,
newTail.copy(followTailGrowth = true),
),
)
}
@Test
fun `voice anchor transition suppresses retained tail following`() {
val previous = viewportSnapshot(visibleBottomDistancePx = 0)
val duringTransition = previous.copy(
visibleBottomDistancePx = 52,
followTailGrowth = false,
followViewportResize = false,
)
assertEquals(0, ownedBottomFollowScroll(previous, duringTransition))
assertEquals(
false,
shouldCorrectConversationBottomAfterLayout(
previous = previous,
current = duringTransition,
atExactBottom = false,
userScrolledAway = false,
userDragging = false,
isStreaming = false,
smoothAutoScroll = true,
viewportFollowAllowed = false,
),
)
}
@Test
fun `voice transcript can correct late layout after anchor transition`() {
assertEquals(
true,
shouldFollowConversationViewportResize(
userScrolledAway = false,
userDragging = false,
imeBottomPx = 0,
followImeResize = false,
voiceDockAnchorTransitionActive = false,
),
)
}
@Test
fun `keyboard follow arms only from the conversation bottom`() {
assertEquals(
true,
shouldFollowImeAfterInsetChange(
wasFollowing = false,
previousImeBottomPx = 0,
currentImeBottomPx = 1,
wasAtBottom = true,
userDragging = false,
),
)
assertEquals(
false,
shouldFollowImeAfterInsetChange(
wasFollowing = false,
previousImeBottomPx = 0,
currentImeBottomPx = 1,
wasAtBottom = false,
userDragging = false,
),
)
}
@Test
fun `keyboard follow survives animation and clears on close or drag`() {
assertEquals(
true,
shouldFollowImeAfterInsetChange(
wasFollowing = true,
previousImeBottomPx = 120,
currentImeBottomPx = 480,
wasAtBottom = false,
userDragging = false,
),
)
assertEquals(
false,
shouldFollowImeAfterInsetChange(
wasFollowing = true,
previousImeBottomPx = 480,
currentImeBottomPx = 0,
wasAtBottom = true,
userDragging = false,
),
)
assertEquals(
false,
shouldFollowImeAfterInsetChange(
wasFollowing = true,
previousImeBottomPx = 120,
currentImeBottomPx = 480,
wasAtBottom = true,
userDragging = true,
),
)
}
@Test
fun `completion and keyboard settle exactly only while bottom follow is owned`() {
assertEquals(
true,
shouldExactlySettleConversation(
autoFollowEnabled = true,
userScrolledAway = false,
userDragging = false,
hasMessages = true,
),
)
assertEquals(
false,
shouldExactlySettleConversation(
autoFollowEnabled = true,
userScrolledAway = true,
userDragging = false,
hasMessages = true,
),
)
assertEquals(
false,
shouldExactlySettleConversation(
autoFollowEnabled = true,
userScrolledAway = false,
userDragging = true,
hasMessages = true,
),
)
}
@Test
fun `disabled auto follow and empty conversations do not request exact settlement`() {
assertEquals(
false,
shouldExactlySettleConversation(
autoFollowEnabled = false,
userScrolledAway = false,
userDragging = false,
hasMessages = true,
),
)
assertEquals(
false,
shouldExactlySettleConversation(
autoFollowEnabled = true,
userScrolledAway = false,
userDragging = false,
hasMessages = false,
),
)
}
@Test
fun `stream start captures the live tail renderer`() {
assertEquals(
"assistant-live",
releaseRetainedLiveTail("assistant-live", "different-message"),
)
assertNull(releaseRetainedLiveTail(null, "assistant-live"))
}
@Test
fun `tall markdown tail is positioned by its trailing edge`() {
assertEquals(
1_208,
tailEndScrollOffset(
tailSizePx = 2_400,
footerSizePx = 8,
viewportSizePx = 1_200,
),
)
assertEquals(
0,
tailEndScrollOffset(
tailSizePx = 600,
footerSizePx = 8,
viewportSizePx = 1_200,
retainedLiveTailAfterTransition(
retainedUiKey = null,
streamStarted = true,
lastMessageUiKey = "assistant-live",
),
)
}
@Test
fun `same-tail stream completion requests an atomic bottom anchor`() {
val streaming = snapshot(isStreaming = true)
val complete = snapshot(isStreaming = false)
assertTrue(complete.isCompletionAfter(streaming))
fun `same-tail completion keeps the stable live renderer`() {
assertEquals(
"assistant-live",
retainedLiveTailAfterTransition(
retainedUiKey = "assistant-live",
streamStarted = false,
lastMessageUiKey = "assistant-live",
),
)
}
@Test
fun `tail replacement is not mistaken for stream completion`() {
val streaming = snapshot(isStreaming = true)
val replaced = snapshot(isStreaming = false, lastMessageUiKey = "replacement-tail")
assertFalse(replaced.isCompletionAfter(streaming))
}
@Test
fun `message list rebuild is not mistaken for stream completion`() {
val streaming = snapshot(isStreaming = true)
val rebuilt = snapshot(isStreaming = false, messageCount = 10)
assertFalse(rebuilt.isCompletionAfter(streaming))
}
@Test
fun `ordinary streaming growth is not a completion`() {
val before = snapshot(contentLength = 4_000, isStreaming = true)
val after = snapshot(contentLength = 4_500, isStreaming = true)
assertFalse(after.isCompletionAfter(before))
}
@Test
fun `starting a stream is not a completion`() {
val idle = snapshot(isStreaming = false)
val streaming = snapshot(isStreaming = true)
assertFalse(streaming.isCompletionAfter(idle))
fun `new tail releases the retained renderer`() {
assertNull(
retainedLiveTailAfterTransition(
retainedUiKey = "assistant-live",
streamStarted = false,
lastMessageUiKey = "next-row",
),
)
assertNull(
retainedLiveTailAfterTransition(
retainedUiKey = null,
streamStarted = false,
lastMessageUiKey = "next-row",
),
)
}
@Test
fun `server id adoption remains an observable tail change`() {
val local = snapshot(isStreaming = false)
val local = snapshot()
val reconciled = local.copy(lastMessageId = "assistant-server-id")
assertNotEquals(local, reconciled)
}
private fun snapshot(
contentLength: Int = 12_000,
isStreaming: Boolean,
messageCount: Int = 8,
lastMessageUiKey: String = "assistant-ui-key",
) = ChatScrollSnapshot(
messageCount = messageCount,
private fun snapshot() = ChatScrollSnapshot(
messageCount = 8,
lastMessageId = "assistant-live-id",
lastMessageUiKey = lastMessageUiKey,
lastContentLength = contentLength,
lastMessageUiKey = "assistant-ui-key",
lastContentLength = 12_000,
lastThinkingLength = 1_200,
lastToolCallCount = 2,
isStreaming = isStreaming,
isStreaming = false,
)
private fun viewportSnapshot(
totalItemsCount: Int = 10,
tailSizePx: Int? = 400,
viewportHeightPx: Int = 1_000,
visibleBottomDistancePx: Int? = null,
followTailGrowth: Boolean = true,
followViewportResize: Boolean = false,
) = ChatViewportFollowSnapshot(
totalItemsCount = totalItemsCount,
tailUiKey = "assistant-live",
tailSizePx = tailSizePx,
viewportHeightPx = viewportHeightPx,
visibleBottomDistancePx = visibleBottomDistancePx,
followTailGrowth = followTailGrowth,
followViewportResize = followViewportResize,
)
}
@@ -5,6 +5,9 @@ import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.R
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.diagnostics.CheckStatus
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
import com.hermesandroid.relay.network.shared.ConnectivityObserver
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.upstream.ServerCapabilities
@@ -68,4 +71,43 @@ class DiagnosticsScreenTest {
},
)
}
@Test
fun failedStatusCheckLeadsWithActionableSuggestion() {
val suggestion = "Verify Relay is running and listening on the configured host and port."
val entry = DiagnosticLogEntry(
timestampMs = 123L,
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay connection refused",
detail = "Failed to connect",
suggestion = suggestion,
)
val checks = buildStatusChecks(
network = ConnectivityObserver.Status.Available,
dashboardUrl = "https://hermes.example.com",
gatewayAvailability = GatewayAvailability.Ready,
apiConfigured = false,
apiHealth = ConnectionViewModel.HealthStatus.Unknown,
apiUrl = "",
capabilities = ServerCapabilities.DISCONNECTED,
authState = AuthState.Unpaired,
relayConfigured = true,
relayHealth = ConnectionViewModel.HealthStatus.Unreachable,
relayReady = false,
relayUpdateInfo = null,
voiceReady = true,
relayVoiceReady = false,
recentEntries = listOf(entry),
context = context,
)
val relay = checks.single {
it.name == context.getString(R.string.active_section_optional_relay)
}
assertEquals(CheckStatus.Fail, relay.status)
assertEquals(suggestion, relay.reason)
assertEquals(123L, relay.timestampMs)
}
}
@@ -0,0 +1,49 @@
package com.hermesandroid.relay.ui.screens
import com.hermesandroid.relay.data.SessionActivityState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class SessionActivityStateTest {
@Test
fun `multiple background turns remain visible beside current working turn`() {
val resolved = resolveSessionActivityStates(
background = mapOf(
"session-a" to SessionActivityState.Working,
"session-b" to SessionActivityState.NeedsInput,
),
currentSessionId = "session-c",
isStreaming = true,
needsInput = false,
)
assertEquals(SessionActivityState.Working, resolved["session-a"])
assertEquals(SessionActivityState.NeedsInput, resolved["session-b"])
assertEquals(SessionActivityState.Working, resolved["session-c"])
}
@Test
fun `needs input takes precedence over current working state`() {
val resolved = resolveSessionActivityStates(
background = emptyMap(),
currentSessionId = "session-a",
isStreaming = true,
needsInput = true,
)
assertEquals(SessionActivityState.NeedsInput, resolved["session-a"])
}
@Test
fun `selected idle session does not retain a stale background state`() {
val resolved = resolveSessionActivityStates(
background = mapOf("session-a" to SessionActivityState.Working),
currentSessionId = "session-a",
isStreaming = false,
needsInput = false,
)
assertFalse(resolved.containsKey("session-a"))
}
}
@@ -9,6 +9,7 @@ import java.net.URLDecoder
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -101,6 +102,10 @@ class IssueReportAndDiagnosticsTest {
endpointRole: String? = null,
url: String? = null,
detail: String? = null,
operation: String? = null,
configuredUrl: String? = null,
requestUrl: String? = null,
suggestion: String? = null,
) = DiagnosticLogEntry(
timestampMs = 0L,
category = DiagnosticCategory.Api,
@@ -109,13 +114,17 @@ class IssueReportAndDiagnosticsTest {
detail = detail,
endpointRole = endpointRole,
url = url,
operation = operation,
configuredUrl = configuredUrl,
requestUrl = requestUrl,
suggestion = suggestion,
)
@Test
fun errorEntriesKeepBugTitleAndLabel() {
val entry = sampleEntry(DiagnosticSeverity.Error, title = "API key rejected")
assertEquals("[Bug]: API key rejected", DiagnosticIssuePrefill.issueTitle(entry))
assertEquals("bug", DiagnosticIssuePrefill.issueLabels(entry))
assertEquals("bug,area:android", DiagnosticIssuePrefill.issueLabels(entry))
}
@Test
@@ -124,7 +133,7 @@ class IssueReportAndDiagnosticsTest {
val entry = sampleEntry(severity)
assertEquals("[Diagnostic]: Testing API connection", DiagnosticIssuePrefill.issueTitle(entry))
// "question" already exists on the repo — the prefill must not invent labels.
assertEquals("question", DiagnosticIssuePrefill.issueLabels(entry))
assertEquals("question,area:android", DiagnosticIssuePrefill.issueLabels(entry))
}
}
@@ -161,6 +170,7 @@ class IssueReportAndDiagnosticsTest {
)
assertTrue(body.contains("- Connection mode: tailscale"))
assertFalse(body.contains("LAN / Tailscale / public TLS / other"))
assertFalse(body.contains("10.0.0.5"))
}
@Test
@@ -182,6 +192,65 @@ class IssueReportAndDiagnosticsTest {
assertTrue(body.contains("- Connection mode: unknown"))
}
@Test
fun bodyExplainsConfiguredRouteActualRequestAndNextStep() {
val body = DiagnosticIssuePrefill.issueBody(
sampleEntry(
severity = DiagnosticSeverity.Error,
title = "Relay connection refused",
operation = "Relay health probe before WebSocket connection",
configuredUrl = "ws://10.3.0.5:8767",
requestUrl = "http://10.3.0.5:8767/health",
suggestion = "Verify Relay is running and listening on the configured host and port.",
),
)
assertTrue(body.contains("- Operation: Relay health probe before WebSocket connection"))
assertTrue(body.contains("- Configured URL: ws://[host]"))
assertTrue(body.contains("- Request: http://[host]/health"))
assertTrue(
body.contains(
"- Suggested next step: Verify Relay is running and listening on the configured host and port.",
),
)
assertFalse(body.contains("10.3.0.5"))
assertFalse(body.lineSequence().any { it.startsWith("- URL:") })
}
@Test
fun connectionModePrefersConfiguredRouteOverConvertedProbeRequest() {
val entry = sampleEntry(
severity = DiagnosticSeverity.Error,
configuredUrl = "wss://100.80.1.2:8767",
requestUrl = "https://relay.example.com:8767/health",
)
assertEquals("tailscale", DiagnosticIssuePrefill.connectionMode(entry))
}
@Test
fun recordSanitizesStructuredDiagnosticContext() {
DiagnosticsLog.clear()
DiagnosticsLog.record(
category = DiagnosticCategory.Relay,
severity = DiagnosticSeverity.Error,
title = "Relay failed",
operation = " Relay health probe ",
configuredUrl = "ws://token=secret@relay.example.com:8767?token=secret",
requestUrl = "http://relay.example.com:8767/health?api_key=secret",
suggestion = " Retry with token=secret after checking the service. ",
url = "http://legacy.example.com:8767",
)
val entry = DiagnosticsLog.recent(limit = 1).single()
assertEquals("Relay health probe", entry.operation)
assertEquals("ws://[host]", entry.configuredUrl)
assertEquals("http://[host]/health", entry.requestUrl)
assertEquals("Retry with token=[hidden] after checking the service.", entry.suggestion)
assertNull(entry.url)
}
@Test
fun recordErrorRedactsSecretsInTheStacktrace() {
DiagnosticsLog.clear()
@@ -0,0 +1,46 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.network.upstream.ApiModelRoutingErrorCode
import com.hermesandroid.relay.network.upstream.ApiModelRoutingException
import java.net.SocketTimeoutException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
class ModelInventoryFailurePolicyTest {
@Test
fun `background inventory failure stays out of global notices`() {
val failure = ApiModelRoutingException(
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
"Model inventory could not be loaded.",
)
assertNull(modelInventoryFailureNotice(failure, userInitiated = false))
}
@Test
fun `user initiated inventory failure remains actionable`() {
val failure = ApiModelRoutingException(
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
"Model inventory unavailable (HTTP 503).",
)
assertEquals(
"Couldn't refresh API model inventory: Model inventory unavailable (HTTP 503).",
modelInventoryFailureNotice(failure, userInitiated = true),
)
}
@Test
fun `routing failure retains the network cause used by diagnostics`() {
val cause = SocketTimeoutException("timeout")
val failure = ApiModelRoutingException(
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
"Model inventory could not be loaded.",
cause,
)
assertSame(cause, failure.cause)
}
}
@@ -0,0 +1,269 @@
# Android reliability and support audit
**Date:** 2026-08-04
**Scope:** Android fatal crashes, handled failures, diagnostics, symbolication,
privacy, recovery, and user-initiated support submission.
## Executive summary
Hermes-Relay already has the right product posture: crash capture is local,
reports are never uploaded automatically, the platform crash handler still runs,
and users can copy, share, or open GitHub themselves. The implementation is not
yet one reliability system, however. Fatal crashes use a private one-file JSON
format, handled errors use an in-memory diagnostics ring, issue builders duplicate
environment and truncation rules, and release mappings are not retained outside
the transient build workspace. The UI therefore asks users to review developer
traces without enough interaction context while maintainers receive obfuscated or
truncated reports that cannot always be retraced.
The foundational change should align those seams without adding telemetry:
1. use one allowlisted, versioned reliability event contract for fatal and handled
failures;
2. redact locally before persistence, display, copy, share, or issue prefill;
3. retain a small deterministic local history and build a support export entirely
on device;
4. make the recovery UI lead with outcome and next steps, with technical detail
behind an explicit review action;
5. retain release mapping files outside public release assets by immutable
version/SHA and make Android's own issue prefills request the Android area.
No automatic upload, analytics SDK, remote crash service, prompt/message capture,
or background logcat collection is justified.
## Current end-to-end architecture
### Fatal crashes
- `HermesRelayApp.onCreate()` installs `CrashReporter` before other app setup.
- The process-wide uncaught-exception handler synchronously writes
`files/crash/last-crash.json`, then delegates to Android's previous handler.
This preserves the platform crash path and Google Play Android vitals.
- The next main-app launch displays `CrashReportGate`. The report remains until
the user dismisses, shares, or opens GitHub; copy alone does not acknowledge it.
- The persisted record contains time, version/code/flavor, manufacturer/model,
Android release/API, thread, exception summary, and the full Java trace.
- GitHub prefill truncates the trace to 3,000 characters because it is encoded in
a browser URL. Copy/share use the full persisted trace.
Failure modes:
- the raw exception message and trace are written before privacy redaction;
- a single non-atomic file means a second crash replaces the first and an
interrupted write can leave no readable report;
- the format has no explicit schema version, retention period, size bound, report
identifier, or migration contract;
- the primary recovery experience is a large stack trace rather than what
happened, what recovered, and what the user can do;
- a crash in a secondary process can compete for the same file;
- fatal OOM may leave too little memory for serialization or file I/O.
### Handled errors and coroutine failures
- `classifyError()` maps common network, auth, HTTP, SSL, permission, and voice
failures to `HumanError`, then records every non-null throwable in
`DiagnosticsLog`.
- `DiagnosticsLog` is an in-memory 200-entry ring. List fields are capped at 180
characters and error traces at 8,000 characters. It is cleared on process death.
- Network ownership is partially represented by `DiagnosticCategory` (`API`,
`Relay`, `Session`, `Voice`, `Route`, `Auth`), but `send_message` defaults to
API even when Gateway owns the turn. Dashboard and upstream Gateway do not have
first-class categories.
- Multiple long-lived `SupervisorJob` scopes exist in services, clients, and the
process runtime. There is no process-wide `CoroutineExceptionHandler`; failures
are visible only where a caller catches/classifies them or where they become
uncaught exceptions.
- Expected cancellation and user denial are not a first-class taxonomy. A caught
cancellation passed to `classifyError()` can appear as an error and become
reportable noise.
### Connectivity, auth, HTTP, WebSocket, and voice
- Typed DNS, connection-refused, timeout, TLS, permission, and generic I/O
failures receive humane copy and retry/repair hints.
- HTTP status handling is message-based. 401/403/404/413/500/503 have distinct
outcomes, but rate limiting is not first-class and ownership depends on a small
caller-provided context string.
- Gateway, Dashboard, API fallback, optional Relay, and Voice have separate live
checks in Diagnostics, but the captured error data contract cannot represent
all five owners precisely.
- `isConnectivityError()` lets startup UI avoid duplicate scary snackbars, but
those failures are still recorded as generic errors.
### OOM and ANR boundaries
- OOM is only captured if the uncaught handler has enough memory and storage to
finish. The current reporter allocates a `StringWriter` containing the complete
trace, which is specifically fragile during OOM.
- ANRs do not throw through the uncaught handler. Google Play can observe Play
builds, while sideload builds currently have no local ANR signal.
- Adding a main-thread watchdog immediately would introduce false positives during
debugger pauses, device sleep, startup, and legitimate long frames. It belongs
in a later opt-in/bounded phase after lifecycle-aware design and device testing.
### Diagnostics, breadcrumbs, and correlation
- Diagnostics has read-only subsystem checks plus the in-memory activity ring.
- Relay envelopes and some bridge/TUI paths have request IDs, and chat has durable
session/run/message IDs. These identifiers are not joined to crash reports.
- Raw session, profile, and connection identifiers may themselves be sensitive.
The safe foundation is a new random per-launch app-session ID and per-event
report ID. Product/session IDs should only be added later as short local hashes
after a demonstrated diagnostic need.
- There is no bounded breadcrumb contract. Capturing arbitrary diagnostic detail
would risk prompts, message text, media paths, profile names, and host data.
### R8, ProGuard, and symbolication
- Release builds enable R8 and preserve `SourceFile`/`LineNumberTable`; source file
names are normalized to `SourceFile`.
- AGP writes `mapping.txt` under `app/build/outputs/mapping/<variant>/`. The file
is overwritten by subsequent builds.
- The Play AAB contains its mapping, so Play reports can be deobfuscated in Play
Console. Sideload reports need the exact locally retained variant mapping.
- The release workflow publishes the sideload APK and Play AAB but does not retain
either release mapping as a versioned workflow artifact. A GitHub issue containing
a sideload trace therefore may be practically irretrievable after the runner is
gone.
- Maintainer procedure should be deterministic:
`retrace <mapping-for-exact-version-and-flavor> <trace-file>`.
### Recent-report evidence
- #289 (`1.6.0`, Play) contains a `NoSuchElementException` trace with application
frames reduced to names such as `gn5.g(SourceFile:2)` and is truncated mid-frame.
The interaction that preceded the crash is absent.
- #292 (`1.6.0-sideload`) contains a duplicate Compose lazy-list key but only
obfuscated application frames and a trace truncated by URL limits.
- #298 (`1.6.1`, Play) shows the same duplicate-key class on another device, again
with obfuscated application frames. There is no route/session/interaction
context to distinguish the owning list.
- #299 (`1.6.1`, Play) provides the missing human context—Focus-mode controls
animate but taps usually do not complete—but has no diagnostic event, app-session
correlation, or technical trail.
- All four Android issues received `area:plugin`. The former issue-triage workflow
tested broad `relay|plugin|...` keywords before Android terms, so the
repository/app name won before `Android app`, device, Compose, or voice context
was considered. Current `dev` has since retired that unreliable keyword
labeler in favor of maintainer review; it should not be reintroduced.
The reports demonstrate both halves of the gap: traces without interaction context
and interaction context without a safe technical trail.
## Privacy threat review
The following must never be collected by the reliability contract:
- authentication headers, cookies, API keys, Relay/session/pairing tokens, OAuth
codes, or signed URLs;
- prompt, response, transcript, reasoning, tool arguments/results, or notification
content;
- real hostnames, IP addresses, full URLs, SSIDs, proxy routes, or private
infrastructure names;
- profile/agent/person names, raw connection/session/run/message IDs, contacts, or
account identifiers;
- local/media/workspace paths, attachment names, clipboard contents, screenshots,
audio, or camera data.
The contract should allow only enumerated owner/kind/status values, version/device
metadata, random local correlation IDs, bounded redacted summaries/traces, route
roles (for example `lan` or `public TLS`, never the host), and bounded allowlisted
breadcrumbs with no arbitrary payload.
Redaction is defense in depth, not permission to collect prohibited fields. It
must run before disk persistence and again when rendering/exporting legacy data.
## Shared taxonomy and data contract
### Kinds
| Kind | Persistence / UI policy |
|---|---|
| Fatal crash | Persist synchronously; show recovery once; reporting is useful |
| ANR/watchdog signal | Contract reserved; later lifecycle-aware implementation |
| Recoverable product error | Persist bounded history; show owned recovery action |
| Connectivity | Low-noise; retry/offline guidance; do not nag for reports |
| Authentication | Name owning surface; repair/sign-in guidance |
| Rate limit | Show retry timing when safely known; do not report by default |
| Service unavailable | Retry guidance; report only if repeated/unexpected |
| Expected cancellation | Do not persist or offer reporting |
| User denial | Do not persist or offer reporting; explain how to change permission |
### Owners
`Android`, `Dashboard`, `API`, `Relay`, `Upstream Gateway`, `Voice`, and `Unknown`.
Standard Dashboard/Gateway/API ownership remains upstream; Relay is optional and
must never be presented as required for standard recovery.
### Versioned record
Each record contains: schema version, random report ID, random app-session ID,
timestamp, kind, owner, severity, humane summary, recovery outcome, whether a
report is recommended, bounded redacted technical detail, and allowlisted app /
OS / device / flavor metadata. Optional context contains route role and bounded
allowlisted breadcrumbs only.
## UX recommendation
### Post-crash recovery
Lead with “Hermes-Relay restarted after an unexpected problem.” Explain that work
on the Hermes server may still be running, no report was sent, and the user can
continue. Technical information stays collapsed behind “Review report.” Copy,
share, and GitHub use the same reviewed redacted payload. Dismiss remains the
lowest-friction path and never nags again for that event.
### Inline handled errors
Use owner + outcome + action: “Dashboard sign-in expired — chat can use API
fallback” or “Relay unavailable — standard Chat and Manage are unaffected.” Do not
offer reporting for connectivity, expected cancellation, user denial, or a missing
optional Relay feature. Keep report actions in Diagnostics rather than snackbars.
### Settings / About / Diagnostics
Diagnostics should expose “Review support information,” showing exactly the
bounded text that copy/share will receive. It should work offline and include no
new probe. About should continue to show version/flavor; duplicating export entry
points there is unnecessary in the foundation.
### Accessibility and localization
The crash dialog must support narrow/foldable layouts, scrolling, screen-reader
labels, large text, and an explicit technical-detail toggle. All new visible copy
must use resources across supported locales. Clipboard/share/browser absence must
fall back without losing the local report.
## Phased plan
### Immediate coherent foundation
- Add the versioned taxonomy/record and centralized local redactor.
- Persist fatal and centrally classified handled failures in a bounded atomic
local store; migrate the legacy one-file crash record.
- Suppress persistence/report prompting for expected cancellation and user denial.
- Build crash/support text and GitHub prefill from the same redacted contract.
- Add explicit review-before-sharing UI and a Diagnostics support export.
- Route Android crash and Diagnostics issue prefills to `area:android`; keep the
unreliable repository-wide keyword labeler disabled.
- Retain Play and sideload `mapping.txt` files as non-release version/SHA workflow
artifacts and document retrace.
- Add focused privacy, bounds, classification, migration, issue-prefill, and UI
state tests.
### Later, evidence-gated follow-up
- Lifecycle-aware ANR watchdog with debugger/sleep/startup suppression and device
false-positive testing.
- Strict allowlisted breadcrumbs at high-value transitions (screen/feature owner,
route transition, retry outcome), never user content.
- Hashed product correlation IDs only where a concrete diagnosis requires them.
- OOM emergency record preallocation / minimal writer.
- Broader structured error adoption at WebSocket, coroutine-scope, and service
boundaries that currently bypass `classifyError()`.
- Maintainer tooling that downloads the exact release mapping and runs retrace from
a report's version/code/flavor tuple.
Automatic telemetry, remote upload, full logcat collection, prompt/transcript
capture, and third-party crash SDKs remain out of scope unless separately proposed
and approved.
+41
View File
@@ -2548,3 +2548,44 @@ top of a response bubble without ever covering its text or jumping through it.
provably safe, no visit is preferable to a partially obscured message.
- Position, activity truth, accessibility behavior, and Petdex fallback semantics
share one source of runtime truth instead of drifting across route-local effects.
## ADR 45 — Provider/model reasoning levels use an optional capability overlay
**Context.** Upstream Hermes owns model discovery and selection. Its
`model.options` contract supplies provider/model identities and may report a
reasoning boolean, but not every provider exposes an authoritative list of
selectable effort values. A client-side provider table would drift, while making
Relay mandatory would break the Vanilla Hermes path.
**Decision.**
- Upstream `model.options` remains the source of provider/model identity. Relay
never invents models or gates model selection or chat.
- When available, the optional Relay `POST /relay/model-capabilities` overlay
resolves capability metadata for the exact, profile-scoped `{provider, model}`
pairs supplied by the client. A remote caller needs a paired session with an
active `chat` grant; loopback operator calls may omit it.
- Resolution uses this precedence: an exact upstream effort list, then an exact
Relay effort list, then an explicit upstream `reasoning: false`, then the
canonical advisory set `none`, `minimal`, `low`, `medium`, `high`, `xhigh`,
`max`, `ultra`.
- Only a coherent provider/model identity may produce an exact result. API model
aliases may contribute only when their provider resolves uniquely. Missing,
malformed, unsupported, unauthenticated, or failed overlay calls keep the
advisory set instead of disabling the control.
- Exact lists are selectable truth for the next request. Advisory lists are
compatibility choices, not a claim that every value is provider-supported.
An effort confirmed by the active session may remain visible as current state
even when it is no longer selectable for a new request.
- Requests are schema-versioned and bounded to 64 exact pairs. Relay may perform
bounded provider discovery using host-owned credentials, but returns only
capability metadata. Credentials, credential fingerprints, probe details, and
internal cache keys never cross the route.
- Primary UI copy describes **available levels** for exact results and
**standard levels** for advisory results. Provider source and overlay details
belong in diagnostics, not in the normal composer.
**Consequences.** Vanilla Hermes remains complete without Relay. A connected
Relay can narrow choices after capability discovery without changing the model
inventory. Old Relay versions, a missing `chat` grant, network failures, and
unsupported providers fail soft to the same stable advisory behavior.
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated",
"review_refs": [],
"source_sha256": {
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
},
"surfaces": {
+38 -6
View File
@@ -89,12 +89,13 @@ This app is a community project and is not affiliated with or endorsed by NousRe
Paste into Play Console → **What's new** (≤500 characters):
```
v1.6.1 - Clearer recovery, steadier chat
v1.7.0 - Smarter controls, steadier sessions
* Clear optional Relay status and recovery guidance.
* Full session history through compatible paging.
* Stable streamed text selection and Voice microphone handoff.
* New-chat coaching stays clear of Voice controls.
* Provider-aware reasoning levels with a standard fallback.
* Working and needs-input states in the searchable session drawer.
* Restored chats stay bottom-pinned without overriding scrollback.
* Private support information and clearer connection diagnostics.
* Reliable Focus Voice controls and recovered chat rows.
```
## Category
@@ -161,13 +162,44 @@ This app is a client for a Hermes server the user runs themselves, so a fresh in
### Foreground service permissions
The Play build declares `**FOREGROUND_SERVICE_SPECIAL_USE**` for `GatewayKeepAliveService`. It protects user-started active chat turns automatically and also backs the opt-in **Persistent connection** feature for idle connectivity. At submission, complete **App content → Foreground service permissions** for `specialUse`:
Before promoting any Play release, compare this section with the merged
`googlePlayRelease` manifest and complete **App content → Foreground service
permissions** for every declared type. Google blocks the Android Publisher API
edit at commit time when a declaration is missing, even if the Production draft
upload itself succeeded.
The Play build declares `**FOREGROUND_SERVICE_SPECIAL_USE**` for
`GatewayKeepAliveService`. It protects user-started active chat turns
automatically and also backs the opt-in **Persistent connection** feature for
idle connectivity. Declare `specialUse` with:
- **Use case:** keeps a user-started Hermes turn connected until it finishes or pauses for user input, including multiple concurrent sessions; optionally maintains the idle connection when the user enables Persistent connection.
- **Why a foreground service:** it's a real-time, user-initiated streaming connection that must survive Doze / background execution limits; `dataSync` is force-stopped after a 6-hour/day cap on Android 15, so `specialUse` is the only fit for "stay connected."
- **User control:** the service starts when the user sends a chat message and stops after all active turns settle. Continuous idle retention is off by default and enabled only via *Settings → Quick Controls → Persistent connection*. The ongoing notification shows the active/waiting session count; its **Turn off always-on** action disables idle retention without interrupting active work. Swiping the app from recents ends it.
- Google usually asks for a short screen recording of a backgrounded active turn, the ongoing notification, and the optional persistent toggle.
The Play build also declares `**FOREGROUND_SERVICE_MICROPHONE**` for two
explicitly user-started voice features. Declare `microphone` and cover both
entry points in its description and demonstration:
- **Local wake word:** when the user explicitly enables *Hey Hermes* in Voice
settings, `WakeWordForegroundService` listens on-device for the configured
wake phrase. Audio before activation remains on the device, the ongoing
microphone notification has a Stop action, and the service is never started
at boot.
- **Voice overlay:** when the user opens the app-owned voice overlay while
Hermes is visible, `VoiceOverlayForegroundService` preserves microphone
access for that active voice session while the overlay is shown over another
app. Hiding or exiting the overlay, stopping voice, or removing the task ends
the service.
- **Why a foreground service:** Android requires an active microphone
foreground service for user-visible capture that continues while the app is
backgrounded. Both paths are opt-in, show Android's persistent microphone
indicator and ongoing notification, and expose an immediate Stop action.
- Record a short video that enables the wake listener and shows its notification
and Stop action, then opens the voice overlay, backgrounds Hermes, and ends
the session from the overlay or notification.
The Play build does **not** declare `FOREGROUND_SERVICE_MEDIA_PROJECTION` or the Device Control accessibility/bridge services — those are sideload-only.
### Data safety
+19 -1
View File
@@ -7,7 +7,9 @@ Hermes-Relay is a companion app for the Hermes agent. It connects only to server
- The app makes **no connections** to Anthropic, Google, or any third party by default
- **No telemetry**, analytics, crash reports, or tracking data are sent externally
- **No advertising SDKs** or third-party SDKs that phone home are included
- Your Hermes server may connect to AI providers such as OpenAI or Anthropic; that is server-side and outside this app's scope
- Your Hermes server may connect to configured AI providers for inference and
bounded capability discovery; that traffic is server-side and uses the
credentials already owned by the selected Hermes profile
## Build Tracks
@@ -31,6 +33,7 @@ All app data is stored on-device in the app's private sandbox:
| Relay session token | EncryptedSharedPreferences | Same encryption as API key |
| Theme and display preferences | DataStore preferences | Tool display mode, reasoning toggle, voice preferences |
| Stats for Nerds counters | DataStore preferences | Response times, token counts, health stats — local only |
| Reliability reports | App-private JSON | Up to 20 locally redacted crash/handled-error records, retained for 14 days; no prompts, messages, profile names, hosts, tokens, or media |
Chat messages are **not cached locally**. They are loaded from the Hermes API server on demand and exist only in memory while the app is running.
@@ -41,9 +44,21 @@ The app connects only to user-configured endpoints:
- **HTTP/SSE** to your Hermes API server for chat streaming
- **WSS** to your relay server for terminal/TUI relay, Bridge Core status, media handoff, notification companion, and paired-session management
- **HTTP(S)/WSS** to your relay server's `/voice/*` routes for voice settings, speech-to-text uploads, realtime voice websocket sessions, and text-to-speech audio when you use Voice mode
- **HTTP(S)** to your optional Relay server's `/relay/model-capabilities` route
when Android refines reasoning-effort choices for models already reported by
upstream Hermes. The phone sends provider/model identifiers and the selected
profile name, not provider credentials.
- Cleartext HTTP is permitted for local/private network connections to user-configured servers; the app warns when using insecure remote connections
- No DNS prefetching and no background pings to external services
The Relay capability resolver may make short, bounded requests from the Hermes
host to a configured provider endpoint. Results are cached for five minutes by
profile, endpoint, model, and a one-way credential fingerprint to limit repeated
provider traffic. Credentials and fingerprints remain on the host; Android
receives only reasoning support, ordered effort values, whether the values are
exact, and diagnostic provenance. A manual refresh requests a new server-side
resolution but does not expose or copy the selected profile's secrets.
## Permissions
Google Play build:
@@ -74,6 +89,9 @@ Notification companion is opt-in. The app only forwards notification metadata af
From Settings, users can:
- **Review support information** in Diagnostics, then explicitly copy or share
the exact redacted local text. Nothing is uploaded automatically.
- **Export** a full connection backup. The file includes server URLs,
preferences, API keys, relay session tokens, device IDs, and dashboard
cookies so restored connections can work without manual re-entry. Keep it
+71
View File
@@ -325,8 +325,79 @@ See [`docs/spec.md` §3.3](spec.md) for the full auth flow and the QR wire forma
| Device Control routes (`/screen`, `/tap`, `/type`, and peers) | GET/POST | Require `Authorization: Bearer <session_token>` and an active `bridge` grant before any request data is forwarded to a connected Android client. Host tools supply the token through `ANDROID_BRIDGE_TOKEN`; loopback callers do not bypass this gate. |
| `/media/inspect` | GET | **Loopback only.** Returns `{"media": [ {token, file_name, content_type, size, created_at, expires_at, last_accessed, is_expired}, ... ]}` — `MediaRegistry.list_all()` snapshot, newest first. Absolute file paths are **never** included — only `file_name` (basename). Query param: `?include_expired=true` includes evicted entries (default false, hides them). 403 for non-loopback callers. Consumed by the dashboard plugin's Media Inspector tab. |
| `/relay/info` | GET | Aggregate status and capability contract. Loopback dashboard calls may omit auth; remote callers require a paired-device bearer. Returns backward-compatible `version` plus `plugin_version`, `protocol_version`, stable `capabilities`, per-profile `relay_state`, counters, `health`, and an optional sanitized `gateway_heartbeat` assessment. The heartbeat is diagnostic-only and never changes Relay health, fallback, or restart policy. |
| `/relay/model-capabilities` | POST | Resolve reasoning-effort metadata for up to 64 exact `{provider, model}` pairs. Loopback callers may omit auth; remote callers require a paired-device bearer with an active `chat` grant. Request schema v1 accepts optional `profile` and `refresh`; response rows include `reasoning`, ordered `reasoning_efforts`, `reasoning_efforts_exact`, and `source`. Provider credentials and internal cache scopes never leave the host. Advertised by `/relay/info` as `model_reasoning_capabilities_v1`. |
| `/relay/security` | GET/PATCH | **Loopback only.** Runtime security toggles for local operators, `hermes relay insecure-api-key`, and `hermes-relay insecure-api-key`. `GET` returns `{"allow_insecure_api_bearer": false, "trust_proxy_headers": false, "scope": "runtime"}`. `PATCH {"allow_insecure_api_bearer": true}` enables plain-LAN API-key voice auth immediately for the running relay; `false` disables it. This is not persisted across restarts. |
### Model capability overlay
`POST /relay/model-capabilities` is an optional metadata overlay for clients
that already obtained coherent provider/model identities from upstream Hermes.
It does not list models, select a model, proxy chat, or make Relay a requirement
for reasoning controls.
Request schema v1:
```json
{
"schema_version": 1,
"profile": "default",
"refresh": false,
"models": [
{"provider": "example-provider", "model": "reasoner-v1"}
]
}
```
`profile` defaults to `default`, and `refresh` defaults to `false`. `models`
must contain 1–64 exact pairs; provider values are limited to 128 characters,
model values to 512 characters, and profile values to 128 characters. An
unsupported schema, malformed pair, unknown profile, or over-limit request is
rejected with a structured 4xx response.
Response contract 1.0:
```json
{
"schema_version": 1,
"contract_version": "1.0",
"capabilities": [
{
"provider": "example-provider",
"model": "reasoner-v1",
"reasoning": true,
"reasoning_efforts": ["low", "medium", "high"],
"reasoning_efforts_exact": true,
"source": "provider-adapter"
}
]
}
```
`reasoning_efforts_exact: true` means the ordered list is verified for that
specific provider/model pair. `false` means the row is advisory; clients should
label it as standard compatibility choices rather than guaranteed provider
support. `source` is diagnostic provenance, not UI copy or a precedence signal.
Android merges the overlay after an exact upstream effort list and before an
explicit upstream `reasoning: false`; otherwise it retains the canonical
advisory set `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`,
`ultra`.
Loopback callers may omit authentication. Remote callers must send a valid
Relay session bearer with an active `chat` grant. The route is profile-isolated:
it reads only the selected profile's configuration and credential scope. Some
providers can be answered from static or upstream metadata; others require
short, bounded provider requests. The resolver caps provider concurrency,
applies per-request timeouts, caches results for five minutes by profile,
endpoint, model, and credential fingerprint, and fences an explicit refresh so
stale in-flight work cannot repopulate the refreshed profile cache.
Provider credentials stay on the Hermes host. Responses never include secrets,
credential fingerprints, endpoint probe details, or internal cache keys. A
provider timeout or unsupported provider produces a safe non-exact row; an old
Relay `404`, absent pairing, expired grant, unsupported response schema, or
network failure is handled client-side by keeping the advisory choices. These
fail-soft cases never block model selection or chat.
### Dashboard plugin proxy routes
The hermes-agent dashboard plugin at `plugin/dashboard/` exposes a FastAPI router mounted at `/api/plugins/hermes-relay/*` on the gateway's web server. Each route is a loopback-only pass-through to the corresponding relay HTTP route above, so the relay remains the source of truth. Implementation lives in `plugin/dashboard/plugin_api.py`.
+25
View File
@@ -80,6 +80,31 @@ python scripts/screenshots.py validate
- The README sources are currently `1080x2244`. The Play export crops them to
`1080x2160` because Google Play requires screenshot dimensions between
320 and 3840 pixels and the long side cannot exceed twice the short side.
## Foreground-service policy video
`scripts/android-fgs-demo.py` drives a real connected phone through the Google
Play foreground-service evidence sequence and records it with Android's native
`screenrecord`. It verifies each expected control and notification through the
UI hierarchy, and aborts rather than producing misleading evidence when a step
is unavailable.
Use an explicit device serial so a connected emulator cannot receive the run by
accident:
```bash
python scripts/android-fgs-demo.py --serial <adb-serial>
```
The sequence enables Persistent connection, shows its ongoing notification,
sends and backgrounds a chat turn, returns through the notification, disables
the setting and proves the notification clears, then repeats the evidence flow
for the app-owned voice overlay and its **Stop voice** notification action.
Run `--no-record --pause-scale 0.25` first after material UI changes. Before
publishing the resulting MP4, review every notification-shade frame for private
network names and unrelated notification content; the driver does not clear or
alter the user's other notifications.
- Google Play listing upload stays on Gradle Play Publisher, matching the
existing Android publishing toolchain. Fastlane is not required.
+18
View File
@@ -16,6 +16,9 @@ AccessibilityService-backed Device Control (screen reading, taps, typing, screen
- Codes use the full `A-Z / 0-9` alphabet (36 chars). The earlier "no ambiguous 0/O/1/I" restriction was dropped when the pairing flow moved from "human retypes code from display" to "code flows phone ↔ server via QR + HTTP" (see `docs/decisions.md` §6a).
- `POST /pairing/register` is gated to loopback callers only (`127.0.0.1` / `::1`) — only a process with host shell access on the relay machine can inject pairing codes. A LAN attacker cannot.
- Relay session tokens carry per-channel grants. Voice routes require explicit `voice:config`, `voice:stt`, `voice:tts`, or `voice:realtime` grants when called with a Relay session token.
- Remote `POST /relay/model-capabilities` calls require a valid Relay session
bearer with an active `chat` grant. Loopback host-operator calls may omit the
bearer. Hermes API bearer tokens are not accepted on this route.
- `/voice/config`, `/voice/transcribe`, `/voice/synthesize`, `/voice/output/*`, and `/voice/realtime/*` may also accept the Hermes API bearer token used by API-server clients. The Android app uses this fallback for chat+voice-only setups when no Relay session is paired. This is a narrow exception for chat/media-adjacent voice features only; the API bearer token is not accepted for sessions, media, clipboard, terminal, TUI, bridge, profile writes, or Android control routes.
- Hermes API bearer use on voice routes requires HTTPS for non-loopback callers by default. Loopback plaintext is allowed for local clients; reverse-proxy TLS is accepted only when `RELAY_TRUST_PROXY_HEADERS=1`, and plaintext LAN testing requires an explicit opt-in. Use `hermes relay insecure-api-key on` for a running relay, or `RELAY_ALLOW_INSECURE_API_BEARER=1` at startup.
@@ -29,6 +32,21 @@ AccessibilityService-backed Device Control (screen reading, taps, typing, screen
- The server relay only accepts one phone at a time
- All tool commands are proxied through the relay — the phone is never directly exposed
### Model capability discovery
The optional model-capability route accepts 1–64 validated provider/model pairs
and a bounded profile name. Resolution is isolated to that profile's config and
credential scope. Dynamic provider checks use short timeouts and a shared
four-request concurrency ceiling; results are cached for five minutes. An
explicit refresh advances a profile generation so an older in-flight request
cannot restore stale cache entries.
Provider credentials are loaded and used only on the Hermes host. The response
contains capability metadata and diagnostic source labels, never credentials,
credential fingerprints, provider response bodies, or internal cache keys.
Unsupported providers and probe failures return non-exact advisory metadata
instead of expanding authority or preventing chat.
## Known Limitations
### Plaintext `ws://` legs are possible
+6
View File
@@ -402,6 +402,7 @@ Bottom navigation bar with 4 tabs:
- **Behavior and motion director** — direct interaction wins over agent activity, which wins over a pending response visit, autonomous roaming, and idle reactions. Agent activity therefore cannot be overwritten by locomotion. Horizontal travel uses directional walking clips with distance quantized to complete walk cycles; turns pause briefly. Vertical movement uses distance-scaled timing, anticipation, `jumping` to the apex, `falling` through descent, an altitude-responsive shadow, and a landing squash. Cross-level transfers are length-capped on every route, including Settings, so a screen with no explicitly registered safe intermediate level stays on its current rail rather than jumping through UI. Idle patrols alternate short hops, waves, and rests without turning response bubbles into arbitrary roaming terrain. **Calm**, **Balanced** (default), and **Playful** change response-visit, patrol, and idle-reaction cadence and cap older-bubble exploration at one, two, or three stops; safety, foreground, activity, scrolling, dialog, reduced-motion, and accessibility gates remain authoritative.
- **Activity versus locomotion** — idle/thinking/writing/error, tool-burst, and completion choose the agent-state clip. Directional `walking-*`/`running-*` clips describe only physical screen travel and are eligible only while the agent is idle; agent activity always wins. Microphone/TTS presentation remains Sphere/voice rather than a roaming-pet surface on Android.
- **Input bar** — text field with 4096 char limit, `/` palette button, send button, stop button during streaming. Inline autocomplete on `/` keystroke + full searchable command palette (bottom sheet). Commands sourced from: 29 gateway built-ins, dynamic personalities from `config.agent.personalities`, and server skills from native `GET /v1/skills`.
- **Reasoning effort** — choices follow the selected upstream provider/model identity. When upstream or the optional Relay capability overlay reports an exact model-specific list, Android shows only those available levels. Otherwise it shows the standard advisory set (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`). An explicit upstream `reasoning: false` suppresses the control unless a higher-precedence exact list exists. Missing Relay configuration, pairing, route support, or network access never blocks model selection or chat. A server-confirmed current effort may remain visible as session truth even when it is not selectable for the next request. See ADR 45.
- **Empty state** — Logo + "Start a conversation" + suggestion chips that populate input
- **Agent sheet — Profile section (v0.6.0, updated 2026-05-18)** — upstream Hermes profiles auto-discovered by the relay at `~/.hermes/profiles/*/`. Selecting one routes chat/session calls to that profile's advertised `api_server_url` when present, giving proper Hermes isolation for sessions, memory, tools, provider auth, and SOUL/default model. If no profile API route is advertised, the app falls back to overlaying `model` + `SOUL.md` (as `system_message`) on the active Connection's API server. Selection is persisted per Connection/profile context. Hidden when the server advertises no profiles. See `docs/decisions.md` §21.
- **Agent sheet — Personality section** — personalities fetched from `GET /api/config` (`config.agent.personalities`). Shows server default (from `config.display.personality`) + all configured. Active personality name shown on assistant chat bubbles.
@@ -489,6 +490,11 @@ vanilla upstream path with live thinking/reasoning events. When that gateway is
unavailable, Android falls back to API-server SSE routes. The native Sessions
API fallback looks like:
Model inventory also stays upstream-owned. Android may call the optional Relay
`POST /relay/model-capabilities` route to refine reasoning-effort choices for
the exact provider/model pairs returned by upstream, but that metadata call is
not a chat proxy and is never a prerequisite for sending a message.
```
1. POST /api/sessions → create session → get session_id
2. POST /api/sessions/{session_id}/chat/stream → send message, get SSE stream
+3 -1
View File
@@ -1,6 +1,6 @@
# Hermes-Relay Surface Matrix
Updated: 2026-07-18
Updated: 2026-08-05
This matrix records the v1.0.0 route ownership contract. It is meant to keep
future app, plugin, and agent work honest about what is vanilla upstream
@@ -28,9 +28,11 @@ Verified upstream source snapshot:
| `/v1/skills`, `/v1/toolsets` | Upstream API server | No | Discovery | Authenticated read-only API-server skill/toolset inventory; Android Diagnostics summarizes enabled toolsets and Relay tool visibility. |
| Dashboard `/api/status`, `/api/auth/me` | Upstream dashboard | No | Manage auth | Dashboard cookie/session path; separate from API bearer. Optional status diagnostics include Nous bootstrap validity and profile/gateway topology; these do not gate transport selection. |
| Dashboard `/api/auth/ws-ticket`, `/api/ws` | Upstream dashboard/tui_gateway | No | Preferred chat transport | Vanilla Hermes gateway chat path with live reasoning/thinking events. |
| Dashboard `model.options` / `/api/model/*` | Upstream dashboard/tui_gateway | No | Provider/model inventory and selection | Source of truth for coherent provider/model identities. A reasoning boolean or exact effort list is consumed when present; clients do not infer provider identity from a model string alone. |
| Dashboard `/api/audio/transcribe`, `/api/audio/speak-stream`, `/api/audio/speak` | Upstream dashboard | No | Vanilla Hermes voice | Manage sign-in unlocks Vanilla Hermes voice. Assistant text streams into upstream speech when available; older hosts fall back to whole-request speech before audio starts. API server has no `/v1/audio/*` route today. |
| Dashboard `/api/config`, `/api/profiles/*`, `/api/env`, `/api/model/*`, `/api/mcp/*`, `/api/providers/custom-endpoints*` | Upstream dashboard | No | Manage | Do not proxy through Relay. MCP list/actions/OAuth carry Android's effective profile explicitly; Android detects hosted-OAuth support with a read-only missing-flow status GET and caches that capability per dashboard/profile. Hosted OAuth itself stays server-owned, opens only the returned HTTPS URL, and persists only the opaque flow id/server/profile plus a normalized non-secret dashboard/connection identity; polling is held whenever the active connection does not own that flow. Custom-endpoint routes are process-scoped in the current public contract, so Android does not claim or append profile scoping; credentials are write-only and blank edits preserve an existing key. |
| `/pairing/*`, `/sessions`, `/voice/*`, `/desktop/*`, `/media/*`, `/notifications/*` on Relay | Hermes-Relay plugin/server | Yes | Relay pairing, terminal, bridge, relay voice, desktop tools | Owned by `plugin/relay/server.py`; Android must gate behind Relay readiness/session grants. |
| `POST /relay/model-capabilities` | Hermes-Relay plugin/server | Optional | Refine reasoning-effort choices for exact upstream provider/model pairs | Never supplies model inventory or gates chat. Missing, old, unpaired, malformed, or unreachable Relay falls back to standard advisory choices. Remote calls require a paired bearer with an active `chat` grant. |
| Dashboard `/api/plugins/hermes-relay/*` | Hermes-Relay dashboard plugin | Yes for live data | Relay dashboard tab | FastAPI plugin backend proxies loopback requests to the Relay server. |
| `hermes relay doctor` | Hermes-Relay plugin CLI | No for diagnostics | Operator/agent diagnostics | Reports vanilla upstream Hermes route reachability (including `/v1/toolsets`), dashboard Nous/topology state, sanitized gateway event-loop heartbeat state, plugin layout, Relay loopback state, and legacy bootstrap presence. |
| `hermes_relay_bootstrap` routes | Legacy compatibility monkeypatch | No, but non-upstream | Fallback only | Installed via `.pth` by legacy installer. Injects only compatibility-only gaps: session search, memory, legacy skill detail/toggle, config, available-models, slash middleware. Sessions CRUD and skill/toolset lists are native upstream and retired from the bootstrap. Retained session-database work is offloaded (`AsyncSessionDB` when available, `asyncio.to_thread` fallback), and memory mutations reset newer upstream's request-local consolidation-failure budget. |
+9 -2
View File
@@ -83,8 +83,15 @@ git worktree remove <path> # delete a worktree (must be clean, or pass --force)
(`../hermes-feat-x`), not under the repo root, or it gets swept into globs and
IDE indexing.
- **Build outputs are per-folder.** That's the point (warm caches), but it also
means three worktrees ≈ three `build/` trees on disk. Prune merged worktrees so
they don't accumulate.
means three worktrees ≈ three `build/` trees on disk. Gradle's local build
cache is shared through the Gradle user home and can reuse compatible task
outputs across worktrees. Prune merged worktrees so build trees don't
accumulate.
- **Serialize heavy Gradle invocations on one host.** Worktrees isolate source
and output directories, but concurrent Android compiles, lint, tests, and APK
packaging still compete for the same CPU, memory, daemon pool, and local
cache. Keep one heavy Gradle lane active at a time; parallelism inside that
invocation remains enabled.
## How this maps to releasing
+4
View File
@@ -1,4 +1,8 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
org.gradle.daemon=true
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.parallel=true
android.useAndroidX=true
android.suppressUnsupportedCompileSdk=37
kotlin.code.style=official
+2 -2
View File
@@ -1,6 +1,6 @@
[versions]
appVersionName = "1.6.1"
appVersionCode = "38"
appVersionName = "1.7.0"
appVersionCode = "39"
agp = "9.3.1"
kotlin = "2.4.10"
compose-bom = "2026.06.01"
+1 -1
View File
@@ -3,7 +3,7 @@
"label": "Relay",
"description": "Paired devices, bridge activity, media inspection, and remote access for hermes-relay",
"icon": "Activity",
"version": "1.5.1",
"version": "1.6.0",
"tab": {
"path": "/relay",
"position": "after:skills"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hermes-relay-dashboard",
"version": "1.5.1",
"version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hermes-relay-dashboard",
"version": "1.5.1",
"version": "1.6.0",
"devDependencies": {
"esbuild": "^0.25.12",
"qrcode": "^1.5.4"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hermes-relay-dashboard",
"version": "1.5.1",
"version": "1.6.0",
"private": true,
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
name: hermes-relay
manifest_version: 1
version: 1.5.1
version: 1.6.0
description: "Hermes-Relay plugin for QR pairing, relay sessions, dashboard management, remote desktop/phone tooling, and optional legacy compatibility diagnostics. Standard chat, Manage, and dashboard voice remain vanilla upstream Hermes surfaces."
author: Axiom Labs
# All three are OPTIONAL — only needed if you use the relay's extra /
+1 -1
View File
@@ -19,7 +19,7 @@ See ``plugin/relay/server.py`` for the aiohttp server,
# Desktop releases use desktop/package.json and desktop-v* tags. The /health endpoint
# reports this plugin version, and stale values make live diagnosis harder than
# it should be.
__version__ = "1.5.1"
__version__ = "1.6.0"
from .server import create_app, main # noqa: E402 — must come after __version__
+840
View File
@@ -0,0 +1,840 @@
"""Plugin-owned provider/model reasoning capability resolution.
The Relay extension cannot require changes to upstream Hermes. This module
therefore reads only the selected profile's existing files and probes provider
endpoints directly with bounded aiohttp calls. Cache keys include endpoint and
credential fingerprints; secrets are never retained in capability results or
returned on the wire.
"""
from __future__ import annotations
import asyncio
import base64
from dataclasses import dataclass
import hashlib
import json
import logging
import time
from pathlib import Path
from typing import Any, Iterable
import aiohttp
from .config import (
RelayConfig,
_effective_default_profile_home,
_load_yaml_mapping,
)
logger = logging.getLogger("hermes_relay.model_capabilities")
SCHEMA_VERSION = 1
CONTRACT_VERSION = "1.0"
MAX_MODEL_PAIRS = 64
_MAX_OLLAMA_PROBES = 16
_MAX_PROBE_CONCURRENCY = 4
_CACHE_TTL_SECONDS = 300.0
_PROFILE_CONTEXT_TIMEOUT_SECONDS = 1.0
_REQUEST_TIMEOUT_SECONDS = 2.5
_PROBE_TIMEOUT_SECONDS = 1.25
_CANONICAL_EFFORTS = (
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
"ultra",
)
_EFFORT_RANK = {value: index for index, value in enumerate(_CANONICAL_EFFORTS)}
_PROVIDER_ALIASES = {
"github": "copilot",
"github-copilot": "copilot",
"github-models": "copilot",
"github-model": "copilot",
"lm-studio": "lmstudio",
"ollama": "ollama-cloud",
}
@dataclass(frozen=True)
class ReasoningCapability:
efforts: tuple[str, ...]
exact: bool
source: str
def wire(self, provider: str, model: str) -> dict[str, Any]:
return {
"provider": provider,
"model": model,
"reasoning": bool(self.efforts),
"reasoning_efforts": list(self.efforts),
"reasoning_efforts_exact": self.exact,
"source": self.source,
}
@dataclass(frozen=True)
class _ProfileContext:
name: str
home: Path
config: dict[str, Any]
env: dict[str, str]
credential_pool: dict[str, list[dict[str, Any]]]
def _provider_key(provider: str) -> str:
value = str(provider or "").strip().lower()
return _PROVIDER_ALIASES.get(value, value)
def _model_key(model: str) -> str:
return str(model or "").strip().lower().rsplit("/", 1)[-1]
def _ordered_efforts(values: Iterable[Any]) -> tuple[str, ...]:
aliases = {"off": "none", "on": "medium"}
selected = {
aliases.get(str(value).strip().lower(), str(value).strip().lower())
for value in values
}
return tuple(value for value in _CANONICAL_EFFORTS if value in selected)
def _static_capability(provider: str, model: str) -> ReasoningCapability:
normalized_provider = _provider_key(provider)
normalized_model = _model_key(model)
if normalized_provider == "zai" and any(
token in normalized_model for token in ("glm-5.2", "glm-5-2", "glm-5p2")
):
return ReasoningCapability(("none", "high", "max"), True, "provider-adapter")
deepseek_thinking = (
normalized_model.startswith("deepseek-v")
and not normalized_model.startswith("deepseek-v3")
) or normalized_model == "deepseek-reasoner"
if normalized_provider in {"deepseek", "opencode-go"} and deepseek_thinking:
return ReasoningCapability(
("none", "low", "medium", "high", "max"),
True,
"provider-adapter",
)
if normalized_provider == "opencode-go" and any(
token in normalized_model for token in ("glm-5.2", "glm-5-2", "glm-5p2")
):
return ReasoningCapability(("high", "max"), True, "provider-adapter")
if normalized_provider == "kimi-for-coding" or (
normalized_provider == "opencode-go" and normalized_model.startswith("kimi-k2")
):
return ReasoningCapability(
("none", "low", "medium", "high"), True, "provider-adapter"
)
if normalized_provider == "upstage":
if any(marker in normalized_model for marker in ("solar-mini", "syn-pro")):
return ReasoningCapability((), True, "provider-adapter")
return ReasoningCapability(
("none", "low", "medium", "high"), True, "provider-adapter"
)
if normalized_provider == "actual":
return ReasoningCapability(
("none", "low", "medium", "high", "max"),
True,
"provider-adapter",
)
return ReasoningCapability(_CANONICAL_EFFORTS, False, "canonical-fallback")
def _static_capabilities(
pairs: list[tuple[str, str]],
) -> list[ReasoningCapability]:
"""Apply plugin adapters plus upstream's stable no-reasoning signal."""
results = [_static_capability(provider, model) for provider, model in pairs]
try:
from agent.models_dev import get_model_capabilities
except Exception:
return results
models_dev_provider = {"copilot": "github-copilot"}
for index, (provider, model) in enumerate(pairs):
if results[index].exact:
continue
normalized = _provider_key(provider)
try:
metadata = get_model_capabilities(
models_dev_provider.get(normalized, normalized), model
)
except Exception:
continue
if metadata is not None and metadata.supports_reasoning is False:
results[index] = ReasoningCapability((), True, "models.dev")
return results
def _fingerprint(secret: str) -> str:
if not secret:
return "anonymous"
return hashlib.blake2b(secret.encode("utf-8"), digest_size=12).hexdigest()
def _chatgpt_account_id(access_token: str) -> str:
"""Extract the non-secret account routing claim from a Codex OAuth JWT."""
try:
payload = access_token.split(".")[1]
payload += "=" * (-len(payload) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload))
account_id = _mapping(claims.get("https://api.openai.com/auth")).get(
"chatgpt_account_id"
)
return account_id if isinstance(account_id, str) else ""
except (IndexError, ValueError, TypeError, json.JSONDecodeError):
return ""
def _mapping(value: object) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _profile_model_config(context: _ProfileContext) -> dict[str, Any]:
return _mapping(context.config.get("model"))
def _provider_config(context: _ProfileContext, provider: str) -> dict[str, Any]:
providers = _mapping(context.config.get("providers"))
return _mapping(providers.get(provider))
def _first_string(*values: object) -> str:
for value in values:
if value is not None:
text = str(value).strip()
if text:
return text
return ""
def _credential_pool_entries(path: Path, provider: str) -> list[dict[str, Any]]:
try:
if path.stat().st_size > 1_048_576:
return []
parsed = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return []
pool = _mapping(_mapping(parsed).get("credential_pool"))
entries = pool.get(provider)
if not isinstance(entries, list):
return []
return [entry for entry in entries if isinstance(entry, dict)]
def _profile_secret(context: _ProfileContext, provider: str) -> str:
model = _profile_model_config(context)
configured = _provider_config(context, provider)
if provider == "lmstudio":
env_names = ("LM_API_KEY",)
elif provider == "ollama-cloud":
env_names = ("OLLAMA_API_KEY",)
elif provider == "copilot":
env_names = ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
else:
env_names = ()
for name in env_names:
value = context.env.get(name)
if value:
return value
value = _first_string(configured.get("api_key"), configured.get("key"))
if value:
return value
if _provider_key(str(model.get("provider") or "")) == provider:
value = _first_string(model.get("api_key"), model.get("key"))
if value:
return value
entries = context.credential_pool.get(provider, [])
for entry in entries:
# Capability discovery must not revive a credential that upstream has
# marked unavailable. Conservatively fail to non-exact metadata; the
# normal Hermes runtime owns cooldown expiry and token refresh.
if str(entry.get("last_status") or "").strip().lower() in {"dead", "exhausted"}:
continue
value = _first_string(
entry.get("access_token"),
entry.get("api_key"),
entry.get("token"),
entry.get("key"),
)
if value:
return value
return ""
def _provider_base_url(context: _ProfileContext, provider: str) -> str:
model = _profile_model_config(context)
configured = _provider_config(context, provider)
if provider == "openai-codex":
return _first_string(
context.env.get("OPENAI_CODEX_BASE_URL"),
configured.get("base_url"),
"https://chatgpt.com/backend-api/codex",
).rstrip("/")
if provider == "lmstudio":
env_name, default = "LM_BASE_URL", "http://127.0.0.1:1234/v1"
elif provider == "ollama-cloud":
env_name, default = "OLLAMA_BASE_URL", "https://ollama.com/v1"
else:
return _first_string(
context.env.get("COPILOT_BASE_URL"),
context.env.get("GITHUB_COPILOT_BASE_URL"),
configured.get("base_url"),
"https://api.githubcopilot.com",
).rstrip("/")
model_url = (
model.get("base_url")
if _provider_key(str(model.get("provider") or "")) == provider
else None
)
return _first_string(
context.env.get(env_name), configured.get("base_url"), model_url, default
).rstrip("/")
class ModelCapabilityResolver:
"""Resolve exact capabilities with bounded, profile-isolated probes."""
def __init__(self, config: RelayConfig) -> None:
self._config = config
self._cache: dict[
tuple[str, str, str, str, str], tuple[ReasoningCapability, float]
] = {}
self._cache_lock = asyncio.Lock()
self._profile_generations: dict[str, int] = {}
# One limiter for every dynamic provider and every HTTP request. A
# burst of concurrent inventory refreshes cannot multiply LM Studio,
# Ollama, and Copilot sessions beyond this process-wide resolver cap.
self._probe_semaphore = asyncio.Semaphore(_MAX_PROBE_CONCURRENCY)
def _profile_context(self, profile: str) -> _ProfileContext | None:
root_config = Path(self._config.hermes_config_path).expanduser()
root_home = root_config.parent
if profile == "default":
home = _effective_default_profile_home(root_home)
else:
if not profile or profile in {".", ".."} or Path(profile).name != profile:
return None
home = root_home / "profiles" / profile
if not home.is_dir():
return None
config_path = home / "config.yaml"
if not config_path.is_file():
return None
config = _load_yaml_mapping(config_path)
try:
from agent.secret_scope import build_profile_secret_scope
env = build_profile_secret_scope(home)
except Exception:
# Older supported upstream builds predate secret_scope. The plugin
# parser is read-only and never mutates process-global os.environ.
from .config import _profile_dotenv_values
env = _profile_dotenv_values(home)
credential_pool: dict[str, list[dict[str, Any]]] = {}
try:
from agent.secret_scope import (
reset_secret_scope,
set_secret_scope,
)
from hermes_constants import (
reset_hermes_home_override,
set_hermes_home_override,
)
from hermes_cli.auth import read_credential_pool
home_token = set_hermes_home_override(home)
secret_token = set_secret_scope(env)
try:
loaded_pool = read_credential_pool(None)
finally:
reset_secret_scope(secret_token)
reset_hermes_home_override(home_token)
if isinstance(loaded_pool, dict):
credential_pool = {
str(key): [entry for entry in value if isinstance(entry, dict)]
for key, value in loaded_pool.items()
if isinstance(value, list)
}
except Exception:
# Compatibility fallback mirrors upstream's per-provider root
# fallback without executing subprocesses or changing env state.
for provider in (
"copilot",
"lmstudio",
"ollama-cloud",
"openai-codex",
):
entries = _credential_pool_entries(home / "auth.json", provider)
if not entries and root_home != home:
entries = _credential_pool_entries(
root_home / "auth.json", provider
)
if entries:
credential_pool[provider] = entries
return _ProfileContext(profile, home, config, env, credential_pool)
async def _cached(
self, key: tuple[str, str, str, str, str]
) -> ReasoningCapability | None:
now = time.monotonic()
async with self._cache_lock:
expired = [
item for item, (_value, expiry) in self._cache.items() if expiry <= now
]
for item in expired:
self._cache.pop(item, None)
entry = self._cache.get(key)
return entry[0] if entry else None
async def _store(
self,
key: tuple[str, str, str, str, str],
capability: ReasoningCapability,
generation: int,
) -> bool:
async with self._cache_lock:
if self._profile_generations.get(key[0], 0) != generation:
return False
self._cache[key] = (
capability,
time.monotonic() + _CACHE_TTL_SECONDS,
)
return True
async def _generation(self, profile: str) -> int:
async with self._cache_lock:
return self._profile_generations.get(profile, 0)
async def _clear(self, profile: str) -> int:
async with self._cache_lock:
generation = self._profile_generations.get(profile, 0) + 1
self._profile_generations[profile] = generation
for key in [item for item in self._cache if item[0] == profile]:
self._cache.pop(key, None)
return generation
async def resolve_many(
self,
pairs: list[tuple[str, str]],
*,
profile: str = "default",
refresh: bool = False,
) -> list[dict[str, Any]]:
try:
context = await asyncio.wait_for(
asyncio.to_thread(self._profile_context, profile),
timeout=_PROFILE_CONTEXT_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.info("Profile credential resolution timed out for %s", profile)
return [
_static_capability(provider, model).wire(provider, model)
for provider, model in pairs
]
if context is None:
raise KeyError(profile)
if refresh:
generation = await self._clear(context.name)
else:
generation = await self._generation(context.name)
async def _run() -> list[ReasoningCapability]:
results = await asyncio.to_thread(_static_capabilities, pairs)
lm_indexes = [
i
for i, pair in enumerate(pairs)
if _provider_key(pair[0]) == "lmstudio"
]
ollama_indexes = [
i
for i, pair in enumerate(pairs)
if _provider_key(pair[0]) == "ollama-cloud"
]
copilot_indexes = [
i for i, pair in enumerate(pairs) if _provider_key(pair[0]) == "copilot"
]
codex_indexes = [
i
for i, pair in enumerate(pairs)
if _provider_key(pair[0]) == "openai-codex"
]
await asyncio.gather(
self._resolve_lmstudio(context, pairs, lm_indexes, results, generation),
self._resolve_ollama(
context, pairs, ollama_indexes, results, generation
),
self._resolve_copilot(
context, pairs, copilot_indexes, results, generation
),
self._resolve_codex(
context, pairs, codex_indexes, results, generation
),
)
return results
try:
resolved = await asyncio.wait_for(_run(), timeout=_REQUEST_TIMEOUT_SECONDS)
except asyncio.TimeoutError:
logger.info("Model capability resolution timed out for profile %s", profile)
resolved = await asyncio.to_thread(_static_capabilities, pairs)
return [
capability.wire(provider, model)
for (provider, model), capability in zip(pairs, resolved)
]
async def _resolve_lmstudio(
self,
context: _ProfileContext,
pairs: list[tuple[str, str]],
indexes: list[int],
results: list[ReasoningCapability],
generation: int,
) -> None:
if not indexes:
return
endpoint = _provider_base_url(context, "lmstudio")
secret = _profile_secret(context, "lmstudio")
pending: list[int] = []
for index in indexes:
key = (
context.name,
"lmstudio",
pairs[index][1].lower(),
endpoint.lower(),
_fingerprint(secret),
)
cached = await self._cached(key)
if cached is not None:
results[index] = cached
else:
pending.append(index)
if not pending:
return
server = endpoint[:-3] if endpoint.endswith("/v1") else endpoint
headers = {"Authorization": f"Bearer {secret}"} if secret else {}
try:
async with self._probe_semaphore:
timeout = aiohttp.ClientTimeout(total=_PROBE_TIMEOUT_SECONDS)
async with aiohttp.ClientSession(
timeout=timeout, headers=headers
) as session:
async with session.get(f"{server}/api/v1/models") as response:
if response.status != 200:
return
payload = await response.json(content_type=None)
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError):
return
items = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(items, list):
items = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(items, list):
return
options: dict[str, tuple[str, ...]] = {}
for item in items:
if not isinstance(item, dict):
continue
reasoning = _mapping(_mapping(item.get("capabilities")).get("reasoning"))
allowed = reasoning.get("allowed_options")
if not isinstance(allowed, list):
continue
efforts = _ordered_efforts(allowed)
for field in ("id", "key"):
identifier = str(item.get(field) or "").strip()
if identifier:
options[identifier.lower()] = efforts
for index in pending:
model = pairs[index][1]
if model.lower() not in options:
continue
capability = ReasoningCapability(
options[model.lower()], True, "provider-catalog"
)
results[index] = capability
await self._store(
(
context.name,
"lmstudio",
model.lower(),
endpoint.lower(),
_fingerprint(secret),
),
capability,
generation,
)
async def _resolve_ollama(
self,
context: _ProfileContext,
pairs: list[tuple[str, str]],
indexes: list[int],
results: list[ReasoningCapability],
generation: int,
) -> None:
endpoint = _provider_base_url(context, "ollama-cloud")
secret = _profile_secret(context, "ollama-cloud")
server = endpoint[:-3] if endpoint.endswith("/v1") else endpoint
async def _probe(index: int) -> None:
provider, model = pairs[index]
key = (
context.name,
"ollama-cloud",
model.lower(),
endpoint.lower(),
_fingerprint(secret),
)
cached = await self._cached(key)
if cached is not None:
results[index] = cached
return
async with self._probe_semaphore:
headers = {"Authorization": f"Bearer {secret}"} if secret else {}
try:
timeout = aiohttp.ClientTimeout(total=_PROBE_TIMEOUT_SECONDS)
async with aiohttp.ClientSession(
timeout=timeout, headers=headers
) as session:
async with session.post(
f"{server}/api/show",
json={"name": model.split(":cloud", 1)[0]},
) as response:
if response.status != 200:
return
payload = await response.json(content_type=None)
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError):
return
capabilities = (
payload.get("capabilities") if isinstance(payload, dict) else None
)
if not isinstance(capabilities, list):
return
efforts = (
("none", "low", "medium", "high", "max")
if "thinking" in capabilities
else ()
)
capability = ReasoningCapability(efforts, True, "provider-catalog")
results[index] = capability
await self._store(key, capability, generation)
await asyncio.gather(*(_probe(index) for index in indexes[:_MAX_OLLAMA_PROBES]))
async def _resolve_copilot(
self,
context: _ProfileContext,
pairs: list[tuple[str, str]],
indexes: list[int],
results: list[ReasoningCapability],
generation: int,
) -> None:
if not indexes:
return
raw_token = _profile_secret(context, "copilot")
if not raw_token:
return
account_scope = _fingerprint(raw_token)
endpoint = _provider_base_url(context, "copilot")
cache_endpoint = endpoint
pending: list[int] = []
for index in indexes:
key = (
context.name,
"copilot",
pairs[index][1].lower(),
endpoint.lower(),
account_scope,
)
cached = await self._cached(key)
if cached is not None:
results[index] = cached
else:
pending.append(index)
if not pending:
return
try:
async with self._probe_semaphore:
api_token, endpoint = await self._copilot_api_token(raw_token, endpoint)
timeout = aiohttp.ClientTimeout(total=_PROBE_TIMEOUT_SECONDS)
headers = {
"Authorization": f"Bearer {api_token}",
"Copilot-Integration-Id": "vscode-chat",
"Editor-Version": "vscode/1.104.1",
"User-Agent": "HermesRelay/1.0",
}
async with aiohttp.ClientSession(
timeout=timeout, headers=headers
) as session:
async with session.get(
f"{endpoint.rstrip('/')}/models"
) as response:
if response.status != 200:
return
payload = await response.json(content_type=None)
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError, KeyError):
return
items = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(items, list):
return
by_id = {
str(item.get("id") or "").strip().lower(): item
for item in items
if isinstance(item, dict) and str(item.get("id") or "").strip()
}
for index in pending:
model = pairs[index][1]
item = by_id.get(model.lower())
# A catalog is authoritative only for rows it actually returned.
# Missing IDs may be aliases, stale picker entries, or rollout
# differences; keep their static non-exact fallback untouched.
if item is None:
continue
efforts: tuple[str, ...] = ()
supports = _mapping(_mapping(item.get("capabilities")).get("supports"))
raw_efforts = supports.get("reasoning_effort")
if isinstance(raw_efforts, list):
efforts = _ordered_efforts(raw_efforts)
capability = ReasoningCapability(efforts, True, "github-catalog")
results[index] = capability
await self._store(
(
context.name,
"copilot",
model.lower(),
cache_endpoint.lower(),
account_scope,
),
capability,
generation,
)
async def _resolve_codex(
self,
context: _ProfileContext,
pairs: list[tuple[str, str]],
indexes: list[int],
results: list[ReasoningCapability],
generation: int,
) -> None:
if not indexes:
return
access_token = _profile_secret(context, "openai-codex")
if not access_token:
return
endpoint = _provider_base_url(context, "openai-codex")
account_scope = _fingerprint(access_token)
pending: list[int] = []
for index in indexes:
key = (
context.name,
"openai-codex",
pairs[index][1].lower(),
endpoint.lower(),
account_scope,
)
cached = await self._cached(key)
if cached is not None:
results[index] = cached
else:
pending.append(index)
if not pending:
return
headers = {"Authorization": f"Bearer {access_token}"}
account_id = _chatgpt_account_id(access_token)
if account_id:
headers["ChatGPT-Account-Id"] = account_id
try:
async with self._probe_semaphore:
timeout = aiohttp.ClientTimeout(total=_PROBE_TIMEOUT_SECONDS)
async with aiohttp.ClientSession(
timeout=timeout, headers=headers
) as session:
async with session.get(
f"{endpoint}/models?client_version=1.0.0"
) as response:
if response.status != 200:
return
payload = await response.json(content_type=None)
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError):
return
items = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(items, list):
return
by_id: dict[str, dict[str, Any]] = {}
for item in items:
if not isinstance(item, dict):
continue
identifier = _first_string(item.get("slug"), item.get("id")).lower()
if identifier:
by_id[identifier] = item
for index in pending:
model = pairs[index][1]
item = by_id.get(_model_key(model))
if item is None:
continue
raw_levels = item.get("supported_reasoning_levels")
if not isinstance(raw_levels, list):
continue
efforts = _ordered_efforts(
level.get("effort") if isinstance(level, dict) else level
for level in raw_levels
)
capability = ReasoningCapability(efforts, True, "provider-catalog")
results[index] = capability
await self._store(
(
context.name,
"openai-codex",
model.lower(),
endpoint.lower(),
account_scope,
),
capability,
generation,
)
async def _copilot_api_token(
self, raw_token: str, endpoint: str
) -> tuple[str, str]:
# Exchanged Copilot tokens are semicolon-delimited; raw GitHub tokens
# must be exchanged without invoking profile-global upstream state.
if ";" in raw_token and "=" in raw_token:
return raw_token, endpoint
timeout = aiohttp.ClientTimeout(total=_PROBE_TIMEOUT_SECONDS)
headers = {
"Authorization": f"token {raw_token}",
"Accept": "application/json",
"Editor-Version": "vscode/1.104.1",
"User-Agent": "GitHubCopilotChat/0.26.7",
}
async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session:
async with session.get(
"https://api.github.com/copilot_internal/v2/token"
) as response:
if response.status != 200:
raise ValueError("Copilot token exchange rejected")
payload = await response.json(content_type=None)
api_token = str(payload.get("token") or "").strip()
if not api_token:
raise ValueError("Copilot token exchange returned no token")
endpoints = _mapping(payload.get("endpoints"))
api_endpoint = _first_string(endpoints.get("api"), endpoint).rstrip("/")
return api_token, api_endpoint
+114
View File
@@ -61,6 +61,12 @@ from .channels.tui import TuiHandler
from .config import RelayConfig
from .image_activity import read_image_activity
from .media import MediaRegistrationError, MediaRegistry, validate_media_path
from .model_capabilities import (
CONTRACT_VERSION as MODEL_CAPABILITIES_CONTRACT_VERSION,
MAX_MODEL_PAIRS,
ModelCapabilityResolver,
SCHEMA_VERSION as MODEL_CAPABILITIES_SCHEMA_VERSION,
)
from .session_store import read_phone_threads
from .voice import VoiceHandler
from .voice_output import VoiceOutputHandler
@@ -107,6 +113,7 @@ class RelayServer:
self.voice_output = VoiceOutputHandler(config)
self.realtime_voice = RealtimeVoiceHandler(config)
self.realtime_agent = RealtimeAgentHandler(config)
self.model_capabilities = ModelCapabilityResolver(config)
# Channel handlers
self.chat = ChatHandler(webapi_url=config.webapi_url)
@@ -2188,6 +2195,7 @@ async def handle_relay_info(request: web.Request) -> web.Response:
"capabilities": [
"bridge",
"media",
"model_reasoning_capabilities_v1",
"notifications",
"profiles",
"proactive",
@@ -2209,6 +2217,111 @@ async def handle_relay_info(request: web.Request) -> web.Response:
)
async def handle_model_capabilities(request: web.Request) -> web.Response:
"""Resolve reasoning-effort capabilities for capped exact model pairs.
POST /relay/model-capabilities
{schema_version: 1, profile?, refresh?, models: [{provider, model}]}
Loopback callers may omit auth, matching ``GET /relay/info``. Remote
callers must present a valid paired-device bearer. Provider credentials
remain host-local and are never serialized.
"""
if request.remote in ("127.0.0.1", "::1") and not request.headers.get(
"Authorization"
):
server: RelayServer = request.app["server"]
else:
server, session = _require_bearer_session(request)
if session.channel_is_expired("chat"):
raise web.HTTPForbidden(text="chat grant required")
try:
payload = await request.json()
except (json.JSONDecodeError, ValueError, TypeError):
return web.json_response(
{"error": "invalid_json", "schema_version": MODEL_CAPABILITIES_SCHEMA_VERSION},
status=400,
)
if not isinstance(payload, dict):
return web.json_response(
{"error": "invalid_request", "schema_version": MODEL_CAPABILITIES_SCHEMA_VERSION},
status=400,
)
schema_version = payload.get("schema_version", MODEL_CAPABILITIES_SCHEMA_VERSION)
if schema_version != MODEL_CAPABILITIES_SCHEMA_VERSION:
return web.json_response(
{
"error": "unsupported_schema_version",
"schema_version": MODEL_CAPABILITIES_SCHEMA_VERSION,
},
status=400,
)
profile = str(payload.get("profile") or "default").strip() or "default"
if len(profile) > 128:
return web.json_response(
{"error": "invalid_profile", "schema_version": MODEL_CAPABILITIES_SCHEMA_VERSION},
status=400,
)
refresh = payload.get("refresh", False)
if not isinstance(refresh, bool):
return web.json_response(
{"error": "invalid_refresh", "schema_version": MODEL_CAPABILITIES_SCHEMA_VERSION},
status=400,
)
raw_models = payload.get("models")
if not isinstance(raw_models, list) or not 1 <= len(raw_models) <= MAX_MODEL_PAIRS:
return web.json_response(
{
"error": "invalid_models",
"schema_version": MODEL_CAPABILITIES_SCHEMA_VERSION,
"max_models": MAX_MODEL_PAIRS,
},
status=400,
)
pairs: list[tuple[str, str]] = []
for item in raw_models:
if not isinstance(item, dict):
pairs = []
break
provider = item.get("provider")
model = item.get("model")
if not isinstance(provider, str) or not isinstance(model, str):
pairs = []
break
provider = provider.strip()
model = model.strip()
if not provider or not model or len(provider) > 128 or len(model) > 512:
pairs = []
break
pairs.append((provider, model))
if len(pairs) != len(raw_models):
return web.json_response(
{"error": "invalid_model_pair", "schema_version": MODEL_CAPABILITIES_SCHEMA_VERSION},
status=400,
)
try:
capabilities = await server.model_capabilities.resolve_many(
pairs, profile=profile, refresh=refresh
)
except KeyError:
return web.json_response(
{
"error": "profile_not_found",
"profile": profile,
"schema_version": MODEL_CAPABILITIES_SCHEMA_VERSION,
},
status=404,
)
return web.json_response(
{
"schema_version": MODEL_CAPABILITIES_SCHEMA_VERSION,
"contract_version": MODEL_CAPABILITIES_CONTRACT_VERSION,
"capabilities": capabilities,
}
)
def _relay_security_payload(server: RelayServer) -> dict[str, Any]:
return {
"ok": True,
@@ -4420,6 +4533,7 @@ def create_app(config: RelayConfig) -> web.Application:
app.router.add_get("/bridge/activity", handle_bridge_activity)
app.router.add_get("/media/inspect", handle_media_inspect)
app.router.add_get("/relay/info", handle_relay_info)
app.router.add_post("/relay/model-capabilities", handle_model_capabilities)
app.router.add_get("/relay/security", handle_relay_security_get)
app.router.add_patch("/relay/security", handle_relay_security_patch)
+514
View File
@@ -0,0 +1,514 @@
"""Focused contract and isolation tests for model capability resolution."""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import tempfile
import unittest
from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase, TestClient, TestServer
from plugin.relay.config import RelayConfig
from plugin.relay.model_capabilities import (
MAX_MODEL_PAIRS,
ModelCapabilityResolver,
ReasoningCapability,
)
from plugin.relay.server import create_app, handle_model_capabilities
class ModelCapabilitiesRouteTests(AioHTTPTestCase):
async def get_application(self) -> web.Application:
self._temp = tempfile.TemporaryDirectory()
home = Path(self._temp.name)
(home / "config.yaml").write_text("model: {}\n", encoding="utf-8")
return create_app(RelayConfig(hermes_config_path=str(home / "config.yaml")))
async def asyncTearDown(self) -> None:
await super().asyncTearDown()
self._temp.cleanup()
async def test_static_contract_and_info_advertisement(self) -> None:
info = await self.client.get("/relay/info")
self.assertEqual(info.status, 200)
info_body = await info.json()
self.assertIn("model_reasoning_capabilities_v1", info_body["capabilities"])
response = await self.client.post(
"/relay/model-capabilities",
json={
"schema_version": 1,
"models": [
{"provider": "zai", "model": "glm-5.2"},
{"provider": "unknown", "model": "future-model"},
],
},
)
self.assertEqual(response.status, 200)
body = await response.json()
self.assertEqual(body["schema_version"], 1)
self.assertEqual(body["contract_version"], "1.0")
self.assertEqual(
body["capabilities"][0],
{
"provider": "zai",
"model": "glm-5.2",
"reasoning": True,
"reasoning_efforts": ["none", "high", "max"],
"reasoning_efforts_exact": True,
"source": "provider-adapter",
},
)
self.assertFalse(body["capabilities"][1]["reasoning_efforts_exact"])
self.assertNotIn("scope", json.dumps(body).lower())
self.assertNotIn("token", json.dumps(body).lower())
async def test_validation_caps_pairs_and_rejects_unknown_profile(self) -> None:
too_many = [
{"provider": "zai", "model": f"model-{index}"}
for index in range(MAX_MODEL_PAIRS + 1)
]
response = await self.client.post(
"/relay/model-capabilities",
json={"schema_version": 1, "models": too_many},
)
self.assertEqual(response.status, 400)
self.assertEqual((await response.json())["max_models"], MAX_MODEL_PAIRS)
missing = await self.client.post(
"/relay/model-capabilities",
json={
"schema_version": 1,
"profile": "missing",
"models": [{"provider": "zai", "model": "glm-5.2"}],
},
)
self.assertEqual(missing.status, 404)
self.assertEqual((await missing.json())["error"], "profile_not_found")
class ModelCapabilitiesAuthTests(unittest.IsolatedAsyncioTestCase):
async def test_remote_route_requires_and_accepts_paired_bearer(self) -> None:
with tempfile.TemporaryDirectory() as directory:
home = Path(directory)
(home / "config.yaml").write_text("model: {}\n", encoding="utf-8")
app = create_app(RelayConfig(hermes_config_path=str(home / "config.yaml")))
class _Req:
remote = "10.2.3.4"
def __init__(self, headers: dict[str, str]) -> None:
self.app = app
self.headers = headers
async def json(self):
return {
"schema_version": 1,
"models": [{"provider": "zai", "model": "glm-5.2"}],
}
with self.assertRaises(web.HTTPUnauthorized):
await handle_model_capabilities(_Req({})) # type: ignore[arg-type]
session = app["server"].sessions.create_session("phone", "phone-id")
response = await handle_model_capabilities( # type: ignore[arg-type]
_Req({"Authorization": f"Bearer {session.token}"})
)
self.assertEqual(response.status, 200)
session.grants["chat"] = 1.0
with self.assertRaises(web.HTTPForbidden):
await handle_model_capabilities( # type: ignore[arg-type]
_Req({"Authorization": f"Bearer {session.token}"})
)
class DynamicCapabilityResolverTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self._temp = tempfile.TemporaryDirectory()
self.home = Path(self._temp.name)
(self.home / "config.yaml").write_text("model: {}\n", encoding="utf-8")
self.profiles = self.home / "profiles"
self.profiles.mkdir()
async def asyncTearDown(self) -> None:
self._temp.cleanup()
async def _start_provider(self, app: web.Application) -> tuple[TestClient, str]:
server = TestServer(app)
client = TestClient(server)
await client.start_server()
return client, str(client.make_url("/")).rstrip("/")
def _profile(self, name: str, env: str) -> None:
home = self.profiles / name
home.mkdir()
(home / "config.yaml").write_text("model: {}\n", encoding="utf-8")
(home / ".env").write_text(env, encoding="utf-8")
async def test_lmstudio_and_ollama_live_metadata_are_exact(self) -> None:
async def lm_models(_request: web.Request) -> web.Response:
return web.json_response(
{
"models": [
{
"id": "local-model",
"capabilities": {
"reasoning": {"allowed_options": ["off", "low", "high"]}
},
}
]
}
)
active = 0
peak = 0
calls = 0
async def ollama_show(request: web.Request) -> web.Response:
nonlocal active, peak, calls
calls += 1
active += 1
peak = max(peak, active)
try:
await __import__("asyncio").sleep(0.01)
body = await request.json()
capabilities = (
["completion", "thinking"]
if body["name"].endswith("0")
else ["completion"]
)
return web.json_response({"capabilities": capabilities})
finally:
active -= 1
provider_app = web.Application()
provider_app.router.add_get("/api/v1/models", lm_models)
provider_app.router.add_post("/api/show", ollama_show)
client, base = await self._start_provider(provider_app)
self.addAsyncCleanup(client.close)
self._profile("dynamic", f"LM_BASE_URL={base}/v1\nOLLAMA_BASE_URL={base}/v1\n")
resolver = ModelCapabilityResolver(
RelayConfig(hermes_config_path=str(self.home / "config.yaml"))
)
pairs = [("lmstudio", "local-model")] + [
("ollama-cloud", f"ollama-{index}") for index in range(20)
]
rows = await resolver.resolve_many(pairs, profile="dynamic")
self.assertEqual(rows[0]["reasoning_efforts"], ["none", "low", "high"])
self.assertTrue(rows[0]["reasoning_efforts_exact"])
self.assertEqual(
rows[1]["reasoning_efforts"], ["none", "low", "medium", "high", "max"]
)
self.assertEqual(rows[2]["reasoning_efforts"], [])
self.assertTrue(rows[2]["reasoning_efforts_exact"])
self.assertEqual(calls, 16)
self.assertLessEqual(peak, 4)
self.assertFalse(rows[-1]["reasoning_efforts_exact"])
async def test_codex_catalog_reasoning_levels_are_exact(self) -> None:
async def models(request: web.Request) -> web.Response:
self.assertEqual(request.headers.get("Authorization"), "Bearer codex-token")
return web.json_response(
{
"models": [
{
"slug": "gpt-5.6-sol",
"supported_reasoning_levels": [
{"effort": "low"},
{"effort": "medium"},
{"effort": "high"},
{"effort": "xhigh"},
{"effort": "max"},
{"effort": "ultra"},
],
}
]
}
)
provider_app = web.Application()
provider_app.router.add_get("/models", models)
client, base = await self._start_provider(provider_app)
self.addAsyncCleanup(client.close)
home = self.profiles / "codex"
home.mkdir()
(home / "config.yaml").write_text("model: {}\n", encoding="utf-8")
(home / ".env").write_text(
f"OPENAI_CODEX_BASE_URL={base}\n", encoding="utf-8"
)
(home / "auth.json").write_text(
json.dumps(
{
"credential_pool": {
"openai-codex": [{"access_token": "codex-token"}]
}
}
),
encoding="utf-8",
)
resolver = ModelCapabilityResolver(
RelayConfig(hermes_config_path=str(self.home / "config.yaml"))
)
rows = await resolver.resolve_many(
[
("openai-codex", "gpt-5.6-sol"),
("openai-codex", "future-model"),
],
profile="codex",
)
self.assertEqual(
rows[0]["reasoning_efforts"],
["low", "medium", "high", "xhigh", "max", "ultra"],
)
self.assertTrue(rows[0]["reasoning_efforts_exact"])
self.assertEqual(rows[0]["source"], "provider-catalog")
self.assertFalse(rows[1]["reasoning_efforts_exact"])
async def test_copilot_catalog_cache_is_profile_and_account_isolated(self) -> None:
calls = {"account-a=1;kind=api": 0, "account-b=1;kind=api": 0}
async def models(request: web.Request) -> web.Response:
token = request.headers["Authorization"].removeprefix("Bearer ")
calls[token] += 1
efforts = ["low"] if "account-a" in token else ["xhigh"]
return web.json_response(
{
"data": [
{
"id": "gpt-5.5",
"capabilities": {"supports": {"reasoning_effort": efforts}},
}
]
}
)
provider_app = web.Application()
provider_app.router.add_get("/models", models)
client, base = await self._start_provider(provider_app)
self.addAsyncCleanup(client.close)
self._profile(
"account-a",
f"COPILOT_GITHUB_TOKEN=account-a=1;kind=api\nCOPILOT_BASE_URL={base}\n",
)
self._profile(
"account-b",
f"COPILOT_GITHUB_TOKEN=account-b=1;kind=api\nCOPILOT_BASE_URL={base}\n",
)
resolver = ModelCapabilityResolver(
RelayConfig(hermes_config_path=str(self.home / "config.yaml"))
)
pairs = [("copilot", "gpt-5.5")]
a_first = await resolver.resolve_many(pairs, profile="account-a")
b_first = await resolver.resolve_many(pairs, profile="account-b")
a_cached = await resolver.resolve_many(pairs, profile="account-a")
self.assertEqual(a_first[0]["reasoning_efforts"], ["low"])
self.assertEqual(b_first[0]["reasoning_efforts"], ["xhigh"])
self.assertEqual(a_cached, a_first)
self.assertEqual(
calls,
{
"account-a=1;kind=api": 1,
"account-b=1;kind=api": 1,
},
)
serialized = json.dumps(a_first + b_first)
self.assertNotIn("account-a", serialized)
self.assertNotIn("account-b", serialized)
await resolver.resolve_many(pairs, profile="account-a", refresh=True)
self.assertEqual(calls["account-a=1;kind=api"], 2)
await resolver.resolve_many(pairs, profile="account-b")
self.assertEqual(calls["account-b=1;kind=api"], 1)
async def test_unavailable_copilot_pool_entry_fails_to_non_exact(self) -> None:
home = self.profiles / "unavailable"
home.mkdir()
(home / "config.yaml").write_text("model: {}\n", encoding="utf-8")
(home / "auth.json").write_text(
json.dumps(
{
"credential_pool": {
"copilot": [
{
"access_token": "dead-account=1;kind=api",
"last_status": "dead",
}
]
}
}
),
encoding="utf-8",
)
resolver = ModelCapabilityResolver(
RelayConfig(hermes_config_path=str(self.home / "config.yaml"))
)
rows = await resolver.resolve_many(
[("copilot", "gpt-5.5")], profile="unavailable"
)
self.assertFalse(rows[0]["reasoning_efforts_exact"])
self.assertEqual(rows[0]["source"], "canonical-fallback")
async def test_copilot_catalog_missing_model_keeps_non_exact_fallback(self) -> None:
async def models(_request: web.Request) -> web.Response:
return web.json_response(
{
"data": [
{
"id": "different-model",
"capabilities": {"supports": {"reasoning_effort": []}},
}
]
}
)
provider_app = web.Application()
provider_app.router.add_get("/models", models)
client, base = await self._start_provider(provider_app)
self.addAsyncCleanup(client.close)
self._profile(
"missing-row",
f"COPILOT_GITHUB_TOKEN=missing=1;kind=api\nCOPILOT_BASE_URL={base}\n",
)
resolver = ModelCapabilityResolver(
RelayConfig(hermes_config_path=str(self.home / "config.yaml"))
)
rows = await resolver.resolve_many(
[
("copilot", "requested-model"),
("copilot", "different-model"),
],
profile="missing-row",
)
self.assertTrue(rows[0]["reasoning"])
self.assertFalse(rows[0]["reasoning_efforts_exact"])
self.assertEqual(rows[0]["source"], "canonical-fallback")
self.assertFalse(rows[1]["reasoning"])
self.assertTrue(rows[1]["reasoning_efforts_exact"])
self.assertEqual(rows[1]["source"], "github-catalog")
async def test_outbound_probe_limit_is_global_across_concurrent_refreshes(
self,
) -> None:
active = 0
peak = 0
async def _enter() -> None:
nonlocal active, peak
active += 1
peak = max(peak, active)
await asyncio.sleep(0.03)
def _leave() -> None:
nonlocal active
active -= 1
async def lm_models(_request: web.Request) -> web.Response:
await _enter()
try:
return web.json_response(
{
"models": [
{
"id": "local-model",
"capabilities": {
"reasoning": {"allowed_options": ["low"]}
},
}
]
}
)
finally:
_leave()
async def ollama_show(_request: web.Request) -> web.Response:
await _enter()
try:
return web.json_response({"capabilities": ["completion", "thinking"]})
finally:
_leave()
async def copilot_models(_request: web.Request) -> web.Response:
await _enter()
try:
return web.json_response(
{
"data": [
{
"id": "gpt-5.5",
"capabilities": {
"supports": {"reasoning_effort": ["high"]}
},
}
]
}
)
finally:
_leave()
provider_app = web.Application()
provider_app.router.add_get("/api/v1/models", lm_models)
provider_app.router.add_post("/api/show", ollama_show)
provider_app.router.add_get("/models", copilot_models)
client, base = await self._start_provider(provider_app)
self.addAsyncCleanup(client.close)
self._profile(
"burst",
(
f"LM_BASE_URL={base}/v1\n"
f"OLLAMA_BASE_URL={base}/v1\n"
f"COPILOT_BASE_URL={base}\n"
"COPILOT_GITHUB_TOKEN=burst=1;kind=api\n"
),
)
resolver = ModelCapabilityResolver(
RelayConfig(hermes_config_path=str(self.home / "config.yaml"))
)
pairs = [
("lmstudio", "local-model"),
("copilot", "gpt-5.5"),
*(("ollama-cloud", f"ollama-{index}") for index in range(4)),
]
await asyncio.gather(
*(
resolver.resolve_many(pairs, profile="burst", refresh=True)
for _ in range(6)
)
)
self.assertGreater(peak, 1)
self.assertLessEqual(peak, 4)
async def test_pre_refresh_generation_cannot_repopulate_cache(self) -> None:
resolver = ModelCapabilityResolver(
RelayConfig(hermes_config_path=str(self.home / "config.yaml"))
)
profile = "generation-profile"
key = (profile, "lmstudio", "model", "http://endpoint", "account")
capability = ReasoningCapability(("high",), True, "provider-catalog")
old_generation = await resolver._generation(profile)
new_generation = await resolver._clear(profile)
stale_stored = await resolver._store(key, capability, old_generation)
self.assertFalse(stale_stored)
self.assertIsNone(await resolver._cached(key))
self.assertTrue(await resolver._store(key, capability, new_generation))
self.assertEqual(await resolver._cached(key), capability)
if __name__ == "__main__":
unittest.main()
+13 -31
View File
@@ -234,49 +234,31 @@ class SessionsRoutesTests(AioHTTPTestCase):
)
self.assertEqual(resp.status, 400)
async def test_extend_ttl_only_restarts_clock(self) -> None:
"""ttl_seconds=N sets expires_at to now+N, not old_expiry+N."""
import time as _time
async def test_extend_ttl_rejects_lifetime_expansion(self) -> None:
"""A bearer cannot extend its own operator-approved lifetime."""
token = await self._mint("dev-a", ttl_seconds=3600) # 1h session
old = self._server().sessions.get_session(token)
old_expiry = old.expires_at
# Wait a hair to make sure the new expiry is measurably different
# from the old one, even on fast clocks.
await self._settle()
resp = await self.client.patch(
f"/sessions/{token[:8]}",
json={"ttl_seconds": 7 * 86400}, # 7 days
headers={"Authorization": f"Bearer {token}"},
)
self.assertEqual(resp.status, 200)
body = await resp.json()
new_expiry = body["expires_at"]
self.assertIsNotNone(new_expiry)
# New expiry should be roughly now + 7d, not old_expiry + 7d
now = _time.time()
self.assertAlmostEqual(new_expiry, now + 7 * 86400, delta=5)
# And the persisted session matches
self.assertEqual(resp.status, 403)
updated = self._server().sessions.get_session(token)
self.assertAlmostEqual(updated.expires_at, new_expiry, delta=0.01)
# And it's definitely different from the old expiry
self.assertNotAlmostEqual(updated.expires_at, old_expiry, delta=60)
self.assertAlmostEqual(updated.expires_at, old_expiry, delta=0.01)
async def test_extend_ttl_zero_means_never(self) -> None:
async def test_extend_ttl_rejects_never_expire_escalation(self) -> None:
token = await self._mint("dev-a", ttl_seconds=3600)
old = self._server().sessions.get_session(token)
resp = await self.client.patch(
f"/sessions/{token[:8]}",
json={"ttl_seconds": 0},
headers={"Authorization": f"Bearer {token}"},
)
self.assertEqual(resp.status, 200)
body = await resp.json()
self.assertIsNone(body["expires_at"]) # null = never
# All grants should also be None (never) since session is infinite
for channel in ("chat", "terminal", "bridge"):
self.assertIsNone(body["grants"][channel])
self.assertEqual(resp.status, 403)
updated = self._server().sessions.get_session(token)
self.assertAlmostEqual(updated.expires_at, old.expires_at, delta=0.01)
async def test_extend_grants_only(self) -> None:
token = await self._mint("dev-a", ttl_seconds=30 * 86400) # 30d
@@ -317,16 +299,16 @@ class SessionsRoutesTests(AioHTTPTestCase):
self.assertIsNotNone(grant_expiry, f"{channel} should not be null")
self.assertLessEqual(grant_expiry, new_session_expiry + 0.001)
async def test_extend_self(self) -> None:
"""Caller can extend their own session."""
async def test_extend_self_rejects_policy_expansion(self) -> None:
"""Session identity does not grant session-management authority."""
token = await self._mint("dev-a", ttl_seconds=3600)
resp = await self.client.patch(
f"/sessions/{token[:8]}",
json={"ttl_seconds": 2 * 86400},
headers={"Authorization": f"Bearer {token}"},
)
self.assertEqual(resp.status, 200)
# Caller's session still valid after the extend
self.assertEqual(resp.status, 403)
# The rejected expansion does not revoke the caller.
self.assertIsNotNone(self._server().sessions.get_session(token))
async def _settle(self) -> None:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hermes-relay"
version = "1.5.1"
version = "1.6.0"
description = "Hermes-Relay plugin — Android device control toolset, QR pairing CLI, and WSS relay server for hermes-agent"
requires-python = ">=3.11"
dependencies = [
+454
View File
@@ -0,0 +1,454 @@
#!/usr/bin/env python3
"""Record the Google Play foreground-service demonstration over ADB.
The driver uses the Android accessibility hierarchy for app controls and only
falls back to system status-bar commands for opening and closing notifications.
It intentionally records real foreground services; it does not mock notification
content or invoke service components directly.
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
DEFAULT_PACKAGE = "com.axiomlabs.hermesrelay.sideload"
REMOTE_UI = "/sdcard/hermes-fgs-demo.xml"
REMOTE_VIDEO = "/sdcard/hermes-fgs-demo.mp4"
@dataclass(frozen=True)
class UiNode:
text: str
description: str
bounds: tuple[int, int, int, int]
checkable: bool
checked: bool
@property
def center(self) -> tuple[int, int]:
left, top, right, bottom = self.bounds
return ((left + right) // 2, (top + bottom) // 2)
class DemoError(RuntimeError):
pass
class AdbDemo:
def __init__(self, serial: str, package: str, pause_scale: float) -> None:
self.serial = serial
self.package = package
self.pause_scale = pause_scale
def adb(
self,
*args: str,
check: bool = True,
timeout: float = 30,
) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
["adb", "-s", self.serial, *args],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
check=False,
)
if check and result.returncode != 0:
detail = (result.stderr or result.stdout).strip()
raise DemoError(f"adb {' '.join(args)} failed: {detail}")
return result
def pause(self, seconds: float) -> None:
time.sleep(seconds * self.pause_scale)
def launch(self) -> None:
self.adb("shell", "cmd", "statusbar", "collapse")
self.pause(0.5)
result = self.adb(
"shell",
"monkey",
"-p",
self.package,
"-c",
"android.intent.category.LAUNCHER",
"1",
)
if "Events injected: 1" not in result.stdout:
raise DemoError(f"Could not launch {self.package}: {result.stdout.strip()}")
self.pause(2)
@staticmethod
def parse_bounds(raw: str) -> tuple[int, int, int, int]:
match = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", raw)
if not match:
raise DemoError(f"Unexpected UI bounds: {raw!r}")
return tuple(int(value) for value in match.groups()) # type: ignore[return-value]
def dump(self) -> list[UiNode]:
self.adb("shell", "uiautomator", "dump", REMOTE_UI)
xml = self.adb("exec-out", "cat", REMOTE_UI).stdout
try:
root = ET.fromstring(xml)
except ET.ParseError as error:
raise DemoError("Could not parse the Android UI hierarchy") from error
return [
UiNode(
text=node.attrib.get("text", ""),
description=node.attrib.get("content-desc", ""),
bounds=self.parse_bounds(node.attrib["bounds"]),
checkable=node.attrib.get("checkable") == "true",
checked=node.attrib.get("checked") == "true",
)
for node in root.iter("node")
if "bounds" in node.attrib
]
@staticmethod
def find(
nodes: list[UiNode],
*,
text: str | None = None,
description: str | None = None,
) -> UiNode | None:
matches = [
node
for node in nodes
if (text is None or node.text == text)
and (description is None or node.description == description)
]
if len(matches) > 1:
raise DemoError(f"Ambiguous UI selector text={text!r} description={description!r}")
return matches[0] if matches else None
def require(
self,
nodes: list[UiNode],
*,
text: str | None = None,
description: str | None = None,
) -> UiNode:
node = self.find(nodes, text=text, description=description)
if node is None:
raise DemoError(f"Missing UI selector text={text!r} description={description!r}")
return node
def tap(self, node: UiNode) -> None:
x, y = node.center
self.adb("shell", "input", "tap", str(x), str(y))
def tap_selector(
self,
*,
text: str | None = None,
description: str | None = None,
settle: float = 0.8,
) -> None:
self.tap(self.require(self.dump(), text=text, description=description))
self.pause(settle)
def back(self) -> None:
self.adb("shell", "input", "keyevent", "4")
self.pause(0.8)
def home(self) -> None:
self.adb("shell", "input", "keyevent", "3")
self.pause(1.2)
def expand_notifications(self) -> list[UiNode]:
self.adb("shell", "cmd", "statusbar", "expand-notifications")
self.pause(1.5)
return self.dump()
def collapse_notifications(self) -> None:
self.adb("shell", "cmd", "statusbar", "collapse")
self.pause(0.8)
def persistent_toggle(self, nodes: list[UiNode]) -> UiNode:
label = self.require(nodes, text="Persistent connection")
left, top, right, bottom = label.bounds
candidates = [
node
for node in nodes
if node.checkable
and node.bounds[0] > right
and node.bounds[1] < bottom + 100
and node.bounds[3] > top - 100
]
if len(candidates) != 1:
raise DemoError(f"Expected one Persistent connection switch; found {len(candidates)}")
return candidates[0]
def open_settings(self) -> list[UiNode]:
self.tap_selector(description="Settings")
nodes = self.dump()
self.require(nodes, text="Persistent connection")
return nodes
def set_persistent(self, enabled: bool) -> None:
nodes = self.dump()
toggle = self.persistent_toggle(nodes)
if toggle.checked != enabled:
self.tap(toggle)
self.pause(1.5)
current = self.persistent_toggle(self.dump())
if current.checked != enabled:
raise DemoError(f"Persistent connection did not become {enabled}")
def ensure_chat(self) -> None:
nodes = self.dump()
if self.find(nodes, description="Start voice conversation") is not None:
return
back = self.find(nodes, description="Back")
if back is not None:
self.tap(back)
self.pause(1)
nodes = self.dump()
self.require(nodes, description="Start voice conversation")
def send_message(self, message: str) -> None:
if not re.fullmatch(r"[A-Za-z0-9 ]+", message):
raise DemoError("Demo message must contain only ASCII letters, digits, and spaces")
nodes = self.dump()
composer = self.find(nodes, description="Message…") or self.find(nodes, text="Message…")
if composer is None:
raise DemoError("Missing chat composer labeled Message…")
self.tap(composer)
self.pause(0.5)
encoded = message.replace(" ", "%s")
self.adb("shell", "input", "text", encoded)
self.pause(0.5)
self.tap_selector(description="Send message", settle=0.4)
def notification_posted(self, title: str) -> bool:
notifications = self.adb(
"shell",
"dumpsys",
"notification",
"--noredact",
).stdout
return title in notifications
def wait_for_notification_state(
self,
title: str,
*,
posted: bool,
timeout: float = 30,
) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self.notification_posted(title) is posted:
return
self.pause(1)
state = "appear" if posted else "clear"
raise DemoError(f"Timed out waiting for notification to {state}: {title}")
def wait_absent_from_shade(self, title: str, timeout: float = 30) -> list[UiNode]:
self.wait_for_notification_state(title, posted=False, timeout=timeout)
# Open the shade exactly once for the recorded visual proof. The service
# state assertion above avoids an open/close polling loop and prevents a
# grouped notification from producing a false absence result.
return self.expand_notifications()
def wait_for_notification(self, title: str, timeout: float = 30) -> tuple[list[UiNode], UiNode]:
# First wait against Android's notification registry without touching
# the UI, then open the shade once and reveal any Samsung app grouping.
self.wait_for_notification_state(title, posted=True, timeout=timeout)
nodes = self.expand_notifications()
deadline = time.monotonic() + timeout
expanded_groups: set[tuple[str, tuple[int, int, int, int]]] = set()
while time.monotonic() < deadline:
visible = self.find(nodes, text=title)
if visible is not None:
return nodes, visible
group_counts = [
node
for node in nodes
if (node.text.isdigit() or node.description.isdigit())
and node.center[1] > 550
]
untried = [
node
for node in group_counts
if (node.description, node.bounds) not in expanded_groups
]
if untried:
group = min(untried, key=lambda node: node.center[1])
expanded_groups.add((group.description, group.bounds))
self.tap(group)
self.pause(0.6)
else:
self.pause(1)
nodes = self.dump()
raise DemoError(f"Timed out revealing notification in the open shade: {title}")
def expose_notification_action(self, title: str, action: str) -> list[UiNode]:
nodes = self.dump()
for _ in range(3):
if self.find(nodes, text=action) is not None:
return nodes
anchor = self.require(nodes, text=title)
_, anchor_y = anchor.center
candidates = [
node
for node in nodes
if node.description == "Expand"
or node.description.isdigit()
or node.text.isdigit()
]
if not candidates:
raise DemoError(f"No expansion affordance found for {title}")
nearest = min(candidates, key=lambda node: abs(node.center[1] - anchor_y))
self.tap(nearest)
self.pause(0.6)
nodes = self.dump()
raise DemoError(f"Could not expose notification action: {action}")
def start_recording(self) -> str:
self.adb("shell", "rm", "-f", REMOTE_VIDEO)
command = (
"screenrecord --bit-rate 8000000 --time-limit 180 "
f"{REMOTE_VIDEO} >/dev/null 2>&1 & echo $!"
)
pid = self.adb("shell", command).stdout.strip()
if not pid.isdigit():
raise DemoError(f"Could not start screenrecord: {pid!r}")
self.pause(1)
return pid
def stop_recording(self, pid: str, output: Path) -> None:
self.adb("shell", "kill", "-2", pid, check=False)
self.pause(2)
output.parent.mkdir(parents=True, exist_ok=True)
self.adb("pull", REMOTE_VIDEO, str(output), timeout=120)
if not output.exists() or output.stat().st_size == 0:
raise DemoError("ADB screen recording was not pulled successfully")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--serial", required=True, help="Explicit ADB device serial")
parser.add_argument("--package", default=DEFAULT_PACKAGE)
parser.add_argument(
"--output",
type=Path,
default=Path("artifacts/android-fgs-demo.mp4"),
)
parser.add_argument(
"--message",
default="Confirm background connection in five words",
)
parser.add_argument(
"--pause-scale",
type=float,
default=1.0,
help="Scale visual pauses; values below 1 are useful only for dry runs",
)
parser.add_argument(
"--no-record",
action="store_true",
help="Exercise and assert the sequence without creating an MP4",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
demo = AdbDemo(args.serial, args.package, args.pause_scale)
recording_pid: str | None = None
try:
if not re.fullmatch(r"[A-Za-z0-9 ]+", args.message):
raise DemoError("Demo message must contain only ASCII letters, digits, and spaces")
if demo.adb("get-state").stdout.strip() != "device":
raise DemoError(f"ADB device is not ready: {args.serial}")
installed = demo.adb("shell", "pm", "path", args.package).stdout.strip()
if not installed.startswith("package:"):
raise DemoError(f"Package is not installed for the active user: {args.package}")
demo.launch()
demo.ensure_chat()
settings = demo.open_settings()
if demo.persistent_toggle(settings).checked:
demo.set_persistent(False)
demo.ensure_chat()
if not args.no_record:
recording_pid = demo.start_recording()
demo.pause(3)
print("1/6 Enable Persistent connection")
demo.open_settings()
demo.set_persistent(True)
demo.pause(2)
print("2/6 Show the ongoing connection notification")
nodes = demo.expand_notifications()
demo.require(nodes, text="Hermes connection active")
demo.pause(3)
demo.collapse_notifications()
demo.ensure_chat()
print("3/6 Send a chat turn, background Hermes, and reopen it from the notification")
demo.send_message(args.message)
demo.home()
demo.pause(10)
nodes, connection = demo.wait_for_notification("Hermes connection active")
demo.pause(3)
demo.tap(connection)
demo.pause(2)
print("4/6 Disable Persistent connection and prove the notification clears")
demo.open_settings()
demo.set_persistent(False)
demo.pause(2)
demo.wait_absent_from_shade("Hermes connection active")
demo.pause(3)
demo.collapse_notifications()
demo.ensure_chat()
print("5/6 Start the app-owned voice overlay and show its microphone notification")
demo.tap_selector(description="Start voice conversation", settle=1)
demo.tap_selector(description="Expand voice controls", settle=0.7)
demo.tap_selector(text="Overlay", settle=1.5)
demo.home()
demo.pause(3)
nodes, _ = demo.wait_for_notification("Hermes voice overlay active")
nodes = demo.expose_notification_action("Hermes voice overlay active", "Stop voice")
demo.pause(3)
print("6/6 Stop voice from the notification and prove it clears")
demo.tap(demo.require(nodes, text="Stop voice"))
demo.pause(2)
nodes = demo.dump()
if demo.find(nodes, text="Hermes voice overlay active") is not None:
raise DemoError("Voice overlay notification remained after Stop voice")
demo.pause(3)
demo.collapse_notifications()
if recording_pid is not None:
demo.stop_recording(recording_pid, args.output.resolve())
recording_pid = None
print(f"Recorded: {args.output.resolve()}")
else:
print("Dry run passed; no recording created")
return 0
except (DemoError, subprocess.TimeoutExpired) as error:
print(f"ERROR: {error}", file=sys.stderr)
return 1
finally:
if recording_pid is not None:
demo.stop_recording(recording_pid, args.output.resolve())
if __name__ == "__main__":
raise SystemExit(main())
-1
View File
@@ -88,7 +88,6 @@ def main() -> int:
str(wrapper),
"--console=plain",
"--configuration-cache",
"-Dorg.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8",
*tasks,
]
if not args.skip_tests:
+48 -18
View File
@@ -10,6 +10,9 @@ if "%1"=="release" goto release
if "%1"=="bundle" goto bundle
if "%1"=="install" goto install
if "%1"=="run" goto run
if "%1"=="compile" goto compile
if "%1"=="test-one" goto testone
if "%1"=="install-fast" goto installfast
if "%1"=="test" goto test
if "%1"=="lint" goto lint
if "%1"=="prepush" goto prepush
@@ -23,9 +26,9 @@ if "%1"=="help" goto help
goto help
:build
echo Building debug APK...
call gradlew.bat assembleDebug
echo APK: app\build\outputs\apk\debug\app-debug.apk
echo Building sideload debug APK...
call gradlew.bat :app:assembleSideloadDebug --console=plain
echo APK: app\build\outputs\apk\sideload\debug\
goto end
:release
@@ -49,33 +52,57 @@ echo Location: app\build\outputs\bundle\release\
goto end
:install
echo Building and installing to connected device...
call gradlew.bat installDebug
echo Building and installing sideload debug to connected device...
call gradlew.bat :app:installSideloadDebug --console=plain
if errorlevel 1 goto end
echo Launching app...
REM Explicit FQCN: applicationId is com.axiomlabs.hermesrelay but the
REM Explicit FQCN: the sideload applicationId includes the flavor suffix but the
REM namespace (and thus the real class FQCN) is still com.hermesandroid.relay,
REM so the `.MainActivity` shorthand no longer resolves correctly.
adb shell am start -n com.axiomlabs.hermesrelay/com.hermesandroid.relay.MainActivity
adb shell am start -n com.axiomlabs.hermesrelay.sideload/com.hermesandroid.relay.MainActivity
goto end
:run
echo Building, installing, and launching...
call gradlew.bat installDebug
REM Explicit FQCN: applicationId is com.axiomlabs.hermesrelay but the
echo Building, installing, and launching sideload debug...
call gradlew.bat :app:installSideloadDebug --console=plain
if errorlevel 1 goto end
REM Explicit FQCN: the sideload applicationId includes the flavor suffix but the
REM namespace (and thus the real class FQCN) is still com.hermesandroid.relay,
REM so the `.MainActivity` shorthand no longer resolves correctly.
adb shell am start -n com.axiomlabs.hermesrelay/com.hermesandroid.relay.MainActivity
adb shell am start -n com.axiomlabs.hermesrelay.sideload/com.hermesandroid.relay.MainActivity
adb logcat -s HermesRelay:* --format=brief
goto end
:compile
echo Compiling sideload debug Kotlin...
call gradlew.bat :app:compileSideloadDebugKotlin --console=plain
goto end
:testone
if "%~2"=="" (
echo Usage: scripts\dev.bat test-one ^<fully-qualified-test-class-or-pattern^>
exit /b 2
)
echo Running focused sideload test: %~2
call gradlew.bat :app:testSideloadDebugUnitTest --tests "%~2" --console=plain
goto end
:installfast
echo Building arm64 sideload debug and installing to connected phone...
call gradlew.bat :app:installSideloadDebug -Phermes.devAbi=arm64-v8a --console=plain
if errorlevel 1 goto end
echo Launching app...
adb shell am start -n com.axiomlabs.hermesrelay.sideload/com.hermesandroid.relay.MainActivity
goto end
:test
echo Running unit tests...
call gradlew.bat test
echo Running sideload debug unit tests...
call gradlew.bat :app:testSideloadDebugUnitTest --console=plain
goto end
:lint
echo Running lint...
call gradlew.bat lint
echo Running sideload debug lint...
call gradlew.bat :app:lintSideloadDebug --console=plain
goto end
:prepush
@@ -125,12 +152,15 @@ goto end
:help
echo Hermes-Relay Dev Scripts
echo.
echo build Build debug APK
echo build Build sideload debug APK
echo release Build signed release APK
echo bundle Build release AAB (for Google Play upload)
echo install Build + install to connected device
echo install Build universal sideload + install
echo run Build + install + launch + logcat
echo test Run unit tests
echo compile Compile sideload debug Kotlin only
echo test-one Run one test class or wildcard pattern
echo install-fast Build arm64 only + install + launch
echo test Run sideload debug unit tests
echo lint Run lint checks
echo prepush Run Android repository checks, lint, and focused CI tests
echo clean Clean build outputs

Some files were not shown because too many files have changed in this diff Show More