Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5bb41e46d | ||
|
|
0b0e323b20 | ||
|
|
d8cc3d7082 | ||
|
|
a8db3a2ed8 | ||
|
|
8dc874cbbf | ||
|
|
ea8d09e7f0 | ||
|
|
5c6211e63f | ||
|
|
8c8e24c4d0 | ||
|
|
d16f477d81 | ||
|
|
e9effeeb87 | ||
|
|
e9b00757d5 | ||
|
|
891085ed61 | ||
|
|
b53cfbc906 | ||
|
|
045f42386d | ||
|
|
c46560aeeb | ||
|
|
9593787482 | ||
|
|
44ba8ebb41 | ||
|
|
d25f805fa7 | ||
|
|
3cef7c39e3 | ||
|
|
c76e906b30 | ||
|
|
b56dec859d | ||
|
|
bc6f288b0b | ||
|
|
96a6c963dc | ||
|
|
373b98f316 | ||
|
|
83609a4608 | ||
|
|
2c1d7df764 | ||
|
|
df536b308f | ||
|
|
65f89d4c0b | ||
|
|
e78602c9d8 | ||
|
|
fd2a8b2546 | ||
|
|
37ff218f33 | ||
|
|
45e7911d3d | ||
|
|
1ae0627d92 | ||
|
|
de44059c8e | ||
|
|
0327012666 |
@@ -146,6 +146,9 @@ jobs:
|
||||
--tests com.hermesandroid.relay.network.ArchitectureBoundaryTest \
|
||||
--tests com.hermesandroid.relay.network.relay.RelayUrlDeriverTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
|
||||
--tests com.hermesandroid.relay.util.ServerAddressTest \
|
||||
--tests com.hermesandroid.relay.util.IssueReportAndDiagnosticsTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ChatStreamRecoveryTest \
|
||||
--console=plain
|
||||
|
||||
# Upload reports only for failures. Successful PR report uploads add
|
||||
|
||||
@@ -127,10 +127,12 @@ jobs:
|
||||
# Flavor dimension adds an extra path segment to the AGP output layout.
|
||||
# APKs live under `apk/<flavor>/release/`, AABs under `bundle/<flavor>Release/`
|
||||
# (note the concatenated camelCase — AGP path quirk, documented but
|
||||
# different between APK and AAB). The globs below match both flavors.
|
||||
# different between APK and AAB). Checksums cover EXACTLY the files
|
||||
# attached to the GitHub Release (see the 2-asset policy on the
|
||||
# release step below) so SHA256SUMS.txt matches the assets 1:1.
|
||||
run: |
|
||||
cd app/build/outputs
|
||||
sha256sum apk/*/release/*.apk bundle/*Release/*.aab > SHA256SUMS.txt
|
||||
sha256sum apk/sideload/release/*.apk bundle/googlePlayRelease/*.aab > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Create GitHub Release
|
||||
@@ -140,16 +142,22 @@ jobs:
|
||||
tag_name: android-v${{ needs.validate.outputs.version }}
|
||||
body_path: RELEASE_NOTES.md
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
# Attach all four flavored artifacts — users sideload the
|
||||
# `hermes-relay-<version>-sideload-release.apk` for the full
|
||||
# Phase 3 / Tier 3/4/6 feature set; the
|
||||
# `hermes-relay-<version>-googlePlay-release.aab` is what gets
|
||||
# uploaded to Play Console. APK twin of the googlePlay flavor
|
||||
# and AAB twin of the sideload flavor are included for parity
|
||||
# (useful for diff tooling, not primary downloads).
|
||||
# Deliberate 2-asset policy (#144): attach ONLY
|
||||
# `hermes-relay-<version>-sideload-release.apk` (the file users
|
||||
# install by tapping — full Device Control feature set) and
|
||||
# `hermes-relay-<version>-googlePlay-release.aab` (the Play Console
|
||||
# upload bundle — NOT tap-installable on a phone), plus the
|
||||
# SHA256SUMS.txt covering exactly those two files. GitHub sorts
|
||||
# assets alphabetically, so extra files made the non-installable
|
||||
# .aab list first and confused new users. The parity twins
|
||||
# (googlePlay APK, sideload AAB) are still BUILT by the step above
|
||||
# and reproducible from the tag via CI, just not attached.
|
||||
# NEVER rename the sideload APK: the in-app update checker
|
||||
# (update/UpdateChecker.kt) matches assets by ".apk" + "sideload"
|
||||
# in the name, and user-docs verify steps cite the filename.
|
||||
files: |
|
||||
app/build/outputs/apk/*/release/*.apk
|
||||
app/build/outputs/bundle/*Release/*.aab
|
||||
app/build/outputs/apk/sideload/release/*.apk
|
||||
app/build/outputs/bundle/googlePlayRelease/*.aab
|
||||
app/build/outputs/SHA256SUMS.txt
|
||||
|
||||
- name: Upload to Play Console (production draft)
|
||||
|
||||
@@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.3.0] - 2026-07-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice settings: edit your server's voice engine.** Voice settings now has a **Server voice config** section that reads and writes the host's text-to-speech and speech-to-text settings — provider, voice, model, language, and per-provider options — over the dashboard, the same config the official desktop app edits. It includes an **ElevenLabs voice picker** that lists the voices available on your server's ElevenLabs key (and tells you when no key is set). Works on the no-plugin (Standard) path; sign in to Manage to use it.
|
||||
@@ -24,6 +26,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Changed
|
||||
|
||||
- **Reporting a diagnostic now files the right kind of issue.** The Report button on a diagnostics entry used to turn routine log lines into "[Bug]" GitHub issues with an empty template. Now informational entries first ask "what were you expecting to happen?" and file as a "[Diagnostic]" question, error entries keep the direct bug flow, and every report carries the connection mode you were actually on instead of a placeholder line. (#155, #154, #146)
|
||||
- **Simpler release downloads.** Each Android release on GitHub now attaches just two files — the tap-to-install sideload APK and the Play Store upload bundle — plus checksums, with the release notes leading with the one file most people want. The extra "parity/testing" artifacts are gone from the release page (still reproducible from the tag via CI). (#144)
|
||||
- **Clearer, snappier voice capture and playback.** Voice now engages the device's echo-cancellation and noise-suppression while recording (matching the desktop's microphone setup), and requests audio focus before the first reply so the opening words aren't clipped on a cold start. Listening timing also matches the official desktop: auto-stop ~1.25s after you stop speaking (was 3s), give up after 12s with no speech, and cap a turn at 60s.
|
||||
- **Refreshed chat look.** Message bubbles are wider and denser, each assistant turn shows a small Hermes avatar to its left (once per group), and code blocks are richer — a language label, a copy button, and a clearer inset so fenced code and inline `code` no longer blend into the bubble.
|
||||
- **Desktop CLI: visual + ergonomics refresh.** A single color theme across the CLI, aligned tables for `devices`/`sessions`, status dots for on/off states, and progress spinners for slow operations (the multi-endpoint pairing probe and the gateway connect) so nothing looks hung. Errors now suggest the fix (e.g. re-pair on auth failure).
|
||||
@@ -43,6 +47,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Realtime voice: you can keep talking while a background task runs.** Progress updates from a background task were flipping the voice UI back into "Thinking" with a Stop button on every tick, so the mic never came back until the task finished. Progress now feeds only the task chip; the conversation stays open the whole time.
|
||||
- **Realtime voice: leaving voice mode no longer cancels a running task.** Exiting (or tapping Stop to interrupt speech) used to kill an in-flight background task and could overwrite its already-delivered answer with "Cancelled." in the chat. Exit now detaches — the task keeps running and the result arrives on your next session or as a notification — and a delivered answer always keeps its text (a Stopped badge marks a genuine cancel). The chip's ✕ remains the one deliberate way to cancel.
|
||||
- **Long answers are no longer lost when the connection drops mid-turn.** On slow local models (or skills that delegate long background work), the phone could drop the stream mid-turn — the server finishes and saves the answer, but the chat sat on "Still working…" forever. The app now detects the dropped stream and quietly re-checks the conversation until the finished answer arrives, then completes the turn normally (with the usual done-notification if you've backgrounded the app). Switching chats or sending something new cancels the wait. (#166)
|
||||
- **Onboarding slides fit every screen.** Intro slide text could run past the bottom of the screen with no way to scroll on short displays or large font sizes. Slides now scroll when needed and compact their artwork on short viewports, so no setup guidance is unreachable. (#145)
|
||||
- **Docs: fixed stale setup labels and broken links.** The setup guide referenced a "Vanilla Hermes" button the app hasn't shown since v1.2.2 (it's labeled "Hermes"), several deep links into the getting-started page were dead, and the README under-counted the available phone tools. (docs site)
|
||||
- **Back button on Manage and Bridge now works.** The back arrow on the Manage ("Hermes management") and Bridge screens did nothing — it tried to jump to Chat in a way that silently no-op'd. Back now reliably returns to the screen you opened it from.
|
||||
- **Dropped relay connections from a status-report race.** The phone's periodic device-status report could occasionally be sent to the relay *before* the connection had finished authenticating, which made the relay reject the whole connection and forced a reconnect. The app now holds every message until the connection is authenticated, so the handshake always completes first.
|
||||
- **Fewer needless connection re-checks when switching apps.** Returning to the app after a quick glance at another app no longer triggers a full connection re-probe (and the brief "checking…" flash) when the connection was already healthy — it only re-checks after a longer absence or if something actually looks off.
|
||||
|
||||
@@ -1,6 +1,58 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-07-01 — Realtime voice: live background-run chip (progress, phases, cancel)
|
||||
## 2026-07-06 — Open-issue resolution batch (triage of all 13 open issues + 5 fix branches)
|
||||
|
||||
**Why.** The tracker had accumulated 13 open issues spanning real bugs, already-shipped
|
||||
fixes nobody closed, auto-filed diagnostics noise, and docs drift. A multi-agent triage
|
||||
pass verified every claim against code and release tags (`git merge-base --is-ancestor`,
|
||||
never issue comments), then implementation ran on isolated worktree branches with an
|
||||
independent code-review pass per branch. Plan: `docs/plans/2026-07-06-open-issue-resolution.md`.
|
||||
|
||||
**Triage outcomes.** #131/#129/#124/#70/#94 were already fixed in released tags
|
||||
(v1.2.5 / v1.2.4 / v1.2.3 / v1.1.0+v1.2.3 / v1.2.0 respectively) — closure comments are
|
||||
queued as owner actions in TODO. #121 is scheduled feature work for the next `cli-v*`
|
||||
release. The rest became fix branches.
|
||||
|
||||
**What landed on `dev` (merged `--no-ff`):**
|
||||
- `fix/chat-stream-recovery` (#166) — sessions-SSE turns that die on a transport error
|
||||
now enter a recovery poller (5s→30s backoff, 30-min cap) that reconciles the
|
||||
server-persisted answer instead of stranding "Still working…". Root cause verified
|
||||
against upstream `api_server.py`: the run survives the disconnect and persists; only
|
||||
the client gave up. Review pass caught and fixed two real defects before merge:
|
||||
a wedged streaming state when switching sessions mid-recovery, and a stale-anchor
|
||||
adoption when a repeated short message ("continue") never reached the server —
|
||||
the anchor is now positional (user-row count invariant), not text-only.
|
||||
- `fix/diagnostics-report-noise` (#155/#154/#146) — severity-gated Report flow
|
||||
(`[Diagnostic]`/`question` prefills for non-error entries, expectation pre-flight for
|
||||
Info, real route role in the body), `ServerAddress.loopbackHostWarning()` util
|
||||
(UI wiring deferred to the connections-UI workstream), troubleshooting docs rebuilt
|
||||
around the app's real diagnostic titles.
|
||||
- `fix/onboarding-scroll` (#145) — slides scroll under short viewports/large font,
|
||||
hero compacts under 620dp, compact-height Roborazzi render test added.
|
||||
- `fix/release-assets` (#144) — android releases attach only sideload APK +
|
||||
googlePlay AAB + SHA256SUMS (checksums narrowed to match); release-notes template
|
||||
leads with the install file; RELEASE.md codifies the format. In-app update checker
|
||||
asset matching verified unaffected.
|
||||
- `docs/freshness-pass` — "Vanilla Hermes" button label corrected to the app's
|
||||
actual "Hermes" (stale since v1.2.2), 7 dead anchors fixed across the built site
|
||||
(0 remain), README tool counts corrected (35 android, 25 desktop),
|
||||
security.md plain-`ws://` gating described accurately.
|
||||
|
||||
**Parked, not merged:** `fix/plugin-native-imports` (#165) — package-relative imports
|
||||
so the plugin works under the native `hermes plugins install` loader
|
||||
(`hermes_plugins.<slug>`), dashboard `plugin_api` standalone-load bootstrap, doctor
|
||||
import-chain check, installer venv autodetection (classic/uv/Docker) with generated
|
||||
unit/shims templated to the detected interpreter, and an AST-guard + native-layout
|
||||
smoke test (wired into plugin CI). Holds until the pending plugin release tag is cut,
|
||||
then ships as the next plugin patch release so the in-flight voice e2e validation
|
||||
stays meaningful.
|
||||
|
||||
**Verification.** Per-branch: plugin suite 1051 tests (1 pre-existing environmental
|
||||
failure, verified at base); android unit tests + lint green per branch (12 pre-existing
|
||||
Windows-local DataStore temp-file failures verified at base by three independent
|
||||
agents); VitePress build clean with a full-site anchor sweep; release workflow YAML
|
||||
parses; combined lint + unit gate re-run on merged `dev`. On-device checks
|
||||
(Doze/screen-off recovery, max-font onboarding) are owner-driven and queued in TODO.
|
||||
|
||||
**Why.** With timer-driven spoken progress off by default (see the robustness batch
|
||||
below), the voice overlay's background-run chip became the primary in-between signal —
|
||||
|
||||
+26
-9
@@ -1,26 +1,43 @@
|
||||
# Hermes-Relay-Plugin v__VERSION__
|
||||
|
||||
**Release Date:** June 22, 2026
|
||||
**Since the previous plugin release:** Reliability fixes for the Realtime Agent voice path — brokered Hermes turns no longer drop with `session_not_found`, and long-running Hermes work no longer times out a live voice session.
|
||||
**Release Date:** July 6, 2026
|
||||
**Since the previous plugin release:** The Realtime Agent learns to multitask — long Hermes tasks hand off to the background while the conversation continues, results survive disconnects and are delivered when the phone comes back (or as a proactive notification), and spoken progress is milestone-based instead of a timer. Plus a typed chat stream for desktop clients.
|
||||
|
||||
This is a focused patch for the relay's Realtime Agent. When a spoken turn reached back into Hermes for context or tool work, a session-namespace mismatch could make the API Server reject the turn, and long background tasks could let the voice session lapse mid-run. Both paths are now resilient. Provider-native voice turns and vanilla upstream (no plugin) are unaffected.
|
||||
Pairs with Hermes-Relay-Android v1.3.0, which ships the matching live progress chip and detach-on-exit behavior. Provider-native voice turns and vanilla upstream (no plugin) are unaffected.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Fixed
|
||||
- **Brokered Hermes turns no longer fail with `session_not_found`.** When the Realtime Agent reached back to Hermes for context or tool work, it could hand the API Server a session id from a different session namespace (the gateway/client store), which the API Server rejected. The broker now mints a valid API Server session and retries the turn once when that happens, reuses an existing API Server session when the id is already valid, and reads the API Server's current nested `{"session": {"id": …}}` create-session response (previously only the legacy flat shape) so session creation no longer errors with "created a session without an id."
|
||||
- **Realtime voice survives long Hermes runs.** A heartbeat now keeps the realtime voice session alive while a long-running Hermes task is in flight, so the turn no longer times out before the work finishes.
|
||||
### Added
|
||||
- **Background runs that finish what they started (ADR 33 hardening).** A detached voice session now stays alive while a background Hermes run is in flight (instead of expiring on the 30-second resume window); a finished result found with no phone attached is held and injected on the next resume, and if the session is gone for good it falls back to a proactive notification. Runs that exceed the cap are stopped cleanly and say so.
|
||||
- **Adaptive promotion.** Clearly long-running tools (cron, desktop, browser work) hand the task to the background immediately instead of waiting out the full grace window — with a short quick-finish window so fast calls stay inline.
|
||||
- **Busy answer for a second task.** Asking for another task while one is running gets an explicit "still working on the earlier task" answer (wait, check status, or cancel) instead of silently orphaning the first run.
|
||||
- **Typed chat stream passthrough.** The relay `chat` channel can emit structured `stream.event` envelopes (assistant deltas, tool lifecycle, artifacts, completion) for desktop/CLI consumers that advertise the capability.
|
||||
|
||||
## Install
|
||||
### Changed
|
||||
- **Milestone speech, not timer narration.** The periodic spoken status updates during a long task are off by default — the agent speaks when a task starts in the background, finishes, or fails; the client chip covers the in-between. `realtime_voice_progress_spoken_after_ms` restores timed narration if you prefer it.
|
||||
- **Live progress metadata.** `hermes.run.progress` events carry the active tool, completed-step count, and elapsed time, which drive the Android app's live chip.
|
||||
|
||||
### Fixed
|
||||
- **A benign provider cancel-notice no longer kills a live voice turn.** xAI's "cancellation failed: no active response found" was treated as fatal and closed the session right as the answer was about to be spoken — it's now filtered, and needless cancels are floor-gated so they aren't sent in the first place.
|
||||
- **Provider sockets ride out idle stretches.** Realtime provider WebSockets use protocol-level heartbeats instead of a total-connection timeout, so long silent tool phases no longer sever the provider leg.
|
||||
|
||||
## Install / update
|
||||
|
||||
```bash
|
||||
pip install hermes-relay==__VERSION__
|
||||
# Classic install / update on a systemd host (recommended):
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
# or, if already installed:
|
||||
hermes-relay-update
|
||||
```
|
||||
|
||||
> **Known issue:** the native `hermes plugins install` path currently breaks
|
||||
> `hermes relay start` (#165, `ModuleNotFoundError: No module named 'plugin'`).
|
||||
> The fix ships in the next plugin release — use the classic installer until then.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
python -m relay_server --help
|
||||
hermes relay doctor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -311,7 +311,7 @@ docker build -t hermes-relay relay_server/ && docker run -d --network host --nam
|
||||
ln -s "$PWD/plugin" ~/.hermes/plugins/hermes-relay
|
||||
```
|
||||
|
||||
Then restart hermes and run `hermes pair` to verify. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
|
||||
Then restart hermes and run `hermes pair` to verify. The 35 `android_*` and 25 `desktop_*` tools register regardless of hermes-agent version. See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+22
-8
@@ -403,12 +403,25 @@ the new app version and a higher `appVersionCode`.
|
||||
- `RELEASE_NOTES.md` — body of the GitHub Release for this version
|
||||
(rewritten each release; the workflow uses this as-is). This is the
|
||||
operator-facing summary, not the CHANGELOG mirror. Keep the
|
||||
**Download** section near the top — it should spell out which file
|
||||
to grab by its `-sideload-release.apk` / `-googlePlay-release.aab`
|
||||
suffix (every artifact is version-tagged as
|
||||
**Download** section near the top, in the required format (#144):
|
||||
1. A lead callout naming the **one file most people want** —
|
||||
"Installing on your phone? Download
|
||||
`hermes-relay-<version>-sideload-release.apk` and tap it"
|
||||
(full feature set), with the Play Store link for the
|
||||
conservative build.
|
||||
2. One explicit line that the `.aab` is a Play Console upload
|
||||
bundle and **cannot** be installed by tapping it on a phone.
|
||||
3. The `SHA256SUMS.txt` verify line + sideload-guide link.
|
||||
No download table, no parity/testing artifacts: releases attach
|
||||
exactly **two** app artifacts — the sideload APK and the googlePlay
|
||||
AAB — plus `SHA256SUMS.txt` covering exactly those two (the 2-asset
|
||||
policy in `.github/workflows/release-android.yml`; the parity twins
|
||||
stay reproducible from the tag via CI but are not attached).
|
||||
Every artifact is version-tagged as
|
||||
`hermes-relay-<version>-<flavor>-<buildType>` via `archivesName`
|
||||
in `app/build.gradle.kts`) and link to the sideload guide.
|
||||
The v0.3.0 body is a good template.
|
||||
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.
|
||||
- `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
|
||||
@@ -651,9 +664,10 @@ On every push of a tag matching `android-v*`, `.github/workflows/release-android
|
||||
regression slice with explicit timeouts.
|
||||
3. Decodes `HERMES_KEYSTORE_BASE64` into `$RUNNER_TEMP/release.keystore`
|
||||
and exports `HERMES_KEYSTORE_PATH` (skipped if the secret is unset).
|
||||
4. Builds both Android release artifacts:
|
||||
`./gradlew bundleRelease assembleRelease`.
|
||||
5. Generates `SHA256SUMS.txt` covering both.
|
||||
4. Builds all four flavored release artifacts
|
||||
(`./gradlew bundleRelease assembleRelease`); only the sideload APK and
|
||||
googlePlay AAB are attached (see §Release assets).
|
||||
5. Generates `SHA256SUMS.txt` covering the two attached files.
|
||||
6. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
|
||||
the body. Attaches the APK, AAB, and `SHA256SUMS.txt`. Tags any version
|
||||
containing a dash (e.g. `android-v0.2.0-beta.1`) as a prerelease automatically.
|
||||
|
||||
+23
-18
@@ -1,22 +1,17 @@
|
||||
# Hermes-Relay-Android v1.2.6
|
||||
# Hermes-Relay-Android v1.3.0
|
||||
|
||||
**Release Date:** June 27, 2026
|
||||
**Since v1.2.5:** A fix for chats stuck showing "Untitled", and a calmer way to surface connection status. Chats now keep your first message as a stand-in title until the server names them (and titles reconcile after a turn / via a new refresh button in the session drawer), renaming sticks on non-default agent profiles, and the connection-status card no longer floats over your chat — transient states slide the screen down as a thin top banner, with the floating alert reserved for persistent errors.
|
||||
**Release Date:** July 6, 2026
|
||||
**Since v1.2.6:** Realtime voice grows up — long tasks hand off to the background with a live progress chip while you keep talking, results survive disconnects (and arrive as a notification if you've left), and leaving voice mode no longer cancels a running task. Chats stop losing answers when the connection drops mid-reply, your agent can message you first (opt-in) with replies straight from the notification, and a stack of polish landed: app font picker, proportionate markdown, scrollable onboarding, smarter diagnostics reporting, and a cleaner Connections screen.
|
||||
|
||||
v1.2.6 is recommended for everyone.
|
||||
v1.3.0 is recommended for everyone. Realtime-voice background tasks pair best with relay plugin v1.3.0 on the server; the no-plugin (vanilla Hermes) path is unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
v1.2.6 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
**Installing on your phone?** Download **`hermes-relay-1.3.0-sideload-release.apk`** and tap it — that's the direct-install build with the full feature set (installs as `com.axiomlabs.hermesrelay.sideload`). Prefer the conservative build (no Device Control surface)? Get it from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
| Flavor | File | Who it's for |
|
||||
|---|---|---|
|
||||
| Google Play | `hermes-relay-1.2.6-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
|
||||
| sideload | `hermes-relay-1.2.6-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-1.2.6-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-1.2.6-sideload-release.aab` | Parity/testing artifact. |
|
||||
The other file, `hermes-relay-1.3.0-googlePlay-release.aab`, is an Android App Bundle for uploading to Play Console — it **cannot** be installed by tapping it on a phone.
|
||||
|
||||
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
|
||||
|
||||
@@ -24,15 +19,25 @@ Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload
|
||||
|
||||
## Highlights
|
||||
|
||||
### Fixed
|
||||
- **Chats stuck showing "Untitled".** The session drawer treated the server's session list as fully authoritative for the title, so a re-list that arrived before (or without) the server auto-naming a chat overwrote the optimistic first-message preview with a blank title. The drawer now keeps a known local title when the server returns a blank one, re-pulls shortly after a turn settles, and offers a manual refresh button — so chats stop reading "Untitled". The api_server SSE path never auto-titles, which is why the preview is now the durable fallback there. (#133)
|
||||
- **Rename on a non-default agent profile.** A non-default profile's chats live in that profile's own store, but rename went through the shared path — so the new title never landed. Renaming is now profile-scoped (the write twin of the earlier session-delete and list fixes).
|
||||
### Voice, hands-free
|
||||
- **Background tasks with a live chip.** Ask for something big and keep talking — the task hands off to the background with a chip showing the current step, steps done, and a running timer, plus a ✕ to cancel. The answer is spoken when it's ready, even after a brief disconnect; if the voice session is gone for good, it arrives as a notification (the full answer is always in the chat).
|
||||
- **Exit detaches, ✕ cancels.** Leaving voice mode or tapping stop no longer kills a running task or overwrites its delivered answer with "Cancelled." — the chip's ✕ is the one deliberate kill switch.
|
||||
- **Quieter and quicker.** Milestone speech instead of step-by-step narration, immediate handoff for clearly long tools, and a faster first turn (the session warms up when you open voice mode).
|
||||
|
||||
### Changed
|
||||
- **Calmer connection status.** Transient/active/warning connection status — reconnecting, checking, LAN↔Tailscale handoffs — now renders as a thin banner at the top that takes its own space (content slides down) instead of a card floating over the chat. A persistent **error** keeps the floating alert so it still demands attention. Frequent confirmations (copied, profiles updated, profile/personality switches) moved to the same top banner instead of a bottom pop-up.
|
||||
### Chats
|
||||
- **Answers survive dropped connections.** On long turns (slow local models, delegating skills) the app now recovers the finished answer from the server instead of hanging on "Still working…". (#166)
|
||||
- **Proactive messages, two-way.** Your agent can message your phone first (off by default, opt-in on server and phone) and you can reply from the notification or the Hermes inbox.
|
||||
- **Markdown that reads like chat.** Proportionate headings, unified text sizes, styled links, per-group timestamps.
|
||||
|
||||
### Polish
|
||||
- **Pick your font** (Inter, Nunito, or system) and an animated thinking indicator; Quick Controls at the top of Settings.
|
||||
- **Onboarding fits every screen** — slides scroll on short viewports and large font sizes. (#145)
|
||||
- **Smarter diagnostics reporting** — informational entries file as questions with your actual connection mode, not as empty bug reports.
|
||||
- **Connections redesign** — scannable list + tabbed detail (Overview / Routes / Advanced / Security); server voice-engine settings editable from the app.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade notes
|
||||
- This is an app-side release on **both** flavors — no Device Control or server changes needed.
|
||||
- `appVersionCode` is **20**.
|
||||
- App-side release on **both** flavors. Realtime-voice background-task features need relay plugin **v1.3.0** on the server; everything else works on unmodified upstream Hermes.
|
||||
- `appVersionCode` is **21**.
|
||||
- Releases now attach **two** files (sideload APK + Play bundle) instead of four — the parity/testing artifacts are gone from the release page. (#144)
|
||||
|
||||
@@ -6,6 +6,43 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Voice background-run v2 (2026-07-06 roadmap — post plugin-v1.3.0)
|
||||
|
||||
The v1 shape shipped in plugin-v1.3.0 (single durable run, free floor during
|
||||
background work, busy answer, deliver-on-reattach, exit-detaches / chip-✕-
|
||||
cancels). Ranked next increments, in value-per-complexity order:
|
||||
|
||||
1. **Fast lane** — while one durable run is detached, allow a second
|
||||
`hermes_run_task` *inline only*: run it on a separate ephemeral session
|
||||
(context injected the same way turns pass `realtimeAgentContextMessages`),
|
||||
normal grace window; if it would promote, fall through to the busy/queue
|
||||
answer. Fixes the real gap: today ANY second Hermes-backed request is
|
||||
refused during a background run, even a 2-second lookup.
|
||||
2. **Task queue** — upgrade the busy answer from refusal to offer ("want me
|
||||
to queue it?"): small FIFO in the broker session, start-next-on-completion
|
||||
with a spoken handoff, chip shows "+1 queued". Pairs with (1).
|
||||
3. **Chip tap-through to the transcript** — the run executes on a real
|
||||
gateway session, so full tool calls/outputs already live in that session's
|
||||
history; make the chip (or the finished turn) open it. Cheapest "see tool
|
||||
output" step.
|
||||
4. **Live tool-output sheet** — chip expands to a run timeline (tool name,
|
||||
status, capped ~500-char output snippet). Relay adds a truncated output
|
||||
field to `hermes.tool.*` events; client renders a lane (reuse the
|
||||
`SubagentLane` pattern).
|
||||
5. **Injection framing (recorded earlier, still open)** — on providers with
|
||||
native async function calling, leave the tool call pending and deliver the
|
||||
real `function_call_output` late instead of interim-ack + synthetic
|
||||
instruction text. Needs a live xAI parity check first.
|
||||
6. **Pending-result FIFO** — `pending_background_result` is a single slot
|
||||
(correct for one run); generalize to an ordered list the day (1)/(2) land
|
||||
so two results delivered during a detach don't race.
|
||||
7. **Full N-way concurrent background runs — deliberately deferred.** Needs
|
||||
session-per-run topology (a gateway session serializes turns), which
|
||||
fragments conversation context, multiplies delivery/floor/failure modes,
|
||||
and needs run-id-targeted cancel + a multi-run chip. Only worth it when
|
||||
two *long* tasks genuinely need parallel wall-clock; revisit if the queue
|
||||
feels slow in practice.
|
||||
|
||||
## Open-issue resolution batch (2026-07-06) — owner GitHub actions + deferrals
|
||||
|
||||
Plan: `docs/plans/2026-07-06-open-issue-resolution.md` (13 open issues triaged;
|
||||
@@ -69,6 +106,37 @@ Deferred from the batch (coordination / decisions):
|
||||
create a dedicated relay venv under a writable path so the full installer
|
||||
works in-container.
|
||||
|
||||
Implementation-batch follow-ups (from the per-branch reviews):
|
||||
|
||||
- **#166 recovery: empty-session fail-fast.** `HermesApiClient.getMessages()`
|
||||
maps fetch failures to `emptyList()`, so the recovery poller can't distinguish
|
||||
"server unreachable" from "session genuinely empty" — a `Result`-returning
|
||||
history read would let the never-landed-send fail-fast also cover a dropped
|
||||
FIRST message of a fresh session (today that case polls to the cap).
|
||||
- **#166 recovery cap.** Recovery gives up after 30 minutes; longer turns still
|
||||
land in session history but only surface after a manual reload. Consider a
|
||||
"keep waiting" affordance if real turns exceed the cap.
|
||||
- **CI android slice.** `ServerAddressTest` + `IssueReportAndDiagnosticsTest`
|
||||
added to the focused `--tests` slice; the Robolectric/MockWebServer recovery
|
||||
tests and the compact-onboarding Roborazzi test stay local-only (same
|
||||
precedent as `StoreScreenshotTest`) until the broad-suite hang (#32) is fixed.
|
||||
- **Skills docs still cite editable-only fixes.** `skills/devops/hermes-relay-pair/SKILL.md`
|
||||
and `skills/android/SKILL.md` document `python -m plugin.pair` + `pip install -e`
|
||||
as the ModuleNotFoundError fix — add the native-layout equivalent when the
|
||||
#165 branch ships.
|
||||
- **Dashboard API tests not CI-visible.** `plugin/dashboard/test_plugin_api.py`
|
||||
isn't discovered by `unittest discover -s plugin/tests` and needs
|
||||
fastapi/httpx — wire into a CI runner or move under plugin/tests with skips.
|
||||
- **Desktop tool-count drift.** `user-docs/desktop/index.md` counts client-side
|
||||
handlers (clipboard/screenshot/open_in_editor) that have no server-side
|
||||
`desktop_*` registration in `plugin/tools/desktop_tool.py` — reconcile the
|
||||
advertised set; also `user-docs/desktop/pairing.md` wrongly says Android uses
|
||||
`~/.hermes/remote-sessions.json` (it's Keystore/EncryptedSharedPrefs; the file
|
||||
is shared with the Ink TUI). CLAUDE.md Key Files also still says 18/24 tools.
|
||||
- **Info-report button label.** The diagnostics Report button reads "Report"
|
||||
even when the first tap only reveals the expectation field — a "Continue"
|
||||
label would make the two-step flow clearer.
|
||||
|
||||
## Connections UI / status banner (2026-06-30 restructure follow-ups)
|
||||
|
||||
The Connections screen was split into a scannable list + a tabbed detail screen
|
||||
|
||||
@@ -182,7 +182,13 @@ android {
|
||||
// [POC] Roborazzi runs without its Gradle plugin (the plugin needs AGP's
|
||||
// removed TestedExtension). Force record mode via the test-JVM system
|
||||
// property the plugin would otherwise inject, so captureRoboImage writes.
|
||||
unitTests.all { it.systemProperty("roborazzi.test.record", "true") }
|
||||
// Heap: the Roborazzi store renders (1080×2160 native graphics) share a
|
||||
// worker JVM with the Robolectric suites; Gradle's 512m default OOMs
|
||||
// once both are in the same run.
|
||||
unitTests.all {
|
||||
it.systemProperty("roborazzi.test.record", "true")
|
||||
it.maxHeapSize = "2g"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,8 +322,8 @@ dependencies {
|
||||
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
|
||||
// Renders real composables on the JVM at an exact canvas — no device, no
|
||||
// status bar, no clipping. See StoreScreenshotTest.
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.43.1")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.43.1")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.64.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.64.0")
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
testImplementation(libs.compose.ui.test.manifest)
|
||||
testImplementation("androidx.test.ext:junit:1.3.0")
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
v1.2.6 — Tidier chats & calmer status.
|
||||
v1.3.0 — Voice that multitasks & sturdier chats.
|
||||
|
||||
• Chats no longer get stuck on "Untitled" — your first message stands in as the title until the chat is named, plus a new refresh button in the session list. Renaming a chat now sticks on non-default profiles.
|
||||
• Connection status now slides in as a thin banner at the top instead of a card floating over your chat; the floating alert is kept for persistent errors.
|
||||
• Long voice tasks run in the background with a live progress chip — keep talking, cancel with a tap, and hear the result even after a dropped connection.
|
||||
• Chat answers are no longer lost when the connection drops mid-reply.
|
||||
• Your agent can message you first (opt-in), with replies straight from the notification.
|
||||
• Pick your app font; onboarding fits small screens; cleaner Connections screen.
|
||||
|
||||
+270
-226
@@ -1,238 +1,282 @@
|
||||
{
|
||||
"versions": [
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.3.0",
|
||||
"title": "Voice that multitasks & sturdier chats",
|
||||
"date": "2026-07-06",
|
||||
"sections": [
|
||||
{
|
||||
"version": "1.2.6",
|
||||
"title": "Tidier chats & calmer status",
|
||||
"date": "2026-06-27",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Tidier chats",
|
||||
"bullets": [
|
||||
"Chats no longer get stuck showing \"Untitled\" — your first message stands in as the title until the chat is named, titles refresh once a turn settles, and a new refresh button in the session drawer pulls the latest on demand. Renaming a chat now sticks when you're on a non-default agent profile."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Calmer status",
|
||||
"bullets": [
|
||||
"Connection status — reconnecting, checking, network handoffs — now shows as a thin banner at the top that gently slides the screen down, instead of a card floating over your chat; the floating alert is kept for persistent errors. Quick confirmations (copied, profiles updated, profile/personality switches) land in the same calm banner instead of a pop-up at the bottom."
|
||||
]
|
||||
}
|
||||
]
|
||||
"header": "Voice, hands-free",
|
||||
"bullets": [
|
||||
"Ask for something big and keep talking — long tasks hand off to the background with a live chip showing the current step, steps done, and a running timer, with a tap-to-cancel. The answer is spoken when it's ready, even after a brief disconnect — and if the voice session is gone, it arrives as a notification (the full answer is always in the chat).",
|
||||
"Leaving voice mode (or tapping stop to interrupt speech) no longer cancels a running background task — the chip's ✕ is the one deliberate kill switch, and a delivered answer keeps its text instead of flipping to \"Cancelled.\"",
|
||||
"Quieter and quicker: the agent speaks at milestones instead of narrating every step, clearly long tasks hand off to the background right away, and the first turn starts faster — the session warms up when you open voice mode."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.5",
|
||||
"title": "Stability + Try the demo",
|
||||
"date": "2026-06-27",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app when a non-URL value — a UI label, or a line copied from the docs — was entered in the API server or Dashboard URL field. The setup fields now reject anything that isn't a valid host or http(s) URL with an inline error, and the dashboard and voice request paths treat a bad address as unreachable instead of crashing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Try the demo",
|
||||
"bullets": [
|
||||
"A new \"Try the demo\" option on the setup screen — and on the empty chat screen if you skip setup — opens an offline preview of the real chat experience: a sample conversation with Markdown, a tool-progress card, and a rich card, with no server, account, or network. A banner shows it's a demo, with a one-tap Connect to set up for real."
|
||||
]
|
||||
}
|
||||
]
|
||||
"header": "Chats that keep their answers",
|
||||
"bullets": [
|
||||
"An answer is no longer lost when the connection drops mid-reply on a long turn (slow local models, delegating skills) — the app quietly re-checks the conversation and completes the turn when the server finishes, with the usual done-notification if you've switched away.",
|
||||
"Markdown reads like chat: headings are proportionate instead of billboard-sized, lists and paragraphs share one size, links are clearly styled, and timestamps show once per message group."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.4",
|
||||
"title": "Stability + connection security",
|
||||
"date": "2026-06-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app when the dashboard connection check hit a transient network failure — a pooled connection aborting or timing out over Tailscale. The check now reports the failure cleanly and the connection probe degrades gracefully instead of force-closing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "See if you're secure",
|
||||
"bullets": [
|
||||
"The chat status chip, connection card, and route picker now show at a glance whether your connection is encrypted — Encrypted · TLS, Encrypted · Tailscale (both secure), Mixed routes, or Not encrypted — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale or WireGuard route is now correctly shown as encrypted rather than implied insecure."
|
||||
]
|
||||
}
|
||||
]
|
||||
"header": "Your agent can reach out",
|
||||
"bullets": [
|
||||
"Proactive messages: your Hermes agent can message your phone first (off by default, opt-in on both server and phone), and you can reply straight from the notification or the new Hermes inbox — the conversation continues like any other chat."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"title": "Connection crash fix",
|
||||
"date": "2026-06-23",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS) — a live secure connection was being torn down on the main thread as it came up. Securing your connection no longer force-closes the app; plain-LAN connections were never affected."
|
||||
]
|
||||
}
|
||||
]
|
||||
"header": "Make it yours",
|
||||
"bullets": [
|
||||
"Pick your app font — Inter (new default), Nunito, or your system font — applied instantly, everywhere.",
|
||||
"The in-bubble working indicator can be a small animated dot-matrix (Wave, Pulse, Bounce, Sparkle) with a color of your choice.",
|
||||
"Quick Controls at the top of Settings puts Persistent connection and Turn-complete alerts one tap away."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.2",
|
||||
"title": "Multi-profile polish",
|
||||
"date": "2026-06-22",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Profiles that behave",
|
||||
"bullets": [
|
||||
"Deleting a session while a non-default agent profile is active now sticks — it no longer reappears after the list refreshes.",
|
||||
"On a cold start with a non-default profile selected, the session drawer opens on that profile's chats directly instead of briefly showing the default profile's."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Clearer diagnostics",
|
||||
"bullets": [
|
||||
"Diagnostics is now a full screen led by a top-to-bottom list of subsystem health checks — network, API server, chat transport, pairing, relay, and voice — each with a pass / warning / fail state and the reason when something's wrong; tap a failing check for full detail. The recent-activity log stays below."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Small touches",
|
||||
"bullets": [
|
||||
"The default connection is now simply \"Hermes\" (and the optional power features are labelled \"Relay\"), across setup, the switcher, voice, and permissions.",
|
||||
"Distraction-free chat mode gives its text a taller, scrollable area."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.1",
|
||||
"title": "Polish & control",
|
||||
"date": "2026-06-21",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Yours to control",
|
||||
"bullets": [
|
||||
"Lock the app to a single agent profile (Settings → Profile lock) and hide the rest from the pickers."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Find your way back",
|
||||
"bullets": [
|
||||
"A new \"What's New\" entry in Settings shows current and past release notes any time — not just after an update."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "When something breaks",
|
||||
"bullets": [
|
||||
"Diagnostics show clean error titles — tap any entry for a detail view with Copy, Share, and a one-tap GitHub issue.",
|
||||
"A tasteful in-app banner tells you when a newer version is live (Play or sideload) — dismissable, and it never nags."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice fixes",
|
||||
"bullets": [
|
||||
"Stop now halts realtime speech instantly, hold-to-talk is steadier, the voice overlay is easier to read, and a chosen voice applies in Auto mode.",
|
||||
"Realtime turns that reach back to Hermes no longer drop with a session error."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"title": "Make it yours",
|
||||
"date": "2026-06-20",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Personalize",
|
||||
"bullets": [
|
||||
"Eight app themes in Settings → Appearance — the Hermes Relay brand plus ports of the Nous Hermes looks (Teal, Nous Blue, Midnight, Ember, Mono, Cyberpunk, Rosé), with light/dark.",
|
||||
"Swap the agent orb for an animated pet that reacts to what the agent is doing — add, preview, and tune pets right in the app, or generate one from sprite art with the AI authoring kit.",
|
||||
"Reskin the sphere, and give each agent profile its own icon."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "See what's happening",
|
||||
"bullets": [
|
||||
"The chat status strip names the actual streaming path (Gateway, Sessions, Completions, Runs), with a basic→best tier ladder in Chat Settings.",
|
||||
"Tap the context meter for a \"What the agent sees\" sheet — the exact extra context prepended to your next turn.",
|
||||
"Voice and Realtime turns are badged in the scrollback."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Privacy",
|
||||
"bullets": [
|
||||
"When paired to the relay, the agent can mark private media and the phone blurs it per your setting — sensitivity stays model-emitted."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Faster & more reliable",
|
||||
"bullets": [
|
||||
"Cold start is about 3× faster, and model/personality/approvals load honestly instead of showing a maybe-wrong value.",
|
||||
"In-app crash reporting offers a one-tap, pre-filled bug report.",
|
||||
"QR pairing no longer force-closes on unusual cameras (foldables); fixed crashes opening server images and PDFs; in-chat model picks now apply."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice & terminal",
|
||||
"bullets": [
|
||||
"Enhanced voice control for Gemini and xAI providers.",
|
||||
"Leaner terminal with TUI-correct input and an isolated, tuned tmux."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.1.0",
|
||||
"title": "Release plumbing & polish",
|
||||
"date": "2026-06-16",
|
||||
"sections": [
|
||||
{
|
||||
"header": "New",
|
||||
"bullets": [
|
||||
"Automated Play Console upload when a release tag ships (a human still starts the rollout).",
|
||||
"/relay slash commands — status, devices, and pair from any platform — plus a relay-status badge in the dashboard header.",
|
||||
"The relay plugin prompts for its optional voice-provider keys on install, and a tools-only native install path."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Improved",
|
||||
"bullets": [
|
||||
"Settings overhaul: status pills are now exception-only, Power tools shows a single Plugin active/required/offline badge, and Connections moved to the top.",
|
||||
"Release names and notes are now split per surface (Android, plugin, CLI)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Fixed",
|
||||
"bullets": [
|
||||
"No more force-close on connect when the stored credential keyset was corrupt — it now heals in place.",
|
||||
"The installer works on uv-managed Hermes hosts, and the dashboard relay panel buttons are readable again."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"title": "Stable launch",
|
||||
"date": "2026-06-14",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Gateway chat with live thinking",
|
||||
"bullets": [
|
||||
"Chat can ride the upstream dashboard gateway — the only vanilla-upstream path that streams reasoning live, so the Thinking block and sphere light up during generation. \"Auto\" prefers it and falls back to the SSE endpoints per turn.",
|
||||
"Desktop parity: native image/PDF/file attachments, mid-turn steering, edit & resend, approval/clarify/sudo/secret cards, live subagent lanes, a context-window meter, server slash commands, and turn-complete notifications.",
|
||||
"Warm-start and an opt-in Keep connected in background toggle so long-backgrounded conversations resume instantly."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Agents, Manage & media",
|
||||
"bullets": [
|
||||
"Switch agent profiles per conversation — model, SOUL, personality, and skills — with the selection bound to the session, never changing the server default for other clients.",
|
||||
"Manage parity with the desktop dashboard: change models, manage provider keys, edit profiles and SOUL.md, and browse/install skills.",
|
||||
"Open and save chat images and attachments — full-screen viewer with pinch-zoom, plus an Open/Share/Save menu."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Standard path is first-class",
|
||||
"bullets": [
|
||||
"Chat, Manage, and voice all work against an unmodified upstream Hermes agent; the relay plugin is now purely additive.",
|
||||
"Seamless connection UX — LAN↔Tailscale handoffs and reconnects no longer reload the chat, and status shows as in-theme slide-down toasts.",
|
||||
"Persistent Realtime Agent voice that keeps one session across turns, with long runs promoted to tracked background tasks."
|
||||
]
|
||||
}
|
||||
]
|
||||
"header": "Setup & housekeeping",
|
||||
"bullets": [
|
||||
"Onboarding slides now scroll on small screens and large font sizes, so no setup guidance is cut off.",
|
||||
"Reporting a diagnostic files the right kind of issue: informational entries ask what you expected and file as a question, and every report carries your actual connection mode.",
|
||||
"Connections is a scannable list with a tabbed detail screen (Overview, Routes, Advanced, Security), and voice settings can now read and edit your server's voice engine (provider, voice, model) over the dashboard."
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.6",
|
||||
"title": "Tidier chats & calmer status",
|
||||
"date": "2026-06-27",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Tidier chats",
|
||||
"bullets": [
|
||||
"Chats no longer get stuck showing \"Untitled\" — your first message stands in as the title until the chat is named, titles refresh once a turn settles, and a new refresh button in the session drawer pulls the latest on demand. Renaming a chat now sticks when you're on a non-default agent profile."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Calmer status",
|
||||
"bullets": [
|
||||
"Connection status — reconnecting, checking, network handoffs — now shows as a thin banner at the top that gently slides the screen down, instead of a card floating over your chat; the floating alert is kept for persistent errors. Quick confirmations (copied, profiles updated, profile/personality switches) land in the same calm banner instead of a pop-up at the bottom."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.5",
|
||||
"title": "Stability + Try the demo",
|
||||
"date": "2026-06-27",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app when a non-URL value — a UI label, or a line copied from the docs — was entered in the API server or Dashboard URL field. The setup fields now reject anything that isn't a valid host or http(s) URL with an inline error, and the dashboard and voice request paths treat a bad address as unreachable instead of crashing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Try the demo",
|
||||
"bullets": [
|
||||
"A new \"Try the demo\" option on the setup screen — and on the empty chat screen if you skip setup — opens an offline preview of the real chat experience: a sample conversation with Markdown, a tool-progress card, and a rich card, with no server, account, or network. A banner shows it's a demo, with a one-tap Connect to set up for real."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.4",
|
||||
"title": "Stability + connection security",
|
||||
"date": "2026-06-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app when the dashboard connection check hit a transient network failure — a pooled connection aborting or timing out over Tailscale. The check now reports the failure cleanly and the connection probe degrades gracefully instead of force-closing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "See if you're secure",
|
||||
"bullets": [
|
||||
"The chat status chip, connection card, and route picker now show at a glance whether your connection is encrypted — Encrypted · TLS, Encrypted · Tailscale (both secure), Mixed routes, or Not encrypted — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale or WireGuard route is now correctly shown as encrypted rather than implied insecure."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"title": "Connection crash fix",
|
||||
"date": "2026-06-23",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS) — a live secure connection was being torn down on the main thread as it came up. Securing your connection no longer force-closes the app; plain-LAN connections were never affected."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.2",
|
||||
"title": "Multi-profile polish",
|
||||
"date": "2026-06-22",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Profiles that behave",
|
||||
"bullets": [
|
||||
"Deleting a session while a non-default agent profile is active now sticks — it no longer reappears after the list refreshes.",
|
||||
"On a cold start with a non-default profile selected, the session drawer opens on that profile's chats directly instead of briefly showing the default profile's."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Clearer diagnostics",
|
||||
"bullets": [
|
||||
"Diagnostics is now a full screen led by a top-to-bottom list of subsystem health checks — network, API server, chat transport, pairing, relay, and voice — each with a pass / warning / fail state and the reason when something's wrong; tap a failing check for full detail. The recent-activity log stays below."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Small touches",
|
||||
"bullets": [
|
||||
"The default connection is now simply \"Hermes\" (and the optional power features are labelled \"Relay\"), across setup, the switcher, voice, and permissions.",
|
||||
"Distraction-free chat mode gives its text a taller, scrollable area."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.1",
|
||||
"title": "Polish & control",
|
||||
"date": "2026-06-21",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Yours to control",
|
||||
"bullets": [
|
||||
"Lock the app to a single agent profile (Settings → Profile lock) and hide the rest from the pickers."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Find your way back",
|
||||
"bullets": [
|
||||
"A new \"What's New\" entry in Settings shows current and past release notes any time — not just after an update."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "When something breaks",
|
||||
"bullets": [
|
||||
"Diagnostics show clean error titles — tap any entry for a detail view with Copy, Share, and a one-tap GitHub issue.",
|
||||
"A tasteful in-app banner tells you when a newer version is live (Play or sideload) — dismissable, and it never nags."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice fixes",
|
||||
"bullets": [
|
||||
"Stop now halts realtime speech instantly, hold-to-talk is steadier, the voice overlay is easier to read, and a chosen voice applies in Auto mode.",
|
||||
"Realtime turns that reach back to Hermes no longer drop with a session error."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"title": "Make it yours",
|
||||
"date": "2026-06-20",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Personalize",
|
||||
"bullets": [
|
||||
"Eight app themes in Settings → Appearance — the Hermes Relay brand plus ports of the Nous Hermes looks (Teal, Nous Blue, Midnight, Ember, Mono, Cyberpunk, Rosé), with light/dark.",
|
||||
"Swap the agent orb for an animated pet that reacts to what the agent is doing — add, preview, and tune pets right in the app, or generate one from sprite art with the AI authoring kit.",
|
||||
"Reskin the sphere, and give each agent profile its own icon."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "See what's happening",
|
||||
"bullets": [
|
||||
"The chat status strip names the actual streaming path (Gateway, Sessions, Completions, Runs), with a basic→best tier ladder in Chat Settings.",
|
||||
"Tap the context meter for a \"What the agent sees\" sheet — the exact extra context prepended to your next turn.",
|
||||
"Voice and Realtime turns are badged in the scrollback."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Privacy",
|
||||
"bullets": [
|
||||
"When paired to the relay, the agent can mark private media and the phone blurs it per your setting — sensitivity stays model-emitted."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Faster & more reliable",
|
||||
"bullets": [
|
||||
"Cold start is about 3× faster, and model/personality/approvals load honestly instead of showing a maybe-wrong value.",
|
||||
"In-app crash reporting offers a one-tap, pre-filled bug report.",
|
||||
"QR pairing no longer force-closes on unusual cameras (foldables); fixed crashes opening server images and PDFs; in-chat model picks now apply."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice & terminal",
|
||||
"bullets": [
|
||||
"Enhanced voice control for Gemini and xAI providers.",
|
||||
"Leaner terminal with TUI-correct input and an isolated, tuned tmux."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.1.0",
|
||||
"title": "Release plumbing & polish",
|
||||
"date": "2026-06-16",
|
||||
"sections": [
|
||||
{
|
||||
"header": "New",
|
||||
"bullets": [
|
||||
"Automated Play Console upload when a release tag ships (a human still starts the rollout).",
|
||||
"/relay slash commands — status, devices, and pair from any platform — plus a relay-status badge in the dashboard header.",
|
||||
"The relay plugin prompts for its optional voice-provider keys on install, and a tools-only native install path."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Improved",
|
||||
"bullets": [
|
||||
"Settings overhaul: status pills are now exception-only, Power tools shows a single Plugin active/required/offline badge, and Connections moved to the top.",
|
||||
"Release names and notes are now split per surface (Android, plugin, CLI)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Fixed",
|
||||
"bullets": [
|
||||
"No more force-close on connect when the stored credential keyset was corrupt — it now heals in place.",
|
||||
"The installer works on uv-managed Hermes hosts, and the dashboard relay panel buttons are readable again."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"title": "Stable launch",
|
||||
"date": "2026-06-14",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Gateway chat with live thinking",
|
||||
"bullets": [
|
||||
"Chat can ride the upstream dashboard gateway — the only vanilla-upstream path that streams reasoning live, so the Thinking block and sphere light up during generation. \"Auto\" prefers it and falls back to the SSE endpoints per turn.",
|
||||
"Desktop parity: native image/PDF/file attachments, mid-turn steering, edit & resend, approval/clarify/sudo/secret cards, live subagent lanes, a context-window meter, server slash commands, and turn-complete notifications.",
|
||||
"Warm-start and an opt-in Keep connected in background toggle so long-backgrounded conversations resume instantly."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Agents, Manage & media",
|
||||
"bullets": [
|
||||
"Switch agent profiles per conversation — model, SOUL, personality, and skills — with the selection bound to the session, never changing the server default for other clients.",
|
||||
"Manage parity with the desktop dashboard: change models, manage provider keys, edit profiles and SOUL.md, and browse/install skills.",
|
||||
"Open and save chat images and attachments — full-screen viewer with pinch-zoom, plus an Open/Share/Save menu."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Standard path is first-class",
|
||||
"bullets": [
|
||||
"Chat, Manage, and voice all work against an unmodified upstream Hermes agent; the relay plugin is now purely additive.",
|
||||
"Seamless connection UX — LAN↔Tailscale handoffs and reconnects no longer reload the chat, and status shows as in-theme slide-down toasts.",
|
||||
"Persistent Realtime Agent voice that keeps one session across turns, with long runs promoted to tracked background tasks."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
v1.2.6 - Tidier chats & calmer status
|
||||
v1.3.0 - Voice that multitasks & chats that keep their answers
|
||||
|
||||
Voice
|
||||
* Ask for something big and keep talking - long tasks hand off to
|
||||
the background with a live chip (current step, timer, tap to
|
||||
cancel), and the answer is spoken when it's ready, even after a
|
||||
dropped connection. Leaving voice mode no longer cancels a
|
||||
running task.
|
||||
|
||||
Chats
|
||||
* Chats no longer get stuck on "Untitled" — your first message stands
|
||||
in as the title until the chat is named, titles refresh once a turn
|
||||
settles, and a new refresh button in the session drawer pulls the
|
||||
latest. Renaming a chat now sticks on non-default agent profiles.
|
||||
* An answer is no longer lost if the connection drops mid-reply -
|
||||
the app quietly recovers it when the server finishes.
|
||||
* Your agent can message you first (opt-in), and you can reply
|
||||
right from the notification.
|
||||
|
||||
Calmer status
|
||||
* Connection status (reconnecting, checking, network handoffs) now
|
||||
shows as a thin banner at the top that gently slides the screen
|
||||
down, instead of a card floating over your chat — the floating alert
|
||||
is kept for persistent errors. Quick confirmations land there too.
|
||||
Plus
|
||||
* Pick your app font, onboarding fits small screens, smarter
|
||||
issue reporting, and a cleaner Connections screen.
|
||||
|
||||
@@ -177,6 +177,14 @@ object DiagnosticsLog {
|
||||
return noUserInfo.take(MAX_TEXT_LENGTH)
|
||||
}
|
||||
|
||||
/**
|
||||
* Public secret redaction for user-composed report text (e.g. the "what
|
||||
* were you expecting?" answer embedded in a GitHub issue body). Same
|
||||
* redaction + cap as the stored stacktraces — entry fields are already
|
||||
* sanitized at record time; this covers text added after the fact.
|
||||
*/
|
||||
fun redactReportText(value: String?): String? = redactTrace(value)
|
||||
|
||||
private fun clean(value: String?): String? {
|
||||
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return redact(trimmed).take(MAX_TEXT_LENGTH)
|
||||
|
||||
@@ -220,6 +220,22 @@ class ChatHandler {
|
||||
private val _isStreaming = MutableStateFlow(false)
|
||||
val isStreaming: StateFlow<Boolean> = _isStreaming.asStateFlow()
|
||||
|
||||
/**
|
||||
* Silently drop the global streaming flag + turn-status caption without
|
||||
* touching the message list, error, or per-message `isStreaming` flags.
|
||||
* For abandoning an in-flight answer recovery (issue #166) on a path that
|
||||
* clears or reloads the transcript itself: there is no placeholder to
|
||||
* finalize and nothing went wrong, so [onStreamComplete] (which reconciles
|
||||
* a specific message) and [onStreamError] (which raises an error banner)
|
||||
* are both the wrong tool. Leaves per-message streaming flags intact so a
|
||||
* caller that still needs to find the placeholder afterwards (e.g.
|
||||
* cancelStream's Stopped-badge pass) can.
|
||||
*/
|
||||
fun clearStreamingStatus() {
|
||||
_isStreaming.value = false
|
||||
_turnStatus.value = null
|
||||
}
|
||||
|
||||
private val _sessions = MutableStateFlow<List<ChatSession>>(emptyList())
|
||||
val sessions: StateFlow<List<ChatSession>> = _sessions.asStateFlow()
|
||||
|
||||
@@ -2644,6 +2660,9 @@ class ChatHandler {
|
||||
|
||||
fun onStreamError(message: String) {
|
||||
_isStreaming.value = false
|
||||
// The turn is over — a stale lifecycle/recovery caption must not
|
||||
// outlive it (onStreamComplete clears the same way).
|
||||
_turnStatus.value = null
|
||||
_error.value = message
|
||||
// Clear streaming flag on any actively streaming message
|
||||
_messages.update { messages ->
|
||||
|
||||
@@ -178,6 +178,32 @@ class HermesApiClient(
|
||||
companion object {
|
||||
private const val TAG = "HermesApiClient"
|
||||
private val JSON_MEDIA = "application/json".toMediaType()
|
||||
|
||||
/**
|
||||
* Prefix stamped by [streamFailureMessage] on stream failures raised
|
||||
* by the transport layer (the IOException family: socket reset/close,
|
||||
* DNS, TLS, timeouts) as opposed to a server-reported error. The
|
||||
* dropped-stream answer recovery (issue #166) keys on it via
|
||||
* [isTransportStreamError].
|
||||
*/
|
||||
const val TRANSPORT_ERROR_PREFIX = "Connection failed"
|
||||
|
||||
/**
|
||||
* True when a stream `onError` message came from a transport-layer
|
||||
* failure (see [TRANSPORT_ERROR_PREFIX]) — the class of error where
|
||||
* the server may still be running (and persisting) the turn.
|
||||
*/
|
||||
fun isTransportStreamError(errorMsg: String): Boolean =
|
||||
errorMsg.startsWith(TRANSPORT_ERROR_PREFIX)
|
||||
|
||||
/** Shared human-readable message for an SSE [EventSourceListener.onFailure]. */
|
||||
private fun streamFailureMessage(t: Throwable?, response: Response?): String = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
"API error ${response.code}: ${response.message}"
|
||||
t is IOException -> "$TRANSPORT_ERROR_PREFIX: ${t.message}"
|
||||
t != null -> "Stream error: ${t.message}"
|
||||
else -> "Unknown stream error"
|
||||
}
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
@@ -762,13 +788,7 @@ class HermesApiClient(
|
||||
) {
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
"API error ${response.code}: ${response.message}"
|
||||
t is IOException -> "Connection failed: ${t.message}"
|
||||
t != null -> "Stream error: ${t.message}"
|
||||
else -> "Unknown stream error"
|
||||
}
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
@@ -904,13 +924,7 @@ class HermesApiClient(
|
||||
) {
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
"API error ${response.code}: ${response.message}"
|
||||
t is IOException -> "Connection failed: ${t.message}"
|
||||
t != null -> "Stream error: ${t.message}"
|
||||
else -> "Unknown stream error"
|
||||
}
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
@@ -1203,13 +1217,7 @@ class HermesApiClient(
|
||||
) {
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
"API error ${response.code}: ${response.message}"
|
||||
t is IOException -> "Connection failed: ${t.message}"
|
||||
t != null -> "Stream error: ${t.message}"
|
||||
else -> "Unknown stream error"
|
||||
}
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
|
||||
+37
-54
@@ -21,11 +21,15 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -38,6 +42,7 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.BuildConfig
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.util.DiagnosticIssuePrefill
|
||||
import com.hermesandroid.relay.util.IssueReport
|
||||
|
||||
/**
|
||||
@@ -57,6 +62,13 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
val plainText = remember(entry) { entry.toPlainText() }
|
||||
val severityName = entry.severity.name
|
||||
|
||||
// Info-severity pre-flight: routine log lines only become GitHub issues once
|
||||
// the reporter says what they expected instead (that answer replaces the
|
||||
// boilerplate "What happened" line). Error entries keep the direct flow.
|
||||
val needsExpectation = entry.severity == DiagnosticSeverity.Info
|
||||
var expectationVisible by remember(entry) { mutableStateOf(false) }
|
||||
var expectation by remember(entry) { mutableStateOf("") }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
@@ -127,6 +139,20 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
)
|
||||
}
|
||||
|
||||
if (expectationVisible) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
OutlinedTextField(
|
||||
value = expectation,
|
||||
onValueChange = { expectation = it },
|
||||
label = { Text("What were you expecting to happen?") },
|
||||
supportingText = {
|
||||
Text("This is a routine log entry — telling us what looked wrong turns it into an answerable report.")
|
||||
},
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -155,16 +181,24 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
},
|
||||
) { Text("Export") }
|
||||
Button(
|
||||
enabled = !expectationVisible || expectation.isNotBlank(),
|
||||
onClick = {
|
||||
if (needsExpectation && !expectationVisible) {
|
||||
expectationVisible = true
|
||||
return@Button
|
||||
}
|
||||
// Copy full text first; the GitHub URL only carries the
|
||||
// head of long traces, so the user can paste the rest.
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
val opened = IssueReport.openUrl(
|
||||
context,
|
||||
IssueReport.buildGithubIssueUrl(
|
||||
title = "[Bug]: ${entry.title}",
|
||||
bodyMarkdown = entry.toIssueBody(),
|
||||
labels = "bug",
|
||||
title = DiagnosticIssuePrefill.issueTitle(entry),
|
||||
bodyMarkdown = DiagnosticIssuePrefill.issueBody(
|
||||
entry,
|
||||
expectation = expectation.takeIf { expectationVisible },
|
||||
),
|
||||
labels = DiagnosticIssuePrefill.issueLabels(entry),
|
||||
),
|
||||
)
|
||||
toast(
|
||||
@@ -245,54 +279,3 @@ private fun DiagnosticLogEntry.toPlainText(): String = buildString {
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown issue body mirroring the crash-report issue format: environment block
|
||||
* + the captured entry. Trace is capped so the prefilled GitHub URL stays within
|
||||
* browser limits (full text is on the clipboard).
|
||||
*/
|
||||
private const val MAX_TRACE_FOR_URL = 3000
|
||||
|
||||
private fun DiagnosticLogEntry.toIssueBody(): String {
|
||||
val trace = (stacktrace ?: detail).orEmpty().let {
|
||||
if (it.length > MAX_TRACE_FOR_URL) {
|
||||
it.take(MAX_TRACE_FOR_URL) + "\n… (truncated — full diagnostic copied to your clipboard)"
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
val surface = if (BuildConfig.FLAVOR.equals("sideload", ignoreCase = true)) "sideload APK" else "Google Play"
|
||||
return buildString {
|
||||
appendLine(
|
||||
"> ⚠️ Before submitting: remove any secrets, tokens, real hostnames/IPs, " +
|
||||
"or personal data from the detail below.",
|
||||
)
|
||||
appendLine()
|
||||
appendLine("### Affected area")
|
||||
appendLine("Android app")
|
||||
appendLine()
|
||||
appendLine("### What happened?")
|
||||
appendLine("Captured diagnostic from the in-app activity log.")
|
||||
appendLine()
|
||||
appendLine("### Environment")
|
||||
appendLine("- Hermes-Relay version/tag: ${BuildConfig.VERSION_NAME} (code ${BuildConfig.VERSION_CODE})")
|
||||
appendLine("- Install surface: $surface")
|
||||
appendLine("- Connection mode: LAN / Tailscale / public TLS / other")
|
||||
appendLine()
|
||||
appendLine("### Diagnostic")
|
||||
appendLine("- Title: $title")
|
||||
appendLine("- Category: ${category.label}")
|
||||
appendLine("- Severity: ${severity.name}")
|
||||
endpointRole?.let { appendLine("- Route: $it") }
|
||||
url?.let { appendLine("- URL: $it") }
|
||||
elapsedMs?.let { appendLine("- Elapsed: ${it}ms") }
|
||||
if (trace.isNotBlank()) {
|
||||
appendLine()
|
||||
appendLine("```")
|
||||
appendLine(trace)
|
||||
appendLine("```")
|
||||
}
|
||||
appendLine()
|
||||
append("<sub>Captured by the Hermes-Relay in-app diagnostics log</sub>")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,14 @@ fun MessageBubble(
|
||||
* conversation). Null hides the entry.
|
||||
*/
|
||||
onEditMessage: ((ChatMessage) -> Unit)? = null,
|
||||
/**
|
||||
* True while the ViewModel is recovering a dropped stream's answer by
|
||||
* polling the session transcript (issue #166) — the streaming
|
||||
* placeholder's slow-turn label reads "Reconnecting to your answer…"
|
||||
* instead of "Still working…" so the wait is honest about what's
|
||||
* happening.
|
||||
*/
|
||||
recoveringAnswer: Boolean = false,
|
||||
) {
|
||||
val isUser = message.role == MessageRole.USER
|
||||
val isSystem = message.role == MessageRole.SYSTEM
|
||||
@@ -499,10 +507,17 @@ fun MessageBubble(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
if (showStillWorking && awaitingFirstToken) {
|
||||
// During dropped-stream answer recovery the label shows
|
||||
// immediately (the 4s escalation is for a slow first
|
||||
// token; a recovery is already known to be slow).
|
||||
if ((showStillWorking || recoveringAnswer) && awaitingFirstToken) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Still working…",
|
||||
text = if (recoveringAnswer) {
|
||||
"Reconnecting to your answer…"
|
||||
} else {
|
||||
"Still working…"
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -13,6 +14,8 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -69,66 +72,80 @@ fun OnboardingPage(
|
||||
}
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 560.dp)
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(232.dp)
|
||||
.gradientBorder(shape = heroShape, isDarkTheme = isDarkTheme),
|
||||
shape = heroShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (transparentHero) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer
|
||||
)
|
||||
) {
|
||||
val heroModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(232.dp)
|
||||
Box(
|
||||
modifier = if (transparentHero) heroModifier else heroModifier.background(heroBrush),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
heroContent()
|
||||
}
|
||||
// Short viewports (small phones, large font scale, split screen) shrink or
|
||||
// drop the hero so the body text fits; the vertical scroll below is the
|
||||
// safety net when even that isn't enough. The enclosing pager Box centers
|
||||
// short content, so no Arrangement.Center here — it conflicts with
|
||||
// verticalScroll when content overflows.
|
||||
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
|
||||
val heroHeight = when {
|
||||
maxHeight < 480.dp -> 0.dp
|
||||
maxHeight < 620.dp -> 160.dp
|
||||
else -> 232.dp
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
Card(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(shape = bodyShape, isDarkTheme = isDarkTheme),
|
||||
shape = bodyShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
.widthIn(max = 560.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 22.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
if (heroHeight > 0.dp) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(heroHeight)
|
||||
.gradientBorder(shape = heroShape, isDarkTheme = isDarkTheme),
|
||||
shape = heroShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (transparentHero) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer
|
||||
)
|
||||
) {
|
||||
val heroModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(heroHeight)
|
||||
Box(
|
||||
modifier = if (transparentHero) heroModifier else heroModifier.background(heroBrush),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
heroContent()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(shape = bodyShape, isDarkTheme = isDarkTheme),
|
||||
shape = bodyShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 22.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
content()
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ 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.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -208,11 +209,15 @@ fun OnboardingScreen(
|
||||
// Bottom navigation only on informational pages — the wizard
|
||||
// owns its own back/pair affordances.
|
||||
if (pages[pagerState.currentPage] != OnboardingPage.Connect) {
|
||||
// Short viewports get a tighter footer so more of the pager
|
||||
// content stays above the fold; indicator + Back/Next remain
|
||||
// pinned outside the (scrollable) pager pages either way.
|
||||
val compactHeight = LocalConfiguration.current.screenHeightDp < 620
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(bottom = 48.dp),
|
||||
.padding(bottom = if (compactHeight) 16.dp else 48.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
PageIndicator(
|
||||
@@ -220,7 +225,7 @@ fun OnboardingScreen(
|
||||
currentPage = pagerState.currentPage
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Spacer(modifier = Modifier.height(if (compactHeight) 12.dp else 24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -462,6 +462,7 @@ fun ChatScreen(
|
||||
val messages by chatViewModel.messages.collectAsState()
|
||||
val isStreaming by chatViewModel.isStreaming.collectAsState()
|
||||
val turnStatus by chatViewModel.turnStatus.collectAsState()
|
||||
val recoveringAnswer by chatViewModel.recoveringAnswer.collectAsState()
|
||||
val voiceStats by voiceViewModel.voiceStats.collectAsState()
|
||||
var voiceOutputConfig by remember { mutableStateOf<VoiceOutputConfig?>(null) }
|
||||
var realtimeAgentConfig by remember { mutableStateOf<RealtimeVoiceConfig?>(null) }
|
||||
@@ -2068,6 +2069,7 @@ fun ChatScreen(
|
||||
showThinking = showThinking,
|
||||
isFirstInGroup = isFirstInGroup,
|
||||
isLastInGroup = isLastInGroup,
|
||||
recoveringAnswer = recoveringAnswer,
|
||||
onAttachmentRetry = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import com.hermesandroid.relay.BuildConfig
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
|
||||
/**
|
||||
* Pure builder for the GitHub "new issue" prefill derived from a diagnostics
|
||||
* entry — title, labels, and markdown body. Extracted from the detail dialog so
|
||||
* the prefill contract is unit-testable without Compose.
|
||||
*
|
||||
* Only [DiagnosticSeverity.Error] entries are bug reports. Routine Info/Warning
|
||||
* log lines ("Testing API connection", probe results, …) were landing on the
|
||||
* tracker as `[Bug]:` issues with an empty boilerplate body, so non-Error
|
||||
* entries prefill as `[Diagnostic]:` questions instead; for Info entries the
|
||||
* caller additionally collects the reporter's expectation before offering the
|
||||
* link (see [com.hermesandroid.relay.ui.components.DiagnosticDetailDialog]).
|
||||
*/
|
||||
object DiagnosticIssuePrefill {
|
||||
|
||||
/**
|
||||
* Cap for the trace embedded in the prefilled GitHub URL so it stays within
|
||||
* browser limits (the full text is on the clipboard).
|
||||
*/
|
||||
private const val MAX_TRACE_FOR_URL = 3000
|
||||
|
||||
private const val DEFAULT_WHAT_HAPPENED = "Captured diagnostic from the in-app activity log."
|
||||
|
||||
/** `[Bug]:` for Error entries, `[Diagnostic]:` for Info/Warning. */
|
||||
fun issueTitle(entry: DiagnosticLogEntry): String = when (entry.severity) {
|
||||
DiagnosticSeverity.Error -> "[Bug]: ${entry.title}"
|
||||
else -> "[Diagnostic]: ${entry.title}"
|
||||
}
|
||||
|
||||
/**
|
||||
* `bug` for Error entries; `question` (an existing repo label) for
|
||||
* Info/Warning so routine diagnostics don't pollute the bug queue.
|
||||
*/
|
||||
fun issueLabels(entry: DiagnosticLogEntry): String = when (entry.severity) {
|
||||
DiagnosticSeverity.Error -> "bug"
|
||||
else -> "question"
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual active route for the "Connection mode" line: the role stamped
|
||||
* on the entry when present, else inferred from the entry URL, else
|
||||
* `unknown` — never the old unedited "LAN / Tailscale / public TLS / other"
|
||||
* template text.
|
||||
*/
|
||||
fun connectionMode(entry: DiagnosticLogEntry): String =
|
||||
entry.endpointRole
|
||||
?: entry.url?.let { Connection.inferRouteRole(it) }
|
||||
?: "unknown"
|
||||
|
||||
/**
|
||||
* Markdown issue body mirroring the crash-report issue format: environment
|
||||
* block + the captured entry.
|
||||
*
|
||||
* @param expectation the reporter's free-text "What were you expecting to
|
||||
* happen?" answer collected by the Info-severity pre-flight; when
|
||||
* non-blank it replaces the boilerplate "What happened" line. Runs
|
||||
* through the shared diagnostics secret redaction before embedding.
|
||||
*/
|
||||
fun issueBody(entry: DiagnosticLogEntry, expectation: String? = null): String {
|
||||
val trace = (entry.stacktrace ?: entry.detail).orEmpty().let {
|
||||
if (it.length > MAX_TRACE_FOR_URL) {
|
||||
it.take(MAX_TRACE_FOR_URL) + "\n… (truncated — full diagnostic copied to your clipboard)"
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
val surface = if (BuildConfig.FLAVOR.equals("sideload", ignoreCase = true)) "sideload APK" else "Google Play"
|
||||
val whatHappened = DiagnosticsLog.redactReportText(expectation)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: DEFAULT_WHAT_HAPPENED
|
||||
return buildString {
|
||||
appendLine(
|
||||
"> ⚠️ Before submitting: remove any secrets, tokens, real hostnames/IPs, " +
|
||||
"or personal data from the detail below.",
|
||||
)
|
||||
appendLine()
|
||||
appendLine("### Affected area")
|
||||
appendLine("Android app")
|
||||
appendLine()
|
||||
appendLine("### What happened?")
|
||||
appendLine(whatHappened)
|
||||
appendLine()
|
||||
appendLine("### Environment")
|
||||
appendLine("- Hermes-Relay version/tag: ${BuildConfig.VERSION_NAME} (code ${BuildConfig.VERSION_CODE})")
|
||||
appendLine("- Install surface: $surface")
|
||||
appendLine("- Connection mode: ${connectionMode(entry)}")
|
||||
appendLine()
|
||||
appendLine("### Diagnostic")
|
||||
appendLine("- Title: ${entry.title}")
|
||||
appendLine("- Category: ${entry.category.label}")
|
||||
appendLine("- Severity: ${entry.severity.name}")
|
||||
entry.endpointRole?.let { appendLine("- Route: $it") }
|
||||
entry.url?.let { appendLine("- URL: $it") }
|
||||
entry.elapsedMs?.let { appendLine("- Elapsed: ${it}ms") }
|
||||
if (trace.isNotBlank()) {
|
||||
appendLine()
|
||||
appendLine("```")
|
||||
appendLine(trace)
|
||||
appendLine("```")
|
||||
}
|
||||
appendLine()
|
||||
append("<sub>Captured by the Hermes-Relay in-app diagnostics log</sub>")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,37 @@ object ServerAddress {
|
||||
/** True when [raw] forms a valid http(s) address once normalized. Blank → false. */
|
||||
fun isValidUserInput(raw: String?): Boolean = parseUserInput(raw) != null
|
||||
|
||||
/**
|
||||
* Advisory for a server address whose host is loopback / any-interface —
|
||||
* `localhost`, `127.x.x.x`, `::1`, `0.0.0.0`. Such an address works in a
|
||||
* browser *on the server* but can never reach the server from the phone,
|
||||
* a recurring source of "Testing API connection" dead-ends. Accepts the
|
||||
* same scheme-less input [parseUserInput] normalizes. Returns `null` for
|
||||
* blank, unparseable, or non-loopback addresses. NEVER throws.
|
||||
*
|
||||
* Pure helper only — not yet surfaced anywhere; UI wiring is a follow-up.
|
||||
*/
|
||||
fun loopbackHostWarning(raw: String): String? {
|
||||
val trimmed = raw.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
// Bare "::1" never parses without brackets — normalize it directly.
|
||||
val host = parseUserInput(trimmed)?.host?.lowercase()
|
||||
?: trimmed.removePrefix("[").removeSuffix("]").lowercase().takeIf { it == "::1" }
|
||||
?: return null
|
||||
val loopback = host == "localhost" || host == "::1" || host == "0.0.0.0" || isLoopbackIpv4(host)
|
||||
return if (loopback) {
|
||||
"On your phone, localhost points at the phone itself — use the server's LAN IP or Tailscale address."
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLoopbackIpv4(host: String): Boolean {
|
||||
val labels = host.split('.')
|
||||
val parts = labels.mapNotNull { it.toIntOrNull() }
|
||||
return labels.size == 4 && parts.size == 4 && parts[0] == 127
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline error for a server-URL / host text field, or `null` when the value
|
||||
* is acceptable. Blank returns `null` so callers can gate required-ness
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Client-side answer recovery for a sessions-endpoint chat stream that died on
|
||||
* a transport error while the server kept working (issue #166).
|
||||
*
|
||||
* On slow local models + delegating skills, a turn can outlive the phone's SSE
|
||||
* socket (screen-off / Doze / Wi-Fi power-save kill it long before OkHttp's
|
||||
* read timeout). Upstream `api_server` behavior on that disconnect: the SSE
|
||||
* writer dies but the agent run continues in an uncancellable executor thread
|
||||
* and the FINAL ANSWER IS PERSISTED to the session store. So instead of
|
||||
* finalizing the turn as an error, this poller re-reads the session transcript
|
||||
* (the native upstream `/api/sessions/{id}/messages` route — standard-path
|
||||
* safe, no server changes) until the answer lands.
|
||||
*
|
||||
* Cadence: first poll after [Timing.pollIntervalMs], doubling each poll up to
|
||||
* [Timing.maxPollIntervalMs], for at most [Timing.recoveryWindowMs] of slept
|
||||
* time. Elapsed time is accumulated from the delays (not wall-clock reads) so
|
||||
* the loop is virtual-time friendly in tests.
|
||||
*
|
||||
* **Anchoring (positional, not text-only).** The pending send, if it landed,
|
||||
* is the `(priorUserMessageCount + 1)`-th user-role row in the server
|
||||
* transcript — i.e. the first user row AFTER the ones the client already knew
|
||||
* about. The candidate answer is the last non-blank assistant row after that
|
||||
* anchor. Anchoring by position (with a content-match sanity check) — rather
|
||||
* than a bare `indexOfLast` text search — is what stops a short repeated
|
||||
* prompt ("yes", "ok", "continue") from matching a STALE identical earlier row
|
||||
* and adopting a DIFFERENT turn's (static, therefore instantly "stable")
|
||||
* answer when this send never actually reached the server.
|
||||
*
|
||||
* Finish condition: an assistant message that postdates the anchor, is
|
||||
* non-empty, and is stable across two consecutive polls (the signature also
|
||||
* folds in the transcript length, so a still-running run that keeps appending
|
||||
* tool rows after an intermediate assistant message defers the finish).
|
||||
* Intermediate persisted rows are surfaced through [onIntermediateHistory] as
|
||||
* they appear — progressive recovery.
|
||||
*
|
||||
* Fail-fast: when the anchor can't be established — the transcript still holds
|
||||
* only the user rows the client already knew about (the POST died before the
|
||||
* server persisted the turn), or the positional row's content diverges from
|
||||
* the pending send (the transcript was edited/forked out from under us) — the
|
||||
* poller never adopts an answer, because a wrong answer is worse than an error.
|
||||
* It confirms the state across two consecutive polls (guarding against a
|
||||
* transient read mid-persist) and then gives up with [GiveUpReason.RUN_NOT_FOUND]
|
||||
* instead of polling to the 30-minute cap, since no answer for this turn can
|
||||
* ever arrive. An EMPTY transcript carries no information (the history read
|
||||
* maps fetch failures to an empty list too), so it keeps polling.
|
||||
*/
|
||||
class ChatStreamRecovery(
|
||||
private val scope: CoroutineScope,
|
||||
private val fetchHistory: suspend () -> List<MessageItem>,
|
||||
private val timing: Timing = Timing(),
|
||||
) {
|
||||
|
||||
data class Timing(
|
||||
val pollIntervalMs: Long = 5_000L,
|
||||
val maxPollIntervalMs: Long = 30_000L,
|
||||
val recoveryWindowMs: Long = 30L * 60_000L,
|
||||
)
|
||||
|
||||
enum class GiveUpReason {
|
||||
/**
|
||||
* The pending send couldn't be located as a new user row after the
|
||||
* ones the client already knew about — the POST never persisted the
|
||||
* turn, or the transcript diverged. No answer can arrive; resend.
|
||||
*/
|
||||
RUN_NOT_FOUND,
|
||||
|
||||
/** The recovery window elapsed without a stable answer. */
|
||||
TIMED_OUT,
|
||||
}
|
||||
|
||||
/** Whether a poll could establish the anchor for the pending send. */
|
||||
private sealed interface Anchor {
|
||||
/** The `(priorUserCount + 1)`-th user row exists and matches. */
|
||||
data class Found(val index: Int) : Anchor
|
||||
|
||||
/**
|
||||
* The anchor can't be proven: too few user rows (send not persisted)
|
||||
* or a positional row whose content diverges (edited/forked history).
|
||||
*/
|
||||
data object NotEstablished : Anchor
|
||||
}
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
val isActive: Boolean
|
||||
get() = job?.isActive == true
|
||||
|
||||
/**
|
||||
* Start polling. Exactly one poll loop per instance — a second [start]
|
||||
* replaces the first. Exactly one terminal callback fires per loop
|
||||
* ([onRecovered] or [onGaveUp]); cancellation fires none.
|
||||
*
|
||||
* @param priorUserMessageCount how many user-role messages the client knew
|
||||
* existed BEFORE this turn's pending send (excluding the in-flight pair).
|
||||
* Drives the positional anchor — see the class KDoc.
|
||||
*/
|
||||
fun start(
|
||||
pendingUserText: String,
|
||||
priorUserMessageCount: Int,
|
||||
onIntermediateHistory: (List<MessageItem>) -> Unit,
|
||||
onRecovered: (List<MessageItem>) -> Unit,
|
||||
onGaveUp: (GiveUpReason) -> Unit,
|
||||
) {
|
||||
job?.cancel()
|
||||
val pending = pendingUserText.trim()
|
||||
val priorUsers = priorUserMessageCount.coerceAtLeast(0)
|
||||
job = scope.launch {
|
||||
var delayMs = timing.pollIntervalMs
|
||||
var elapsedMs = 0L
|
||||
var lastSignature: String? = null
|
||||
var lastSurfacedCount = -1
|
||||
var unanchoredPolls = 0
|
||||
while (elapsedMs < timing.recoveryWindowMs) {
|
||||
delay(delayMs)
|
||||
elapsedMs += delayMs
|
||||
delayMs = (delayMs * 2).coerceAtMost(timing.maxPollIntervalMs)
|
||||
|
||||
val items = try {
|
||||
fetchHistory()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
continue // unreachable — keep waiting for the network
|
||||
}
|
||||
if (items.isEmpty()) continue
|
||||
|
||||
when (val anchor = resolveAnchor(items, pending, priorUsers)) {
|
||||
is Anchor.NotEstablished -> {
|
||||
// The pending send isn't (verifiably) persisted as a new
|
||||
// user row. A transient read while the server persists
|
||||
// could look like this, so require the state to hold
|
||||
// across two consecutive polls before giving up — but
|
||||
// never poll to the cap for an answer that can't arrive.
|
||||
lastSignature = null
|
||||
if (++unanchoredPolls >= 2) {
|
||||
onGaveUp(GiveUpReason.RUN_NOT_FOUND)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
is Anchor.Found -> {
|
||||
unanchoredPolls = 0
|
||||
val signature = answerSignature(items, anchor.index)
|
||||
if (signature != null && signature == lastSignature) {
|
||||
onRecovered(items)
|
||||
return@launch
|
||||
}
|
||||
lastSignature = signature
|
||||
|
||||
if (items.size != lastSurfacedCount) {
|
||||
lastSurfacedCount = items.size
|
||||
onIntermediateHistory(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onGaveUp(GiveUpReason.TIMED_OUT)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the positional anchor for the pending send. The send, if it
|
||||
* landed, is the `(priorUserCount + 1)`-th user-role row; that row must
|
||||
* ALSO match the pending text (secondary sanity check for edits/forks).
|
||||
* Any other shape is [Anchor.NotEstablished] — never adopt a guess.
|
||||
*/
|
||||
private fun resolveAnchor(
|
||||
items: List<MessageItem>,
|
||||
pendingUserText: String,
|
||||
priorUserCount: Int,
|
||||
): Anchor {
|
||||
val userIndices = items.indices.filter { items[it].role == "user" }
|
||||
if (userIndices.size <= priorUserCount) return Anchor.NotEstablished
|
||||
val anchorPos = userIndices[priorUserCount]
|
||||
if (items[anchorPos].contentText?.trim() != pendingUserText) return Anchor.NotEstablished
|
||||
return Anchor.Found(anchorPos)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stability signature of the candidate answer: the last non-blank
|
||||
* assistant message after the anchor, or null while none exists. The
|
||||
* transcript size is folded in so new rows (tool results of a
|
||||
* still-running run) change the signature and defer the finish.
|
||||
*/
|
||||
private fun answerSignature(items: List<MessageItem>, anchor: Int): String? {
|
||||
val answer = items.drop(anchor + 1).lastOrNull {
|
||||
it.role == "assistant" && !it.contentText.isNullOrBlank()
|
||||
} ?: return null
|
||||
return "${items.size}|${answer.id}|${answer.contentText?.length}"
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.HermesCardAction
|
||||
import com.hermesandroid.relay.data.HermesCardField
|
||||
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.network.upstream.ActiveTurnHandle
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAsk
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
@@ -113,6 +116,26 @@ class ChatViewModel : ViewModel() {
|
||||
*/
|
||||
private var activeStreamIsGateway = false
|
||||
private var intentionallyCancelled = false
|
||||
|
||||
/**
|
||||
* Answer-recovery poller for a sessions-endpoint turn whose SSE transport
|
||||
* died while the server kept running the turn (issue #166) — see
|
||||
* [ChatStreamRecovery] and [startAnswerRecovery]. At most one per turn;
|
||||
* null while idle.
|
||||
*/
|
||||
private var streamRecovery: ChatStreamRecovery? = null
|
||||
|
||||
/** Test seam for the recovery poll cadence — production uses the defaults. */
|
||||
internal var recoveryTimingOverride: ChatStreamRecovery.Timing? = null
|
||||
|
||||
private val _recoveringAnswer = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* True while [streamRecovery] polls for a dropped turn's answer — drives
|
||||
* the "Reconnecting to your answer…" copy on the streaming placeholder.
|
||||
*/
|
||||
val recoveringAnswer: StateFlow<Boolean> = _recoveringAnswer.asStateFlow()
|
||||
|
||||
private var firstTokenNotified = false
|
||||
private var toolHistoryJob: Job? = null
|
||||
private var connectionSwitchJob: Job? = null
|
||||
@@ -1677,6 +1700,7 @@ class ChatViewModel : ViewModel() {
|
||||
intentionallyCancelled = true
|
||||
activeStream?.cancel()
|
||||
activeStream = null
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
sessionRefreshJob?.cancel()
|
||||
_isLoadingSessions.value = false
|
||||
activeProfileContextKey = null
|
||||
@@ -1736,6 +1760,7 @@ class ChatViewModel : ViewModel() {
|
||||
stream.cancel()
|
||||
}
|
||||
activeStream = null
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
val loadGeneration = historyLoadGeneration.incrementAndGet()
|
||||
sessionRefreshGeneration.incrementAndGet()
|
||||
sessionRefreshJob?.cancel()
|
||||
@@ -1945,9 +1970,10 @@ class ChatViewModel : ViewModel() {
|
||||
val client = apiClient ?: return
|
||||
val handler = chatHandler ?: return
|
||||
|
||||
// Cancel any in-flight stream
|
||||
// Cancel any in-flight stream (and any answer-recovery poller)
|
||||
activeStream?.cancel()
|
||||
activeStream = null
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
val loadGeneration = historyLoadGeneration.incrementAndGet()
|
||||
|
||||
// Gateway transport: a new chat is a fresh DRAFT with NO session id.
|
||||
@@ -2035,6 +2061,7 @@ class ChatViewModel : ViewModel() {
|
||||
val handler = chatHandler ?: return
|
||||
activeStream?.cancel()
|
||||
activeStream = null
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
historyLoadGeneration.incrementAndGet()
|
||||
val slug = name.trim().lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-').take(24)
|
||||
val chatId = "t-" + slug.ifBlank { "thread" } + "-" +
|
||||
@@ -2097,10 +2124,12 @@ class ChatViewModel : ViewModel() {
|
||||
apiClient ?: return
|
||||
val handler = chatHandler ?: return
|
||||
|
||||
// Cancel any in-flight stream
|
||||
// Cancel any in-flight stream (and any answer-recovery poller — the
|
||||
// switched-to session must not receive the old turn's reconcile).
|
||||
intentionallyCancelled = true
|
||||
activeStream?.cancel()
|
||||
activeStream = null
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
val loadGeneration = historyLoadGeneration.incrementAndGet()
|
||||
|
||||
handler.setSessionId(sessionId)
|
||||
@@ -2805,6 +2834,183 @@ class ChatViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
|
||||
// === Dropped-stream answer recovery (issue #166) ===
|
||||
|
||||
/**
|
||||
* Terminal side effects every successfully finished turn shares — the
|
||||
* normal stream completion ([startStream]'s onCompleteCb) and a recovered
|
||||
* dropped-stream turn ([startAnswerRecovery]) both end here, so recovery
|
||||
* finalizes with exactly the completion semantics.
|
||||
*/
|
||||
private fun finalizeTurnSideEffects(handler: ChatHandler, messageId: String) {
|
||||
handler.onStreamComplete(messageId)
|
||||
activeStream = null
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
// Turn over — any blocked ask has been resolved server-side
|
||||
// (answer, timeout, or interrupt). Timed cards self-collapse;
|
||||
// an unanswered approval gets a neutral "Resolved" stamp so its
|
||||
// buttons don't dead-end in "no longer active" notices.
|
||||
clearPendingAsk(approvalStamp = "Resolved")
|
||||
|
||||
// Notify when the turn finished while the app is backgrounded —
|
||||
// never for cancelled streams; errors end via onErrorCb instead.
|
||||
maybeNotifyTurnComplete(handler, messageId)
|
||||
|
||||
// v0.4.1 polish: auto-return to Hermes-Relay if the bridge
|
||||
// moved the foreground app during this run. No-op when the
|
||||
// LLM already called `android_return_to_hermes` itself (in
|
||||
// that case the tracker's internal flag was cleared by the
|
||||
// /return_to_hermes dispatch's respond()). See BridgeRunTracker
|
||||
// KDoc for the full contract.
|
||||
com.hermesandroid.relay.bridge.BridgeRunTracker.notifyRunCompleted()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop any in-flight answer recovery — and ALWAYS settle the handler's
|
||||
* streaming/turn-status state when a poller was actually running.
|
||||
*
|
||||
* When recovery is live there is NO live [activeStream] (it was nulled
|
||||
* when the poller started), so nothing else fires onStreamComplete /
|
||||
* onStreamError to clear the "Reconnecting to your answer…" caption and
|
||||
* the global streaming flag. If abort left them set the chat would wedge
|
||||
* in streaming mode (dead Stop button, frozen caption) until process
|
||||
* death (issue #166).
|
||||
*
|
||||
* [settleUi] chooses HOW to settle:
|
||||
* - `true` (a new send about to add its own placeholder) finalizes the
|
||||
* leftover streaming placeholder into a completed bubble.
|
||||
* - `false` (abandon paths — session/profile switch, new chat/thread,
|
||||
* connection switch, user Stop, straggler-completion guard) drops the
|
||||
* global streaming/turn-status flags SILENTLY: no error badge, and no
|
||||
* placeholder finalize that could fight a subsequent loadMessageHistory
|
||||
* or hide the message from cancelStream's Stopped-badge findLast. Those
|
||||
* callers clear or reload the transcript themselves.
|
||||
*/
|
||||
private fun cancelAnswerRecovery(settleUi: Boolean = true) {
|
||||
val hadRecovery = streamRecovery != null
|
||||
streamRecovery?.cancel()
|
||||
streamRecovery = null
|
||||
_recoveringAnswer.value = false
|
||||
if (!hadRecovery) return
|
||||
chatHandler?.let { handler ->
|
||||
if (settleUi) {
|
||||
val streaming = handler.messages.value.findLast { it.isStreaming }
|
||||
if (streaming != null) handler.onStreamComplete(streaming.id)
|
||||
else handler.clearStreamingStatus()
|
||||
} else {
|
||||
handler.clearStreamingStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #166: on slow-model / delegating-skill turns the phone's SSE
|
||||
* socket dies (screen-off, Doze, Wi-Fi power-save) long before the server
|
||||
* finishes — but upstream api_server keeps executing the run after the
|
||||
* SSE writer dies and PERSISTS the final answer to the session store. So
|
||||
* a sessions-endpoint transport error must not finalize the turn as an
|
||||
* error: poll the session transcript (native upstream
|
||||
* `/api/sessions/{id}/messages` — standard-path safe) until the answer
|
||||
* lands, reconciling through the normal [ChatHandler.loadMessageHistory]
|
||||
* path, then finish with the same side effects as a normal completion.
|
||||
* On cap expiry or a run that never started, fall back to the existing
|
||||
* error UI.
|
||||
*/
|
||||
private fun startAnswerRecovery(
|
||||
handler: ChatHandler,
|
||||
sessionId: String,
|
||||
pendingUserText: String,
|
||||
placeholderMessageId: String,
|
||||
cause: String,
|
||||
) {
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
_recoveringAnswer.value = true
|
||||
handler.setTurnStatus("Reconnecting to your answer…")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Api,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Chat stream dropped — recovering the answer in the background",
|
||||
detail = cause,
|
||||
)
|
||||
// Positional invariant for the anchor (issue #166): how many user-role
|
||||
// rows the client knew about BEFORE this send. handler.messages already
|
||||
// holds the in-flight pair (the just-added pending user message + the
|
||||
// streaming assistant placeholder), so subtract the one pending user
|
||||
// row. The pending send, once persisted, must land as the
|
||||
// (priorUserCount+1)-th user row — this stops a short repeated prompt
|
||||
// ("yes"/"continue") from anchoring on a stale identical earlier row.
|
||||
val priorUserCount = (
|
||||
handler.messages.value.count { it.role == MessageRole.USER } - 1
|
||||
).coerceAtLeast(0)
|
||||
val recovery = ChatStreamRecovery(
|
||||
scope = viewModelScope,
|
||||
fetchHistory = { loadSessionHistory(sessionId) },
|
||||
timing = recoveryTimingOverride ?: ChatStreamRecovery.Timing(),
|
||||
)
|
||||
streamRecovery = recovery
|
||||
recovery.start(
|
||||
pendingUserText = pendingUserText,
|
||||
priorUserMessageCount = priorUserCount,
|
||||
onIntermediateHistory = { items ->
|
||||
if (streamRecovery === recovery && handler.currentSessionId.value == sessionId) {
|
||||
// Progressive recovery: surface already-persisted rows as
|
||||
// they appear. The reload drops the (never-persisted)
|
||||
// streaming placeholder, so re-add one — with a stable id —
|
||||
// to keep the reconnecting indicator alive until the
|
||||
// answer lands.
|
||||
handler.loadMessageHistory(items)
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "recovering-$placeholderMessageId",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
agentName = handler.activeAgentName,
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
onRecovered = { items ->
|
||||
if (streamRecovery === recovery) {
|
||||
streamRecovery = null
|
||||
_recoveringAnswer.value = false
|
||||
if (handler.currentSessionId.value == sessionId) {
|
||||
// Server-authoritative reconcile — replaces the
|
||||
// placeholder with the recovered answer (the same
|
||||
// reload path a normal sessions completion uses).
|
||||
handler.loadMessageHistory(items)
|
||||
}
|
||||
finalizeTurnSideEffects(handler, placeholderMessageId)
|
||||
refreshSessions()
|
||||
scheduleTitleReconcile(sessionId)
|
||||
drainQueue()
|
||||
}
|
||||
},
|
||||
onGaveUp = { reason ->
|
||||
if (streamRecovery === recovery) {
|
||||
streamRecovery = null
|
||||
_recoveringAnswer.value = false
|
||||
val message = when (reason) {
|
||||
ChatStreamRecovery.GiveUpReason.RUN_NOT_FOUND ->
|
||||
"Connection dropped before the server received this message — please resend."
|
||||
ChatStreamRecovery.GiveUpReason.TIMED_OUT ->
|
||||
"Lost the connection mid-reply and the answer never arrived — check the server and try again."
|
||||
}
|
||||
AppAnalytics.onStreamError()
|
||||
handler.onStreamError(message)
|
||||
emitError(Exception(message), context = "send_message")
|
||||
_queuedMessages.value = emptyList()
|
||||
// Parity with the sibling stream-error branch: the turn is
|
||||
// over server-side, so force-deny any still-blocked approval
|
||||
// card instead of leaving its buttons dead-ended.
|
||||
clearPendingAsk(approvalStamp = "deny")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun clearQueue() {
|
||||
_queuedMessages.value = emptyList()
|
||||
}
|
||||
@@ -3324,7 +3530,21 @@ class ChatViewModel : ViewModel() {
|
||||
activeStream = null
|
||||
}
|
||||
"hermes.run.cancelled" -> {
|
||||
handler.replaceMessageContent(assistantMessageId, "Cancelled.")
|
||||
// Don't clobber a delivered answer: the cancel confirm can
|
||||
// arrive after the summary already streamed into this bubble
|
||||
// (chip-cancel racing completion, or a stale confirm). Only a
|
||||
// bubble with no real content becomes "Cancelled."; anything
|
||||
// else keeps its text and gets the Stopped badge instead.
|
||||
val existingContent = handler.messages.value
|
||||
.firstOrNull { it.id == assistantMessageId }
|
||||
?.content
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
if (existingContent.isBlank()) {
|
||||
handler.replaceMessageContent(assistantMessageId, "Cancelled.")
|
||||
} else {
|
||||
handler.markStopped(assistantMessageId)
|
||||
}
|
||||
handler.onStreamComplete(assistantMessageId)
|
||||
realtimeAgentUserMessages.remove(assistantMessageId)
|
||||
realtimeAgentInputTranscripts.remove(assistantMessageId)
|
||||
@@ -3530,6 +3750,12 @@ class ChatViewModel : ViewModel() {
|
||||
// fallback when no profile metadata is available.
|
||||
handler.activeAgentName = currentAgentDisplayName()
|
||||
|
||||
// A new send always aborts any in-flight dropped-stream answer
|
||||
// recovery — exactly one poller per turn (issue #166). settleUi
|
||||
// finalizes the previous turn's leftover streaming placeholder so it
|
||||
// can't pulse forever next to this turn's fresh one.
|
||||
cancelAnswerRecovery()
|
||||
|
||||
// A new turn is starting: clear any leftover cancellation flag so a
|
||||
// stale `true` from a PRIOR cancelled turn (the flag is sticky — a
|
||||
// clean gateway cancel never fires onError to consume it) can't make
|
||||
@@ -3544,6 +3770,12 @@ class ChatViewModel : ViewModel() {
|
||||
// but updates when the server sends message.started with its own ID.
|
||||
var currentMessageId = assistantMessageId
|
||||
|
||||
// The SSE endpoint this turn actually dispatched on (null on a gateway
|
||||
// dispatch) — set by dispatchSse below. onErrorCb keys the dropped-
|
||||
// stream answer recovery (issue #166) on "sessions": the other
|
||||
// endpoints keep their existing error behavior.
|
||||
var dispatchedSseEndpoint: String? = null
|
||||
|
||||
// Show placeholder "thinking" message immediately — filled when first delta arrives
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
@@ -3595,28 +3827,13 @@ class ChatViewModel : ViewModel() {
|
||||
handler.onTurnComplete(currentMessageId)
|
||||
}
|
||||
val onCompleteCb = {
|
||||
handler.onStreamComplete(currentMessageId)
|
||||
// Double-finalize guard: if a straggler completion arrives while
|
||||
// the answer-recovery poller is running, the normal completion
|
||||
// wins — stop the poller before finalizing so the turn can't
|
||||
// finish twice.
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
finalizeTurnSideEffects(handler, currentMessageId)
|
||||
AppAnalytics.onStreamComplete(lastInputTokens, lastOutputTokens)
|
||||
activeStream = null
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
// Turn over — any blocked ask has been resolved server-side
|
||||
// (answer, timeout, or interrupt). Timed cards self-collapse;
|
||||
// an unanswered approval gets a neutral "Resolved" stamp so its
|
||||
// buttons don't dead-end in "no longer active" notices.
|
||||
clearPendingAsk(approvalStamp = "Resolved")
|
||||
|
||||
// Notify when the turn finished while the app is backgrounded —
|
||||
// never for cancelled streams; errors end via onErrorCb instead.
|
||||
maybeNotifyTurnComplete(handler, currentMessageId)
|
||||
|
||||
// v0.4.1 polish: auto-return to Hermes-Relay if the bridge
|
||||
// moved the foreground app during this run. No-op when the
|
||||
// LLM already called `android_return_to_hermes` itself (in
|
||||
// that case the tracker's internal flag was cleared by the
|
||||
// /return_to_hermes dispatch's respond()). See BridgeRunTracker
|
||||
// KDoc for the full contract.
|
||||
com.hermesandroid.relay.bridge.BridgeRunTracker.notifyRunCompleted()
|
||||
|
||||
// Command catalog rides the now-live socket after the first real
|
||||
// gateway turn — never a cold /api/ws open at composition.
|
||||
@@ -3715,6 +3932,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
val onErrorCb = { errorMsg: String ->
|
||||
val errorSessionId = handler.currentSessionId.value
|
||||
if (intentionallyCancelled) {
|
||||
intentionallyCancelled = false
|
||||
// Cancellation (user Stop / session switch): suppress the
|
||||
@@ -3723,6 +3941,33 @@ class ChatViewModel : ViewModel() {
|
||||
// button if a cancel and a transport error race.
|
||||
handler.messages.value.findLast { it.isStreaming }
|
||||
?.let { handler.onStreamComplete(it.id) }
|
||||
activeStream = null
|
||||
_queuedMessages.value = emptyList()
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
clearPendingAsk(approvalStamp = "deny")
|
||||
} else if (
|
||||
dispatchedSseEndpoint == "sessions" &&
|
||||
errorSessionId != null &&
|
||||
HermesApiClient.isTransportStreamError(errorMsg)
|
||||
) {
|
||||
// Issue #166: a transport drop on the sessions endpoint does
|
||||
// NOT mean the turn failed — upstream api_server keeps running
|
||||
// it and persists the final answer. Don't finalize as an
|
||||
// error; recover the answer by polling the transcript. The
|
||||
// send queue is deliberately KEPT: a successful recovery
|
||||
// drains it exactly like a normal completion; give-up flushes
|
||||
// it in the error fallback.
|
||||
startAnswerRecovery(
|
||||
handler = handler,
|
||||
sessionId = errorSessionId,
|
||||
pendingUserText = message,
|
||||
placeholderMessageId = currentMessageId,
|
||||
cause = errorMsg,
|
||||
)
|
||||
activeStream = null
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
} else {
|
||||
AppAnalytics.onStreamError()
|
||||
handler.onStreamError(errorMsg)
|
||||
@@ -3735,24 +3980,25 @@ class ChatViewModel : ViewModel() {
|
||||
// switch, watchdog timeout) AFTER the server already finished it
|
||||
// — reload history so the completed answer still surfaces
|
||||
// instead of stranding the turn on its partial/errored state.
|
||||
val sid = handler.currentSessionId.value
|
||||
if (sid != null && (streamingEndpoint == "sessions" || streamingEndpoint == "gateway")) {
|
||||
if (errorSessionId != null &&
|
||||
(streamingEndpoint == "sessions" || streamingEndpoint == "gateway")
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
// Profile-aware read — see onCompleteCb: a bare
|
||||
// getMessages 404s for a non-default-profile session
|
||||
// and silently empties the transcript.
|
||||
val serverMessages = loadSessionHistory(sid)
|
||||
val serverMessages = loadSessionHistory(errorSessionId)
|
||||
handler.loadMessageHistory(serverMessages)
|
||||
}
|
||||
}
|
||||
}
|
||||
activeStream = null
|
||||
_queuedMessages.value = emptyList()
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
clearPendingAsk(approvalStamp = "deny")
|
||||
}
|
||||
activeStream = null
|
||||
_queuedMessages.value = emptyList()
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
clearPendingAsk(approvalStamp = "deny")
|
||||
}
|
||||
|
||||
// === v0.4.1 voice-intent + v0.7.x card-dispatch session sync ===
|
||||
@@ -3843,6 +4089,7 @@ class ChatViewModel : ViewModel() {
|
||||
// branch's per-turn fallback (gateway unreachable / not the resolved
|
||||
// transport). Warns once per dispatch about any attachment it can't carry.
|
||||
fun dispatchSse(endpoint: String): ActiveTurnHandle {
|
||||
dispatchedSseEndpoint = endpoint
|
||||
warnIfAttachmentsDropped(endpoint)
|
||||
return when (endpoint) {
|
||||
"runs" -> client.sendRunStream(
|
||||
@@ -4098,6 +4345,10 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
fun cancelStream() {
|
||||
intentionallyCancelled = true
|
||||
// User Stop also aborts a dropped-stream answer recovery. settleUi
|
||||
// false: the Stopped-badge block below finalizes the placeholder
|
||||
// itself (completing it here first would hide it from findLast).
|
||||
cancelAnswerRecovery(settleUi = false)
|
||||
activeStream?.cancel()
|
||||
activeStream = null
|
||||
_queuedMessages.value = emptyList()
|
||||
@@ -4633,6 +4884,10 @@ class ChatViewModel : ViewModel() {
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
activeStream?.cancel()
|
||||
// viewModelScope teardown already cancels the poller job; this just
|
||||
// drops the reference symmetrically.
|
||||
streamRecovery?.cancel()
|
||||
streamRecovery = null
|
||||
}
|
||||
|
||||
private fun appendRealtimeThinkingStatus(
|
||||
|
||||
@@ -1344,7 +1344,21 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// Chime BEFORE teardown — AudioTrack release would cut it off otherwise.
|
||||
try { sfxPlayer?.playExit() } catch (_: Exception) { /* ignore */ }
|
||||
cancelRealtimeAgentTurn("exit voice mode")
|
||||
// Exit = detach, chip ✕ = cancel. A promoted/durable run stays alive
|
||||
// server-side; the relay delivers its result on the next voice session
|
||||
// or as a proactive notification. Cancelling here both killed the task
|
||||
// and let the relay's run-cancelled confirm overwrite an already-
|
||||
// delivered answer with "Cancelled." in the chat transcript.
|
||||
val detachedRun = _uiState.value.backgroundRun
|
||||
if (detachedRun != null) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Exiting voice mode with background run=${detachedRun.runId ?: "?"} " +
|
||||
"active — detaching, not cancelling",
|
||||
)
|
||||
} else {
|
||||
cancelRealtimeAgentTurn("exit voice mode")
|
||||
}
|
||||
closeRealtimeSession()
|
||||
// B4: tear down the barge-in listener + timers before we kill the
|
||||
// player so AEC doesn't try to track a released audio session.
|
||||
@@ -1678,7 +1692,14 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
TAG,
|
||||
"Interrupting speech pipeline",
|
||||
)
|
||||
cancelRealtimeAgentTurn("interrupt")
|
||||
// Stop = "stop talking", not "kill my task": with a background run
|
||||
// active, silence the audio pipeline below but leave the run alive —
|
||||
// the chip's ✕ is the explicit cancel affordance.
|
||||
if (_uiState.value.backgroundRun != null) {
|
||||
Log.i(TAG, "Interrupt with background run active — stopping audio only")
|
||||
} else {
|
||||
cancelRealtimeAgentTurn("interrupt")
|
||||
}
|
||||
// Drop realtime audio deltas still in flight on the open socket so a
|
||||
// stopped turn's tail can't re-create the player and resume playback.
|
||||
realtimeAudioSuppressed = true
|
||||
@@ -2420,12 +2441,19 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
title = line.trimEnd('.'),
|
||||
detail = "Realtime Agent",
|
||||
)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Thinking,
|
||||
outputAudioActive = false,
|
||||
responseText = line,
|
||||
)
|
||||
// A promoted/durable background run owns the chip; the global
|
||||
// voice state must stay conversational (Idle/Listening) so the
|
||||
// mic keeps working — flipping Thinking on every status event is
|
||||
// what wedged the floor during background runs. Optional spoken
|
||||
// narration below is unaffected.
|
||||
if (_uiState.value.backgroundRun == null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Thinking,
|
||||
outputAudioActive = false,
|
||||
responseText = line,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (speak && (!audioSeen.get() || speakEvenAfterProviderAudio)) {
|
||||
// W3: per-turn throttle independent of the per-key dedupe above.
|
||||
@@ -2553,11 +2581,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
speakEvenAfterProviderAudio = true,
|
||||
)
|
||||
val tool = event.toolName?.replace('_', ' ') ?: "tool"
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Thinking,
|
||||
responseText = "Using $tool...",
|
||||
)
|
||||
if (_uiState.value.backgroundRun == null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Thinking,
|
||||
responseText = "Using $tool...",
|
||||
)
|
||||
}
|
||||
}
|
||||
// Live chip: tool starts are the fastest-updating signal for
|
||||
// a background run (progress events only tick every ~5s).
|
||||
@@ -2571,8 +2601,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
"hermes.tool.delta" -> {
|
||||
realtimeToolProgressLine(event)?.let { line ->
|
||||
_uiState.update {
|
||||
it.copy(state = VoiceState.Thinking, responseText = line)
|
||||
if (_uiState.value.backgroundRun == null) {
|
||||
_uiState.update {
|
||||
it.copy(state = VoiceState.Thinking, responseText = line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2592,12 +2624,14 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
speakEvenAfterProviderAudio = true,
|
||||
)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Thinking,
|
||||
outputAudioActive = false,
|
||||
responseText = line,
|
||||
)
|
||||
if (_uiState.value.backgroundRun == null) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Thinking,
|
||||
outputAudioActive = false,
|
||||
responseText = line,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Live chip: active tool + completed-step count. Progress
|
||||
// arriving at all also means the socket is healthy, so a
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.hermesandroid.relay.screenshots
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Forum
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.unit.Density
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.hermesandroid.relay.ui.onboarding.OnboardingPage
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Regression harness for issue #145 — onboarding slide content overflowed
|
||||
* below the fold with no scroll affordance on short viewports / raised font
|
||||
* scale. Renders [OnboardingPage] the way the pager hosts it (a centering
|
||||
* fillMaxSize Box) at compact heights and a raised font scale, then scrolls
|
||||
* to the last body line to prove every line is reachable.
|
||||
*
|
||||
* Render-success + reachability assertions only — no golden PNGs are
|
||||
* committed; the store screenshot set is untouched.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w320dp-h480dp-xhdpi")
|
||||
class OnboardingCompactScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private val lastLine = "Final onboarding body line for reachability"
|
||||
|
||||
/** Mirrors the pager's page container: fillMaxSize Box, centered content. */
|
||||
@Composable
|
||||
private fun PagerHostedSlide(fontScale: Float) {
|
||||
val base = LocalDensity.current
|
||||
CompositionLocalProvider(
|
||||
LocalDensity provides Density(base.density, fontScale = fontScale)
|
||||
) {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.Forum,
|
||||
title = "Chat",
|
||||
description = "Your Hermes agent, streaming in real time.",
|
||||
) {
|
||||
Text(
|
||||
text = "Live responses with tool progress, markdown, and rich cards as the agent works.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
text = "Switch agent profiles mid-flow — each keeps its own sessions, model, and persona.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
text = lastLine,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 480dp-tall viewport at 1.5x font scale — the hero shrinks/drops and the
|
||||
// body overflows; the page must scroll so the last line stays reachable.
|
||||
@Test
|
||||
fun compactHeight_largeFontScale_scrollsToLastLine() {
|
||||
compose.setContent { PagerHostedSlide(fontScale = 1.5f) }
|
||||
compose.onNodeWithText(lastLine).performScrollTo().assertExists()
|
||||
compose.onRoot().captureRoboImage("build/onboarding-shots/compact_480dp_font1_5.png")
|
||||
}
|
||||
|
||||
// 600dp-tall viewport — the 160dp shrunk-hero tier; content should render
|
||||
// (and remain scroll-reachable) without dropping the hero entirely.
|
||||
@Test
|
||||
@Config(qualifiers = "w360dp-h600dp-xhdpi")
|
||||
fun mediumHeight_defaultFontScale_rendersShrunkHero() {
|
||||
compose.setContent { PagerHostedSlide(fontScale = 1.0f) }
|
||||
compose.onNodeWithText(lastLine).performScrollTo().assertExists()
|
||||
compose.onRoot().captureRoboImage("build/onboarding-shots/medium_600dp.png")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import java.io.IOException
|
||||
@@ -92,6 +93,95 @@ class IssueReportAndDiagnosticsTest {
|
||||
assertTrue(DiagnosticsLog.recent().isEmpty())
|
||||
}
|
||||
|
||||
// --- DiagnosticIssuePrefill: severity-dependent title / labels / body ---
|
||||
|
||||
private fun sampleEntry(
|
||||
severity: DiagnosticSeverity,
|
||||
title: String = "Testing API connection",
|
||||
endpointRole: String? = null,
|
||||
url: String? = null,
|
||||
detail: String? = null,
|
||||
) = DiagnosticLogEntry(
|
||||
timestampMs = 0L,
|
||||
category = DiagnosticCategory.Api,
|
||||
severity = severity,
|
||||
title = title,
|
||||
detail = detail,
|
||||
endpointRole = endpointRole,
|
||||
url = url,
|
||||
)
|
||||
|
||||
@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))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun infoAndWarningEntriesRetitleAsDiagnosticWithQuestionLabel() {
|
||||
for (severity in listOf(DiagnosticSeverity.Info, DiagnosticSeverity.Warning)) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bodyUsesTheExpectationAnswerAsWhatHappened() {
|
||||
val body = DiagnosticIssuePrefill.issueBody(
|
||||
sampleEntry(DiagnosticSeverity.Info),
|
||||
expectation = "I expected the app to connect to my server",
|
||||
)
|
||||
assertTrue(body.contains("### What happened?\nI expected the app to connect to my server\n"))
|
||||
assertFalse(body.contains("Captured diagnostic from the in-app activity log."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bodyFallsBackToBoilerplateWithoutAnExpectation() {
|
||||
val body = DiagnosticIssuePrefill.issueBody(sampleEntry(DiagnosticSeverity.Error))
|
||||
assertTrue(body.contains("### What happened?\nCaptured diagnostic from the in-app activity log.\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expectationAnswerIsSecretRedactedBeforeEmbedding() {
|
||||
val body = DiagnosticIssuePrefill.issueBody(
|
||||
sampleEntry(DiagnosticSeverity.Info),
|
||||
expectation = "it failed with token=super-secret-value somehow",
|
||||
)
|
||||
assertFalse(body.contains("super-secret-value"))
|
||||
assertTrue(body.contains("token=[hidden]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionModeUsesTheEntryRoleWhenPresent() {
|
||||
val body = DiagnosticIssuePrefill.issueBody(
|
||||
sampleEntry(DiagnosticSeverity.Error, endpointRole = "tailscale", url = "http://10.0.0.5:8642"),
|
||||
)
|
||||
assertTrue(body.contains("- Connection mode: tailscale"))
|
||||
assertFalse(body.contains("LAN / Tailscale / public TLS / other"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionModeIsInferredFromTheEntryUrl() {
|
||||
val lan = DiagnosticIssuePrefill.issueBody(
|
||||
sampleEntry(DiagnosticSeverity.Info, url = "http://localhost:8642"),
|
||||
)
|
||||
assertTrue(lan.contains("- Connection mode: lan"))
|
||||
|
||||
val tailscale = DiagnosticIssuePrefill.issueBody(
|
||||
sampleEntry(DiagnosticSeverity.Info, url = "http://100.75.1.2:8642"),
|
||||
)
|
||||
assertTrue(tailscale.contains("- Connection mode: tailscale"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionModeIsUnknownWithoutRouteOrUrl() {
|
||||
val body = DiagnosticIssuePrefill.issueBody(sampleEntry(DiagnosticSeverity.Warning))
|
||||
assertTrue(body.contains("- Connection mode: unknown"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recordErrorRedactsSecretsInTheStacktrace() {
|
||||
DiagnosticsLog.clear()
|
||||
|
||||
@@ -101,6 +101,52 @@ class ServerAddressTest {
|
||||
assertEquals("https", ServerAddress.parse("https://h.example")?.scheme)
|
||||
}
|
||||
|
||||
// --- loopbackHostWarning: loopback addresses can't reach the server from a phone ---
|
||||
|
||||
@Test
|
||||
fun loopbackHostWarningFlagsLoopbackAndAnyInterfaceHosts() {
|
||||
val loopbacks = listOf(
|
||||
"localhost",
|
||||
"localhost:8642",
|
||||
"http://localhost:8642",
|
||||
"127.0.0.1",
|
||||
"https://127.0.0.1:9119",
|
||||
"::1",
|
||||
"[::1]",
|
||||
"http://[::1]:8642",
|
||||
"0.0.0.0",
|
||||
"http://0.0.0.0:8642",
|
||||
)
|
||||
for (value in loopbacks) {
|
||||
assertNotNull("expected warning: '$value'", ServerAddress.loopbackHostWarning(value))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loopbackHostWarningIsNullForReachableAddresses() {
|
||||
val reachable = listOf(
|
||||
"192.168.1.10",
|
||||
"192.168.1.10:8642",
|
||||
"http://10.0.0.5:8642",
|
||||
"100.64.0.1:8642",
|
||||
"hermes.tail1234.ts.net",
|
||||
"https://hermes.example.com:8642",
|
||||
"hermes-box",
|
||||
// Not loopback: hostname that merely starts with 127.
|
||||
"127.evil.example.com",
|
||||
)
|
||||
for (value in reachable) {
|
||||
assertNull("expected no warning: '$value'", ServerAddress.loopbackHostWarning(value))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loopbackHostWarningIsNullForBlankAndJunk() {
|
||||
assertNull(ServerAddress.loopbackHostWarning(""))
|
||||
assertNull(ServerAddress.loopbackHostWarning(" "))
|
||||
assertNull(ServerAddress.loopbackHostWarning("not a host"))
|
||||
}
|
||||
|
||||
// --- fieldError: inline UI message contract ---
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Virtual-time tests for [ChatStreamRecovery] — the issue #166 poller that
|
||||
* recovers a dropped sessions-stream turn from the persisted transcript.
|
||||
*
|
||||
* Timing uses the production defaults (5s → ×2 backoff → 30s cap, 30 min
|
||||
* window); `runTest` virtual time makes even the 30-minute cap instant.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ChatStreamRecoveryTest {
|
||||
|
||||
private val pending = "what's the weather?"
|
||||
|
||||
private fun user(id: String, text: String = pending) = MessageItem(
|
||||
id = id,
|
||||
role = "user",
|
||||
content = JsonPrimitive(text),
|
||||
)
|
||||
|
||||
private fun assistant(id: String, text: String) = MessageItem(
|
||||
id = id,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(text),
|
||||
)
|
||||
|
||||
/** Scripted fetch: returns [responses] in order, repeating the last one. */
|
||||
private class ScriptedHistory(private val responses: List<() -> List<MessageItem>>) {
|
||||
val fetchTimesMs = mutableListOf<Long>()
|
||||
private var calls = 0
|
||||
|
||||
fun fetcher(now: () -> Long): suspend () -> List<MessageItem> = {
|
||||
fetchTimesMs += now()
|
||||
val step = responses[minOf(calls, responses.lastIndex)]
|
||||
calls++
|
||||
step()
|
||||
}
|
||||
|
||||
val fetchCount: Int get() = calls
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoversWhenAnswerIsStableAcrossTwoConsecutivePolls() = runTest {
|
||||
val script = ScriptedHistory(
|
||||
listOf(
|
||||
{ listOf(user("u1")) },
|
||||
{ listOf(user("u1"), assistant("a1", "recovered answer")) },
|
||||
{ listOf(user("u1"), assistant("a1", "recovered answer")) },
|
||||
),
|
||||
)
|
||||
val intermediate = mutableListOf<List<MessageItem>>()
|
||||
var recovered: List<MessageItem>? = null
|
||||
var gaveUp: ChatStreamRecovery.GiveUpReason? = null
|
||||
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(
|
||||
pendingUserText = pending,
|
||||
priorUserMessageCount = 0,
|
||||
onIntermediateHistory = { intermediate += it },
|
||||
onRecovered = { recovered = it },
|
||||
onGaveUp = { gaveUp = it },
|
||||
)
|
||||
|
||||
advanceTimeBy(5_001) // poll 1 — user only, no answer yet
|
||||
assertEquals(1, intermediate.size)
|
||||
assertNull(recovered)
|
||||
|
||||
advanceTimeBy(10_000) // poll 2 — answer appears (signature recorded)
|
||||
assertEquals(2, intermediate.size)
|
||||
assertNull(recovered)
|
||||
|
||||
advanceTimeBy(20_000) // poll 3 — unchanged → stable → recovered
|
||||
assertEquals("recovered answer", recovered?.last()?.contentText)
|
||||
assertNull(gaveUp)
|
||||
assertFalse(recovery.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pollCadenceBacksOffExponentiallyToTheCap() = runTest {
|
||||
val script = ScriptedHistory(listOf({ emptyList() }))
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(pending, 0, {}, {}, {})
|
||||
|
||||
// 5s, +10s, +20s, +30s, +30s… (cap) — cumulative fetch times.
|
||||
advanceTimeBy(5_000 + 10_000 + 20_000 + 30_000 + 30_000 + 1)
|
||||
assertEquals(
|
||||
listOf(5_000L, 15_000L, 35_000L, 65_000L, 95_000L),
|
||||
script.fetchTimesMs,
|
||||
)
|
||||
recovery.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyTranscriptCarriesNoInformationAndKeepsPolling() = runTest {
|
||||
val script = ScriptedHistory(listOf({ emptyList() }))
|
||||
var gaveUp: ChatStreamRecovery.GiveUpReason? = null
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(pending, 0, {}, {}, { gaveUp = it })
|
||||
|
||||
advanceTimeBy(31L * 60_000)
|
||||
assertEquals(ChatStreamRecovery.GiveUpReason.TIMED_OUT, gaveUp)
|
||||
assertTrue("should have kept polling to the cap", script.fetchCount > 10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reachableTranscriptWithoutThePendingSendFailsFast() = runTest {
|
||||
// Server reachable, but the turn's POST never landed: the transcript
|
||||
// still holds only the PREVIOUS exchange (one user row the client
|
||||
// already knew about). No new user row → no anchor → waiting cannot
|
||||
// produce an answer, so give up quickly (after confirming, not on the
|
||||
// very first read) rather than polling to the 30-minute cap.
|
||||
val script = ScriptedHistory(
|
||||
listOf(
|
||||
{
|
||||
listOf(
|
||||
user("u0", "an earlier prompt"),
|
||||
assistant("a0", "an earlier answer"),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
var gaveUp: ChatStreamRecovery.GiveUpReason? = null
|
||||
var recovered = false
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(pending, 1, {}, { recovered = true }, { gaveUp = it })
|
||||
|
||||
advanceTimeBy(5_001) // poll 1 — not established, not yet confirmed
|
||||
assertNull("must confirm across a couple polls before giving up", gaveUp)
|
||||
advanceTimeBy(10_000) // poll 2 — still not established → fail fast
|
||||
assertEquals(ChatStreamRecovery.GiveUpReason.RUN_NOT_FOUND, gaveUp)
|
||||
assertFalse("stale prior answer must never count as the recovery", recovered)
|
||||
assertEquals(2, script.fetchCount)
|
||||
assertFalse(recovery.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleIdenticalEarlierUserMessageIsNotAdoptedWhenSendNeverLanded() = runTest {
|
||||
// History the client already knew: "continue" → A, "tell me more" → B.
|
||||
// The new send is ALSO "continue" but its POST never reached the
|
||||
// server, so the transcript is unchanged. A bare `indexOfLast` text
|
||||
// match would anchor on the STALE first "continue" and adopt B (static
|
||||
// → instantly "stable"). The positional invariant (this send would be
|
||||
// the 3rd user row, but only 2 exist) must refuse to adopt and fail.
|
||||
val stale = listOf(
|
||||
user("u1", "continue"),
|
||||
assistant("a1", "answer A"),
|
||||
user("u2", "tell me more"),
|
||||
assistant("a2", "answer B"),
|
||||
)
|
||||
val script = ScriptedHistory(listOf({ stale }))
|
||||
var recovered: List<MessageItem>? = null
|
||||
var gaveUp: ChatStreamRecovery.GiveUpReason? = null
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(
|
||||
pendingUserText = "continue",
|
||||
priorUserMessageCount = 2, // u1 + u2 were known before this send
|
||||
onIntermediateHistory = {},
|
||||
onRecovered = { recovered = it },
|
||||
onGaveUp = { gaveUp = it },
|
||||
)
|
||||
|
||||
advanceTimeBy(5_000 + 10_000 + 1) // two polls of the static transcript
|
||||
assertNull("must NOT adopt a different turn's answer", recovered)
|
||||
assertEquals(ChatStreamRecovery.GiveUpReason.RUN_NOT_FOUND, gaveUp)
|
||||
assertFalse(recovery.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatedShortPromptAnchorsTheNewTurnNotTheStaleOne() = runTest {
|
||||
// "continue" → A is already in history; the new "continue" DID land, so
|
||||
// the server appended a second identical user row + its own answer C.
|
||||
// The positional anchor must pick the SECOND "continue" and adopt C —
|
||||
// never the stale A that an `indexOfLast`-only heuristic risks.
|
||||
val landed = listOf(
|
||||
user("u1", "continue"),
|
||||
assistant("a1", "answer A"),
|
||||
user("u2", "continue"),
|
||||
assistant("a2", "answer C"),
|
||||
)
|
||||
val script = ScriptedHistory(listOf({ landed }))
|
||||
var recovered: List<MessageItem>? = null
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(
|
||||
pendingUserText = "continue",
|
||||
priorUserMessageCount = 1, // only the first "continue" was known
|
||||
onIntermediateHistory = {},
|
||||
onRecovered = { recovered = it },
|
||||
onGaveUp = {},
|
||||
)
|
||||
|
||||
advanceTimeBy(5_000 + 10_000 + 1) // stable across two polls
|
||||
assertEquals("answer C", recovered?.last()?.contentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchExceptionKeepsPollingUntilTheServerComesBack() = runTest {
|
||||
val script = ScriptedHistory(
|
||||
listOf(
|
||||
{ throw IOException("network still down") },
|
||||
{ listOf(user("u1"), assistant("a1", "late answer")) },
|
||||
{ listOf(user("u1"), assistant("a1", "late answer")) },
|
||||
),
|
||||
)
|
||||
var recovered: List<MessageItem>? = null
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(pending, 0, {}, { recovered = it }, {})
|
||||
|
||||
advanceTimeBy(5_000 + 10_000 + 20_000 + 1)
|
||||
assertEquals("late answer", recovered?.last()?.contentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun growingTranscriptDefersTheFinishUntilStable() = runTest {
|
||||
val script = ScriptedHistory(
|
||||
listOf(
|
||||
{ listOf(user("u1"), assistant("a1", "thinking about it")) },
|
||||
// Run still going: a new assistant row appended → not stable.
|
||||
{
|
||||
listOf(
|
||||
user("u1"),
|
||||
assistant("a1", "thinking about it"),
|
||||
assistant("a2", "final answer"),
|
||||
)
|
||||
},
|
||||
{
|
||||
listOf(
|
||||
user("u1"),
|
||||
assistant("a1", "thinking about it"),
|
||||
assistant("a2", "final answer"),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
var recovered: List<MessageItem>? = null
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(pending, 0, {}, { recovered = it }, {})
|
||||
|
||||
advanceTimeBy(15_001) // polls 1+2 — signature changed between them
|
||||
assertNull(recovered)
|
||||
advanceTimeBy(20_000) // poll 3 — stable now
|
||||
assertEquals("final answer", recovered?.last()?.contentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancelStopsPollingWithoutAnyTerminalCallback() = runTest {
|
||||
val script = ScriptedHistory(listOf({ listOf(user("u1")) }))
|
||||
var recovered = false
|
||||
var gaveUp = false
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(pending, 0, {}, { recovered = true }, { gaveUp = true })
|
||||
|
||||
advanceTimeBy(5_001)
|
||||
assertEquals(1, script.fetchCount)
|
||||
recovery.cancel()
|
||||
|
||||
advanceTimeBy(60L * 60_000)
|
||||
assertEquals("no polls after cancel", 1, script.fetchCount)
|
||||
assertFalse(recovered)
|
||||
assertFalse(gaveUp)
|
||||
assertFalse(recovery.isActive)
|
||||
}
|
||||
}
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.os.Looper
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
import com.hermesandroid.relay.network.upstream.HermesApiClient
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import okhttp3.mockwebserver.SocketPolicy
|
||||
import org.junit.After
|
||||
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.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* End-to-end coverage for the issue #166 dropped-stream answer recovery:
|
||||
* a real [HermesApiClient] against a [MockWebServer] whose sessions SSE
|
||||
* route dies mid-turn (transport error, NOT a server error), with the
|
||||
* `/api/sessions/{id}/messages` route scripted so the poller first finds
|
||||
* no answer and then the persisted final answer.
|
||||
*
|
||||
* Runs under Robolectric so the client's main-thread Handler dispatch and
|
||||
* `viewModelScope` (Dispatchers.Main → Robolectric main looper) are real;
|
||||
* the test drives time by idling the main looper. Recovery cadence is
|
||||
* shrunk via [ChatViewModel.recoveryTimingOverride] so polls take
|
||||
* milliseconds instead of seconds.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ChatViewModelStreamRecoveryTest {
|
||||
|
||||
private companion object {
|
||||
const val SESSION_ID = "s1"
|
||||
const val PROMPT = "hello there"
|
||||
const val RECOVERED = "recovered answer"
|
||||
|
||||
const val USER_ONLY_BODY =
|
||||
"""{"data":[{"id":"u1","role":"user","content":"$PROMPT","timestamp":1000.0}]}"""
|
||||
const val WITH_ANSWER_BODY =
|
||||
"""{"data":[""" +
|
||||
"""{"id":"u1","role":"user","content":"$PROMPT","timestamp":1000.0},""" +
|
||||
"""{"id":"a1","role":"assistant","content":"$RECOVERED","timestamp":1001.0}]}"""
|
||||
const val EMPTY_BODY = """{"data":[]}"""
|
||||
}
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var vm: ChatViewModel
|
||||
private lateinit var handler: ChatHandler
|
||||
|
||||
private val messagesRequests = AtomicInteger(0)
|
||||
private val streamRequests = AtomicInteger(0)
|
||||
|
||||
/** GET /messages body for the [n]-th poll (1-based). */
|
||||
@Volatile
|
||||
private var messagesBodyFor: (Int) -> String = { USER_ONLY_BODY }
|
||||
|
||||
/** Non-first POST /chat/stream responses complete cleanly when true. */
|
||||
@Volatile
|
||||
private var secondStreamSucceeds = false
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val path = request.path ?: return MockResponse().setResponseCode(404)
|
||||
return when {
|
||||
request.method == "POST" &&
|
||||
path == "/api/sessions/$SESSION_ID/chat/stream" -> {
|
||||
val n = streamRequests.incrementAndGet()
|
||||
if (n > 1 && secondStreamSucceeds) {
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "text/event-stream")
|
||||
.setBody("data: [DONE]\n\n")
|
||||
} else {
|
||||
// Transport death mid-turn: advertise a full body
|
||||
// but cut the socket halfway — the SSE listener's
|
||||
// onFailure fires with an IOException, exactly the
|
||||
// Doze / Wi-Fi power-save drop class.
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "text/event-stream")
|
||||
.setBody(": keepalive\n\n: keepalive\n\n: keepalive\n\n")
|
||||
.setSocketPolicy(SocketPolicy.DISCONNECT_DURING_RESPONSE_BODY)
|
||||
}
|
||||
}
|
||||
request.method == "GET" &&
|
||||
path == "/api/sessions/$SESSION_ID/messages" -> {
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(messagesBodyFor(messagesRequests.incrementAndGet()))
|
||||
}
|
||||
else -> MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
}
|
||||
server.start()
|
||||
|
||||
handler = ChatHandler()
|
||||
vm = ChatViewModel()
|
||||
vm.streamingEndpoint = "sessions"
|
||||
vm.recoveryTimingOverride = ChatStreamRecovery.Timing(
|
||||
pollIntervalMs = 150,
|
||||
maxPollIntervalMs = 150,
|
||||
recoveryWindowMs = 60_000,
|
||||
)
|
||||
vm.initialize(HermesApiClient(server.url("/").toString(), "test-key"), handler)
|
||||
handler.setSessionId(SESSION_ID)
|
||||
idle()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
runCatching { server.shutdown() }
|
||||
idle()
|
||||
}
|
||||
|
||||
/** Run queued main-looper tasks and advance the Robolectric clock a bit. */
|
||||
private fun idle(ms: Long = 200) {
|
||||
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* Idle the main looper (advancing virtual time so coroutine delays fire)
|
||||
* while real IO threads make progress, until [condition] holds.
|
||||
*/
|
||||
private fun awaitCondition(what: String, timeoutMs: Long = 20_000, condition: () -> Boolean) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
idle()
|
||||
if (condition()) return
|
||||
Thread.sleep(15)
|
||||
}
|
||||
fail("Timed out waiting for: $what")
|
||||
}
|
||||
|
||||
/** Settle, snapshot the poll counter, idle on, and assert it stayed put. */
|
||||
private fun assertPollingStopped() {
|
||||
// Let any already-in-flight poll (or post-turn reconcile read) land.
|
||||
Thread.sleep(150)
|
||||
idle(500)
|
||||
val settled = messagesRequests.get()
|
||||
repeat(10) {
|
||||
idle(300)
|
||||
Thread.sleep(15)
|
||||
}
|
||||
assertEquals("poller must not keep hitting /messages", settled, messagesRequests.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamKilledMidTurn_recoversPersistedAnswerAndCompletesPlaceholder() {
|
||||
// Poll 1 has no answer yet; the answer is persisted from poll 2 on.
|
||||
messagesBodyFor = { n -> if (n < 2) USER_ONLY_BODY else WITH_ANSWER_BODY }
|
||||
|
||||
vm.sendMessage(PROMPT)
|
||||
idle()
|
||||
|
||||
awaitCondition("streaming placeholder shown") {
|
||||
handler.messages.value.any { it.role == MessageRole.ASSISTANT && it.isStreaming }
|
||||
}
|
||||
awaitCondition("recovery started after the transport drop") {
|
||||
vm.recoveringAnswer.value
|
||||
}
|
||||
|
||||
awaitCondition("recovered answer reconciled + turn finalized") {
|
||||
!vm.recoveringAnswer.value &&
|
||||
!handler.isStreaming.value &&
|
||||
handler.messages.value.any {
|
||||
it.role == MessageRole.ASSISTANT && it.content == RECOVERED && !it.isStreaming
|
||||
}
|
||||
}
|
||||
|
||||
// Stability requires the answer on two consecutive polls, and the
|
||||
// first poll had none — at least 3 polls total.
|
||||
assertTrue("expected >= 3 polls, saw ${messagesRequests.get()}", messagesRequests.get() >= 3)
|
||||
assertNull("a recovered turn must not surface an error", handler.error.value)
|
||||
assertFalse(
|
||||
"no streaming placeholder may survive recovery",
|
||||
handler.messages.value.any { it.isStreaming },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun userCancelDuringRecovery_abortsThePoller() {
|
||||
messagesBodyFor = { USER_ONLY_BODY } // answer never arrives
|
||||
|
||||
vm.sendMessage(PROMPT)
|
||||
awaitCondition("recovery started") { vm.recoveringAnswer.value }
|
||||
awaitCondition("at least one poll issued") { messagesRequests.get() >= 1 }
|
||||
|
||||
vm.cancelStream()
|
||||
idle()
|
||||
|
||||
assertFalse(vm.recoveringAnswer.value)
|
||||
assertFalse(handler.isStreaming.value)
|
||||
assertNull("user cancel is not an error", handler.error.value)
|
||||
assertTrue(
|
||||
"the cancelled placeholder should carry the Stopped badge",
|
||||
handler.messages.value.any { it.role == MessageRole.ASSISTANT && "Stopped" in it.badges },
|
||||
)
|
||||
assertPollingStopped()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionSwitchDuringRecovery_leavesChatNotStreamingWithNoStuckStatus() {
|
||||
messagesBodyFor = { USER_ONLY_BODY } // answer never arrives
|
||||
|
||||
vm.sendMessage(PROMPT)
|
||||
awaitCondition("recovery started") { vm.recoveringAnswer.value }
|
||||
awaitCondition("at least one poll issued") { messagesRequests.get() >= 1 }
|
||||
|
||||
// Switch away while recovery is polling — there is NO live stream, so
|
||||
// aborting the poller must itself settle the streaming/turn-status
|
||||
// state instead of wedging the chat "streaming forever". (s2 has no
|
||||
// scripted /messages route → the history load 404s to empty, which is
|
||||
// fine: this asserts the abort settles, not that s2 loads anything.)
|
||||
vm.switchSession("s2")
|
||||
idle()
|
||||
|
||||
assertFalse("recovery poller must be aborted", vm.recoveringAnswer.value)
|
||||
assertFalse("chat must not be stuck streaming", handler.isStreaming.value)
|
||||
assertNull("no stuck 'Reconnecting…' turn status", handler.turnStatus.value)
|
||||
assertNull("a silent abandon is not an error", handler.error.value)
|
||||
assertPollingStopped()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newSendDuringRecovery_abortsThePoller() {
|
||||
messagesBodyFor = { USER_ONLY_BODY } // first turn's answer never arrives
|
||||
secondStreamSucceeds = true
|
||||
|
||||
vm.sendMessage(PROMPT)
|
||||
awaitCondition("recovery started") { vm.recoveringAnswer.value }
|
||||
awaitCondition("at least one poll issued") { messagesRequests.get() >= 1 }
|
||||
|
||||
vm.sendMessage("a second question")
|
||||
idle()
|
||||
assertFalse("a new send must abort the poller", vm.recoveringAnswer.value)
|
||||
|
||||
awaitCondition("second turn completes") { !handler.isStreaming.value }
|
||||
assertPollingStopped()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveryWindowExpiry_fallsBackToTheErrorUi() {
|
||||
// Empty transcript = "no information" → the poller keeps trying until
|
||||
// the (shrunken) window expires, then the existing error UI fires.
|
||||
messagesBodyFor = { EMPTY_BODY }
|
||||
vm.recoveryTimingOverride = ChatStreamRecovery.Timing(
|
||||
pollIntervalMs = 100,
|
||||
maxPollIntervalMs = 100,
|
||||
recoveryWindowMs = 350,
|
||||
)
|
||||
|
||||
vm.sendMessage(PROMPT)
|
||||
awaitCondition("recovery started") { vm.recoveringAnswer.value }
|
||||
awaitCondition("cap expiry surfaces the error") { handler.error.value != null }
|
||||
|
||||
assertFalse(vm.recoveringAnswer.value)
|
||||
assertFalse(handler.isStreaming.value)
|
||||
assertNotNull(handler.error.value)
|
||||
assertPollingStopped()
|
||||
}
|
||||
}
|
||||
+19
-12
@@ -29,36 +29,43 @@ 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
|
||||
|
||||
## Known Limitations (Prototype)
|
||||
## Known Limitations
|
||||
|
||||
### No Encryption
|
||||
WebSocket connections may use `ws://` (plaintext) instead of `wss://` (TLS). This means:
|
||||
### Plaintext `ws://` legs are possible
|
||||
The relay can run without TLS (`hermes relay start --no-ssl`), in which case connections use `ws://` (plaintext) instead of `wss://` (TLS). On a plaintext leg:
|
||||
- Commands, screen content, and screenshots travel unencrypted
|
||||
- Anyone on the network path between phone and server can intercept traffic
|
||||
- **Mitigation**: Use over a trusted network, or set up a reverse proxy with TLS (nginx/caddy)
|
||||
|
||||
The clients do not accept this silently: each pairing candidate carries a `transport_hint`, and the Android app keeps plain `ws://` disabled until the operator turns on "Allow plain (unencrypted) connections" and acknowledges the one-time warning dialog. TLS connections get trust-on-first-use SPKI certificate pinning on both the Android app and the desktop CLI.
|
||||
- **Mitigation**: Use plaintext only on a trusted network, or front the relay with Tailscale (`hermes-relay-tailscale enable`) or a TLS reverse proxy (nginx/caddy)
|
||||
|
||||
Hermes API bearer tokens on `/voice/*` are stricter than the legacy WebSocket path: non-loopback API-bearer requests are rejected unless the request is HTTPS, comes through a trusted HTTPS reverse-proxy signal, or the operator has explicitly enabled the insecure dev escape hatch with `hermes relay insecure-api-key on` or the startup env var.
|
||||
|
||||
### Full Device Access
|
||||
Once paired, the agent has unrestricted access to:
|
||||
### Broad device access once Device Control is enabled
|
||||
On a `sideload` build with Bridge enabled, the agent can:
|
||||
- Read all screen content (any app)
|
||||
- Tap, type, swipe anywhere
|
||||
- Open any app
|
||||
- Take screenshots
|
||||
- Read installed app list
|
||||
|
||||
There is no granular permission system — the agent can access banking apps, messages, etc.
|
||||
- **Mitigation**: Only pair with trusted Hermes instances. Disconnect when not in use.
|
||||
This access is fenced by several shipped rails rather than a per-app Android permission model:
|
||||
- **Per-channel grants with TTLs** on the relay session token — bridge access can be excluded or time-boxed at pair time and revoked from any client.
|
||||
- **Bridge safety rails** (`BridgeSafetyManager`): a per-app blocklist, destructive-verb confirmation prompts (fail-closed on `/call` and `/send_sms`), and an auto-disable timer.
|
||||
- **Master toggle + status overlay** so control is visible and can be cut instantly on the phone.
|
||||
|
||||
### No Command Audit Log
|
||||
There is no persistent log of what commands the agent executed on the phone.
|
||||
- **Mitigation**: The relay logs commands to stdout when run with INFO logging.
|
||||
The rails are deny-lists and confirmations, not sandboxing — a permissive configuration still exposes sensitive apps.
|
||||
- **Mitigation**: Only pair with trusted Hermes instances, keep the blocklist populated, and disconnect (or let auto-disable fire) when not in use.
|
||||
|
||||
### Command audit is local, not centralized
|
||||
The Bridge screen keeps an on-device activity log of executed device-control commands, and the relay logs commands to its service log at INFO level. There is no centralized, tamper-evident audit trail across surfaces.
|
||||
- **Mitigation**: Review the Bridge activity log on the phone and the relay service log (`journalctl`) on the host.
|
||||
|
||||
### Remote connectivity
|
||||
|
||||
Hermes-Relay does **not** ship its own application-layer crypto. The operator owns both endpoints, and the trust model assumes TLS is terminated somewhere on the path that the operator already controls — Tailscale (managed TLS + tailnet ACL identity), a reverse proxy with Let's Encrypt, a WireGuard / other VPN, or a Cloudflare Tunnel. See [`docs/remote-access.md`](remote-access.md) for the decision matrix and setup recipes per mode.
|
||||
|
||||
Multi-endpoint pairing (ADR 24) makes "same phone, different networks" a first-class case: a single QR carries `lan` / `tailscale` / `public` candidates in strict-priority order and the phone re-probes reachability on every network change. Per-candidate `transport_hint` drives the plaintext-`ws://` consent dialog — explicit operator consent is still required for any unencrypted leg. The TOFU cert pin is keyed by `host:port`, so two endpoints pointing at the same hostname share a pin (correct — same cert, same pin) while distinct hostnames each get their own.
|
||||
Multi-endpoint pairing (ADR 24) makes "same phone, different networks" a first-class case: a single QR carries `lan` / `tailscale` / `public` candidates in strict-priority order and the phone re-probes reachability on every network change. Per-candidate `transport_hint` drives the plaintext-`ws://` gating — an unencrypted leg is used only after the operator has enabled and acknowledged plain connections. The TOFU cert pin is keyed by `host:port`, so two endpoints pointing at the same hostname share a pin (correct — same cert, same pin) while distinct hostnames each get their own.
|
||||
|
||||
## Recommendations for Production Use
|
||||
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
[versions]
|
||||
appVersionName = "1.2.6"
|
||||
appVersionCode = "20"
|
||||
appVersionName = "1.3.0"
|
||||
appVersionCode = "21"
|
||||
agp = "9.2.1"
|
||||
kotlin = "2.4.0"
|
||||
compose-bom = "2026.06.00"
|
||||
compose-bom = "2026.06.01"
|
||||
navigation-compose = "2.9.8"
|
||||
okhttp = "5.4.0"
|
||||
kotlinx-serialization = "1.11.0"
|
||||
kotlinx-coroutines = "1.10.2"
|
||||
mockk = "1.14.9"
|
||||
kotlinx-coroutines = "1.11.0"
|
||||
mockk = "1.14.11"
|
||||
robolectric = "4.16.1"
|
||||
konsist = "0.17.3"
|
||||
security-crypto = "1.1.0"
|
||||
lifecycle = "2.11.0"
|
||||
activity-compose = "1.13.0"
|
||||
core-ktx = "1.18.0"
|
||||
core-ktx = "1.19.0"
|
||||
datastore = "1.2.1"
|
||||
splashscreen = "1.2.0"
|
||||
markdown-renderer = "0.42.0"
|
||||
markdown-renderer = "0.43.0"
|
||||
coil = "3.5.0"
|
||||
haze = "1.7.2"
|
||||
mlkit-barcode = "17.3.0"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
|
||||
@@ -9,7 +9,7 @@ buildscript {
|
||||
dependencies {
|
||||
// Meta Spatial 0.12.0 ships older AGP/Kotlin compiler artifacts on its
|
||||
// plugin classpath; AGP 9.2 provides the Android/Kotlin tooling here.
|
||||
classpath("com.meta.spatial:spatial-gradle-plugin-impl:0.12.0") {
|
||||
classpath("com.meta.spatial:spatial-gradle-plugin-impl:0.13.1") {
|
||||
exclude(group = "com.android.tools.build", module = "gradle")
|
||||
exclude(group = "org.jetbrains.kotlin", module = "kotlin-compiler-embeddable")
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ Ask your agent — from your phone, from the attached TUI, from anywhere — to
|
||||
|
||||
This mirrors how the Android client hands the agent `android_tap` / `android_screenshot`. Zero hermes-agent core changes — the `desktop_*` tools register via the standard plugin system, same pattern as `android_*`. Run `hermes-relay daemon` and the hand stays available with no window open.
|
||||
|
||||
## Demo — native paste into the attached TUI
|
||||
## Demo — native paste into the attached TUI {#demo-native-paste-into-the-attached-tui}
|
||||
|
||||
The escape hatch earns its keep too. You are inside `hermes-relay` (bare invocation drops you straight into the Hermes Ink TUI over a PTY — no subcommand needed), talking to your remote Hermes the same way you would a local one.
|
||||
|
||||
@@ -73,7 +73,7 @@ While inside the shell/TUI session (bare `hermes-relay`, the default mode), `Ctr
|
||||
- **[Workspace awareness](./subcommands.md#hermes-relay-workspace)** — on connect, the client advertises `cwd`, `git_root`, `git_branch`, `repo_name`, `hostname`, `platform`, `active_shell` to the relay so the agent knows which repo you're in. Client-side capability shipped in alpha.6; server-side prompt-context consumption is on the way (see [ROADMAP.md](https://github.com/Codename-11/hermes-relay/blob/main/ROADMAP.md#desktop-track-parallel-lane-to-android--experimental)).
|
||||
- **[Conversation picker](./subcommands.md#hermes-relay-shell)** — on first or fresh attach, choose from recent server-side Hermes conversations with first-prompt previews before the TUI starts.
|
||||
- **[TUI session continuity](./subcommands.md#hermes-relay-sessions)** — bare `hermes-relay` resumes the active/default tmux session, replays recent scrollback, and `sessions list/resume/new/kill` gives explicit control when you need it.
|
||||
- **[Editor tool + interactive patch approval](./tools.md#desktop_open_in_editor-and-interactive-patches)** — agent calls `desktop_open_in_editor(path, line, col)` to open `$VISUAL` / `$EDITOR` / VSCode / Cursor / Sublime / nvim. Agent-proposed patches render as colored unified diffs with `y`/`n`/`e`/`r` prompts.
|
||||
- **[Editor tool + interactive patch approval](./tools.md#desktop-open-in-editor-and-interactive-patches)** — agent calls `desktop_open_in_editor(path, line, col)` to open `$VISUAL` / `$EDITOR` / VSCode / Cursor / Sublime / nvim. Agent-proposed patches render as colored unified diffs with `y`/`n`/`e`/`r` prompts.
|
||||
- **[Daemon mode](./subcommands.md#hermes-relay-daemon)** — `hermes-relay daemon start` runs the tool router headless **in the background** (no console window, survives closing the terminal); `daemon status` / `daemon stop` manage it. Bare `hermes-relay daemon` runs in the foreground.
|
||||
- **[Activity audit](./subcommands.md#hermes-relay-audit)** — `hermes-relay audit` shows what the agent has actually run on your machine through the desktop tools, from a local log — no network, no auth.
|
||||
- **[Relay inspection](./subcommands.md#hermes-relay-relay)** — `hermes-relay relay context` audits the system-prompt context the relay injects into the agent; `relay info` / `relay security` report server state for operators on the relay host.
|
||||
|
||||
@@ -115,7 +115,7 @@ HERMES_RELAY_VERSION=cli-v0.3.0-alpha.18 \
|
||||
|
||||
See [Uninstall](#uninstall) below — the curl one-liner reverses install.sh, with optional tiers for session-data purge and service cleanup.
|
||||
|
||||
## Self-update — `hermes-relay update`
|
||||
## Self-update — `hermes-relay update` {#self-update-hermes-relay-update}
|
||||
|
||||
Once installed, you don't have to keep re-running the `curl | sh` one-liner. The binary self-updates:
|
||||
|
||||
@@ -136,7 +136,7 @@ The updater:
|
||||
|
||||
If `hermes-relay update --check` says "Up to date" but you know there's a newer alpha, see the [troubleshooting note](./troubleshooting.md#hermes-relay-update-says-up-to-date-but-i-know-there-s-a-newer-alpha).
|
||||
|
||||
## Install from source (Node ≥21)
|
||||
## Install from source (Node ≥21) {#install-from-source-node-21}
|
||||
|
||||
For dev / contributors / custom builds:
|
||||
|
||||
|
||||
@@ -244,7 +244,7 @@ The 30-second router ceiling fired. The handler is stuck — usually because `de
|
||||
|
||||
## `certificate pin mismatch` (wss only)
|
||||
|
||||
The TLS peer cert SHA256 differs from the one stored at pair time. Either the relay rotated its cert, or someone is MITMing the connection.
|
||||
The SHA256 pin of the TLS leaf certificate's public key (SPKI, stored as `sha256/<base64>` — the same format as the Android app's pin store) differs from the one recorded at pair time. Either the relay rotated its cert, or someone is MITMing the connection.
|
||||
|
||||
Legitimate rotation: re-pair (which wipes the old pin + stores the new one):
|
||||
```bash
|
||||
|
||||
@@ -16,9 +16,9 @@ A **<span class="track-badge track-badge--sideload">Sideload only</span>** badge
|
||||
| [App Themes](/features/themes) | Eight looks — the whole app follows your choice, light/dark aware |
|
||||
| [Profiles](/features/profiles) | Auto-discovered upstream agent directories — overlay model + SOUL on chat turns |
|
||||
| [Personalities](/features/personalities) | Dynamic from `GET /api/config` — picker, agent name on bubbles |
|
||||
| [Command Palette](/guide/chat#command-palette) | Searchable command browser — 29 gateway commands, personalities, 90+ skills |
|
||||
| [Slash Commands](/guide/chat#inline-autocomplete) | Inline autocomplete as you type `/` |
|
||||
| [Vanilla Hermes Setup](/guide/getting-started#connect-android-to-hermes) | Connect by API URL/key first; scan a QR only when you want Relay pairing |
|
||||
| [Command Palette](/guide/chat#slash-commands) | Searchable command browser — 29 gateway commands, personalities, 90+ skills |
|
||||
| [Slash Commands](/guide/chat#slash-commands) | Inline autocomplete as you type `/` |
|
||||
| [Vanilla Hermes Setup](/guide/getting-started#_3-connect-chat) | Connect by API URL/key first; scan a QR only when you want Relay pairing |
|
||||
| [Token Tracking](/features/tokens) | Per-message usage and cost |
|
||||
| [Tool Progress](/features/tools) | Configurable display — Off, Compact, or Detailed |
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ operate your phone for you. The [Release tracks](/guide/release-tracks) page has
|
||||
the full feature comparison and a decision guide.
|
||||
:::
|
||||
|
||||
### Sideload APK {#sideload-apk}
|
||||
|
||||
::::details Sideload install — step by step (download, verify, install)
|
||||
Grab the signed APK directly from GitHub Releases — works on any Android 8.0+
|
||||
device.
|
||||
@@ -271,7 +273,7 @@ On first launch:
|
||||
|
||||
1. Tap through the onboarding pages.
|
||||
2. On **Connect**, pick whichever is easiest:
|
||||
- **Vanilla Hermes** → tap **Scan for Hermes on LAN** to auto-find the
|
||||
- **Hermes** → tap **Scan for Hermes on LAN** to auto-find the
|
||||
server, then enter your key; or type the API URL
|
||||
(`http://192.168.1.100:8642`) and key by hand.
|
||||
- **Scan setup QR** → scan a QR containing your URL and key. There's no
|
||||
@@ -323,12 +325,14 @@ API server is reachable. If the dot is red:
|
||||
More: [Troubleshooting](/guide/troubleshooting) · [Chat guide](/guide/chat) ·
|
||||
[Connections](/features/connections).
|
||||
|
||||
## 4. Optional — add Relay power tools
|
||||
## 4. Optional — add Relay power tools {#relay-server-optional}
|
||||
|
||||
Skip this unless you want **Terminal**, **Bridge** device control, **Relay
|
||||
sessions**, channel grants, or relay-backed device-control features. Chat, voice,
|
||||
and Manage all work without it.
|
||||
|
||||
### Install the server plugin {#install-the-server-plugin}
|
||||
|
||||
::::details Install the Relay plugin + pair
|
||||
On the Hermes host:
|
||||
|
||||
@@ -447,7 +451,7 @@ back to API-server SSE when it is not.
|
||||
::: details Manual connection setup (no QR)
|
||||
**During onboarding:**
|
||||
|
||||
1. On the **Connect** page, tap **Vanilla Hermes**.
|
||||
1. On the **Connect** page, tap **Hermes**.
|
||||
2. Type your API server URL — e.g. `http://192.168.1.100:8642` — scan for Hermes
|
||||
on LAN, or scan a generic QR containing the API URL/key.
|
||||
3. Enter the value you set in `API_SERVER_KEY` if the QR didn't include it.
|
||||
|
||||
@@ -76,7 +76,7 @@ Download the file ending in `-sideload-release.apk` from the latest GitHub Relea
|
||||
- **Google Play** updates automatically through the Play Store — you'll get new versions in the background like any other Play app.
|
||||
- **Sideload** checks GitHub for a newer release on app cold start (at most once every 6 hours) and shows a dismissable banner at the top of the app when you're behind. Tap **Update** → the sideload APK opens in your browser, Android's Downloads notification hands it to the system installer, done. No second app required, no new permissions. You can also trigger a manual check at **Settings → About → Updates**. If you prefer to subscribe to release notifications directly, click *Watch → Custom → Releases* on the [repo](https://github.com/Codename-11/hermes-relay).
|
||||
|
||||
In both cases, the server-side plugin is updated independently with `git pull && bash install.sh` on whatever machine runs your Hermes agent — see the [installation guide](/guide/getting-started#install-the-server-plugin) for that flow.
|
||||
In both cases, the server-side plugin is updated independently on whatever machine runs your Hermes agent — re-run `hermes plugins install Codename-11/hermes-relay/plugin`, or run the `hermes-relay-update` shim if you used the legacy installer — see the [installation guide](/guide/getting-started#install-the-server-plugin) for that flow.
|
||||
|
||||
## Safety rails
|
||||
|
||||
|
||||
@@ -9,6 +9,43 @@
|
||||
- Check firewall rules on the server
|
||||
- Try the URL in a browser: `http://your-server:8642/health`
|
||||
|
||||
### "No reachable endpoint" in the diagnostics log
|
||||
|
||||
This diagnostic means the app probed every route saved on the connection (LAN,
|
||||
Tailscale, public, custom) and none answered a health check. It is a network
|
||||
result, not an app bug — the fix is making at least one saved route reachable
|
||||
from the phone:
|
||||
|
||||
1. Open each saved route in the **phone's** browser as
|
||||
`http://<host>:8642/health`. Whatever fails there will fail in the app too.
|
||||
2. A LAN route (`192.168.x.x`, `10.x.x.x`) only works while the phone is on the
|
||||
same Wi-Fi network as the server — it goes dark on mobile data or a
|
||||
different network.
|
||||
3. A Tailscale route (`100.x.y.z` or `*.ts.net`) needs Tailscale running on
|
||||
both the phone and the server (see the checklist below).
|
||||
4. Confirm the port: the API server listens on `8642` by default.
|
||||
|
||||
::: warning Don't use `localhost` or `127.0.0.1` on the phone
|
||||
On your phone, `localhost` points at the phone itself — not your server. A
|
||||
server address like `http://localhost:8642` or `http://127.0.0.1:8642` can
|
||||
never connect from the app, even though the same URL works in a browser on the
|
||||
server machine. Use the server's LAN IP (e.g. `http://192.168.1.100:8642`) or
|
||||
its Tailscale address instead.
|
||||
:::
|
||||
|
||||
### Tailscale checklist
|
||||
|
||||
If a Tailscale route fails its probe:
|
||||
|
||||
- Open the Tailscale app on the phone and make sure it is **connected** (it can
|
||||
silently sign out or be paused by battery savers).
|
||||
- Verify the server appears in the phone's Tailscale device list and responds
|
||||
to `http://<tailscale-address>:8642/health` from the phone's browser.
|
||||
- The relay's Tailscale helper (`hermes-relay-tailscale enable`) serves the
|
||||
relay and API ports over the tailnet, but **not** the dashboard on `:9119` —
|
||||
Manage sign-in needs the dashboard reachable separately (for example, run the
|
||||
dashboard on a host Tailscale can reach, or serve `:9119` yourself).
|
||||
|
||||
## Android Studio can't see a phone over Tailscale ADB
|
||||
|
||||
Use Android's **Wireless debugging** flow, but route it through the phone's
|
||||
@@ -36,6 +73,21 @@ debugging** on the phone and copy the current main port.
|
||||
- Look for error banners in the chat — tap **Retry** to resend
|
||||
- Check the Hermes server logs for errors
|
||||
|
||||
## Long turns with local models
|
||||
|
||||
Local models (Ollama, llama.cpp, and similar) can take several minutes per
|
||||
turn, and Android may drop the stream mid-turn — especially with the screen
|
||||
off or the app in the background. The app recovers the finished answer
|
||||
automatically: when it reconnects, the completed turn is fetched from the
|
||||
server and appears in the chat.
|
||||
|
||||
To reduce drops in the first place:
|
||||
|
||||
- Keep the screen on or the phone plugged in during long turns — Android is
|
||||
far less aggressive about cutting network connections then.
|
||||
- Enable the gateway keep-alive option in Settings if you background the app
|
||||
during long turns.
|
||||
|
||||
## "No internet connection" banner
|
||||
|
||||
- The app detected network loss via Android's ConnectivityManager
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ features:
|
||||
width: 40
|
||||
height: 40
|
||||
title: Chat that streams, not spins
|
||||
details: Direct HTTP/SSE to your Hermes API server — markdown, syntax-highlighted code, reasoning blocks, live tool-progress cards. No cloud relay in the path.
|
||||
details: Rides your dashboard's live gateway WebSocket when signed in — live thinking included — and falls back to direct HTTP/SSE against the API server. Markdown, syntax-highlighted code, reasoning blocks, tool-progress cards. No cloud relay in the path.
|
||||
- icon:
|
||||
src: /icons/personalities.svg
|
||||
width: 40
|
||||
|
||||
Reference in New Issue
Block a user