Compare commits

...
Author SHA1 Message Date
Bailey Dixon a8f61f2aeb docs: restore route and localization checks 2026-07-24 22:04:46 -04:00
Bailey Dixon d554c1819a fix(android): preserve promoted background task rows 2026-07-24 22:04:42 -04:00
Bailey Dixon 0ce7c6c6b3 fix(android): trust user-installed certificate authorities 2026-07-24 16:44:36 -04:00
Bailey Dixon 46e8f90c39 fix(android): guard markdown highlight ranges 2026-07-24 16:44:35 -04:00
Bailey Dixon 2d28f171e0 fix(android): hide dashboard source badge 2026-07-24 16:29:44 -04:00
Bailey Dixon fdd8301796 fix(android): smooth image generation transition 2026-07-24 08:29:40 -04:00
Bailey Dixon b0d662b802 feat(relay): bridge image generation activity 2026-07-23 21:36:13 -04:00
Bailey Dixon 63ca0a1428 Merge feature/hrui-ledger-all-20260723 into dev 2026-07-23 21:11:00 -04:00
Bailey Dixon 8196856d76 docs: align upstream impact ledger guidance 2026-07-23 21:10:08 -04:00
Bailey Dixon 605ff00cc0 fix(android): consume upstream display metadata 2026-07-23 21:09:01 -04:00
Bailey Dixon c9b1e1b04e fix(android): keep active chat progress visible 2026-07-23 20:57:10 -04:00
Bailey Dixon a4466e4ca0 fix(android): show image animation when tools are hidden 2026-07-23 20:34:41 -04:00
Bailey Dixon e13e381e3a docs(android): clarify shared dashboard sessions 2026-07-23 20:20:31 -04:00
Bailey Dixon 19d33910d0 fix(android): share dashboard session across routes 2026-07-23 20:17:57 -04:00
Bailey Dixon 41480a3254 fix(android): refresh dashboard-only route changes 2026-07-23 19:33:13 -04:00
Bailey Dixon 50f151edba fix(android): derive dashboard for QR routes 2026-07-23 19:18:50 -04:00
Bailey Dixon 0f108fe971 fix(android): move dashboard with active route 2026-07-23 18:42:13 -04:00
Bailey Dixon c8d6119e1a fix(android): probe remote health with GET 2026-07-23 17:18:10 -04:00
Bailey Dixon b2b7a2572b fix(android): restore remote route surfaces 2026-07-23 09:03:20 -04:00
Bailey Dixon 1ccf87401a fix(android): rebind gateway turns and dedupe sessions 2026-07-22 23:23:34 -04:00
Bailey Dixon 50b1a17895 release(server): server-v1.4.3 2026-07-22 22:52:27 -04:00
Bailey Dixon d536923c55 release(android): android-v1.5.0 2026-07-22 22:50:30 -04:00
Bailey Dixon 14a2e01ac5 Merge feature/hrui-063-standard-voice-streaming into dev 2026-07-22 22:12:47 -04:00
Bailey Dixon a0d03f6947 feat(android): stream Standard Hermes voice replies 2026-07-22 22:12:39 -04:00
Bailey Dixon 478eeac3f7 Merge fix/hrui-061-correction-copy into dev 2026-07-22 22:01:35 -04:00
Bailey Dixon 799159457a fix(android): finish active-turn correction copy 2026-07-22 22:01:18 -04:00
Bailey Dixon f90650bdc9 Merge docs/hrui-056-ops-audit into dev 2026-07-22 21:06:06 -04:00
Bailey Dixon 6a41ec154c Merge feature/hrui-android-diagnostics-manage into dev 2026-07-22 21:05:57 -04:00
Bailey Dixon 53f4c8187b Merge feature/hrui-android-gateway-controls into dev 2026-07-22 21:05:49 -04:00
Bailey Dixon 675252090c Merge feature/hrui-plugin-compat-diagnostics into dev 2026-07-22 21:05:42 -04:00
Bailey Dixon e94db467e8 docs: add Hermes update restart audit 2026-07-22 21:05:25 -04:00
Bailey Dixon 65dae79c8c feat(android): consume dashboard health hints 2026-07-22 21:04:52 -04:00
Bailey Dixon e5d0334c8b feat(android): use gateway redirect and compress RPCs 2026-07-22 20:57:47 -04:00
Bailey Dixon 9cc7b25fd1 fix(plugin): align diagnostics and config route hygiene 2026-07-22 20:34:43 -04:00
Bailey Dixon 2ce352a320 fix(android): add voice settings translations 2026-07-20 20:40:37 -04:00
Bailey Dixon a6957268b9 Merge feature/upstream-ledger-next-batch into dev 2026-07-20 20:22:30 -04:00
Bailey Dixon 8f42b96be1 feat: consume upstream interim gateway events 2026-07-20 20:22:02 -04:00
Bailey Dixon 8a9c058ddb Merge feature/voice-settings-layout-v2 into dev 2026-07-20 20:08:59 -04:00
Bailey Dixon 5ef2c40f81 feat(android): expand voice settings discovery 2026-07-20 20:08:52 -04:00
Bailey Dixon 6b32fe7ddd Merge feature/voice-settings-preview into dev 2026-07-20 10:52:26 -04:00
Bailey Dixon 02f38322fa feat(android): improve voice settings previews 2026-07-20 10:52:11 -04:00
Bailey Dixon f40b7abaf0 Merge feature/open-ledger-batches into dev 2026-07-20 10:20:50 -04:00
Bailey Dixon 5fcbe5ff63 Merge origin/dev into dev 2026-07-20 10:20:37 -04:00
Bailey Dixon 6ce504b1c9 Merge OAuth dashboard binding fix 2026-07-20 10:07:17 -04:00
Bailey Dixon 31ebef825f fix(android): bind oauth flow to dashboard 2026-07-20 10:07:03 -04:00
Bailey Dixon 68abbf156d Merge Manage localization follow-up 2026-07-20 09:39:02 -04:00
Bailey Dixon 73a8af7c8f fix(android): localize manage parity strings 2026-07-20 09:38:44 -04:00
Bailey Dixon 2db00d4c59 Merge feature/hrui-direct-chat-compat 2026-07-20 09:10:47 -04:00
Bailey Dixon 847444d115 fix(android): cap inline image lifecycle memory 2026-07-20 09:09:27 -04:00
Bailey Dixon 798e8eef55 Merge feature/hrui-manage-parity 2026-07-20 09:05:14 -04:00
Bailey Dixon 35f791be50 Merge pull request #238 from Codename-11/feature/axi-129-image-generation-animation
feat(android): show diffusion animation during image generation
2026-07-20 09:03:45 -04:00
Bailey Dixon e727979897 fix(android): safely gate hosted oauth 2026-07-20 09:03:35 -04:00
Bailey Dixon 91025b733f fix(android): bound inline data image memory 2026-07-20 09:01:16 -04:00
Bailey Dixon 34da86a96a fix(android): refresh locale source hashes after image gen string
Translated catalogs already include image_generation_rendering; update
localization-status source_sha256 so check-android-locales stays green.

Forge: AXI-129
2026-07-20 08:56:50 -04:00
Bailey Dixon 30f9f51e2a fix(android): preserve hosted oauth scope 2026-07-20 08:54:33 -04:00
Bailey Dixon 713529f79f Merge feature/hrui-session-controls 2026-07-20 08:50:09 -04:00
Bailey Dixon 16d1728306 fix(android): honor session control reset semantics 2026-07-20 08:49:20 -04:00
Bailey Dixon 7a813995ba feat(android): show diffusion animation during image generation
Specialize pending image_generate tool parts with a theme-aware procedural
diffusion placeholder and accessibility announcement. Vanilla Hermes already
emits the generic tool lifecycle, so this stays client-only.

Forge: AXI-129
2026-07-20 08:49:12 -04:00
Bailey Dixon c86b2224ce feat(android): align direct chat with upstream contracts 2026-07-20 08:47:20 -04:00
Bailey Dixon 11a782bc44 feat(android): add hosted manage parity 2026-07-20 08:36:48 -04:00
Bailey Dixon 884a17ded7 Merge certification coverage fixes 2026-07-20 08:25:32 -04:00
Bailey Dixon 745904d468 test: cover background delivery ownership 2026-07-20 08:21:54 -04:00
Bailey Dixon 4258322acc test: run delegation ownership certification 2026-07-20 08:17:43 -04:00
Bailey Dixon 2c544b8ab0 Merge feature/hrui-live-certification 2026-07-20 08:11:43 -04:00
Bailey Dixon 5122ee69f6 test: automate upstream compatibility preflight 2026-07-20 08:11:18 -04:00
Bailey Dixon 512199448e Merge origin/dev into dev 2026-07-20 08:02:22 -04:00
dependabot[bot] 7a7b10f08a chore(deps): bump com.meta.spatial:spatial-gradle-plugin-impl (#237)
Bumps com.meta.spatial:spatial-gradle-plugin-impl from 0.13.1 to 0.13.2.

---
updated-dependencies:
- dependency-name: com.meta.spatial:spatial-gradle-plugin-impl
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 11:56:33 +00:00
dependabot[bot] f7845291e7 chore(deps): bump spatialsdk from 0.13.1 to 0.13.2 (#236)
Bumps `spatialsdk` from 0.13.1 to 0.13.2.

Updates `com.meta.spatial:meta-spatial-sdk` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-compose` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-ovrmetrics` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-toolkit` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-vr` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-isdk` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-castinputforward` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-hotreload` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-datamodelinspector` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-uiset` from 0.13.1 to 0.13.2

Updates `com.meta.spatial:meta-spatial-sdk-mruk` from 0.13.1 to 0.13.2

---
updated-dependencies:
- dependency-name: com.meta.spatial:meta-spatial-sdk
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-compose
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-ovrmetrics
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-toolkit
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-vr
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-isdk
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-castinputforward
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-hotreload
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-datamodelinspector
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-uiset
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: com.meta.spatial:meta-spatial-sdk-mruk
  dependency-version: 0.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 11:56:03 +00:00
dependabot[bot] 689219405b chore(deps): bump the testing group with 2 updates (#235)
Bumps the testing group with 2 updates: [io.github.takahirom.roborazzi:roborazzi](https://github.com/takahirom/roborazzi) and [io.github.takahirom.roborazzi:roborazzi-compose](https://github.com/takahirom/roborazzi).


Updates `io.github.takahirom.roborazzi:roborazzi` from 1.68.0 to 1.70.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.68.0...1.70.0)

Updates `io.github.takahirom.roborazzi:roborazzi-compose` from 1.68.0 to 1.70.0
- [Release notes](https://github.com/takahirom/roborazzi/releases)
- [Commits](https://github.com/takahirom/roborazzi/compare/1.68.0...1.70.0)

---
updated-dependencies:
- dependency-name: io.github.takahirom.roborazzi:roborazzi
  dependency-version: 1.70.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
- dependency-name: io.github.takahirom.roborazzi:roborazzi-compose
  dependency-version: 1.70.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: testing
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 11:55:47 +00:00
Bailey Dixon f4e9de425a Merge feature/upstream-ledger-batch into dev
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/ui/screens/DiagnosticsScreen.kt
#	docs/upstream-surface-matrix.md
2026-07-20 07:41:48 -04:00
Bailey Dixon d4cdb96bab fix: preserve running queued recovery handoff 2026-07-19 20:20:36 -04:00
Bailey Dixon c5f35145b4 Merge queued recovery boundary fixes 2026-07-19 19:53:02 -04:00
Bailey Dixon 3283c9b601 fix: preserve resumed gateway turn boundaries 2026-07-19 19:52:46 -04:00
Bailey Dixon 44f030f93b docs: block one-turn model automation on atomicity 2026-07-19 19:44:35 -04:00
Bailey Dixon abce00f49e Revert "Merge feature/hrui-046-model-once"
This reverts commit 61c4337807, reversing
changes made to 6e1338004c.
2026-07-19 19:44:19 -04:00
Bailey Dixon 123f1d1263 docs: record upstream ledger follow-ups 2026-07-19 19:37:47 -04:00
Bailey Dixon 61c4337807 Merge feature/hrui-046-model-once 2026-07-19 19:26:42 -04:00
Bailey Dixon 6e1338004c Merge feature/hrui-047-queued-recovery 2026-07-19 19:25:59 -04:00
Bailey Dixon 348152a7cb Merge feature/hrui-044-windows-ca 2026-07-19 19:25:59 -04:00
Bailey Dixon 3ec34502ec feat(chat): add one-turn model selection 2026-07-19 19:25:55 -04:00
Bailey Dixon d30de8656d fix(android): scope diagnostics toolsets to profile 2026-07-19 19:25:52 -04:00
Bailey Dixon 9b9b8c7c06 Merge feature/hrui-038-profile-routing
# Conflicts:
#	app/src/main/kotlin/com/hermesandroid/relay/network/upstream/DashboardApiClient.kt
2026-07-19 19:25:21 -04:00
Bailey Dixon 93b82aa538 feat: surface queued recovery and project labels 2026-07-19 19:24:20 -04:00
Bailey Dixon 7f2049fa0a fix(desktop): trust Windows system certificate authorities 2026-07-19 19:24:16 -04:00
Bailey Dixon 92444a7039 feat(android): route multiplex profile API traffic 2026-07-19 19:23:31 -04:00
Bailey Dixon a02d7e10df Merge feature/hrui-diagnostics-bundle
# Conflicts:
#	docs/upstream-surface-matrix.md
2026-07-19 19:18:29 -04:00
Bailey Dixon 8a3935ffa1 feat: add upstream gateway diagnostics 2026-07-19 19:17:18 -04:00
Bailey Dixon e48935929a Merge bootstrap database initialization follow-up 2026-07-19 19:07:15 -04:00
Bailey Dixon 1e82347e7d fix(plugin): offload bootstrap database initialization 2026-07-19 19:06:59 -04:00
Bailey Dixon 75ed3c4226 Merge feature/hrui-004-012-bootstrap-async 2026-07-19 18:34:26 -04:00
Bailey Dixon bade61ac34 fix(plugin): offload bootstrap compatibility state 2026-07-19 18:28:14 -04:00
Bailey Dixon f88559f856 docs: define upstream compatibility gates 2026-07-19 18:25:57 -04:00
157 changed files with 13298 additions and 963 deletions
@@ -0,0 +1,79 @@
# Hermes-Relay - Desktop Vanilla-Upstream Baseline
#
# Manual/scheduled confidence gate for HRUI-055. This keeps the first CI shape
# intentionally small: check out a clean upstream hermes-agent beside Relay and
# run the desktop typed-stream/renderer tests that protect the gateway event
# contract. A later expansion can boot the upstream gateway with a mock provider
# once that harness is stable enough for CI.
name: CI - Desktop Upstream Baseline
on:
workflow_dispatch:
inputs:
upstream_ref:
description: "NousResearch/hermes-agent ref to check"
required: false
default: "main"
schedule:
- cron: "30 6 * * 1"
concurrency:
group: ci-desktop-upstream-baseline-${{ github.ref }}
cancel-in-progress: true
jobs:
desktop-baseline:
name: Desktop typed gateway baseline
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout hermes-relay
uses: actions/checkout@v7
- name: Resolve upstream ref
id: ref
run: |
if [ -n "${{ github.event.inputs.upstream_ref }}" ]; then
REF="${{ github.event.inputs.upstream_ref }}"
else
REF="main"
fi
echo "ref=$REF" >> "$GITHUB_OUTPUT"
- name: Checkout vanilla upstream
uses: actions/checkout@v7
with:
repository: NousResearch/hermes-agent
ref: ${{ steps.ref.outputs.ref }}
path: _upstream
fetch-depth: 1
- name: Assert upstream checkout is vanilla
run: |
if [ -e "_upstream/hermes_relay_bootstrap" ] || \
[ -e "_upstream/plugin/hermes_relay_bootstrap" ] || \
find _upstream -name "hermes_relay_bootstrap.pth" 2>/dev/null | grep -q .; then
echo "FAIL: upstream checkout contains a relay bootstrap."; exit 1
fi
git -C _upstream status --short --untracked-files=no
- name: Run desktop gateway baseline contract
run: python scripts/check-desktop-upstream-baseline.py "_upstream"
- name: Set up Node
uses: actions/setup-node@v7
with:
node-version: "22"
cache: "npm"
cache-dependency-path: desktop/package-lock.json
- name: Install desktop dependencies
working-directory: desktop
run: npm ci
- name: Run desktop gateway baseline tests
working-directory: desktop
env:
HERMES_UPSTREAM_BASELINE: ${{ github.workspace }}/_upstream
run: npx tsx --test tests/gatewayTypes.test.ts tests/renderer.test.ts tests/typedStreamRenderer.test.ts
+35
View File
@@ -6,11 +6,46 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Added
- **Image generation stays visible when upstream tool progress is hidden.** A paired Relay can expose read-only image-tool activity from Hermes session state so Android shows and completes its existing generation animation during Standard Gateway turns; the image canvas replaces generic streaming progress and crossfades into the result within one stable assistant bubble. Native Gateway lifecycle events remain authoritative and Relay remains optional.
### Fixed
- **Promoted voice tasks keep their Chat row through background delivery.** Completing the provider's initial spoken handoff no longer removes an otherwise empty assistant bubble that still owns a running background task.
- **Android accepts deliberately installed private certificate authorities.** Google Play and sideload builds now use Android's user CA store alongside system roots for self-hosted HTTPS/WSS connections while preserving certificate-chain, hostname, and Relay pin verification.
- **Malformed code blocks no longer crash Android Markdown rendering.** Syntax highlighting now bounds dependency-provided spans before applying them, preserving valid highlighting while safely ignoring reversed or out-of-bounds ranges.
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
## [Server 1.4.3] - 2026-07-22
### Added
- **Relay diagnostics describe upstream Gateway compatibility.** Doctor and `/relay/info` report optional Gateway health, configuration-route, and capability signals so clients can distinguish an older upstream install from a Relay failure.
### Fixed
- **Relay trust boundaries are enforced across privileged interfaces.** Pairing policy is host-authorized, Android bridge and terminal dispatch require active grants, ordinary sessions can only reduce their own policy, remote profile config is restricted to a public schema, and voice callers cannot redirect host provider credentials.
- **Plugin bootstrap work no longer blocks the Gateway event loop.** Database initialization and compatibility-state inspection run off the async request path while preserving older upstream bootstrap behavior.
- **Starting Relay no longer terminates a running Hermes gateway on Windows.** Profile discovery now checks gateway PIDs through non-signalling process APIs, including during periodic rescans.
## [Android 1.5.0] - 2026-07-22
### Added
- **Voice settings are organized around Standard and Realtime paths.** Provider, model, and voice choices use a cleaner card layout with upstream-aware discovery, useful descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
- **Standard Hermes speech streams while replies are generated.** Android plays completed speech segments as they arrive, interrupts prior playback before starting another preview or reply, and stops audio when leaving voice mode.
- **Manage and diagnostics expose more upstream Gateway controls.** Android consumes health hints, follows canonical redirects, compresses larger RPC payloads, scopes diagnostics by profile, and surfaces compatibility information without requiring Relay-only behavior.
- **Chat shows richer upstream state.** One-turn model selection, queued-recovery and project labels, interim Gateway events, and a theme-aware image-generation animation make active work easier to follow.
### Fixed
- **Voice settings and active-turn correction remain usable across supported languages.** New voice controls are localized and correction copy accurately describes the turn being replaced.
- **Chat reconnects preserve the running Gateway turn without duplicating it.** Android reactivates the original live session after a socket loss, avoids resubmitting a prompt when its acknowledgement was lost, and de-duplicates session rows before they reach the drawer.
- **Relay pairing preserves Tailscale and other fallback routes.** Adding Relay to an existing Standard connection now keeps every signed QR route, restores older per-device endpoints hidden by the connection upgrade, and gives remote Dashboard routes their API fallback. When a host-scoped Dashboard sign-in is still required, Chat shows the route-specific sign-in action instead of loading indefinitely.
- **Remote routes move every Hermes surface together.** Android uses `GET /health` instead of misclassifying the API server's `405 Method Not Allowed` response to `HEAD`, and the selected Tailscale route now carries Dashboard/Gateway, sessions, Manage, and Standard Voice with API and Relay instead of leaving them pinned to the saved LAN host. Manage also distinguishes host-side Nous provider authentication from Dashboard sign-in.
- **Hosted Manage and direct-chat compatibility stay bounded and secure.** OAuth state remains tied to the selected dashboard, inline image memory is capped, and session reset and queued-recovery boundaries follow upstream contracts.
## [1.4.9] - 2026-07-19
### Changed
+52
View File
@@ -1,5 +1,57 @@
# Hermes-Relay — Dev Log
## 2026-07-24 — Realtime background-task delivery continuity
Chat stream completion now preserves an otherwise empty assistant row when it
owns a promoted background task. This keeps the task identity available after
the provider's initial spoken handoff, so later progress, completion, and
forced-summary events update and settle the initiating row even when a newer
persistent Voice command has started. Existing empty-response cleanup remains
unchanged for assistant rows without background work.
## 2026-07-23 — Android user-CA trust for self-hosted Hermes
The shared Android network security configuration now accepts CA certificates
that the device owner deliberately installed in Android's user credential store,
in addition to system roots. Because the policy is attached to the common
application manifest, it covers both product flavors and every platform-backed
Hermes transport: endpoint probes, Dashboard requests and authentication
WebView, redirects, Gateway WebSockets, API streaming, Standard Voice, and
Relay HTTPS/WSS. Default OkHttp and WebView certificate-chain and hostname
checks remain active, and Relay's independent certificate pinner is not
overridden.
An app-wide policy is required for arbitrary operator-supplied server names and
for consistent WebView behavior. A runtime opt-in would require parallel custom
trust implementations for each client and could not safely reconfigure WebView;
per-connection CA import would add private trust-material lifecycle without
covering all transports. The tradeoff is that Android disables public
Certificate Transparency verification when user trust anchors are enabled.
User documentation records the deliberate-installation boundary and a device or
emulator validation procedure with positive chain and negative hostname checks.
JVM coverage locks the accepted anchor sources and rejects pin overrides or
debug-only trust additions.
## 2026-07-23 — Bounded Android code-highlighting ranges
Android Markdown code rendering now validates every syntax-highlighting span
before applying it to Compose text. The bundled highlighting dependency can
emit a reversed multiline-comment range when malformed or incomplete code
contains a closing delimiter before its opening delimiter; the Markdown
renderer previously passed that range directly to `AnnotatedString` and
crashed. Both fenced and indented code use the guarded renderer, valid spans
remain highlighted, and malformed ranges are clipped or ignored. Focused JVM
coverage reproduces the dependency output and verifies the safe conversion.
## 2026-07-20 — Image generation placeholder during turns
Android chat now specializes the generic tool lifecycle for active
`image_generate` calls. While the tool is pending, the message shows a
theme-aware procedural diffusion canvas with a polite live-region announcement
instead of a generic tool card. The completed tool result still replaces the
placeholder through the existing tool completion path. Coverage includes pure
JVM selection/denoise tests and a Compose accessibility snapshot test.
## 2026-07-19 — Android 1.4.9 release preparation
Android advanced to 1.4.9 with versionCode 32 after the dashboard-primary
+7 -5
View File
@@ -1,20 +1,22 @@
# Hermes-Relay-Plugin v__VERSION__
**Release Date:** July 15, 2026
**Release Date:** July 22, 2026
This patch aligns Server default with Hermes' sticky active profile and lets paired clients import conventional profile avatar files without exposing host paths.
This patch hardens Relay authorization, adds upstream-aware diagnostics, and keeps plugin bootstrap work off the Gateway event loop.
Pairs with Hermes-Relay-Android v1.4.6 for profile image import. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
It can accompany Hermes-Relay-Android v1.5.0 for optional Relay diagnostics and power features. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
## What's changed
### Added
- **Paired clients can import profile avatars.** Relay discovers conventional direct-child images such as `avatar.png` and `profile.jpg`, validates their media type, size, and profile boundary, and serves the bytes through an authenticated route.
- **Upstream-aware Gateway diagnostics.** Doctor and `/relay/info` expose optional health, configuration-route, and capability signals so clients can explain compatibility gaps without treating an older upstream install as a broken Relay.
### Fixed
- **Server default follows Hermes' active profile.** Advertised identity, model, SOUL, profile metadata, and avatar resolve through the sticky `active_profile` marker instead of always using the root profile.
- **Privileged Relay paths enforce host authorization and active grants.** Pairing, Android bridge, terminal, session policy, remote profile configuration, and voice provider origins retain their intended trust boundaries.
- **Plugin bootstrap remains responsive.** Database initialization and compatibility inspection run outside the Gateway event loop while preserving compatibility with older upstream bootstrap contracts.
- **Windows Gateway detection is non-signalling.** Starting Relay and periodic profile rescans no longer risk terminating an existing Gateway process.
## Install / update
+15 -10
View File
@@ -1,10 +1,10 @@
# Hermes-Relay-Android v1.4.9
# Hermes-Relay-Android v1.5.0
**Release Date:** July 19, 2026
**Release Date:** July 22, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.4.9-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.5.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,19 +12,24 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This patch makes the Hermes dashboard the standard connection path and refreshes setup, connection management, and profile identity throughout Android.
This feature release overhauls voice setup and playback, expands upstream Gateway-aware controls, and makes active Hermes work easier to understand.
## Changed
## Added
- Connect through the Hermes dashboard for Chat, sessions, Manage, and voice with one sign-in. The API server remains an automatic fallback or headless compatibility path, and Relay remains optional for power features.
- Onboarding and connection management now explain nearby, remote, Tailscale, custom-port, and Relay paths with clearer status, route, startup, Advanced, and Security controls.
- Standard and Realtime voice settings now have distinct, organized cards for provider, model, and voice selection, with upstream-aware discovery, descriptions, inline previews, waveform feedback, loading skeletons, and an expandable scrolling voice browser.
- Standard Hermes speech now streams completed reply segments as they arrive. Starting another preview or reply stops the prior audio, and leaving voice mode stops playback.
- Manage and diagnostics consume upstream health hints and compatibility details, follow canonical Gateway redirects, compress larger RPC payloads, and preserve profile-scoped behavior.
- Chat surfaces one-turn model selection, queued recovery, project labels, interim Gateway events, and image-generation progress.
## Fixed
- Server default now shows Hermes' pinned active profile consistently across Chat, sessions, agent details, settings, voice, diagnostics, and profile inspection.
- Successful local discovery adds useful hostname identity without replacing a custom connection label.
- Voice settings and active-turn correction copy remain complete across supported languages.
- Chat reactivates the original live Gateway session after a connection loss, avoids duplicate prompt submission when an acknowledgement is lost, and prevents duplicate session rows from crashing the drawer.
- Relay pairing retains Tailscale and other QR fallback routes when added to an existing Standard connection, recovers older stored routes, and shows a route-specific Dashboard sign-in action instead of leaving remote Chat loading.
- Remote route checks use the API server's supported `GET /health` contract. Selecting Tailscale now moves Dashboard/Gateway, sessions, Manage, Standard Voice, API, and Relay together instead of leaving dashboard-backed features on the saved LAN host; Manage also labels host-side Nous provider authentication separately from Dashboard sign-in.
- Hosted Manage OAuth remains bound to the selected dashboard, direct-chat image memory is bounded, and session reset and recovery behavior follow upstream contracts.
## Install / Verify
- App version: **1.4.9** (versionCode **32**).
- App version: **1.5.0** (versionCode **33**).
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
+50 -1
View File
@@ -36,6 +36,55 @@ removal.
---
## Upstream impact certification follow-ups (2026-07-19)
The client/plugin implementation batch for queued recovery,
multiplex-profile fallback routing, gateway diagnostics, Windows system-CA
trust, and retained bootstrap async safety is implemented. The following gates
intentionally remain outside that code batch:
- **Image-generation lifecycle while tool progress is hidden.** The upstream
TUI gateway suppresses every `tool.start` / `tool.complete` event when
`display.tool_progress` is off, so a client cannot distinguish an active
`image_generate` turn from generic model work. Propose a narrow upstream
exception that always emits the lifecycle for `image_generate` while leaving
unrelated tool diagnostics hidden. Android already treats that lifecycle as
presentation state rather than a generic tool card and keeps the diffusion
canvas visible when its local tool display is off.
- Run `docs/upstream-compatibility-certification.md` against an approved test
gateway with real provider calls and an Android device. Include concurrent
model/image routing, turn isolation off/on, queued reconnect, same-profile
background-completion ownership, compression lineage, and the explicitly
approved restart case. Static upstream fixtures are necessary but do not
prove device or restart behavior.
- Upstream the atomic one-turn model arm/submit contract proposed in
`docs/upstream-contributions.md`. Until then, document the narrow race where a
disconnect or Stop after `/model --once` succeeds but before prompt submission
can leave the override armed for a later prompt.
- Keep HRUI-052 (`/new` session-control reset parity) blocked until upstream
exposes a reset on the active gateway session or an authoritative reset event.
`slash.exec` runs the command in a separate worker today, and the mirrored
slash side effects do not reset the active TUI session's agent. Relay must not
clear local model, reasoning, or Fast pins from a successful command response
that did not mutate the agent those controls describe.
- Keep profile-scoped cron execution attempts blocked on the public upstream API
proposed in `docs/upstream-contributions.md`. The first-class interim
assistant event is no longer blocked: Relay Android and desktop consume
upstream `message.interim` / `response_previewed`.
- Keep Standard voice labeled host-global until upstream exposes a stable
profile/per-request audio contract; do not emulate it through Relay on the
vanilla path.
- Keep provider exclusion/disable filtering out of Android Manage until the
public model-options payload identifies excluded and disabled providers.
`include_unconfigured=1` currently re-adds indistinguishable setup rows, so
empty models are not authoritative evidence that a provider should be hidden.
- Expand the desktop upstream-baseline workflow into a live mock-provider E2E
once the harness can boot a credential-free upstream gateway deterministically.
The initial `ci-desktop-upstream-baseline` gate only checks a clean vanilla
checkout and the desktop typed gateway renderer/tests.
---
## Multi-profile Phone/Threads routing — deferred (2026-07-12)
Android profile hot-swap and concurrent Gateway turns are separate from proactive
@@ -1070,7 +1119,7 @@ Things to look into:
- **Update discovery (shipped 2026-06-30 — CLI + dashboard + app).** `hermes relay update-check`, a dashboard "Plugin version" card, and an app **About → "Relay"** row all compare the installed plugin against the latest `plugin-v*` release and surface the right update command (`hermes plugins update hermes-relay` vs `hermes-relay-update`). The app polls the relay's `GET /relay/update-check` (`:8767`, bearer) on each `auth.ok`; the relay is the single source of truth (the app never hits GitHub). Possible polish (deferred): a more prominent dismissible "relay is behind" banner outside About (today it's capability-first + the About row), and showing the app's own version alongside the relay's in the same readout (the app-Version row already exists separately just above it).
- **Per-profile enablement (shipped 2026-06-30).** `hermes relay profiles list|enable [--all|NAME]` + `plugin/profiles.py` resolve the install-once/enable-per-profile papercut; docs now cover the pair-once/one-relay model. Possible follow-up: an `install.sh` / `hermes plugins install` prompt offering "enable for all existing profiles" so new installs don't need the manual `profiles enable --all`.
- `**hermes-relay-self-setup` SKILL.md as a precedent** — we just shipped a self-installing skill that an LLM can fetch from a raw GitHub URL and execute. Does this pattern generalize? Could it become a recommended way for any third-party Hermes project to ship setup automation?
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla/partial upstream. This is intentional but feels like a hack. The original broad PR #8556 was **closed as superseded**; native upstream now covers sessions/chat/fork via [#33134](https://github.com/NousResearch/hermes-agent/pull/33134) and skill/toolset discovery via `/v1/skills` + `/v1/toolsets` (#33016). **Done (2026-07-08, HRUI-002):** the bootstrap's sessions CRUD/messages/fork handlers and the legacy `GET /api/skills` list were retired outright — no pre-#33134 fallback remains; old core builds degrade via the client capability probe. **Still gapped (bootstrap remains for these):** config, memory, legacy `/api/skills/{name}` detail + `PUT /api/skills/toggle` (501 stub), available-models, `/api/sessions/search`, and the slash-command middleware — each retires individually when a native replacement lands or the dependent UX is removed. Track upstream per surface.
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla/partial upstream. This is intentional but feels like a hack. The original broad PR #8556 was **closed as superseded**; native upstream now covers sessions/chat/fork via [#33134](https://github.com/NousResearch/hermes-agent/pull/33134) and skill/toolset discovery via `/v1/skills` + `/v1/toolsets` (#33016). **Done (2026-07-08, HRUI-002):** the bootstrap's sessions CRUD/messages/fork handlers and the legacy `GET /api/skills` list were retired outright — no pre-#33134 fallback remains; old core builds degrade via the client capability probe. **Done (2026-07-19, HRUI-004/012):** retained session search now uses upstream `AsyncSessionDB` when available and `asyncio.to_thread` on older Hermes, and every compatibility memory mutation resets the upstream consolidation-failure budget when that API exists. **Still gapped (bootstrap remains for these):** config, memory, legacy `/api/skills/{name}` detail + `PUT /api/skills/toggle` (501 stub), available-models, `/api/sessions/search`, and the slash-command middleware — each retires individually when a native replacement lands or the dependent UX is removed. Track upstream per surface.
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Sibling follow-up to the native session-control baseline (#33134). Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
- **Gateway slash-command preprocessor — bootstrap middleware (Stage 1 equivalent).** Sibling shim in `hermes_relay_bootstrap/_command_middleware.py` that mirrors the upstream Stage 1 PR as an aiohttp middleware injected at bootstrap time. Ships the hallucination fix to vanilla-upstream installs before the upstream PR lands. Planned for v0.4.1, after the current bridge feature branch wraps. See `ROADMAP.md` v0.4.1 entry.
- **Stage 2 — stateful slash-command dispatch on `/api/sessions/{id}/chat/stream`.** Unblocked now that session primitives shipped upstream (#33134 / `f7527b0`). Add a preprocessor scoped to the session chat stream endpoint only, using `session_id` as the persistence handle. Separate upstream PR + matching bootstrap middleware. See `docs/upstream-contributions.md` §5 ("Stage 2").
+2 -2
View File
@@ -326,8 +326,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.68.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.68.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.70.0")
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.70.0")
testImplementation(libs.compose.ui.test.junit4)
testImplementation(libs.compose.ui.test.manifest)
testImplementation("androidx.test.ext:junit:1.3.0")
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
android:exported="true"
android:screenOrientation="portrait" />
</application>
</manifest>
@@ -0,0 +1,128 @@
package com.hermesandroid.relay.ui.screens
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.Modifier
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.network.relay.RealtimeProviderInfo
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import com.hermesandroid.relay.viewmodel.VoicePreviewUiState
/** Debug-build-only deterministic host for design QA screenshots. */
class VoiceSettingsDesignQaActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val themePreference = intent.getStringExtra("theme") ?: "auto"
setContent { HermesRelayTheme(themePreference = themePreference) { VoiceSettingsDesignQaScene() } }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun VoiceSettingsDesignQaScene() {
val provider = remember {
RealtimeProviderInfo(
id = "xai_tts",
name = "xAI Grok TTS",
status = "ready",
models = listOf("grok-tts", "grok-tts-fast"),
voices = listOf("eve", "ara", "sal", "rex", "leo"),
model_labels = mapOf("grok-tts" to "Grok TTS"),
voice_labels = mapOf("eve" to "Eve", "ara" to "Ara", "sal" to "Sal"),
recommended_voices = listOf("eve", "ara"),
supports_tts = true,
)
}
var selectedSection by remember { mutableStateOf(VoiceSettingsSection.Output) }
var selectedVoice by remember { mutableStateOf("eve") }
var expanded by remember { mutableStateOf(false) }
val allVoices = remember {
listOf(
VoiceChoice("eve", "Eve", "Warm · expressive", recommended = true),
VoiceChoice("ara", "Ara", "Clear · balanced", recommended = true),
VoiceChoice("sal", "Sal", "Calm · grounded"),
VoiceChoice("rex", "Rex", "Direct · confident"),
VoiceChoice("leo", "Leo", "Bright · conversational"),
)
}
Scaffold(topBar = { TopAppBar(title = { Text("Voice") }) }) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.58f),
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Hermes Chat + Voice Output", style = MaterialTheme.typography.titleMedium)
Text("Default profile · Profile voice", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
VoiceSettingsTabs(selectedSection) { selectedSection = it }
VoiceProviderGroupCard(
provider = provider,
providerValue = provider.id,
enabled = true,
providerChoices = listOf(VoiceChoice(provider.id, provider.name.orEmpty())),
onEnabledChange = {},
onProviderChange = {},
controlsEnabled = true,
)
ModelAndVoiceGroupCard(
modelValue = "grok-tts",
modelChoices = listOf(VoiceChoice("grok-tts", "Grok TTS")),
voices = previewVoiceChoices(allVoices, selectedVoice),
allVoices = allVoices,
selectedVoice = selectedVoice,
previewState = VoicePreviewUiState(
selectionKey = "voice:eve",
isPlaying = true,
amplitude = 0.42f,
),
onModelChange = {},
onVoiceChange = { selectedVoice = it },
onPreviewVoice = {},
enabled = true,
)
LanguageQualityCard(
expanded = expanded,
onExpandedChange = { expanded = it },
language = "English",
languages = listOf(VoiceChoice("en", "English")),
onLanguageChange = {},
sampleRate = "24000",
sampleRates = listOf(VoiceChoice("24000", "24 kHz")),
onSampleRateChange = {},
enabled = true,
)
}
}
}
@@ -1 +1 @@
Connect through the Hermes dashboard with one sign-in for Chat, sessions, Manage, and voice. Setup and connection details now explain nearby, remote, Tailscale, custom-port, optional Relay, route, and security choices. Server default also displays Hermes' pinned active profile consistently across the app.
Browse Standard and Realtime voice providers, models, and voices in a cleaner layout with inline previews. Standard Hermes replies now speak completed segments while the answer is generated, and new audio stops prior playback. This release also expands Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
@@ -1 +1 @@
现在可通过 Hermes 控制面板一次登录使用聊天、会话、管理和语音。设置与连接详情会清楚说明附近设备、远程访问、Tailscale、自定义端口、可选 Relay、路由和安全选项。“服务器默认”也会在整个应用中一致显示 Hermes 当前固定的活跃配置文件。
现在可在更清晰的界面中浏览标准和实时语音的提供商、模型与声音,并直接试听。标准 Hermes 回复会在生成过程中分段朗读;开始新的音频时会停止之前的播放。本次更新还增强了与 Gateway 兼容的管理、诊断、模型选择、恢复及生成状态。
+28
View File
@@ -1,5 +1,33 @@
{
"versions": [
{
"version": "1.5.0",
"title": "Voice that keeps pace",
"date": "2026-07-22",
"sections": [
{
"header": "A clearer voice studio",
"bullets": [
"Standard and Realtime paths now organize provider, model, and voice choices in focused cards with upstream-aware discovery and descriptions.",
"Preview voices inline with loading feedback and a lighter waveform, then expand and scroll the voice browser without leaving the page."
]
},
{
"header": "Natural streaming speech",
"bullets": [
"Standard Hermes replies begin speaking completed segments while the rest of the answer is still being generated.",
"Starting new audio stops the prior preview or reply, and leaving voice mode stops playback."
]
},
{
"header": "More upstream-aware controls",
"bullets": [
"Manage and diagnostics consume Gateway health and compatibility details while keeping Standard Hermes usable without Relay.",
"Chat now shows one-turn model choices, queued recovery, project labels, interim events, and image-generation progress."
]
}
]
},
{
"version": "1.4.9",
"title": "Clearer Hermes connections",
+4 -4
View File
@@ -1,5 +1,5 @@
v1.4.9 - Clearer Hermes connections
v1.5.0 - Voice that keeps pace
* Connect through the Hermes dashboard with one sign-in; API fallback and optional Relay remain available.
* Onboarding and connection details now explain nearby, remote, Tailscale, custom-port, route, and security choices.
* Server default consistently displays Hermes' pinned active profile, and discovered servers show useful host identity.
* Browse Standard and Realtime providers, models, and voices in a cleaner layout with inline previews.
* Hear Standard Hermes replies as completed speech segments arrive; starting new audio stops the prior playback.
* Use richer Gateway-aware Manage, diagnostics, model selection, recovery, and generation status.
@@ -13,6 +13,8 @@ data class DashboardConnectionStatus(
val authProvider: String? = null,
val gatewayTicketAvailable: Boolean? = null,
val message: String? = null,
val gatewayMode: String? = null,
val profiles: List<String> = emptyList(),
)
/**
@@ -112,6 +114,8 @@ data class Connection(
const val LEGACY_TOKEN_STORE_KEY: String = "hermes_companion_auth_hw"
const val DEFAULT_DASHBOARD_PORT: Int = 9119
const val DEFAULT_API_PORT: Int = 8642
const val DEFAULT_RELAY_PORT: Int = 8767
/**
* Derive a stable per-connection EncryptedSharedPreferences filename
@@ -187,6 +191,29 @@ data class Connection(
return "$scheme://$hostPart:$dashboardPort"
}
/** Derive the conventional same-host direct API fallback from a Dashboard URL. */
fun deriveDefaultApiUrl(
dashboardUrl: String,
apiPort: Int = DEFAULT_API_PORT,
): String? {
val trimmed = dashboardUrl.trim().trimEnd('/')
if (trimmed.isEmpty()) return null
val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
val scheme = when (uri.scheme?.lowercase()) {
"http" -> "http"
"https" -> "https"
else -> return null
}
val host = uri.host?.takeIf { it.isNotBlank() } ?: return null
val hostPart = if (host.contains(":") && !host.startsWith("[")) {
"[$host]"
} else {
host
}
return "$scheme://$hostPart:$apiPort"
}
fun isAutoManagedDashboardUrl(dashboardUrl: String?, apiServerUrl: String): Boolean {
val trimmed = dashboardUrl?.trim()?.trimEnd('/').orEmpty()
if (trimmed.isEmpty()) return true
@@ -196,7 +223,7 @@ data class Connection(
fun deriveDefaultRelayUrl(
apiServerUrl: String,
relayPort: Int = 8767,
relayPort: Int = DEFAULT_RELAY_PORT,
): String? {
val trimmed = apiServerUrl.trim().trimEnd('/')
if (trimmed.isEmpty()) return null
@@ -135,6 +135,91 @@ class RelayHttpClient(
val text: String,
)
@Serializable
data class ImageActivitySnapshot(
@SerialName("session_id") val sessionId: String,
val profile: String,
val activities: List<ImageActivity> = emptyList(),
)
@Serializable
data class ImageActivity(
@SerialName("call_id") val callId: String,
@SerialName("tool_name") val toolName: String,
val state: String,
@SerialName("started_at") val startedAt: Double,
@SerialName("completed_at") val completedAt: Double? = null,
)
/**
* Poll the optional Relay image lifecycle bridge. A null success means the
* connected Relay predates the endpoint; callers should stop polling and
* continue using native Gateway events without surfacing an error.
*/
suspend fun fetchImageActivity(
profile: String,
sessionId: String,
sinceEpochSeconds: Double,
): Result<ImageActivitySnapshot?> = withContext(Dispatchers.IO) {
val relayUrl = relayUrlProvider()?.trim().orEmpty()
if (relayUrl.isEmpty()) {
return@withContext Result.failure(
IllegalStateException("Relay URL not configured")
)
}
val sessionToken = sessionTokenProvider()
if (sessionToken.isNullOrBlank()) {
return@withContext Result.failure(
IllegalStateException("Relay not paired — session token missing")
)
}
val httpBase = relayUrl
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
.trimEnd('/')
val url = try {
"$httpBase/chat/image-activity".toHttpUrl().newBuilder()
.addQueryParameter("profile", profile)
.addQueryParameter("session_id", sessionId)
.addQueryParameter("since", sinceEpochSeconds.toString())
.build()
} catch (e: IllegalArgumentException) {
return@withContext Result.failure(IOException("Invalid relay URL: ${e.message}"))
}
val request = Request.Builder()
.url(url)
.get()
.header("Authorization", "Bearer $sessionToken")
.header("Accept", "application/json")
.build()
val activityClient = okHttpClient.newBuilder()
.callTimeout(3, java.util.concurrent.TimeUnit.SECONDS)
.build()
try {
activityClient.newCall(request).execute().use { response ->
if (response.code == 404) {
return@withContext Result.success(null)
}
if (!response.isSuccessful) {
return@withContext Result.failure(
IOException("Image activity request failed (HTTP ${response.code})")
)
}
val body = response.body?.string().orEmpty()
if (body.isBlank()) {
return@withContext Result.failure(IOException("Empty response body"))
}
Result.success(
sessionsJson.decodeFromString(ImageActivitySnapshot.serializer(), body)
)
}
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Fetch `GET /media/<token>` from the relay over HTTP(S). Returns a
* [Result] — success carries a [FetchedMedia], failure wraps the
@@ -615,6 +700,14 @@ class RelayHttpClient(
val capabilities: List<String> = emptyList(),
val profiles: List<RelayProfileInfo> = emptyList(),
val health: String = "unknown",
@SerialName("gateway_heartbeat") val gatewayHeartbeat: GatewayHeartbeat? = null,
)
@Serializable
data class GatewayHeartbeat(
val status: String = "missing",
val supported: Boolean = false,
@SerialName("age_seconds") val ageSeconds: Int? = null,
)
/** Fetch the installed plugin/protocol/profile capability contract. */
@@ -834,6 +834,11 @@ class RelayVoiceClient(
suspend fun runVoiceOutput(
text: String,
renderMode: String? = "verbatim",
provider: String? = null,
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
language: String? = null,
onHandoff: (VoiceHandoffEvent) -> Unit = {},
onEvent: (RealtimeVoiceEvent) -> Unit,
): Result<VoiceOutputSummary> = withContext(Dispatchers.IO) {
@@ -844,7 +849,15 @@ class RelayVoiceClient(
return@withContext Result.failure(missingAuthError())
}
val sessionResult = createVoiceOutputSession(httpBase, token)
val sessionResult = createVoiceOutputSession(
httpBase = httpBase,
token = token,
provider = provider,
model = model,
voice = voice,
sampleRate = sampleRate,
language = language,
)
if (sessionResult.isFailure) {
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Voice output session failed"))
}
@@ -1261,8 +1274,10 @@ class RelayVoiceClient(
inputSampleRate: Int = 16_000,
chatSessionId: String? = null,
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
provider: String? = null,
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
onHandoff: (VoiceHandoffEvent) -> Unit = {},
turnInputs: kotlinx.coroutines.channels.ReceiveChannel<RealtimeTurnInput>? = null,
onTurnComplete: (RealtimeVoiceSummary) -> Unit = {},
@@ -1290,8 +1305,10 @@ class RelayVoiceClient(
token = token,
chatSessionId = chatSessionId,
conversationContext = conversationContext,
provider = provider,
model = model,
voice = voice,
sampleRate = sampleRate,
)
if (sessionResult.isFailure) {
return@withContext Result.failure(sessionResult.exceptionOrNull() ?: IOException("Realtime agent session failed"))
@@ -2658,17 +2675,25 @@ class RelayVoiceClient(
token: String,
chatSessionId: String?,
conversationContext: List<RealtimeConversationContextMessage> = emptyList(),
provider: String? = null,
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
): Result<RealtimeSessionResponse> {
val body = buildJsonObject {
putProfile()
provider?.trim()?.takeIf { it.isNotBlank() }?.let {
put("provider", JsonPrimitive(it))
}
model?.trim()?.takeIf { it.isNotBlank() }?.let {
put("model", JsonPrimitive(it))
}
voice?.trim()?.takeIf { it.isNotBlank() }?.let {
put("voice", JsonPrimitive(it))
}
sampleRate?.takeIf { it > 0 }?.let {
put("sample_rate", JsonPrimitive(it))
}
chatSessionId?.trim()?.takeIf { it.isNotBlank() }?.let {
put("chat_session_id", JsonPrimitive(it))
}
@@ -2726,8 +2751,23 @@ class RelayVoiceClient(
}
}
private fun createVoiceOutputSession(httpBase: String, token: String): Result<VoiceOutputSessionResponse> {
val body = buildJsonObject { putProfile() }.toString()
private fun createVoiceOutputSession(
httpBase: String,
token: String,
provider: String? = null,
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
language: String? = null,
): Result<VoiceOutputSessionResponse> {
val body = buildVoiceOutputSessionPayload(
profile = currentProfileName(),
provider = provider,
model = model,
voice = voice,
sampleRate = sampleRate,
language = language,
)
val request = Request.Builder()
.url("$httpBase/voice/output/session")
.post(body.toRequestBody(JSON_MEDIA_TYPE))
@@ -2970,6 +3010,22 @@ class RelayVoiceClient(
}
}
internal fun buildVoiceOutputSessionPayload(
profile: String?,
provider: String? = null,
model: String? = null,
voice: String? = null,
sampleRate: Int? = null,
language: String? = null,
): String = buildJsonObject {
profile?.trim()?.takeIf { it.isNotBlank() }?.let { put("profile", JsonPrimitive(it)) }
provider?.trim()?.takeIf { it.isNotBlank() }?.let { put("provider", JsonPrimitive(it)) }
model?.trim()?.takeIf { it.isNotBlank() }?.let { put("model", JsonPrimitive(it)) }
voice?.trim()?.takeIf { it.isNotBlank() }?.let { put("voice", JsonPrimitive(it)) }
sampleRate?.let { put("sample_rate", JsonPrimitive(it)) }
language?.trim()?.takeIf { it.isNotBlank() }?.let { put("language", JsonPrimitive(it)) }
}.toString()
/**
* Wire shape of `GET /voice/config`. Providers are returned as nested
* objects describing the currently-active STT and TTS backend. Extra
@@ -59,8 +59,8 @@ data class RouteProbeOutcome(
* priority over a higher one. Reachability is **only** the tiebreaker
* among candidates that share the same priority.
* * **Reachability probe.** Dashboard-first routes use `GET
* ${dashboard.url}/api/status`; legacy API routes use `HEAD
* ${api.url}/health`. Relay-only routes use `HEAD ${relay.httpUrl}/health`.
* ${dashboard.url}/api/status`; legacy API routes use `GET
* ${api.url}/health`. Relay-only routes use `GET ${relay.httpUrl}/health`.
* Each request has a 4-second
* per-candidate timeout. Positive results are cached longer than negative
* results so repeated `connect()` calls don't hammer healthy routes, while
@@ -110,7 +110,6 @@ class EndpointResolver(
val baseUrl: String,
val requestUrl: String,
val path: String,
val useHead: Boolean,
)
private val probeCache = ConcurrentHashMap<String, CacheEntry>()
@@ -326,7 +325,11 @@ class EndpointResolver(
val requestBuilder = Request.Builder()
.url(url)
.header("Accept", "*/*")
val request = if (target.useHead) requestBuilder.head().build() else requestBuilder.get().build()
// Hermes API's aiohttp health route accepts GET but returns 405 to
// HEAD. That response proves connectivity while the old probe marked
// the route unreachable. Health payloads are tiny, so follow the
// endpoint's actual public contract on every surface.
val request = requestBuilder.get().build()
return withContext(Dispatchers.IO) {
try {
withTimeoutOrNull(PROBE_TIMEOUT_MS + 200L) {
@@ -407,7 +410,6 @@ class EndpointResolver(
baseUrl = base,
requestUrl = "$base/api/status",
path = "/api/status",
useHead = false,
)
}
@@ -416,7 +418,6 @@ class EndpointResolver(
baseUrl = base,
requestUrl = "$base/health",
path = "/health",
useHead = true,
)
}
@@ -436,7 +437,6 @@ class EndpointResolver(
baseUrl = relayUrl,
requestUrl = "$httpBase/health",
path = "/health",
useHead = true,
)
}
@@ -1,6 +1,7 @@
package com.hermesandroid.relay.network.shared
import java.net.URI
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
/**
* Resolves profile-scoped Hermes API URLs for phone use.
@@ -43,6 +44,44 @@ object ProfileApiUrlResolver {
return "$scheme://$hostPart$portPart$pathPart$queryPart$fragmentPart".trimEnd('/')
}
/**
* Resolve the canonical API base for a selected Hermes profile.
*
* A dedicated profile API URL remains authoritative when advertised. When
* the dashboard has positively identified a shared multiplex gateway and
* the selected non-default profile is in its served-profile list, route
* through the upstream `/p/<profile>` mirror on the connection's root API
* origin. Older/single-profile servers and incomplete topology snapshots
* deliberately keep the root URL.
*/
fun resolveChatBase(
profileApiUrl: String?,
baseApiUrl: String?,
selectedProfileName: String?,
gatewayMode: String?,
servedProfiles: Collection<String>,
): String? {
val base = normalize(baseApiUrl)
val dedicated = resolveForConnection(profileApiUrl, base)
if (dedicated != null) return dedicated
val profile = selectedProfileName
?.trim()
?.takeIf { it.isNotBlank() && !it.equals("default", ignoreCase = true) }
?: return base
val isKnownMultiplexProfile = gatewayMode.equals("multiplex", ignoreCase = true) &&
servedProfiles.any { it.equals(profile, ignoreCase = false) }
if (!isKnownMultiplexProfile) return base
val root = base?.toHttpUrlOrNull() ?: return base
return root.newBuilder()
.addPathSegment("p")
.addPathSegment(profile)
.build()
.toString()
.trimEnd('/')
}
private fun isLocalBindHost(host: String): Boolean {
return when (host.lowercase().trim('[', ']')) {
"localhost", "127.0.0.1", "0.0.0.0", "::1", "::" -> true
@@ -3,6 +3,31 @@ package com.hermesandroid.relay.network.shared
import com.hermesandroid.relay.data.VoiceAudioRoute
import java.io.File
enum class VoiceSpeechStreamStatus {
Completed,
Fallback,
Stopped,
Failed,
}
data class VoiceSpeechStreamOutcome(
val status: VoiceSpeechStreamStatus,
val audioStarted: Boolean,
val error: Throwable? = null,
)
data class VoiceSpeechStreamCallbacks(
val onStart: (sampleRate: Int, channels: Int) -> Unit = { _, _ -> },
val onPcm: (pcm16Le: ByteArray, sampleRate: Int) -> Unit,
)
interface VoiceSpeechStream {
fun append(text: String)
fun finish()
fun stop()
suspend fun awaitOutcome(): VoiceSpeechStreamOutcome
}
/**
* Transport-neutral STT/TTS contract. The routing seam between the Standard
* (dashboard) and Relay voice clients — implementations live in `network.upstream`
@@ -25,6 +50,16 @@ interface VoiceAudioClient {
suspend fun transcribe(audioFile: File): Result<String>
suspend fun synthesize(text: String): Result<File>
/**
* Open one provider-backed PCM stream for an assistant reply. A null
* success means this route has no streaming surface and the caller should
* keep using [synthesize]. Concrete implementations must queue [VoiceSpeechStream.append]
* calls made before the socket opens and report whether any PCM was emitted
* so callers never replay already-heard audio during compatibility fallback.
*/
suspend fun openSpeechStream(callbacks: VoiceSpeechStreamCallbacks): Result<VoiceSpeechStream?> =
Result.success(null)
}
/**
@@ -70,6 +105,12 @@ class AutoVoiceAudioClient(
override suspend fun synthesize(text: String): Result<File> =
runWithSelectedRoute { it.synthesize(text) }
override suspend fun openSpeechStream(
callbacks: VoiceSpeechStreamCallbacks,
): Result<VoiceSpeechStream?> = runWithSelectedRoute { client ->
client.openSpeechStream(callbacks)
}
private suspend fun <T> runWithSelectedRoute(
block: suspend (VoiceAudioClient) -> Result<T>,
): Result<T> {
@@ -1208,10 +1208,14 @@ class ChatHandler {
val syncedRealtimeTurnContents = mutableSetOf<String>()
val loaded = items.mapNotNull { item ->
val role = when (item.role) {
"user" -> MessageRole.USER
"assistant" -> MessageRole.ASSISTANT
"system" ->
val displayKind = item.displayKind?.trim()?.lowercase()
if (displayKind == "hidden") return@mapNotNull null
val role = when {
displayKind == "model_switch" ||
displayKind == "async_delegation_complete" -> MessageRole.SYSTEM
item.role == "user" -> MessageRole.USER
item.role == "assistant" -> MessageRole.ASSISTANT
item.role == "system" ->
// Upstream injects role:system STEERING markers into the
// session history on model/personality change — e.g.
// "[System: The active model for this chat has changed to …]"
@@ -1227,9 +1231,11 @@ class ChatHandler {
} else {
MessageRole.SYSTEM
}
"tool" -> return@mapNotNull null // Merged into assistant tool calls above
item.role == "tool" -> return@mapNotNull null // Merged into assistant tool calls above
else -> return@mapNotNull null
}
val displayContent = displayEventContent(displayKind, item.displayMetadata)
val rawServerContent = displayContent ?: item.contentText ?: ""
// If > 1e12, already in milliseconds; otherwise convert from seconds
val ts = item.timestamp ?: 0.0
val timestampMs = if (ts > 1e12) ts.toLong() else (ts * 1000).toLong()
@@ -1241,8 +1247,8 @@ class ChatHandler {
emptyList()
}
val messageId = item.id?.toString() ?: java.util.UUID.randomUUID().toString()
val rawContent = item.contentText ?: ""
val messageId = item.id ?: java.util.UUID.randomUUID().toString()
val rawContent = rawServerContent
// Run the media marker parser on assistant content; strip matched
// lines and queue hits for post-assignment dispatch.
@@ -1496,19 +1502,53 @@ class ChatHandler {
* [loadMessageHistory] so reconciliation only adopts ids onto rows that
* actually render.
*/
private fun renderedRoleOf(item: MessageItem): MessageRole? = when (item.role) {
"user" -> MessageRole.USER
"assistant" -> MessageRole.ASSISTANT
"system" ->
if (!showSystemMarkers &&
item.contentText?.trimStart()?.startsWith("[System:") == true
) {
null
} else {
MessageRole.SYSTEM
private fun renderedRoleOf(item: MessageItem): MessageRole? =
when (item.displayKind?.trim()?.lowercase()) {
"hidden" -> null
"model_switch", "async_delegation_complete" -> MessageRole.SYSTEM
else -> when (item.role) {
"user" -> MessageRole.USER
"assistant" -> MessageRole.ASSISTANT
"system" ->
if (!showSystemMarkers &&
item.contentText?.trimStart()?.startsWith("[System:") == true
) {
null
} else {
MessageRole.SYSTEM
}
else -> null
}
else -> null
}
}
private fun displayEventContent(displayKind: String?, metadata: JsonObject?): String? =
when (displayKind) {
"model_switch" -> {
val model = metadata.stringField("model")
?: metadata.stringField("to_model")
?: metadata.stringField("target_model")
if (model.isNullOrBlank()) "Model changed" else "Model changed to $model"
}
"async_delegation_complete" -> {
val count = metadata.intField("task_count")
?: metadata.intField("tasks")
?: metadata.intField("count")
when (count) {
null -> "Background work completed"
1 -> "1 background task completed"
else -> "$count background tasks completed"
}
}
else -> null
}
private fun JsonObject?.stringField(key: String): String? =
(this?.get(key) as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotEmpty() }
private fun JsonObject?.intField(key: String): Int? =
(this?.get(key) as? JsonPrimitive)?.let { primitive ->
primitive.contentOrNull?.toIntOrNull()
}
/**
* Normalize content for reconciliation matching: strip `MEDIA:`/`CARD:` marker
@@ -1682,7 +1722,10 @@ class ChatHandler {
// sessions as "Untitled" in the drawer (issue #133). Preserve the known
// local title whenever the server hasn't supplied a non-blank one.
val existingById = _sessions.value.associateBy { it.sessionId }
val mapped = items.map { item ->
// The drawer is always composed, even while closed, and keys rows by
// session id. A refresh race or duplicated upstream row must not put
// the same key into Compose's LazyColumn.
val mapped = items.distinctBy { it.id }.map { item ->
val startedAtMs = timestampToMillis(item.startedAt)
val lastActivityAtMs = timestampToMillis(item.resolvedLastActivity)
val activityAtMs = firstPositive(lastActivityAtMs, startedAtMs)
@@ -1760,7 +1803,15 @@ class ChatHandler {
* Add a newly created session to the list.
*/
fun addSession(session: ChatSession) {
_sessions.update { listOf(session) + it }
// Gateway onSessionId and an overlapping REST refresh can both publish
// the same freshly-created session. Treat this as an idempotent upsert,
// preferring an existing server-enriched row while collapsing any
// duplicates that were already present.
_sessions.update { current ->
val existing = current.firstOrNull { it.sessionId == session.sessionId }
listOf(existing ?: session) +
current.filterNot { it.sessionId == session.sessionId }
}
}
// --- SSE streaming event entry points ---
@@ -2867,13 +2918,22 @@ class ChatHandler {
}
_messages.update { messages ->
messages.map { msg ->
if (msg.id == messageId || msg.isStreaming) {
msg.copy(isStreaming = false, isThinkingStreaming = false)
} else {
msg
messages
.filterNot { msg ->
msg.id == messageId &&
msg.role == MessageRole.ASSISTANT &&
msg.toolCalls.isEmpty() &&
msg.backgroundTask == null &&
msg.thinkingContent.isBlank() &&
(msg.content.isBlank() || isIntentionalSilenceMarker(msg.content))
}
.map { msg ->
if (msg.id == messageId || msg.isStreaming) {
msg.copy(isStreaming = false, isThinkingStreaming = false)
} else {
msg
}
}
}
}
// Post-stream reconciliation: re-scan final content for any annotation
@@ -3073,3 +3133,15 @@ internal fun formatPhoneActionResult(
append("Status ${result.status}.")
}
}
internal fun isIntentionalSilenceMarker(content: String): Boolean =
when (content.trim().trim('"', '\'', '`').uppercase()) {
"NO_REPLY",
"[NO_REPLY]",
"SILENT",
"[SILENT]",
"<SILENT>",
"(SILENT)",
-> true
else -> false
}
@@ -16,6 +16,7 @@ import com.hermesandroid.relay.auth.buildRawTokenStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@@ -51,6 +52,37 @@ data class DashboardStatus(
val authProviderDetails: List<DashboardAuthProvider> = emptyList(),
val version: String? = null,
val message: String? = null,
@SerialName("nous_session_valid") val nousSessionValid: String? = null,
val profiles: List<String> = emptyList(),
@SerialName("gateway_mode") val gatewayMode: String? = null,
val gateways: List<DashboardGatewayTopology> = emptyList(),
val componentHealth: DashboardComponentHealthRollup = DashboardComponentHealthRollup(),
)
@Serializable
data class DashboardGatewayTopology(
val profile: String,
val ports: Map<String, Int> = emptyMap(),
@SerialName("served_profiles") val servedProfiles: List<String> = emptyList(),
)
@Serializable
data class DashboardComponentHealthRollup(
val supported: Boolean = false,
val overall: String? = null,
val components: List<DashboardComponentHealth> = emptyList(),
)
@Serializable
data class DashboardComponentHealth(
val name: String,
val status: String,
val message: String? = null,
val configured: Int? = null,
val connected: Int? = null,
val healthy: Boolean? = null,
val ok: Boolean? = null,
@SerialName("unhandled_5xx_count_5m") val unhandled5xxCount5m: Int? = null,
)
@Serializable
@@ -92,6 +124,53 @@ data class DashboardChatDisplaySettings(
val toolDisplay: String? = null,
)
data class DashboardMcpOAuthFlow(
val flowId: String,
val serverName: String,
val status: String,
val authorizationUrl: String? = null,
val error: String? = null,
) {
val isTerminal: Boolean get() = status == "approved" || status == "error"
}
data class DashboardCustomEndpoint(
val id: String,
val name: String,
val baseUrl: String,
val model: String,
val models: List<String> = emptyList(),
val contextLength: Int? = null,
val discoverModels: Boolean = true,
val hasApiKey: Boolean = false,
val apiKeyPreview: String? = null,
val isCurrent: Boolean = false,
)
data class DashboardCustomEndpoints(
val endpoints: List<DashboardCustomEndpoint>,
val currentProvider: String? = null,
val currentModel: String? = null,
)
data class DashboardCustomEndpointDraft(
val id: String? = null,
val name: String,
val baseUrl: String,
val model: String,
val apiKey: String? = null,
val contextLength: Int? = null,
val discoverModels: Boolean = true,
val makeDefault: Boolean = false,
)
data class DashboardCustomEndpointValidation(
val ok: Boolean,
val reachable: Boolean,
val message: String,
val models: List<String>,
)
/** One entry from `GET /api/audio/elevenlabs/voices` — non-secret voice metadata. */
data class ElevenLabsVoice(
val voiceId: String,
@@ -255,6 +334,14 @@ class DashboardApiClient(
*/
suspend fun getConfigSchema(): Result<JsonObject> = getJsonObject("/api/config/schema")
/**
* Runtime TTS provider matrix used by upstream's Tools/Capabilities picker.
* This adds plugin provider names and readiness metadata. Command-provider
* IDs remain sourced from the dynamic config-schema enum.
*/
suspend fun getTtsToolsetConfig(): Result<JsonObject> =
getJsonObject("/api/tools/toolsets/tts/config")
/**
* Replace the runtime config (`PUT /api/config`). Upstream `save_config`
* writes the WHOLE document, so [config] MUST be the full values tree
@@ -466,25 +553,100 @@ class DashboardApiClient(
suspend fun deleteCronJob(jobId: String, profile: String? = null): Result<JsonObject> =
deleteJsonObject("/api/cron/jobs/${pathSegment(jobId)}${profileQuery(profile)}")
suspend fun setMcpServerEnabled(name: String, enabled: Boolean): Result<JsonObject> =
suspend fun setMcpServerEnabled(
name: String,
enabled: Boolean,
profile: String? = null,
): Result<JsonObject> =
putJsonObject(
path = "/api/mcp/servers/${pathSegment(name)}/enabled",
path = "/api/mcp/servers/${pathSegment(name)}/enabled${profileQuery(profile)}",
payload = buildJsonObject { put("enabled", enabled) },
)
suspend fun testMcpServer(name: String): Result<JsonObject> =
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test")
suspend fun testMcpServer(name: String, profile: String? = null): Result<JsonObject> =
postJsonObject("/api/mcp/servers/${pathSegment(name)}/test${profileQuery(profile)}")
suspend fun removeMcpServer(name: String): Result<JsonObject> =
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}")
suspend fun removeMcpServer(name: String, profile: String? = null): Result<JsonObject> =
deleteJsonObject("/api/mcp/servers/${pathSegment(name)}${profileQuery(profile)}")
suspend fun startMcpOAuth(
name: String,
profile: String? = null,
): Result<DashboardMcpOAuthFlow> =
postJsonObject("/api/mcp/servers/${pathSegment(name)}/auth${profileQuery(profile)}")
.mapCatching(::parseMcpOAuthFlow)
suspend fun getMcpOAuthFlow(flowId: String): Result<DashboardMcpOAuthFlow> =
getJsonObject("/api/mcp/oauth/flows/${pathSegment(flowId)}")
.mapCatching(::parseMcpOAuthFlow)
/**
* Read-only hosted-OAuth capability probe. New dashboards recognize the
* flow-status route and return its canonical expired-flow 404; older
* FastAPI routers return the generic route-level 404. No OAuth worker is
* started and no provider/browser interaction occurs.
*/
suspend fun supportsHostedMcpOAuth(): Result<Boolean> {
val result = getJsonObject("/api/mcp/oauth/flows/__relay_capability_probe_never_a_flow__")
return result.fold(
onSuccess = { Result.success(true) },
onFailure = { error ->
val message = error.message.orEmpty()
when {
message.contains("OAuth flow not found or expired") -> Result.success(true)
message.contains("HTTP 404") -> Result.success(false)
else -> Result.failure(error)
}
},
)
}
suspend fun getCustomEndpoints(): Result<DashboardCustomEndpoints> =
getJsonObject("/api/providers/custom-endpoints")
.mapCatching(::parseCustomEndpoints)
suspend fun saveCustomEndpoint(
draft: DashboardCustomEndpointDraft,
): Result<DashboardCustomEndpoints> =
postJsonObject(
"/api/providers/custom-endpoints",
customEndpointPayload(draft),
).mapCatching(::parseCustomEndpoints)
suspend fun validateCustomEndpoint(
draft: DashboardCustomEndpointDraft,
): Result<DashboardCustomEndpointValidation> =
postJsonObject(
"/api/providers/custom-endpoints/validate",
customEndpointPayload(draft),
).mapCatching { root ->
DashboardCustomEndpointValidation(
ok = root.booleanField("ok") == true,
reachable = root.booleanField("reachable") == true,
message = root.stringField("message").orEmpty(),
models = root.stringList("models"),
)
}
suspend fun activateCustomEndpoint(
id: String,
): Result<JsonObject> =
postJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}/activate")
suspend fun deleteCustomEndpoint(
id: String,
): Result<DashboardCustomEndpoints> =
deleteJsonObject("/api/providers/custom-endpoints/${pathSegment(id)}")
.mapCatching(::parseCustomEndpoints)
suspend fun installMcpCatalogEntry(
name: String,
env: Map<String, String> = emptyMap(),
enable: Boolean = true,
profile: String? = null,
): Result<JsonObject> =
postJsonObject(
path = "/api/mcp/catalog/install",
path = "/api/mcp/catalog/install${profileQuery(profile)}",
payload = buildJsonObject {
put("name", name)
put(
@@ -929,6 +1091,57 @@ class DashboardApiClient(
return params.joinToString(prefix = "?", separator = "&")
}
private fun parseMcpOAuthFlow(root: JsonObject): DashboardMcpOAuthFlow {
val flowId = root.stringField("flow_id")
?: throw IOException("MCP OAuth response did not include a flow id")
val status = root.stringField("status")
?: throw IOException("MCP OAuth response did not include a status")
return DashboardMcpOAuthFlow(
flowId = flowId,
serverName = root.stringField("server_name").orEmpty(),
status = status,
authorizationUrl = root.stringField("authorization_url"),
error = root.stringField("error"),
)
}
private fun parseCustomEndpoints(root: JsonObject): DashboardCustomEndpoints {
val current = root["current"] as? JsonObject
val endpoints = (root["endpoints"] as? JsonArray).orEmpty().mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val id = obj.stringField("id") ?: return@mapNotNull null
DashboardCustomEndpoint(
id = id,
name = obj.stringField("name") ?: id,
baseUrl = obj.stringField("base_url").orEmpty(),
model = obj.stringField("model").orEmpty(),
models = obj.stringList("models"),
contextLength = obj.intField("context_length"),
discoverModels = obj.booleanField("discover_models") != false,
hasApiKey = obj.booleanField("has_api_key") == true,
apiKeyPreview = obj.stringField("api_key_preview"),
isCurrent = obj.booleanField("is_current") == true,
)
}
return DashboardCustomEndpoints(
endpoints = endpoints,
currentProvider = current?.stringField("provider"),
currentModel = current?.stringField("model"),
)
}
private fun customEndpointPayload(draft: DashboardCustomEndpointDraft): JsonObject =
buildJsonObject {
draft.id?.takeIf { it.isNotBlank() }?.let { put("id", it) }
put("name", draft.name)
put("base_url", draft.baseUrl)
put("model", draft.model)
draft.apiKey?.takeIf { it.isNotBlank() }?.let { put("api_key", it) }
draft.contextLength?.takeIf { it > 0 }?.let { put("context_length", it) }
put("discover_models", draft.discoverModels)
put("make_default", draft.makeDefault)
}
fun defaultClient(
cookieStore: DashboardCookieStore = InMemoryDashboardCookieStore(),
): OkHttpClient = OkHttpClient.Builder()
@@ -947,6 +1160,20 @@ class DashboardApiClient(
?: root["providers"]
?: authObject?.get("providers")
val providers = parseProviders(providersElement)
val profiles = (root["profiles"] as? JsonArray).orEmpty().mapNotNull {
(it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf(String::isNotBlank)
}
val gateways = (root["gateways"] as? JsonArray).orEmpty().mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val profile = obj.stringField("profile") ?: return@mapNotNull null
val ports = (obj["ports"] as? JsonObject).orEmpty().mapNotNull { (name, value) ->
(value as? JsonPrimitive)?.contentOrNull?.toIntOrNull()?.let { name to it }
}.toMap()
val served = (obj["served_profiles"] as? JsonArray).orEmpty().mapNotNull {
(it as? JsonPrimitive)?.contentOrNull
}
DashboardGatewayTopology(profile = profile, ports = ports, servedProfiles = served)
}
return DashboardStatus(
authRequired = root.booleanField("auth_required")
?: authObject.booleanField("required")
@@ -955,6 +1182,45 @@ class DashboardApiClient(
authProviderDetails = providers,
version = root.stringField("version"),
message = root.stringField("message") ?: root.stringField("detail"),
nousSessionValid = root.stringField("nous_session_valid"),
profiles = profiles,
gatewayMode = root.stringField("gateway_mode"),
gateways = gateways,
componentHealth = parseComponentHealth(root),
)
}
private fun parseComponentHealth(root: JsonObject): DashboardComponentHealthRollup {
val rawComponents = root["components"] as? JsonObject
?: return DashboardComponentHealthRollup()
val components = rawComponents.mapNotNull { (name, element) ->
val component = element as? JsonObject ?: return@mapNotNull null
val safeName = name.trim().takeIf { it.isNotBlank() } ?: return@mapNotNull null
DashboardComponentHealth(
name = safeName,
status = component.stringField("status")
?: component.stringField("state")
?: component.stringField("health")
?: component.booleanField("ok")?.let { if (it) "ok" else "degraded" }
?: component.booleanField("healthy")?.let { if (it) "ok" else "degraded" }
?: "unknown",
message = component.stringField("message")
?: component.stringField("summary")
?: component.stringField("error")
?: component.stringField("reason"),
configured = component.intField("configured"),
connected = component.intField("connected"),
healthy = component.booleanField("healthy"),
ok = component.booleanField("ok"),
unhandled5xxCount5m = component.intField("unhandled_5xx_count_5m"),
)
}.sortedBy { it.name }
return DashboardComponentHealthRollup(
supported = true,
overall = root.stringField("overall")
?: root.stringField("status")
?: root.stringField("health"),
components = components,
)
}
@@ -1203,6 +1469,61 @@ class DashboardCookieJar(
}
}
/**
* Copy only Hermes' authenticated dashboard session cookies to another host
* that belongs to the same saved Connection. Dashboard cookies are host-only
* by design, while a Connection may reach one server through LAN and
* Tailscale hostnames/IPs. The encrypted store remains the source of truth and
* explicit sign-out clears every mirrored host together.
*
* PKCE, SSO-attempt, and unrelated application cookies are intentionally not
* copied. Secure cookies also remain Secure; this helper never downgrades them
* for an HTTP route.
*/
fun mirrorDashboardSessionCookies(
store: DashboardCookieStore,
targetUrl: String,
trustedHosts: Set<String>,
clockMillis: () -> Long = { System.currentTimeMillis() },
): Int {
val targetHost = targetUrl.toHttpUrlOrNull()?.host?.lowercase() ?: return 0
val allowedHosts = trustedHosts.mapTo(mutableSetOf()) { it.lowercase() }
if (targetHost !in allowedHosts) return 0
val now = clockMillis()
val all = store.load()
val live = all.filterNot { it.isExpired(now) }
val existingTargetKeys = live.asSequence()
.filter { it.domain.equals(targetHost, ignoreCase = true) }
.map { "${it.name.lowercase()}|$targetHost|${it.path}" }
.toSet()
val mirrored = live.asSequence()
.filter { it.isDashboardSessionCookie() }
.filter { it.domain.lowercase() in allowedHosts }
.filterNot { it.domain.equals(targetHost, ignoreCase = true) }
.groupBy { "${it.name.lowercase()}|${it.path}" }
.values
.mapNotNull { candidates -> candidates.maxByOrNull { it.expiresAt } }
.map { it.copy(domain = targetHost, hostOnly = true) }
.filterNot { it.key in existingTargetKeys }
.toList()
if (mirrored.isNotEmpty() || live.size != all.size) {
store.save(live + mirrored)
}
return mirrored.size
}
private fun StoredDashboardCookie.isDashboardSessionCookie(): Boolean {
val bareName = name
.removePrefix("__Host-")
.removePrefix("__Secure-")
return bareName == "hermes_session" ||
bareName == "hermes_session_at" ||
bareName == "hermes_session_rt" ||
bareName == "hermes_session_provider"
}
/**
* Cookie jar that resolves the backing per-connection store at request time.
*
@@ -1353,3 +1674,8 @@ private fun JsonObject?.booleanField(name: String): Boolean? =
private fun JsonObject?.intField(name: String): Int? =
(this?.get(name) as? JsonPrimitive)?.contentOrNull?.toIntOrNull()
private fun JsonObject?.stringList(name: String): List<String> =
(this?.get(name) as? JsonArray).orEmpty().mapNotNull { element ->
(element as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { it.isNotBlank() }
}
@@ -84,7 +84,44 @@ fun parseConfigSchema(schemaRoot: JsonObject): List<ConfigSchemaField> {
* `category` field so it is robust to upstream's category-merging.
*/
fun voiceConfigFields(fields: List<ConfigSchemaField>): List<ConfigSchemaField> =
fields.filter { it.key.startsWith("tts.") || it.key.startsWith("stt.") }
fields.filter {
it.key.startsWith("tts.") ||
it.key.startsWith("stt.") ||
it.key.startsWith("voice.")
}
/** A TTS provider advertised by upstream's `hermes tools` provider registry. */
data class TtsToolsetProvider(
val id: String,
val name: String,
val status: String? = null,
val isActive: Boolean = false,
)
/**
* Parse `GET /api/tools/toolsets/tts/config` into provider choices.
*
* Unlike the config-schema enum, this payload adds readiness metadata and
* reliably discovered plugin providers. Command providers remain schema-owned.
* Older upstream builds may omit `tts_provider`;
* those rows are ignored because their picker label is not a stable config ID.
*/
fun parseTtsToolsetProviders(root: JsonObject): List<TtsToolsetProvider> {
val providers = root["providers"] as? JsonArray ?: return emptyList()
return providers.mapNotNull { element ->
val provider = element as? JsonObject ?: return@mapNotNull null
val id = provider.configString("tts_provider")
?.trim()
?.takeIf { it.isNotEmpty() }
?: return@mapNotNull null
TtsToolsetProvider(
id = id,
name = provider.configString("name")?.takeIf { it.isNotBlank() } ?: id,
status = provider.configString("status"),
isActive = (provider["is_active"] as? JsonPrimitive)?.contentOrNull?.toBooleanStrictOrNull() ?: false,
)
}.distinctBy { it.id }
}
/** Read the value at a dot-path from the nested config values tree, or null. */
fun configValueAt(tree: JsonObject, dotPath: String): JsonElement? {
@@ -1,6 +1,8 @@
package com.hermesandroid.relay.network.upstream
import android.util.Log
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.UsageInfo
import com.hermesandroid.relay.util.AppForegroundTracker
import com.hermesandroid.relay.util.TurnLatencyTracer
import kotlinx.coroutines.CompletableDeferred
@@ -17,6 +19,7 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -24,6 +27,7 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
@@ -82,6 +86,8 @@ class GatewayChatClient(
/** Test seam — idle-progress watchdog base. Production keeps [TURN_TIMEOUT_MS]. */
private val turnIdleTimeoutMs: Long = TURN_TIMEOUT_MS,
) {
/** Existing upstream rich-chat vocabulary; do not invent a Relay-only source. */
private val sessionSource = "webui"
companion object {
private const val TAG = "GatewayChatClient"
@@ -128,6 +134,7 @@ class GatewayChatClient(
* not enough.
*/
private const val ATTACH_RPC_TIMEOUT_MS = 60_000L
private const val COMPRESS_RPC_TIMEOUT_MS = 120_000L
/** Upstream image byte-upload RPC (underscore — `image.attach_bytes`, content_base64). */
private const val ATTACH_METHOD_UPSTREAM = "image.attach_bytes"
@@ -289,6 +296,10 @@ class GatewayChatClient(
private val _serverContext = MutableStateFlow<Pair<Int, Int>?>(null)
val serverContext: StateFlow<Pair<Int, Int>?> = _serverContext.asStateFlow()
/** Optional upstream project identity for the active session. */
private val _serverProject = MutableStateFlow<GatewaySessionProject?>(null)
val serverProject: StateFlow<GatewaySessionProject?> = _serverProject.asStateFlow()
/** Serializes connect / session-establish so concurrent sends share one socket. */
private val connectMutex = Mutex()
@@ -528,10 +539,10 @@ class GatewayChatClient(
// into the SSE fallback, which would resubmit the same
// prompt as a duplicate turn. Recovery belongs to the
// stream: the watchdog and mid-turn rejoin own it.
if (turn.started || turn.ended) {
if (turn.started || turn.ended || turn.transportRecoveryStarted) {
Log.w(
TAG,
"prompt.submit ack failed after turn start " +
"prompt.submit ack failed after turn start/rejoin " +
"(${submitted.exceptionOrNull()?.message}) — no SSE fallback",
)
return@launch
@@ -720,14 +731,15 @@ class GatewayChatClient(
* New Hermes gateways expose `session.activate`, which attaches the new
* WebSocket transport to the exact live id saved in the client checkpoint.
* If that id has already been reaped (or the method is unavailable), fall
* back to `session.resume` by durable session id. Its `running` + `inflight`
* fields decide whether a live mapper is installed or history should settle
* the turn instead.
* back to `session.resume` by durable session id. Its `running`, `inflight`,
* and optional `queued` fields decide whether a live mapper is installed or
* history should settle the turn instead.
*/
suspend fun recoverTurn(
storedId: String,
preferredLiveId: String?,
callbacks: GatewayTurnCallbacks,
queuedTurnProvider: ((GatewayQueuedTurn) -> GatewayInboundTurnRegistration?)? = null,
): Result<GatewaySessionRecovery> = runCatching {
require(storedId.isNotBlank()) { "stored session id required" }
val requestedProfile = currentSessionProfile()
@@ -757,6 +769,7 @@ class GatewayChatClient(
boundTurn = GatewayTurn(
callbacks = dispatchOn(callbacks),
dedupeAdjacentMessageStarts = true,
deferEvents = true,
).also { turn ->
turn.markRecoveredStarted()
activeTurn = turn
@@ -770,6 +783,7 @@ class GatewayChatClient(
response = activated.getOrNull()
if (response == null) {
if (activeTurn === boundTurn) activeTurn = null
boundTurn.discardDeferredEvents()
boundTurn.detach()
boundTurn = null
Log.d(
@@ -786,6 +800,7 @@ class GatewayChatClient(
buildJsonObject {
put("session_id", storedId)
put("cols", DEFAULT_COLS)
put("source", sessionSource)
requestedProfile?.let { put("profile", it) }
},
).getOrElse { error ->
@@ -807,7 +822,7 @@ class GatewayChatClient(
storedSessionId = storedId
liveSessionProfile = requestedProfile
updateCancelledDrainLiveSession(storedId, recoveredLiveId)
(response["info"] as? JsonObject)?.let { applySessionInfo(it) }
applySessionResultInfo(response)
val inflight = (response["inflight"] as? JsonObject)?.let { value ->
GatewayInflightTurn(
@@ -816,6 +831,11 @@ class GatewayChatClient(
streaming = value.booleanField("streaming") == true,
)
}
val queued = (response["queued"] as? JsonObject)?.let { value ->
value.stringField("user")
?.takeIf { it.isNotBlank() }
?.let(::GatewayQueuedTurn)
}
val running = response.booleanField("running") == true || inflight?.streaming == true
if (running) {
@@ -828,10 +848,47 @@ class GatewayChatClient(
activeTurn = turn
}
}
queued?.let { queuedTurn ->
queuedTurnProvider?.invoke(queuedTurn)?.let { registration ->
boundTurn.installQueuedSuccessor(registration)
}
}
boundTurn.releaseDeferredEvents()
boundTurn.armWatchdog()
} else if (queued != null) {
// A queued-only snapshot belongs to the NEXT turn. Never let
// its events flow through the completed checkpoint's mapper.
val priorBoundTurn = boundTurn
boundTurn = null
val registration = queuedTurnProvider?.invoke(queued)
if (registration != null) {
val queuedTurn = GatewayTurn(
callbacks = dispatchOn(registration.callbacks),
dedupeAdjacentMessageStarts = true,
)
// recoverTurn is resumed on its caller's coroutine context;
// ChatViewModel calls it from Main, so this admission runs
// atomically with the checkpoint handoff. Posting back
// through bindInboundTurn would deadlock waiting on the
// same paused Main dispatcher during cold-start recovery.
if (registration.onHandle(queuedTurn)) {
priorBoundTurn?.redirectDeferredEventsTo(queuedTurn)
boundTurn = queuedTurn
activeTurn = queuedTurn
queuedTurn.armWatchdog()
priorBoundTurn?.detach()
} else {
priorBoundTurn?.discardDeferredEvents()
priorBoundTurn?.detach()
}
} else {
priorBoundTurn?.discardDeferredEvents()
priorBoundTurn?.detach()
}
} else {
if (boundTurn != null) {
if (activeTurn === boundTurn) activeTurn = null
boundTurn.discardDeferredEvents()
boundTurn.detach()
}
boundTurn = null
@@ -843,36 +900,109 @@ class GatewayChatClient(
running = running,
status = response.stringField("status"),
inflight = inflight,
handle = boundTurn?.takeUnless { it.ended },
queued = queued,
handle = (if (boundTurn?.ended == true) activeTurn else boundTurn)
?.takeUnless { it.ended },
)
}
}
/**
* Inject [text] into the in-flight turn (`session.steer`). The server
* only accepts a steer while a tool batch is running — [SteerResult.Rejected]
* means "no batch in flight, queue it instead". [SteerResult.Failed]
* covers transport/RPC failure (no live session, socket down, 4010 …) —
* callers should fall back to the local queue for both non-queued cases.
* Inject [text] into the in-flight turn. Current upstream exposes this as
* `session.redirect`, which can redirect an active model turn while keeping
* valid work/context. Older gateways only expose `session.steer`; fall back
* to that legacy RPC only when the redirect method is absent/unsupported so
* old installs still queue the correction instead of dropping it.
*/
suspend fun steer(text: String): SteerResult {
val sid = liveSessionId ?: return SteerResult.Failed
val result = rpc(
"session.steer",
buildJsonObject {
put("session_id", sid)
put("text", text)
},
)
val params = buildJsonObject {
put("session_id", sid)
put("text", text)
}
val redirect = rpc("session.redirect", params)
val result = if (redirect.isLegacyRedirectUnsupported()) {
Log.i(TAG, "session.redirect unsupported — falling back to session.steer (session=$storedSessionId)")
rpc("session.steer", params)
} else {
redirect
}
val outcome = when (result.getOrNull()?.stringField("status")) {
"queued" -> SteerResult.Queued
"redirected", "queued" -> SteerResult.Queued
"rejected" -> SteerResult.Rejected
else -> SteerResult.Failed
}
Log.i(TAG, "Steer → $outcome (session=$storedSessionId)")
Log.i(TAG, "Active-turn correction → $outcome (session=$storedSessionId)")
return outcome
}
/**
* Compress the live gateway session through the dedicated upstream RPC.
* `/compress` is intentionally not sent through generic slash execution on
* modern gateways because `session.compress` returns authoritative transcript,
* usage, title/model/session-info, and compute-host isolation metadata.
*/
suspend fun compressSession(focusTopic: String? = null): Result<GatewayCompressResult> {
val sid = liveSessionId
?: return Result.failure(GatewayRpcException("no live session"))
val params = buildJsonObject {
put("session_id", sid)
focusTopic?.trim()?.takeIf { it.isNotBlank() }?.let { put("focus_topic", it) }
}
val direct = rpc("session.compress", params, timeoutMs = COMPRESS_RPC_TIMEOUT_MS)
val result = if (direct.exceptionOrNull().isMethodNotFound()) {
val legacyCommand = "/compress" + focusTopic
?.trim()
?.takeIf { it.isNotBlank() }
?.let { " $it" }
.orEmpty()
slashExec(legacyCommand).map { slashResult ->
buildJsonObject {
put("status", "legacy")
slashResult.stringField("output")?.let { put("output", it) }
}
}
} else {
direct
}
return result.map { payload ->
applySessionResultInfo(payload)
payload.toGatewayCompressResult()
}
}
private fun Result<JsonObject>.isLegacyRedirectUnsupported(): Boolean {
val error = exceptionOrNull() ?: return false
val message = error.message.orEmpty()
return error.isMethodNotFound() ||
(error as? GatewayRpcException)?.code == 4010 ||
message.contains("does not support active-turn redirect", ignoreCase = true)
}
private fun JsonObject.toGatewayCompressResult(): GatewayCompressResult =
GatewayCompressResult(
status = stringField("status") ?: "completed",
output = stringField("output"),
removed = (this["removed"] as? JsonPrimitive)?.intOrNull,
beforeMessages = (this["before_messages"] as? JsonPrimitive)?.intOrNull,
afterMessages = (this["after_messages"] as? JsonPrimitive)?.intOrNull,
beforeTokens = (this["before_tokens"] as? JsonPrimitive)?.intOrNull,
afterTokens = (this["after_tokens"] as? JsonPrimitive)?.intOrNull,
usage = GatewayEventMapper.parseGatewayUsage(this["usage"] as? JsonObject),
info = this["info"] as? JsonObject,
messages = (this["messages"] as? JsonArray)?.let { messages ->
runCatching {
json.decodeFromJsonElement(
ListSerializer(MessageItem.serializer()),
messages,
)
}.getOrElse {
Log.w(TAG, "session.compress returned unreadable messages", it)
emptyList()
}
}.orEmpty(),
)
/** Answer a [GatewayAsk.Kind.CLARIFY] ask. */
suspend fun respondClarify(requestId: String, answer: String): Result<GatewayAskResponse> =
rpc(
@@ -1350,6 +1480,7 @@ class GatewayChatClient(
buildJsonObject {
put("session_id", storedId)
put("cols", DEFAULT_COLS)
put("source", sessionSource)
requestedProfile?.let { put("profile", it) }
},
)
@@ -1368,13 +1499,14 @@ class GatewayChatClient(
// resume result's embedded `info` (same shape session.info carries),
// so a reopened session shows its ACTUAL model immediately instead of
// a misleading default until the first turn's async session.info.
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
applySessionResultInfo(result)
}
}
/**
* Apply connection-level session info (model / provider / reasoning effort /
* personality / yolo / fast / context usage) into the `_server*` state flows.
* personality / yolo / fast / context usage / project) into the `_server*`
* state flows.
* Shared by the `session.info` event handler and the `session.resume` RPC
* result — the resume response embeds the same `info` object, so reopening a
* session can paint its real model up front rather than waiting for a turn.
@@ -1396,6 +1528,18 @@ class GatewayChatClient(
info.stringField("credential_warning")?.takeIf { it.isNotBlank() }
(info["yolo"] as? JsonPrimitive)?.booleanOrNull?.let { _serverYolo.value = it }
(info["fast"] as? JsonPrimitive)?.booleanOrNull?.let { _serverFast.value = it }
_serverProject.value = (info["project"] as? JsonObject)?.let { project ->
project.stringField("name")
?.takeIf { it.isNotBlank() }
?.let { name ->
GatewaySessionProject(
id = project.stringField("id")?.takeIf { it.isNotBlank() },
slug = project.stringField("slug")?.takeIf { it.isNotBlank() },
name = name,
primaryPath = project.stringField("primary_path")?.takeIf { it.isNotBlank() },
)
}
}
// Context usage: require used > 0 — a COLD resume resets counters and
// reports 0 until the first turn rebuilds the prompt; painting 0 would
// mislead on a session that actually has history.
@@ -1408,6 +1552,12 @@ class GatewayChatClient(
}
}
/** Apply a session create/resume result without leaking metadata from the prior session. */
private fun applySessionResultInfo(result: JsonObject) {
_serverProject.value = null
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
}
/** Resolve a process RPC against the exact live id, resuming after reconnect when possible. */
private suspend fun ensureLiveProcessSession(): Result<String> {
val requestedProfile = currentSessionProfile()
@@ -1495,6 +1645,7 @@ class GatewayChatClient(
buildJsonObject {
put("session_id", requestedStoredId)
put("cols", DEFAULT_COLS)
put("source", sessionSource)
requestedProfile?.let { put("profile", it) }
},
)
@@ -1505,7 +1656,7 @@ class GatewayChatClient(
storedSessionId = requestedStoredId
liveSessionProfile = requestedProfile
updateCancelledDrainLiveSession(requestedStoredId, live)
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
applySessionResultInfo(result)
return
}
Log.w(
@@ -1519,6 +1670,7 @@ class GatewayChatClient(
"session.create",
buildJsonObject {
put("cols", DEFAULT_COLS)
put("source", sessionSource)
if (!newSessionTitle.isNullOrBlank()) put("title", newSessionTitle)
requestedProfile?.let { put("profile", it) }
// Bind the in-chat overrides to the new session as its
@@ -1877,12 +2029,14 @@ class GatewayChatClient(
/**
* Recover an in-flight turn after a mid-turn socket loss by reconnecting
* the SOCKET ONLY and keeping [preservedLiveId] as the live session id.
* the socket and rebinding [preservedLiveId] to the new transport.
*
* A bare reconnect lets the tail — including the final
* `message.complete` — keep matching without another RPC. Current upstream
* can rebind live sessions too, but older builds cannot, so this remains the
* lowest-common-denominator same-client recovery path.
* Current upstream detaches a live session from a closed WebSocket; a new
* socket receives no tail until `session.activate` attaches that exact live
* id. Older gateways do not expose the method, so method-not-found alone
* retains the legacy bare-socket behavior. We never call `session.resume`
* here: resuming a durable id can create a different live runtime and
* orphan the turn already executing server-side.
*
* Retries with backoff for up to [midTurnRejoinWindowMs] so a multi-second
* radio blip doesn't abandon the turn. Events emitted while the socket was
@@ -1900,7 +2054,39 @@ class GatewayChatClient(
connectCooldownUntil = 0L
ensureConnected()
}
true
if (preservedLiveId == null) {
true
} else {
// Bind before activation so a tail event racing the RPC ack
// still matches the original active turn.
liveSessionId = preservedLiveId
val activated = rpc(
"session.activate",
buildJsonObject { put("session_id", preservedLiveId) },
)
when {
activated.isSuccess -> {
activated.getOrNull()?.let(::applySessionResultInfo)
true
}
activated.exceptionOrNull().isMethodNotFound() -> {
Log.i(
TAG,
"session.activate unsupported during mid-turn rejoin — " +
"using legacy socket recovery",
)
true
}
else -> {
Log.d(
TAG,
"Mid-turn session.activate retry failed: " +
activated.exceptionOrNull()?.message,
)
false
}
}
}
} catch (e: Exception) {
Log.d(TAG, "Mid-turn reconnect retry failed: ${e.message}")
false
@@ -1912,7 +2098,8 @@ class GatewayChatClient(
if (preservedLiveId != null) liveSessionId = preservedLiveId
Log.i(
TAG,
"Gateway socket rejoined mid-turn (session=$storedSessionId) — kept live session, awaiting tail",
"Gateway socket rejoined mid-turn (session=$storedSessionId) — " +
"rebound live session, awaiting tail",
)
// A reconnect that followed a route RETARGET gets a short settle
// (the fresh socket won't replay the in-flight turn); a normal
@@ -2091,8 +2278,14 @@ class GatewayChatClient(
private inner class GatewayTurn(
val callbacks: GatewayTurnCallbacks,
dedupeAdjacentMessageStarts: Boolean = false,
deferEvents: Boolean = false,
) : ActiveTurnHandle {
private val mapper = GatewayEventMapper(callbacks, dedupeAdjacentMessageStarts)
private val deferredEventLock = Any()
private val deferredEvents = mutableListOf<Pair<String, JsonObject?>>()
private var eventsDeferred = deferEvents
private var redirectedTo: GatewayTurn? = null
private var queuedSuccessor: Pair<GatewayInboundTurnRegistration, GatewayTurn>? = null
/** t0 = construction ≈ sendTurn entry (the moment the user sent). */
val tracer = TurnLatencyTracer("gateway")
@@ -2113,10 +2306,22 @@ class GatewayChatClient(
*/
fun beginRejoin(): Boolean {
val shouldRejoin = !ended && rejoinAttempts.incrementAndGet() <= MAX_TURN_REJOINS
if (shouldRejoin) reconcileRequired = true
if (shouldRejoin) {
reconcileRequired = true
transportRecoveryStarted = true
}
return shouldRejoin
}
/**
* A socket loss after `prompt.submit` makes server acceptance ambiguous
* even when no turn event or RPC ack reached Android. Once recovery has
* started, the caller must not resubmit through SSE.
*/
@Volatile
var transportRecoveryStarted = false
private set
private var watchdog: Job? = null
val ended: Boolean get() = mapper.turnEnded || cancelled
@@ -2133,6 +2338,21 @@ class GatewayChatClient(
private set
fun onEvent(type: String, payload: JsonObject?) {
val redirect = synchronized(deferredEventLock) {
if (eventsDeferred) {
deferredEvents += type to payload
return
}
redirectedTo
}
if (redirect != null) {
redirect.onEvent(type, payload)
return
}
processEvent(type, payload)
}
private fun processEvent(type: String, payload: JsonObject?) {
if (type != "session.info") started = true
tracer.mark("ttfe")
if (type == "message.delta" || type == "reasoning.delta" || type == "thinking.delta") {
@@ -2152,6 +2372,74 @@ class GatewayChatClient(
if (mapper.turnEnded) {
disarmWatchdog()
tracer.done()
handoffQueuedSuccessor()
}
}
/**
* Preserve a queued prompt reported beside an in-flight recovery as a
* distinct next turn. Its mapper starts deferred so events that race
* the resume acknowledgement cannot paint the completing prior turn.
*/
fun installQueuedSuccessor(registration: GatewayInboundTurnRegistration) {
synchronized(deferredEventLock) {
if (queuedSuccessor == null) {
queuedSuccessor = registration to GatewayTurn(
callbacks = dispatchOn(registration.callbacks),
dedupeAdjacentMessageStarts = true,
deferEvents = true,
)
}
}
}
private fun handoffQueuedSuccessor() {
val successor = synchronized(deferredEventLock) {
queuedSuccessor?.also {
queuedSuccessor = null
redirectedTo = it.second
}
} ?: return
val (registration, turn) = successor
// Claim socket ownership immediately so the next message.start is
// buffered by this exact successor instead of being admitted as a
// generic unsolicited turn. UI admission is ordered after the
// prior turn's terminal callbacks on the shared dispatcher.
activeTurn = turn
callbackDispatcher {
if (registration.onHandle(turn)) {
turn.releaseDeferredEvents()
turn.armWatchdog()
} else {
turn.discardDeferredEvents()
turn.detach()
}
}
}
fun releaseDeferredEvents() {
val pending = synchronized(deferredEventLock) {
eventsDeferred = false
deferredEvents.toList().also { deferredEvents.clear() }
}
pending.forEach { (type, payload) -> onEvent(type, payload) }
}
fun redirectDeferredEventsTo(target: GatewayTurn) {
val pending = synchronized(deferredEventLock) {
eventsDeferred = false
redirectedTo = target
deferredEvents.toList().also { deferredEvents.clear() }
}
pending.forEach { (type, payload) -> target.onEvent(type, payload) }
}
fun discardDeferredEvents() {
synchronized(deferredEventLock) {
eventsDeferred = false
redirectedTo = null
deferredEvents.clear()
}
}
@@ -2315,6 +2603,9 @@ class GatewayChatClient(
onSessionId = { v -> callbackDispatcher { callbacks.onSessionId(v) } },
onStart = { callbackDispatcher { callbacks.onStart() } },
onTextDelta = { v -> callbackDispatcher { callbacks.onTextDelta(v) } },
onInterimMessage = { text, alreadyStreamed ->
callbackDispatcher { callbacks.onInterimMessage(text, alreadyStreamed) }
},
onThinkingDelta = { v -> callbackDispatcher { callbacks.onThinkingDelta(v) } },
onToolCallStart = { a, b -> callbackDispatcher { callbacks.onToolCallStart(a, b) } },
onToolCallDone = { a, b -> callbackDispatcher { callbacks.onToolCallDone(a, b) } },
@@ -2340,12 +2631,12 @@ class GatewayChatClient(
)
}
/** Outcome of [GatewayChatClient.steer] — Rejected and Failed both mean "queue locally instead". */
/** Outcome of an active-turn correction — Rejected and Failed both mean "queue locally instead". */
enum class SteerResult {
/** Server accepted — text lands in the next tool batch's last result. */
/** Server accepted the active-turn correction. */
Queued,
/** Server reachable but no tool batch in flight to steer. */
/** Server reachable but no active turn is available to correct. */
Rejected,
/** Transport/RPC failure (no live session, socket down, unsupported …). */
@@ -2377,6 +2668,28 @@ internal class GatewayRpcException(message: String, val code: Int? = null) : Exc
private const val JSONRPC_METHOD_NOT_FOUND = -32601
data class GatewayCompressResult(
val status: String,
val output: String? = null,
val removed: Int? = null,
val beforeMessages: Int? = null,
val afterMessages: Int? = null,
val beforeTokens: Int? = null,
val afterTokens: Int? = null,
val usage: UsageInfo? = null,
val info: JsonObject? = null,
val messages: List<MessageItem> = emptyList(),
) {
val effectiveUsage: UsageInfo?
get() = usage ?: GatewayEventMapper.parseGatewayUsage(info?.get("usage") as? JsonObject)
val title: String?
get() = info?.stringField("title")?.takeIf { it.isNotBlank() }
val isAuthoritative: Boolean
get() = messages.isNotEmpty()
}
private fun Throwable?.isMethodNotFound(): Boolean {
val rpcError = this as? GatewayRpcException ?: return false
if (rpcError.code == JSONRPC_METHOD_NOT_FOUND) return true
@@ -35,6 +35,7 @@ class GatewayEventMapper(
private var previousEventType: String? = null
private var sawTextDelta = false
private var sawThinkingDelta = false
private var previewedText: String? = null
private var syntheticToolCounter = 0
private var providerWaitStatusActive = false
private var compactionStatusActive = false
@@ -95,13 +96,30 @@ class GatewayEventMapper(
"message.delta" -> {
val text = payload.string("text")
if (!text.isNullOrEmpty()) {
if (!text.isNullOrEmpty() && !isIntentionalSilenceMarker(text)) {
clearActivityStatuses()
sawTextDelta = true
previewedText = null
callbacks.onTextDelta(text)
}
}
"message.interim" -> {
val text = payload.string("text") ?: payload.string("message")
?: payload.string("preview") ?: payload.string("rendered")
val alreadyStreamed = payload.boolean("already_streamed") == true
if (!text.isNullOrBlank() || alreadyStreamed) {
clearActivityStatuses()
if (!sawMessageStart) {
sawMessageStart = true
callbacks.onStart()
}
callbacks.onInterimMessage(text.orEmpty(), alreadyStreamed)
previewedText = text
sawTextDelta = alreadyStreamed
}
}
"message.start" -> {
// The upstream background-completion poller currently emits
// message.start immediately before _run_prompt_submit(), which
@@ -114,6 +132,7 @@ class GatewayEventMapper(
// assistant message began — close out the previous one.
if (sawMessageStart) callbacks.onTurnComplete()
sawMessageStart = true
previewedText = null
callbacks.onStart()
}
@@ -164,7 +183,15 @@ class GatewayEventMapper(
// Non-streaming servers (or error turns) deliver everything
// here; backfill whatever never streamed.
val text = payload.string("text")
if (!sawTextDelta && !text.isNullOrEmpty()) {
val responsePreviewed = payload.boolean("response_previewed") == true
val duplicatesPreview = responsePreviewed &&
!text.isNullOrEmpty() &&
previewedText?.let { preview -> text.startsWith(preview) || preview.startsWith(text) } == true
if (!text.isNullOrEmpty() &&
!duplicatesPreview &&
!isIntentionalSilenceMarker(text) &&
(!sawTextDelta || previewedText != null)
) {
callbacks.onTextDelta(text)
}
val reasoning = payload.string("reasoning")
@@ -297,6 +324,13 @@ class GatewayEventMapper(
),
)
"clarify.expire" -> callbacks.onInteractionExpired(
GatewayAskExpiry(
kind = GatewayAsk.Kind.CLARIFY,
requestId = payload.string("request_id"),
),
)
// Forward-compatible consumer for the proposed upstream approval
// expiry event. Approvals correlate by session, never request id.
"approval.expire" -> callbacks.onInteractionExpired(
@@ -97,6 +97,19 @@ data class GatewayInflightTurn(
val streaming: Boolean,
)
/** A next-turn prompt accepted by upstream while the current turn was busy. */
data class GatewayQueuedTurn(
val user: String,
)
/** Optional project identity attached to newer upstream session metadata. */
data class GatewaySessionProject(
val id: String?,
val slug: String?,
val name: String,
val primaryPath: String?,
)
/** Result of reattaching Android to an existing durable Gateway session. */
data class GatewaySessionRecovery(
val storedSessionId: String,
@@ -104,9 +117,14 @@ data class GatewaySessionRecovery(
val running: Boolean,
val status: String?,
val inflight: GatewayInflightTurn?,
val queued: GatewayQueuedTurn?,
/** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */
val handle: ActiveTurnHandle?,
)
) {
/** Whether upstream still owes this client live turn events. */
val hasPendingWork: Boolean
get() = running || queued != null
}
/** A detached sibling turn reached its terminal event on the shared Gateway socket. */
data class GatewayBackgroundTurnCompletion(
@@ -296,7 +314,9 @@ data class GatewayModelOptions(
*
* [model] is the model id (e.g. `grok-4.3`); [provider] is the authenticated
* provider slug (e.g. `xai`). [reasoningEffort] is the upstream effort string
* (`low`/`medium`/`high`/…). [fast] pins the priority service tier when true.
* (`low`/`medium`/`high`/…). [fast] follows the contract-v4 tri-state: `true`
* pins priority, `false` explicitly pins normal, and `null` omits the field so
* the profile's service tier is inherited.
* Note `yolo` is intentionally absent — upstream `session.create` does NOT
* accept it as a per-session override, so it is applied post-create instead.
*/
@@ -328,6 +348,13 @@ class GatewayTurnCallbacks(
/** A gateway `message.start` opened an assistant response for this turn. */
val onStart: () -> Unit,
val onTextDelta: (String) -> Unit,
/**
* Gateway `message.interim` sealed an attempted assistant message before
* the terminal `message.complete`. When [alreadyStreamed] is false, [text]
* has not arrived through `message.delta` and should be rendered before
* sealing the current assistant segment.
*/
val onInterimMessage: (text: String, alreadyStreamed: Boolean) -> Unit = { _, _ -> },
val onThinkingDelta: (String) -> Unit,
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
val onToolCallDone: (toolCallId: String, resultPreview: String?) -> Unit,
@@ -21,6 +21,7 @@ import com.hermesandroid.relay.util.TurnLatencyTracer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
@@ -159,6 +160,146 @@ internal fun parseSkillListBody(json: Json, body: String): List<SkillInfo>? {
}
}
@Serializable
data class ToolsetInfo(
val name: String,
val label: String = "",
val description: String = "",
val enabled: Boolean = false,
val configured: Boolean = false,
val tools: List<String> = emptyList(),
)
@Serializable
private data class ToolsetListResponse(val data: List<ToolsetInfo> = emptyList())
internal fun parseToolsetListBody(json: Json, body: String): List<ToolsetInfo>? = try {
json.decodeFromString<ToolsetListResponse>(body).data
} catch (_: Exception) {
null
}
/** One OpenAI-compatible `/v1/models` row. [id] is always the request value. */
data class ApiModelOption(
val id: String,
val root: String? = null,
val parent: String? = null,
) {
/** Secondary picker copy for a configured route alias. */
val routeDetail: String?
get() = root?.takeIf { it.isNotBlank() && it != id }?.let { "Routes to $it" }
}
internal fun parseModelOptionsBody(json: Json, body: String): List<ApiModelOption>? {
val data = try {
(json.parseToJsonElement(body) as? JsonObject)?.get("data") as? JsonArray
} catch (_: Exception) {
null
} ?: return null
return data.mapNotNull { row ->
val obj = row as? JsonObject ?: return@mapNotNull null
val id = (obj["id"] as? JsonPrimitive)?.contentOrNull
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
ApiModelOption(
id = id,
root = (obj["root"] as? JsonPrimitive)?.contentOrNull,
parent = (obj["parent"] as? JsonPrimitive)?.contentOrNull,
)
}
}
private const val STREAM_ERROR_BODY_LIMIT = 16L * 1024L
/** Preserve the upstream drain code and bounded retry hint without leaking large bodies. */
internal fun streamHttpFailureMessage(
code: Int,
reason: String,
retryAfter: String?,
body: String?,
json: Json,
): String {
val error = body?.takeIf { it.length <= STREAM_ERROR_BODY_LIMIT }?.let { raw ->
runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull()?.get("error")
}
val errorObj = error as? JsonObject
val errorCode = (errorObj?.get("code") as? JsonPrimitive)?.contentOrNull
val detail = (errorObj?.get("message") as? JsonPrimitive)?.contentOrNull
?: (error as? JsonPrimitive)?.contentOrNull
return buildString {
append("API error ").append(code).append(": ")
if (!errorCode.isNullOrBlank()) append(errorCode).append(": ")
append(detail?.takeIf { it.isNotBlank() } ?: reason)
retryAfter?.trim()?.toIntOrNull()?.takeIf { it in 0..60 }?.let {
append(" (Retry-After: ").append(it).append("s)")
}
}
}
internal fun gatewayDrainRetryDelayMillis(
httpCode: Int?,
retryAfter: String?,
errorMessage: String,
receivedEvent: Boolean,
retryAlreadyScheduled: Boolean,
): Long? {
if (httpCode != 503 || receivedEvent || retryAlreadyScheduled ||
!errorMessage.startsWith("API error 503: gateway_draining:")
) return null
val seconds = retryAfter?.trim()?.toIntOrNull()?.coerceIn(0, 5) ?: 1
return seconds * 1_000L
}
/** Owns the initial SSE, its one delayed drain retry, and the replacement SSE. */
private class RetryingEventSource(
private val originalRequest: Request,
private val handler: Handler,
) : EventSource {
private val lock = Any()
private var active: EventSource? = null
private var retryRunnable: Runnable? = null
private var cancelled = false
override fun request(): Request = originalRequest
fun attach(source: EventSource) {
synchronized(lock) {
if (cancelled) source.cancel() else active = source
}
}
fun retryAfter(delayMillis: Long, create: () -> EventSource) {
val task = Runnable {
synchronized(lock) {
retryRunnable = null
if (cancelled) return@Runnable
// Keep creation under the same lock as cancel(): once Stop or
// a session switch wins, no delayed POST can start afterward.
active = create()
}
}
synchronized(lock) {
if (cancelled) return
retryRunnable = task
handler.postDelayed(task, delayMillis)
}
}
override fun cancel() {
val source: EventSource?
val task: Runnable?
synchronized(lock) {
if (cancelled) return
cancelled = true
source = active
active = null
task = retryRunnable
retryRunnable = null
}
task?.let(handler::removeCallbacks)
source?.cancel()
}
}
/**
* Direct HTTP/SSE client for the Hermes API Server.
*
@@ -199,8 +340,13 @@ class HermesApiClient(
/** 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}"
response != null && !response.isSuccessful -> streamHttpFailureMessage(
code = response.code,
reason = response.message,
retryAfter = response.header("Retry-After"),
body = runCatching { response.peekBody(STREAM_ERROR_BODY_LIMIT).string() }.getOrNull(),
json = Json { ignoreUnknownKeys = true },
)
t is IOException -> "$TRANSPORT_ERROR_PREFIX: ${t.message}"
t != null -> "Stream error: ${t.message}"
else -> "Unknown stream error"
@@ -445,6 +591,24 @@ class HermesApiClient(
emptyList()
}
/** Authenticated read-only inventory from upstream `GET /v1/toolsets`. */
suspend fun getToolsets(): Result<List<ToolsetInfo>> = withContext(Dispatchers.IO) {
try {
val request = authRequest("$baseUrl/v1/toolsets").get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return@withContext Result.failure(IOException("HTTP ${response.code}"))
}
val body = response.body?.string().orEmpty()
val parsed = parseToolsetListBody(json, body)
?: return@withContext Result.failure(IOException("Malformed toolset inventory"))
Result.success(parsed)
}
} catch (e: Exception) {
Result.failure(e)
}
}
// --- Available models ---
/**
@@ -453,17 +617,13 @@ class HermesApiClient(
* picker. Returns ids in server order; empty on any failure (the picker
* then offers only "Server default").
*/
suspend fun getModels(): List<String> = withContext(Dispatchers.IO) {
suspend fun getModelOptions(): List<ApiModelOption> = withContext(Dispatchers.IO) {
try {
val request = authRequest("$baseUrl/v1/models").get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) return@withContext emptyList()
val body = response.body?.string() ?: return@withContext emptyList()
val data = (json.parseToJsonElement(body) as? JsonObject)
?.get("data") as? JsonArray ?: return@withContext emptyList()
data.mapNotNull {
((it as? JsonObject)?.get("id") as? JsonPrimitive)?.contentOrNull
}
parseModelOptionsBody(json, body).orEmpty()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to fetch models: ${e.message}")
@@ -471,6 +631,9 @@ class HermesApiClient(
}
}
/** Compatibility view for callers that only need request ids. */
suspend fun getModels(): List<String> = getModelOptions().map { it.id }
// --- Server personalities ---
/**
@@ -623,6 +786,9 @@ class HermesApiClient(
}
val completeCalled = AtomicBoolean(false)
val receivedEvent = AtomicBoolean(false)
val drainRetryScheduled = AtomicBoolean(false)
val turnSource = RetryingEventSource(request, mainHandler)
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
val tracer = TurnLatencyTracer("sessions")
@@ -636,6 +802,7 @@ class HermesApiClient(
type: String?,
data: String
) {
receivedEvent.set(true)
tracer.mark("ttfe")
if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) {
@@ -799,9 +966,20 @@ class HermesApiClient(
t: Throwable?,
response: Response?
) {
val msg = streamFailureMessage(t, response)
val retryDelay = gatewayDrainRetryDelayMillis(
response?.code,
response?.header("Retry-After"),
msg,
receivedEvent.get(),
drainRetryScheduled.get(),
)
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
return
}
tracer.done("error")
if (completeCalled.compareAndSet(false, true)) {
val msg = streamFailureMessage(t, response)
mainHandler.post { onError(msg) }
}
}
@@ -814,7 +992,8 @@ class HermesApiClient(
}
}
return sseFactory.newEventSource(request, listener)
turnSource.attach(sseFactory.newEventSource(request, listener))
return turnSource
}
// --- OpenAI-compatible chat streaming via /v1/chat/completions ---
@@ -876,6 +1055,9 @@ class HermesApiClient(
val completeCalled = AtomicBoolean(false)
val messageStarted = AtomicBoolean(false)
val receivedEvent = AtomicBoolean(false)
val drainRetryScheduled = AtomicBoolean(false)
val turnSource = RetryingEventSource(request, mainHandler)
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
val tracer = TurnLatencyTracer("completions")
@@ -886,6 +1068,7 @@ class HermesApiClient(
type: String?,
data: String
) {
receivedEvent.set(true)
tracer.mark("ttfe")
if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) {
@@ -941,9 +1124,20 @@ class HermesApiClient(
t: Throwable?,
response: Response?
) {
val msg = streamFailureMessage(t, response)
val retryDelay = gatewayDrainRetryDelayMillis(
response?.code,
response?.header("Retry-After"),
msg,
receivedEvent.get(),
drainRetryScheduled.get(),
)
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
return
}
tracer.done("error")
if (completeCalled.compareAndSet(false, true)) {
val msg = streamFailureMessage(t, response)
mainHandler.post { onError(msg) }
}
}
@@ -956,7 +1150,8 @@ class HermesApiClient(
}
}
return sseFactory.newEventSource(request, listener)
turnSource.attach(sseFactory.newEventSource(request, listener))
return turnSource
}
private fun openAiChoice(event: JsonObject): JsonObject? =
@@ -1070,6 +1265,9 @@ class HermesApiClient(
}
val completeCalled = AtomicBoolean(false)
val receivedEvent = AtomicBoolean(false)
val drainRetryScheduled = AtomicBoolean(false)
val turnSource = RetryingEventSource(request, mainHandler)
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
val tracer = TurnLatencyTracer("runs")
@@ -1080,6 +1278,7 @@ class HermesApiClient(
type: String?,
data: String
) {
receivedEvent.set(true)
tracer.mark("ttfe")
if (data == "[DONE]") {
if (completeCalled.compareAndSet(false, true)) {
@@ -1246,9 +1445,20 @@ class HermesApiClient(
t: Throwable?,
response: Response?
) {
val msg = streamFailureMessage(t, response)
val retryDelay = gatewayDrainRetryDelayMillis(
response?.code,
response?.header("Retry-After"),
msg,
receivedEvent.get(),
drainRetryScheduled.get(),
)
if (retryDelay != null && drainRetryScheduled.compareAndSet(false, true)) {
turnSource.retryAfter(retryDelay) { sseFactory.newEventSource(request, this) }
return
}
tracer.done("error")
if (completeCalled.compareAndSet(false, true)) {
val msg = streamFailureMessage(t, response)
mainHandler.post { onError(msg) }
}
}
@@ -1261,7 +1471,8 @@ class HermesApiClient(
}
}
return sseFactory.newEventSource(request, listener)
turnSource.attach(sseFactory.newEventSource(request, listener))
return turnSource
}
// --- Capability detection ---
@@ -0,0 +1,80 @@
package com.hermesandroid.relay.network.upstream
import kotlinx.coroutines.delay
import kotlinx.coroutines.CancellationException
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.io.IOException
/**
* Ephemeral hosted MCP OAuth driver. The opaque flow id and authorization URL
* remain in memory only; OAuth codes, callback state, and tokens never enter
* the Android client. The dashboard owns the complete PKCE/callback exchange.
*/
class McpOAuthFlowCoordinator(
private val client: DashboardApiClient,
private val pollDelayMillis: Long = 1_000,
private val maxPolls: Int = 900,
private val maxConsecutiveFailures: Int = 3,
private val sleep: suspend (Long) -> Unit = { delay(it) },
) {
suspend fun start(
serverName: String,
profile: String? = null,
): Result<DashboardMcpOAuthFlow> = client.startMcpOAuth(serverName, profile).mapCatching { started ->
if (started.status == "error") {
throw IOException(started.error ?: "MCP OAuth failed to start")
}
started
}
suspend fun resume(flowId: String): Result<DashboardMcpOAuthFlow> = runCatching {
var failures = 0
repeat(maxPolls.coerceAtLeast(1)) {
val current = client.getMcpOAuthFlow(flowId)
if (current.isFailure) {
failures += 1
if (failures >= maxConsecutiveFailures.coerceAtLeast(1)) {
throw current.exceptionOrNull() ?: IOException("MCP OAuth status check failed")
}
} else {
failures = 0
val flow = current.getOrThrow()
when (flow.status) {
"approved" -> return@runCatching flow
"error" -> throw IOException(flow.error ?: "MCP OAuth authorization failed")
}
}
sleep(pollDelayMillis.coerceAtLeast(0))
}
throw IOException("MCP OAuth authorization timed out")
}.onFailure { error ->
if (error is CancellationException) throw error
}
suspend fun complete(
serverName: String,
profile: String? = null,
openAuthorization: (String) -> Boolean,
): Result<DashboardMcpOAuthFlow> = runCatching {
val started = start(serverName, profile).getOrThrow()
if (started.status == "approved") return@runCatching started
val authorizationUrl = validatedAuthorizationUrl(started).getOrThrow()
if (!openAuthorization(authorizationUrl)) {
throw IOException("No browser is available to complete MCP OAuth")
}
resume(started.flowId).getOrThrow()
}.onFailure { error ->
if (error is CancellationException) throw error
}
companion object {
fun validatedAuthorizationUrl(flow: DashboardMcpOAuthFlow): Result<String> = runCatching {
val url = flow.authorizationUrl
?: throw IOException("MCP OAuth server did not provide an authorization URL")
if (url.toHttpUrlOrNull()?.scheme != "https") {
throw IOException("MCP OAuth authorization URL must use HTTPS")
}
url
}
}
}
@@ -3,6 +3,12 @@ package com.hermesandroid.relay.network.upstream
import android.content.Context
import com.hermesandroid.relay.data.VoiceAudioRoute
import com.hermesandroid.relay.network.shared.VoiceAudioClient
import com.hermesandroid.relay.network.shared.VoiceSpeechStream
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamCallbacks
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamOutcome
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamStatus
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
@@ -17,6 +23,9 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
import java.io.File
import java.io.IOException
import java.util.Base64
@@ -48,6 +57,7 @@ class StandardHermesVoiceClient(
// upstream ever adds profile-aware TTS. Until then, standard voice remains
// the host's global TTS (see VoiceViewModel's standard-voice profile notice).
private val profileProvider: () -> String? = { null },
private val webSocketFactory: ((Request, WebSocketListener) -> WebSocket)? = null,
private val json: Json = Json {
ignoreUnknownKeys = true
isLenient = true
@@ -57,9 +67,7 @@ class StandardHermesVoiceClient(
override val route: VoiceAudioRoute = VoiceAudioRoute.Standard
private val callClient: OkHttpClient =
okHttpClient.newBuilder()
.callTimeout(90, TimeUnit.SECONDS)
.build()
standardHermesDashboardAudioClient(okHttpClient)
override suspend fun transcribe(audioFile: File): Result<String> = withContext(Dispatchers.IO) {
val baseUrl = dashboardBaseUrl()
@@ -148,6 +156,42 @@ class StandardHermesVoiceClient(
}
}
override suspend fun openSpeechStream(
callbacks: VoiceSpeechStreamCallbacks,
): Result<VoiceSpeechStream?> = withContext(Dispatchers.IO) {
try {
val baseUrl = dashboardBaseUrl()
?: throw IllegalStateException("Hermes dashboard URL not configured")
val ticketUrl = "$baseUrl/api/auth/ws-ticket".toHttpUrlOrNull()
?: throw IOException("Hermes dashboard URL is not a valid address: $baseUrl")
val ticketRequest = Request.Builder()
.url(ticketUrl)
.post(ByteArray(0).toRequestBody(null))
.build()
val ticket = executeJson(ticketRequest, "Dashboard websocket ticket")
.getOrThrow()
.stringField("ticket")
?: throw IOException("Dashboard websocket ticket response missing ticket")
val websocketUrl = DashboardApiClient.gatewayWebSocketUrl(
baseUrl = baseUrl,
ticket = ticket,
path = "/api/audio/speak-stream",
) ?: throw IOException("Could not build Hermes speech stream URL")
val request = Request.Builder().url(websocketUrl).build()
StandardHermesSpeechStream(
request = request,
callbacks = callbacks,
json = json,
socketFactory = webSocketFactory ?: callClient::newWebSocket,
).also { it.connect() }
.let { Result.success<VoiceSpeechStream?>(it) }
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
Result.failure(error)
}
}
private fun dashboardBaseUrl(): String? =
dashboardUrlProvider()?.trim()?.trimEnd('/')?.takeIf { it.isNotBlank() }
@@ -242,3 +286,209 @@ class StandardHermesVoiceClient(
const val MAX_TRANSCRIBE_BYTES = 25L * 1024 * 1024
}
}
private class StandardHermesSpeechStream(
private val request: Request,
private val callbacks: VoiceSpeechStreamCallbacks,
private val json: Json,
private val socketFactory: (Request, WebSocketListener) -> WebSocket,
) : VoiceSpeechStream {
private val lock = Any()
private val outcome = CompletableDeferred<VoiceSpeechStreamOutcome>()
private val pendingFrames = ArrayDeque<String>()
private var socket: WebSocket? = null
private var opened = false
private var stopped = false
private var finished = false
private var audioStarted = false
private var sampleRate = DEFAULT_SAMPLE_RATE
private var oddByteCarry: Byte? = null
private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
val frames = synchronized(lock) {
if (stopped || outcome.isCompleted) {
webSocket.cancel()
return
}
socket = webSocket
opened = true
pendingFrames.toList().also { pendingFrames.clear() }
}
frames.forEach(webSocket::send)
}
override fun onMessage(webSocket: WebSocket, text: String) {
val root = runCatching { json.decodeFromString<JsonObject>(text) }.getOrNull() ?: return
when (root.stringField("type")) {
"start" -> {
val nextRate = (root["sample_rate"] as? JsonPrimitive)?.contentOrNull
?.toIntOrNull()
?.takeIf { it > 0 }
?: DEFAULT_SAMPLE_RATE
val channels = (root["channels"] as? JsonPrimitive)?.contentOrNull
?.toIntOrNull()
?.takeIf { it > 0 }
?: 1
if (channels != 1) {
settle(
status = VoiceSpeechStreamStatus.Fallback,
error = IOException("Hermes speech stream returned $channels channels; mono required"),
)
webSocket.cancel()
return
}
synchronized(lock) { sampleRate = nextRate }
callbacks.onStart(nextRate, channels)
}
"fallback" -> {
val heardAudio = synchronized(lock) { audioStarted }
settle(
status = if (heardAudio) {
VoiceSpeechStreamStatus.Completed
} else {
VoiceSpeechStreamStatus.Fallback
},
)
webSocket.close(1000, "fallback")
}
"end" -> {
settle(VoiceSpeechStreamStatus.Completed)
webSocket.close(1000, "complete")
}
}
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
val delivery = synchronized(lock) {
if (stopped || outcome.isCompleted) return
var incoming = bytes.toByteArray()
oddByteCarry?.let { carry ->
incoming = byteArrayOf(carry) + incoming
oddByteCarry = null
}
val usable = incoming.size - (incoming.size % 2)
if (usable < incoming.size) oddByteCarry = incoming.last()
if (usable == 0) return
audioStarted = true
incoming.copyOf(usable) to sampleRate
}
runCatching { callbacks.onPcm(delivery.first, delivery.second) }
.onFailure { error ->
settle(VoiceSpeechStreamStatus.Failed, error)
webSocket.cancel()
}
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
settleForDisconnect(IOException("Hermes speech stream closed ($code): $reason"))
webSocket.close(code, reason)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
settleForDisconnect(IOException("Hermes speech stream closed ($code): $reason"))
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
settleForDisconnect(t)
}
}
fun connect() {
val created = socketFactory(request, listener)
synchronized(lock) {
if (socket == null) socket = created
if (stopped) created.cancel()
}
}
override fun append(text: String) {
if (text.isEmpty()) return
sendFrame(buildJsonObject { put("text", text) })
}
override fun finish() {
synchronized(lock) {
if (finished || stopped || outcome.isCompleted) return
finished = true
}
sendFrame(buildJsonObject { put("done", true) })
}
override fun stop() {
val current = synchronized(lock) {
if (stopped) return
stopped = true
socket
}
if (current != null) {
current.send(json.encodeToString(JsonObject.serializer(), buildJsonObject { put("stop", true) }))
current.cancel()
}
settle(VoiceSpeechStreamStatus.Stopped)
}
override suspend fun awaitOutcome(): VoiceSpeechStreamOutcome = outcome.await()
private fun sendFrame(frame: JsonObject) {
val encoded = json.encodeToString(JsonObject.serializer(), frame)
val current = synchronized(lock) {
if (stopped || outcome.isCompleted) return
if (!opened) {
pendingFrames.addLast(encoded)
return
}
socket
}
if (current?.send(encoded) != true) {
settleForDisconnect(IOException("Hermes speech stream send failed"))
}
}
private fun settleForDisconnect(error: Throwable) {
val heardAudio = synchronized(lock) { audioStarted }
settle(
status = if (heardAudio) VoiceSpeechStreamStatus.Failed else VoiceSpeechStreamStatus.Fallback,
error = error,
)
}
private fun settle(status: VoiceSpeechStreamStatus, error: Throwable? = null) {
val result = synchronized(lock) {
if (outcome.isCompleted) return
VoiceSpeechStreamOutcome(
status = status,
audioStarted = audioStarted,
error = error,
)
}
outcome.complete(result)
}
private fun JsonObject.stringField(name: String): String? =
((this[name] as? JsonPrimitive)?.contentOrNull)?.trim()?.takeIf { it.isNotBlank() }
private companion object {
const val DEFAULT_SAMPLE_RATE = 24_000
}
}
internal const val STANDARD_HERMES_DASHBOARD_AUDIO_TIMEOUT_SECONDS = 180L
internal fun standardHermesDashboardAudioTimeoutSeconds(requestedSeconds: Long): Long =
requestedSeconds.coerceIn(
minimumValue = 180L,
maximumValue = 600L,
)
internal fun standardHermesDashboardAudioClient(
baseClient: OkHttpClient,
timeoutSeconds: Long = STANDARD_HERMES_DASHBOARD_AUDIO_TIMEOUT_SECONDS,
): OkHttpClient {
val boundedTimeoutSeconds = standardHermesDashboardAudioTimeoutSeconds(timeoutSeconds)
return baseClient.newBuilder()
.callTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
.readTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
.writeTimeout(boundedTimeoutSeconds, TimeUnit.SECONDS)
.build()
}
@@ -259,6 +259,8 @@ data class MessageItem(
val toolCallId: String? = null,
val timestamp: Double? = null,
@SerialName("finish_reason") val finishReason: String? = null,
@SerialName("display_kind") val displayKind: String? = null,
@SerialName("display_metadata") val displayMetadata: JsonObject? = null,
// Reasoning persisted with the assistant message (upstream serializes
// both names; reasoning is the canonical one). Restored into
// ChatMessage.thinkingContent so the Thought-process block survives a
@@ -769,6 +769,9 @@ fun RelayApp() {
chatViewModel.setSelectedProfileProvider {
connectionViewModel.selectedProfile.value
}
chatViewModel.setIsolatedProfileApiProvider {
connectionViewModel.selectedProfileUsesIsolatedApiRoute()
}
chatViewModel.setSessionProfileNameProvider {
connectionViewModel.effectiveSessionProfileName.value
}
@@ -1445,7 +1448,8 @@ fun RelayApp() {
// the previous run). The pre-warm fills cold keys and refreshes
// stale (disk-hydrated) ones, then mirrors results back to disk.
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
LaunchedEffect(activeConnection?.id, effectiveDashboardUrl) {
val effectiveManageProfile by connectionViewModel.effectiveSessionProfileName.collectAsState()
LaunchedEffect(activeConnection?.id, effectiveDashboardUrl, effectiveManageProfile) {
val connection = activeConnection ?: return@LaunchedEffect
if (effectiveDashboardUrl.isBlank()) return@LaunchedEffect
val snapshot = connection.dashboardLastStatus ?: return@LaunchedEffect
@@ -1462,6 +1466,7 @@ fun RelayApp() {
cookieStore = cookieStore,
connectionId = connection.id,
dashboardUrl = effectiveDashboardUrl,
effectiveProfileName = effectiveManageProfile,
cacheDir = hydrateContext.cacheDir,
context = hydrateContext,
)
@@ -2098,6 +2098,9 @@ fun ActiveCardRoutesSection(
outcomeFor = { candidate ->
routeProbeOutcomes[connectionViewModel.routeOutcomeKey(candidate)]
},
dashboardAuthenticated = connection.dashboardLastStatus?.authenticated,
dashboardSignInRequired = connection.dashboardLastStatus?.authRequired == true &&
connection.dashboardLastStatus.authenticated != true,
preferredRole = preferredRole,
manualOverrideRole = manualOverrideRole,
onUseNow = { candidate -> connectionViewModel.useRouteNow(candidate.role) },
@@ -2127,10 +2130,12 @@ fun ActiveCardRoutesSection(
if (routeEditorOpen) {
RouteEditorDialog(
original = routeEditorOriginal,
onSave = { role, apiUrl, onResult ->
relayEnabled = connection.relayUrl.isNotBlank() ||
endpoints.any { it.relay != null },
onSave = { role, dashboardUrl, onResult ->
connectionViewModel.saveExtraRoute(
role = role,
apiUrl = apiUrl,
dashboardUrl = dashboardUrl,
original = routeEditorOriginal,
onResult = onResult,
)
@@ -161,14 +161,37 @@ private fun putInlineImage(key: String, bitmap: ImageBitmap, sensitive: Boolean)
fun extractChatInlineImages(content: String): Pair<String, List<ChatInlineImage>> {
if (!content.contains("![")) return content to emptyList()
val images = mutableListOf<ChatInlineImage>()
var inlineDataImages = 0
var inlineDataBytes = 0L
var inlineOverflowNoticeAdded = false
val stripped = MARKDOWN_IMAGE_REGEX.replace(content) { m ->
val spoilerWrapped = m.groupValues[1].isNotEmpty() && m.groupValues[4].isNotEmpty()
val alt = m.groupValues[2].trim()
val src = normalizeImageSrc(m.groupValues[3].trim())
val isInlineData = src.startsWith("data:image/", ignoreCase = true)
val inlineDataSize = inlineImageDecodedSizeUpperBound(src)
val exceedsInlineBudget = isInlineData && (
inlineDataSize == null || inlineDataSize > INLINE_IMAGE_DATA_MAX_BYTES ||
inlineDataImages >= INLINE_IMAGE_DATA_MAX_PER_MESSAGE ||
inlineDataBytes + inlineDataSize > INLINE_IMAGE_DATA_MAX_TOTAL_BYTES
)
if (exceedsInlineBudget) {
return@replace if (inlineOverflowNoticeAdded) {
""
} else {
inlineOverflowNoticeAdded = true
"\n\n_Additional inline images omitted for memory safety._\n\n"
}
}
images += ChatInlineImage(
alt = alt,
src = normalizeImageSrc(m.groupValues[3].trim()),
src = src,
sensitive = spoilerWrapped || isSensitiveAltText(alt),
)
if (inlineDataSize != null) {
inlineDataImages += 1
inlineDataBytes += inlineDataSize
}
""
}
if (images.isEmpty()) return content to emptyList()
@@ -208,6 +231,9 @@ private fun ChatInlineImage.isRemote(): Boolean {
return s.startsWith("http://") || s.startsWith("https://")
}
private fun ChatInlineImage.isInlineDataImage(): Boolean =
src.startsWith("data:image/", ignoreCase = true)
/**
* An absolute server-side path (e.g. `/home/agent/out.png`) — what the relay's
* `/media/by-path` route expects. Not a remote URL and not a relative ref.
@@ -232,6 +258,7 @@ fun ChatInlineImages(
images.forEach { image ->
when {
image.isRemote() -> RemoteChatImage(image, maxWidth)
image.isInlineDataImage() -> DataUrlChatImage(image, maxWidth)
// A server-local file the agent referenced — fetch it through
// /media/by-path and render inline; on failure the notice shows
// the ACTUAL reason (for debugging) instead of a generic message.
@@ -253,6 +280,94 @@ fun ChatInlineImages(
}
}
private sealed interface DataUrlImagePhase {
data object Loading : DataUrlImagePhase
data class Loaded(
val bitmap: ImageBitmap,
val mime: String,
) : DataUrlImagePhase
data object Rejected : DataUrlImagePhase
}
/** Render the bounded data URLs emitted by upstream `_resolve_media_to_data_urls`. */
@Composable
private fun DataUrlChatImage(image: ChatInlineImage, maxWidth: Dp) {
var phase by remember(image.src) { mutableStateOf<DataUrlImagePhase>(DataUrlImagePhase.Loading) }
var viewerOpen by remember(image.src) { mutableStateOf(false) }
val blurMode = LocalMediaBlurMode.current
var revealed by remember(image.src) { mutableStateOf(false) }
LaunchedEffect(image.src) {
phase = withInlineImageDecodeLock {
val decoded = decodeInlineImageDataUrl(image.src)
?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(decoded.bytes, 0, decoded.bytes.size, bounds)
val sample = inlineImageSampleSize(bounds.outWidth, bounds.outHeight)
?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
val bitmap = BitmapFactory.decodeByteArray(
decoded.bytes,
0,
decoded.bytes.size,
BitmapFactory.Options().apply {
inSampleSize = sample
inPreferredConfig = android.graphics.Bitmap.Config.ARGB_8888
},
)
?.asImageBitmap() ?: return@withInlineImageDecodeLock DataUrlImagePhase.Rejected
DataUrlImagePhase.Loaded(bitmap, decoded.mime)
}
}
when (val current = phase) {
DataUrlImagePhase.Loading -> Box(
modifier = Modifier
.widthIn(max = maxWidth)
.height(120.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) }
DataUrlImagePhase.Rejected -> UnrenderableImageNotice(
image.copy(src = "inline image"),
reason = "Unsupported or oversized inline image.",
)
is DataUrlImagePhase.Loaded -> {
if (viewerOpen) {
ChatImageViewer(
source = ChatImageViewerSource.Bitmap(
bitmap = current.bitmap,
displayName = image.alt.ifBlank { "image" },
mime = current.mime,
// Decode the already-retained data URL only when the
// user requests Save/Share; don't keep a second 5 MiB
// byte array beside every thumbnail.
bytesProvider = { decodeInlineImageDataUrlOffMain(image.src)?.bytes },
),
onDismiss = { viewerOpen = false },
sensitive = image.sensitive,
initiallyRevealed = revealed,
)
}
InlineImageColumn(image, maxWidth) {
BlurredMedia(
blurred = !revealed && shouldBlurImage(blurMode, image.sensitive),
onReveal = { revealed = true },
) {
androidx.compose.foundation.Image(
bitmap = current.bitmap,
contentDescription = image.alt.ifBlank { "Generated image" },
contentScale = ContentScale.Fit,
modifier = Modifier
.widthIn(max = maxWidth)
.heightIn(max = 360.dp)
.clip(RoundedCornerShape(12.dp))
.clickable { viewerOpen = true },
)
}
}
}
}
}
@Composable
private fun RemoteChatImage(image: ChatInlineImage, maxWidth: Dp) {
var viewerOpen by remember { mutableStateOf(false) }
@@ -74,7 +74,7 @@ import kotlinx.coroutines.delay
* !isStreaming && hasContent -> SEND // Send arrow, primary (Relay)
* !isStreaming -> VOICE // GraphicEq, primary
* isStreaming && !hasContent -> STOP // Stop in a Danger-outlined circle
* canSteer (gateway transport) -> STEER // Send glyph, tertiary (Cyan)
* canCorrect (gateway transport) -> STEER // Send glyph, tertiary (Cyan)
* else -> QUEUE // Send glyph + clock badge, tertiary
* ```
*/
@@ -120,7 +120,7 @@ data class ChatInputPickerControl(
* the limit) only when length > [charLimit] - 200 — supportingText
* reflows the bar, the overline doesn't.
* - [caption] renders a single relayMetadataStyle line above the bar
* (steer/queue hinting during streaming-with-text); Cyan when the slot
* (correct/queue hinting during streaming-with-text); Cyan when the slot
* is STEER, muted otherwise. Null collapses the row.
* - Voice: GraphicEq glyph ("voice session", not "record"); when
* ![voiceReady] the button stays FULL alpha with a 6dp Amber dot badge
@@ -191,7 +191,7 @@ fun ChatInputBar(
}
Column(modifier = modifier.fillMaxWidth()) {
// Caption row — steer/queue hinting, single line, no buttons.
// Caption row — correct/queue hinting, single line, no buttons.
AnimatedVisibility(visible = caption != null) {
Text(
text = caption ?: lastCaption.orEmpty(),
@@ -84,6 +84,7 @@ import com.hermesandroid.relay.data.ProfilePresence
import com.hermesandroid.relay.data.ProfilePresenceResolver
import com.hermesandroid.relay.data.displayLabel
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.network.upstream.ApiModelOption
import com.hermesandroid.relay.network.upstream.ChatMode
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.relay.ConnectionState
@@ -671,7 +672,7 @@ fun AgentInfoSheet(
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
val personalityNames by chatViewModel.personalityNames.collectAsState()
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
val availableModels by chatViewModel.availableModels.collectAsState()
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
val modelProviders by chatViewModel.modelProviders.collectAsState()
val yoloEnabled by chatViewModel.yoloEnabled.collectAsState()
@@ -714,9 +715,12 @@ fun AgentInfoSheet(
// Connection summary state.
val authState by connectionViewModel.authState.collectAsState()
val apiServerUrl by connectionViewModel.apiServerUrl.collectAsState()
val effectiveApiServerUrl by connectionViewModel.effectiveApiServerUrl.collectAsState()
val apiServerReachable by connectionViewModel.apiServerReachable.collectAsState()
val chatMode by connectionViewModel.chatMode.collectAsState()
val relayUrl by connectionViewModel.relayUrl.collectAsState()
val effectiveRelayUrl by connectionViewModel.effectiveRelayUrl.collectAsState()
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
val relayConnectionState by connectionViewModel.relayConnectionState.collectAsState()
val pairingCode by connectionViewModel.pairingCode.collectAsState()
val serverModelName by chatViewModel.serverModelName.collectAsState()
@@ -1289,11 +1293,13 @@ fun AgentInfoSheet(
// SSE fallback model list — /v1/models plus the configured profiles'
// models (used only when the gateway model.options groups aren't
// available, e.g. on an SSE transport).
val sseModelOptions = remember(availableModels, agentProfiles, selectedModelOverride) {
(availableModels.mapNotNull(AgentDisplay::displayModelName) +
agentProfiles.mapNotNull { AgentDisplay.displayModelName(it.model) } +
listOfNotNull(AgentDisplay.displayModelName(selectedModelOverride)))
.distinct()
val sseModelOptions = remember(apiModelOptions, agentProfiles, selectedModelOverride) {
(apiModelOptions +
agentProfiles.mapNotNull { profile ->
AgentDisplay.requestModelName(profile.model)?.let { ApiModelOption(it) }
} +
listOfNotNull(AgentDisplay.requestModelName(selectedModelOverride)?.let { ApiModelOption(it) }))
.distinctBy { it.id }
}
// Always show the Model picker — choosing a model is always possible
// (Server default at minimum). While the provider/model list is still
@@ -1362,14 +1368,14 @@ fun AgentInfoSheet(
} else {
sseModelOptions.forEach { model ->
ProfileRadioRow(
primary = model,
secondary = null,
selected = selectedModelOverride == model,
primary = AgentDisplay.displayModelName(model.id) ?: model.id,
secondary = model.routeDetail,
selected = selectedModelOverride == model.id,
enabled = !isStreaming,
onSelect = {
if (selectedModelOverride != model) {
chatViewModel.selectModel(model)
toast(modelToast.format(model))
if (selectedModelOverride != model.id) {
chatViewModel.selectApiModel(model.id)
toast(modelToast.format(model.id))
}
},
)
@@ -1563,11 +1569,12 @@ fun AgentInfoSheet(
connectionViewModel.resolveStreamingEndpoint(streamingEndpoint),
)
val routeLabel = if (sessionTransport.isGateway) {
val dashboardUrl = activeConnection?.resolvedDashboardUrl.orEmpty()
activeConnection?.routeCandidates
?.firstOrNull { it.dashboard?.url?.trimEnd('/') == dashboardUrl.trimEnd('/') }
?.firstOrNull {
it.dashboard?.url?.trimEnd('/') == effectiveDashboardUrl.trimEnd('/')
}
?.displayLabel()
?: dashboardUrl.takeIf { it.isNotBlank() }?.let { url ->
?: effectiveDashboardUrl.takeIf { it.isNotBlank() }?.let { url ->
com.hermesandroid.relay.data.Connection.endpointCandidateFromDashboardUrl(
role = com.hermesandroid.relay.data.Connection.inferRouteRole(url),
priority = 0,
@@ -1577,7 +1584,7 @@ fun AgentInfoSheet(
} else {
activeEndpoint?.displayLabel()
?: com.hermesandroid.relay.data.Connection
.extractDefaultLabel(apiServerUrl)
.extractDefaultLabel(effectiveApiServerUrl)
.takeIf { it.isNotBlank() }
}
val relayConnected = relayConnectionState == ConnectionState.Connected
@@ -1711,8 +1718,8 @@ fun AgentInfoSheet(
routeLabel = routeLabel,
relayConnectionState = relayConnectionState,
capabilities = sessionCaps,
apiServerUrl = apiServerUrl,
relayUrl = relayUrl,
apiServerUrl = effectiveApiServerUrl,
relayUrl = effectiveRelayUrl,
streamingEndpoint = streamingEndpoint,
gatewayAvailability = gatewayAvailability,
serverCapabilities = serverCapabilities,
@@ -15,6 +15,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lan
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Shield
@@ -29,6 +31,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.ui.text.input.KeyboardType
@@ -47,6 +50,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.Connection
@@ -61,6 +65,7 @@ import com.hermesandroid.relay.data.routeAuthority
import com.hermesandroid.relay.network.shared.RouteProbeOutcome
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import kotlinx.coroutines.launch
import java.net.URI
/**
* ADR 24 — per-endpoint visibility + override card for the Connection
@@ -75,9 +80,9 @@ import kotlinx.coroutines.launch
* on app start, tried first on every resolve; cleared by
* [onClearPreferred].
*
* Verbose by design: per-route visibility is retained instead of collapsing
* into a master row. The card itself is wrapped in a
* [SettingsExpandableCard] by the caller for page layout hygiene.
* Each route stays visible, while its Dashboard/API/Relay topology uses
* progressive disclosure: the active route opens by default and fallback
* routes keep a compact surface-and-port summary.
*
* Not visible on legacy installs: when [endpoints] is empty we render a
* helpful one-liner instead of an empty card, so freshly-upgraded users
@@ -109,6 +114,9 @@ fun EndpointsCard(
* resolver's cache-key scheme.
*/
outcomeFor: (EndpointCandidate) -> RouteProbeOutcome? = { null },
/** Auth state applies only to the currently active Dashboard route. */
dashboardAuthenticated: Boolean? = null,
dashboardSignInRequired: Boolean = false,
/**
* Route management — the standard path's manual equivalent of a v3 QR's
* `endpoints` array. Null callbacks hide the corresponding affordance.
@@ -177,6 +185,13 @@ fun EndpointsCard(
isPreferred = preferredRole?.equals(candidate.role, ignoreCase = true) == true,
isProbing = isProbing,
outcome = outcomeFor(candidate),
dashboardAuthenticated = dashboardAuthenticated.takeIf { activeEndpoint != null &&
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
activeEndpoint.routeAuthority() == candidate.routeAuthority()
},
dashboardSignInRequired = dashboardSignInRequired && activeEndpoint != null &&
activeEndpoint.role.equals(candidate.role, ignoreCase = true) &&
activeEndpoint.routeAuthority() == candidate.routeAuthority(),
onUseNow = { onUseNow(candidate) },
onPrefer = { onPreferEndpoint(candidate) },
onClearPrefer = onClearPreferred,
@@ -221,6 +236,8 @@ private fun EndpointRow(
isPreferred: Boolean,
isProbing: Boolean = false,
outcome: RouteProbeOutcome? = null,
dashboardAuthenticated: Boolean? = null,
dashboardSignInRequired: Boolean = false,
onUseNow: () -> Unit,
onPrefer: () -> Unit,
onClearPrefer: () -> Unit,
@@ -232,6 +249,7 @@ private fun EndpointRow(
var menuOpen by remember { mutableStateOf(false) }
var pinDialogText by remember { mutableStateOf<String?>(null) }
var confirmRemove by remember { mutableStateOf(false) }
var detailsExpanded by remember(isActive) { mutableStateOf(isActive) }
val scope = rememberCoroutineScope()
val noPinRecordedText = stringResource(R.string.endpoints_no_pin_recorded)
@@ -280,17 +298,6 @@ private fun EndpointRow(
)
}
}
// Full URL, scheme included: http vs https decides whether
// the health probe TLS-handshakes, so two rows that both
// read "host:8642" can behave completely differently. The
// scheme must be visible to be debuggable.
Text(
text = candidate.primaryRouteUrl().orEmpty() +
(candidate.relay?.transportHint?.let { " · $it" } ?: ""),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
when {
isProbing -> Text(
text = stringResource(R.string.endpoints_checking),
@@ -309,6 +316,47 @@ private fun EndpointRow(
color = MaterialTheme.colorScheme.error,
)
}
val dashboardSurfaceUrl = candidate.dashboard?.url
?: candidate.api?.url?.let(Connection::deriveDefaultDashboardUrl)
val dashboardLabel = stringResource(R.string.active_section_dashboard)
val apiLabel = stringResource(R.string.active_section_api_server)
val relayLabel = stringResource(R.string.active_section_relay)
val surfaceSummary = listOfNotNull(
dashboardSurfaceUrl?.let { "$dashboardLabel ${displayPort(it)}" },
candidate.api?.url?.let { "$apiLabel ${displayPort(it)}" },
candidate.relay?.url?.let { "$relayLabel ${displayPort(it)}" },
).joinToString(" · ")
if (surfaceSummary.isNotBlank()) {
Row(
modifier = Modifier
.padding(top = 4.dp)
.clip(RoundedCornerShape(6.dp))
.clickable { detailsExpanded = !detailsExpanded }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = surfaceSummary,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Icon(
imageVector = if (detailsExpanded) {
Icons.Filled.ExpandLess
} else {
Icons.Filled.ExpandMore
},
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
}
}
}
// 3-dot overflow menu — actions per-row so the card stays flat
@@ -394,6 +442,15 @@ private fun EndpointRow(
}
}
}
if (detailsExpanded) {
RouteSurfaceMap(
candidate = candidate,
dashboardAuthenticated = dashboardAuthenticated,
dashboardSignInRequired = dashboardSignInRequired,
modifier = Modifier.padding(start = 26.dp, end = 4.dp, top = 8.dp),
)
}
}
if (confirmRemove && onRemove != null) {
@@ -438,6 +495,106 @@ private fun EndpointRow(
}
}
@Composable
private fun RouteSurfaceMap(
candidate: EndpointCandidate,
dashboardAuthenticated: Boolean? = null,
dashboardSignInRequired: Boolean = false,
modifier: Modifier = Modifier,
) {
val dashboardUrl = candidate.dashboard?.url
?: candidate.api?.url?.let(Connection::deriveDefaultDashboardUrl)
val apiUrl = candidate.api?.url
val relayUrl = candidate.relay?.url
Surface(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f),
shape = RoundedCornerShape(10.dp),
modifier = modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(9.dp),
) {
RouteSurfaceRow(
label = stringResource(R.string.active_section_dashboard),
url = dashboardUrl,
status = when {
dashboardSignInRequired -> stringResource(R.string.active_section_sign_in_required)
dashboardAuthenticated == true -> stringResource(R.string.active_section_signed_in)
dashboardUrl != null -> stringResource(R.string.active_section_configured)
else -> stringResource(R.string.active_section_not_configured)
},
warning = dashboardSignInRequired,
)
RouteSurfaceRow(
label = stringResource(R.string.active_section_api_server),
url = apiUrl,
status = stringResource(
if (apiUrl != null) R.string.active_section_configured
else R.string.active_section_not_configured,
),
)
RouteSurfaceRow(
label = stringResource(R.string.active_section_relay),
url = relayUrl,
status = stringResource(
if (relayUrl != null) R.string.active_section_configured
else R.string.active_section_not_configured,
),
)
}
}
}
@Composable
private fun RouteSurfaceRow(
label: String,
url: String?,
status: String,
warning: Boolean = false,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = url ?: "—",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
text = status,
style = MaterialTheme.typography.labelSmall,
color = if (warning) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
private fun displayPort(url: String): String {
val uri = runCatching { URI(url) }.getOrNull()
val port = uri?.port?.takeIf { it > 0 } ?: when (uri?.scheme?.lowercase()) {
"https", "wss" -> 443
else -> 80
}
return ":$port"
}
@Composable
private fun ActiveChip(label: String) {
Row(
@@ -533,19 +690,21 @@ private fun EndpointCandidate.routeSecurityKind(): SurfaceSecurityKind = when {
* v3 pairing QR's `endpoints` array, so standard (no-Relay) connections can
* set up LAN ↔ Tailscale roaming without the plugin.
*
* The relay URL is derived from the API URL (same `:8767` convention the
* wizard uses); routes that need a custom relay URL still come from a QR.
* The editor is host-first: Dashboard/Gateway uses `:9119`, direct API
* fallback uses `:8642`, and an already-enabled Relay uses `:8767`.
* Routes that need custom per-surface hosts or ports still come from a QR.
*
* @param original null = add a new route; non-null = edit (pre-fills role +
* URL, keeps the stored priority).
* @param onSave invoked with (role, apiUrl, resultCallback); the callback
* @param onSave invoked with (role, dashboardUrl, resultCallback); the callback
* receives a user-facing error string to render inline, or null on
* success (the dialog then closes itself).
*/
@Composable
fun RouteEditorDialog(
original: EndpointCandidate?,
onSave: (role: String, apiUrl: String, onResult: (String?) -> Unit) -> Unit,
relayEnabled: Boolean = false,
onSave: (role: String, dashboardUrl: String, onResult: (String?) -> Unit) -> Unit,
onDismiss: () -> Unit,
) {
val uriHandler = LocalUriHandler.current
@@ -618,12 +777,20 @@ fun RouteEditorDialog(
// Live preview of what will actually be saved — scheme and
// port defaults applied — so "what, which port, http or
// https?" is answered before Save, not after a failed probe.
val previewCandidate = remember(url, effectiveRole) {
val previewCandidate = remember(url, effectiveRole, relayEnabled) {
url.takeIf { it.isNotBlank() }?.let {
val dashboardUrl = Connection.normalizeDashboardUrlInput(it)
val apiUrl = Connection.deriveDefaultApiUrl(dashboardUrl)
Connection.endpointCandidateFromDashboardUrl(
role = effectiveRole.ifBlank { "custom" },
priority = original?.priority ?: 1,
dashboardUrl = Connection.normalizeDashboardUrlInput(it),
dashboardUrl = dashboardUrl,
apiServerUrl = apiUrl,
relayUrl = if (relayEnabled) {
apiUrl?.let(Connection::deriveDefaultRelayUrl)
} else {
null
},
)
}
}
@@ -654,6 +821,9 @@ fun RouteEditorDialog(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
previewCandidate?.let { candidate ->
RouteSurfaceMap(candidate = candidate)
}
if (selectedRole == "tailscale") {
Text(
text = stringResource(R.string.endpoints_tailscale_setup_hint),
@@ -0,0 +1,200 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ToolCall
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.sin
private const val IMAGE_GENERATION_TOOL = "image_generate"
private const val GRID_COLUMNS = 42
private const val GRID_ROWS = 24
internal fun ToolCall.showsImageGenerationPlaceholder(): Boolean =
!isComplete && name.trim().lowercase() == IMAGE_GENERATION_TOOL
/**
* Keep the image canvas alive across the short tool-complete → media-arrival
* handoff. Once a result surface exists it can crossfade into the same bubble;
* a failed or fully-finished turn never leaves a stale canvas behind.
*/
internal fun shouldShowImageGenerationPlaceholder(
toolCalls: List<ToolCall>,
isStreaming: Boolean,
hasMediaResult: Boolean,
): Boolean {
val imageCalls = toolCalls.filter {
it.name.trim().lowercase() == IMAGE_GENERATION_TOOL
}
if (imageCalls.any { !it.isComplete }) return true
return !hasMediaResult &&
isStreaming &&
imageCalls.any { it.isComplete && it.success != false }
}
/**
* Image generation is a user-visible result lifecycle, not generic tool
* diagnostics. Keep its active canvas visible even when upstream
* `display.tool_progress` hides ordinary tool cards.
*/
internal fun ToolCall.isVisibleForToolDisplay(toolDisplay: String): Boolean =
toolDisplay != "off" || showsImageGenerationPlaceholder()
/**
* Theme-aware latent diffusion preview for an active Hermes image-generation
* tool. It specializes the generic tool lifecycle already emitted by vanilla
* Hermes; no Relay-only protocol or server patch is required.
*/
@Composable
fun ImageGenerationPlaceholder(
modifier: Modifier = Modifier,
) {
val description = stringResource(R.string.image_generation_rendering)
val transition = rememberInfiniteTransition(label = "imageGenerationDiffusion")
val phase by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 4_800, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "imageGenerationDiffusionPhase",
)
val background = MaterialTheme.colorScheme.surfaceVariant
val foreground = MaterialTheme.colorScheme.onSurfaceVariant
val primary = MaterialTheme.colorScheme.primary
val tertiary = MaterialTheme.colorScheme.tertiary
Box(
modifier = modifier
.widthIn(max = 360.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(background)
.semantics {
contentDescription = description
liveRegion = LiveRegionMode.Polite
},
) {
DiffusionCanvas(
phase = phase,
background = background,
foreground = foreground,
primary = primary,
tertiary = tertiary,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f),
)
}
}
@Composable
private fun DiffusionCanvas(
phase: Float,
background: Color,
foreground: Color,
primary: Color,
tertiary: Color,
modifier: Modifier = Modifier,
) {
Canvas(modifier = modifier) {
drawRect(background)
val cellWidth = size.width / GRID_COLUMNS
val cellHeight = size.height / GRID_ROWS
val denoise = diffusionDenoise(phase)
val time = phase * 9f
repeat(GRID_ROWS) { row ->
repeat(GRID_COLUMNS) { column ->
val signal = diffusionSignal(column, row, time, denoise)
if (signal < 0.2f) return@repeat
val x = column * cellWidth + cellWidth * 0.5f
val y = row * cellHeight + cellHeight * 0.5f
val radius = (cellWidth.coerceAtMost(cellHeight) * (0.14f + signal * 0.24f))
.coerceAtLeast(0.7.dp.toPx())
val warmMix = ((signal - 0.35f) / 0.65f).coerceIn(0f, 1f)
val base = lerpColor(foreground, primary, warmMix)
val color = lerpColor(base, tertiary, hash01(column + 17, row - 11) * 0.32f)
drawRoundRect(
color = color.copy(alpha = (0.08f + signal * 0.76f).coerceAtMost(0.84f)),
topLeft = Offset(x - radius, y - radius),
size = Size(radius * 2f, radius * 2f),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(radius * 0.45f),
)
}
}
}
}
internal fun diffusionDenoise(phase: Float): Float {
val normalized = phase - floor(phase)
return if (normalized < 0.82f) {
smoothstep(0.02f, 0.82f, normalized)
} else {
1f - smoothstep(0.82f, 1f, normalized)
}
}
internal fun diffusionSignal(column: Int, row: Int, time: Float, denoise: Float): Float {
val nx = (column + 0.5f) / GRID_COLUMNS - 0.52f
val ny = (row + 0.5f) / GRID_ROWS - 0.5f
val radius = kotlin.math.sqrt(nx * nx * 1.35f + ny * ny)
val bloom = (1f - radius * 2.35f).coerceIn(0f, 1f)
val ring = (1f - abs(radius - (0.23f + sin(time * 0.44f) * 0.025f)) * 15f)
.coerceIn(0f, 1f)
val latent = (bloom * 0.75f + ring * 0.5f).coerceIn(0f, 1f)
val staticNoise = hash01(column + floor(time * 3f).toInt() * 19, row - floor(time * 3f).toInt() * 11)
val livingNoise = hash01(column + floor(time * 7f).toInt(), row + floor(time * 5f).toInt())
return (
staticNoise * (1f - denoise) +
latent * denoise +
(livingNoise - 0.5f) * (0.42f - denoise * 0.2f)
).coerceIn(0f, 1f)
}
private fun hash01(x: Int, y: Int): Float {
val value = sin(x * 127.1 + y * 311.7) * 43_758.5453
return (value - floor(value)).toFloat()
}
private fun smoothstep(edge0: Float, edge1: Float, value: Float): Float {
val t = ((value - edge0) / (edge1 - edge0)).coerceIn(0f, 1f)
return t * t * (3f - 2f * t)
}
private fun lerpColor(from: Color, to: Color, amount: Float): Color = Color(
red = from.red + (to.red - from.red) * amount,
green = from.green + (to.green - from.green) * amount,
blue = from.blue + (to.blue - from.blue) * amount,
alpha = from.alpha + (to.alpha - from.alpha) * amount,
)
@@ -0,0 +1,127 @@
package com.hermesandroid.relay.ui.components
import java.util.Base64
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
internal const val INLINE_IMAGE_DATA_MAX_BYTES = 5 * 1024 * 1024
internal const val INLINE_IMAGE_THUMBNAIL_MAX_DIMENSION = 1_024
internal const val INLINE_IMAGE_THUMBNAIL_MAX_PIXELS = 1_500_000L
internal const val INLINE_IMAGE_SOURCE_MAX_DIMENSION = 32_768
internal const val INLINE_IMAGE_DATA_MAX_PER_MESSAGE = 4
internal const val INLINE_IMAGE_DATA_MAX_TOTAL_BYTES = 8 * 1024 * 1024
private const val MAX_HEADER_CHARS = 64
private val inlineImageDecodeMutex = Mutex()
private val DATA_IMAGE_HEADER = Regex(
"^data:(image/(?:png|jpeg|gif|webp|bmp));base64$",
RegexOption.IGNORE_CASE,
)
internal data class DecodedInlineImageData(
val bytes: ByteArray,
val mime: String,
)
/**
* Strict decoder for upstream API-server inline images.
*
* Only the five raster formats upstream emits for this path are accepted. The
* encoded input is bounded before allocation, strict base64 is required, and
* the decoded magic bytes must agree with the declared MIME type.
*/
internal fun decodeInlineImageDataUrl(
source: String,
maxBytes: Int = INLINE_IMAGE_DATA_MAX_BYTES,
): DecodedInlineImageData? {
if (maxBytes <= 0 || !source.startsWith("data:", ignoreCase = true)) return null
val comma = source.indexOf(',')
if (comma <= 0 || comma > MAX_HEADER_CHARS) return null
val mime = DATA_IMAGE_HEADER.matchEntire(source.substring(0, comma))
?.groupValues?.get(1)?.lowercase() ?: return null
val encoded = source.substring(comma + 1)
if (encoded.isEmpty()) return null
// ceil(maxBytes / 3) * 4 plus at most two trailing padding characters.
val maxEncoded = ((maxBytes.toLong() + 2L) / 3L) * 4L
if (encoded.length.toLong() > maxEncoded) return null
val bytes = runCatching { Base64.getDecoder().decode(encoded) }.getOrNull() ?: return null
if (bytes.isEmpty() || bytes.size > maxBytes || !matchesImageMagic(mime, bytes)) return null
return DecodedInlineImageData(bytes, mime)
}
/** Conservative decoded-size estimate that does not allocate a byte array. */
internal fun inlineImageDecodedSizeUpperBound(source: String): Long? {
if (!source.startsWith("data:image/", ignoreCase = true)) return null
val comma = source.indexOf(',')
if (comma <= 0 || comma > MAX_HEADER_CHARS) return null
val encodedLength = source.length.toLong() - comma - 1L
if (encodedLength <= 0) return null
val padding = when {
source.endsWith("==") -> 2L
source.endsWith('=') -> 1L
else -> 0L
}
return ((encodedLength + 3L) / 4L) * 3L - padding
}
/**
* Serialize inline-image byte/bitmap work and keep it off the caller (usually
* Compose Main) thread. This prevents multiple Base64 and bitmap decode
* allocations from overlapping.
*/
internal suspend fun <T> withInlineImageDecodeLock(
dispatcher: CoroutineDispatcher = Dispatchers.Default,
block: () -> T,
): T = withContext(dispatcher) {
inlineImageDecodeMutex.withLock { block() }
}
internal suspend fun decodeInlineImageDataUrlOffMain(
source: String,
dispatcher: CoroutineDispatcher = Dispatchers.Default,
): DecodedInlineImageData? = withInlineImageDecodeLock(dispatcher) {
decodeInlineImageDataUrl(source)
}
private fun matchesImageMagic(mime: String, bytes: ByteArray): Boolean = when (mime) {
"image/png" -> bytes.size >= 8 && bytes.sliceArray(0..7).contentEquals(
byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
)
"image/jpeg" -> bytes.size >= 3 &&
bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() && bytes[2] == 0xff.toByte()
"image/webp" -> bytes.size >= 12 &&
bytes.copyOfRange(0, 4).contentEquals("RIFF".encodeToByteArray()) &&
bytes.copyOfRange(8, 12).contentEquals("WEBP".encodeToByteArray())
"image/gif" -> bytes.size >= 6 &&
(bytes.copyOfRange(0, 6).contentEquals("GIF87a".encodeToByteArray()) ||
bytes.copyOfRange(0, 6).contentEquals("GIF89a".encodeToByteArray()))
"image/bmp" -> bytes.size >= 2 && bytes[0] == 'B'.code.toByte() && bytes[1] == 'M'.code.toByte()
else -> false
}
/**
* Power-of-two sample factor that bounds the decoded ARGB thumbnail to roughly
* 6 MiB and 1024 px on either axis. Null rejects invalid/extreme source bounds.
*/
internal fun inlineImageSampleSize(
width: Int,
height: Int,
maxDimension: Int = INLINE_IMAGE_THUMBNAIL_MAX_DIMENSION,
maxPixels: Long = INLINE_IMAGE_THUMBNAIL_MAX_PIXELS,
): Int? {
if (width <= 0 || height <= 0 || maxDimension <= 0 || maxPixels <= 0 ||
width > INLINE_IMAGE_SOURCE_MAX_DIMENSION || height > INLINE_IMAGE_SOURCE_MAX_DIMENSION
) return null
var sample = 1
while (true) {
val sampledWidth = (width + sample - 1L) / sample
val sampledHeight = (height + sample - 1L) / sample
if (sampledWidth <= maxDimension && sampledHeight <= maxDimension &&
sampledWidth <= maxPixels / sampledHeight
) return sample
if (sample >= 1 shl 15) return null
sample *= 2
}
}
@@ -41,8 +41,6 @@ import com.mikepenz.markdown.compose.components.MarkdownComponentModel
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.elements.MarkdownDivider
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeBlock
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeFence
import com.mikepenz.markdown.compose.elements.MarkdownTable
import com.mikepenz.markdown.compose.elements.MarkdownTableHeader
import com.mikepenz.markdown.compose.elements.MarkdownTableRow
@@ -152,7 +150,7 @@ fun MarkdownContent(
),
components = markdownComponents(
codeBlock = {
MarkdownHighlightedCodeBlock(
SafeMarkdownHighlightedCodeBlock(
content = it.content,
node = it.node,
highlightsBuilder = highlightsBuilder,
@@ -160,7 +158,7 @@ fun MarkdownContent(
)
},
codeFence = {
MarkdownHighlightedCodeFence(
SafeMarkdownHighlightedCodeFence(
content = it.content,
node = it.node,
highlightsBuilder = highlightsBuilder,
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
@@ -205,6 +206,11 @@ fun MessageBubble(
extractChatInlineImages(message.content)
}
}
val showImageGeneration = shouldShowImageGenerationPlaceholder(
toolCalls = message.toolCalls,
isStreaming = message.isStreaming,
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
)
// Provide the sensitive-media blur mode to the attachment / inline-image
// renderers below, sourced as locally as possible (here, not threaded
@@ -309,6 +315,7 @@ fun MessageBubble(
val showBubble = isUser || isSystem ||
message.content.isNotBlank() ||
message.isStreaming ||
showImageGeneration ||
message.cards.isNotEmpty() ||
message.attachments.isNotEmpty() ||
inlineImages.isNotEmpty()
@@ -478,32 +485,41 @@ fun MessageBubble(
}
}
// Attachments — two or more loaded images collapse into one
// grid + swipe-across gallery. Every other item stays on the
// unified InboundAttachmentCard path, and layout items retain
// their original ChatMessage.attachments indices so retry /
// manual-fetch callbacks cannot drift after grouping.
if (message.attachments.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
val attachmentItems = remember(message.attachments) {
attachmentLayoutItems(message.attachments)
}
attachmentItems.forEach { item ->
when (item) {
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
attachments = item.attachmentIndices.map(message.attachments::get),
maxWidth = maxBubbleWidth - 24.dp,
modifier = Modifier.padding(vertical = 2.dp),
)
is AttachmentLayoutItem.Single -> {
val index = item.attachmentIndex
InboundAttachmentCard(
attachment = message.attachments[index],
onRetry = { onAttachmentRetry(message.id, index) },
onManualFetch = { onAttachmentManualFetch(message.id, index) },
// Image generation owns the bubble's progress slot. It replaces
// the generic first-token dots, remains mounted through the
// tool-complete → MEDIA marker handoff, then crossfades into the
// attachment renderer in this same Surface.
Crossfade(
targetState = showImageGeneration,
animationSpec = tween(durationMillis = 220),
label = "imageGenerationToResult",
) { generating ->
if (generating) {
Spacer(modifier = Modifier.height(4.dp))
ImageGenerationPlaceholder()
} else if (message.attachments.isNotEmpty()) {
// Two or more loaded images collapse into one grid +
// swipe-across gallery. Every other item stays on the
// unified attachment path, retaining original indices.
Spacer(modifier = Modifier.height(4.dp))
val attachmentItems = attachmentLayoutItems(message.attachments)
attachmentItems.forEach { item ->
when (item) {
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
attachments = item.attachmentIndices.map(message.attachments::get),
maxWidth = maxBubbleWidth - 24.dp,
modifier = Modifier.padding(vertical = 2.dp),
)
is AttachmentLayoutItem.Single -> {
val index = item.attachmentIndex
InboundAttachmentCard(
attachment = message.attachments[index],
onRetry = { onAttachmentRetry(message.id, index) },
onManualFetch = { onAttachmentManualFetch(message.id, index) },
maxWidth = maxBubbleWidth - 24.dp,
modifier = Modifier.padding(vertical = 2.dp),
)
}
}
}
}
@@ -514,7 +530,11 @@ fun MessageBubble(
// signal, so the pulsing dots stop (Messenger/Telegram drop the
// typing bubble the moment content appears) instead of throbbing
// under the text for the whole turn.
if (message.isStreaming && message.content.isBlank()) {
if (
message.isStreaming &&
message.content.isBlank() &&
!showImageGeneration
) {
// After a few seconds with no content yet, escalate the bare
// dots to a labeled "Still working…" so a slow first token
// never reads as a hang on the SSE / sessions paths.
@@ -0,0 +1,145 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.LocalMarkdownPadding
import com.mikepenz.markdown.compose.LocalMarkdownTypography
import com.mikepenz.markdown.compose.elements.MarkdownCodeBackground
import com.mikepenz.markdown.compose.elements.MarkdownCodeBlock
import com.mikepenz.markdown.compose.elements.MarkdownCodeFence
import com.mikepenz.markdown.compose.elements.material.MarkdownBasicText
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.BoldHighlight
import dev.snipme.highlights.model.ColorHighlight
import dev.snipme.highlights.model.SyntaxLanguage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.intellij.markdown.ast.ASTNode
@Composable
internal fun SafeMarkdownHighlightedCodeFence(
content: String,
node: ASTNode,
style: TextStyle = LocalMarkdownTypography.current.code,
highlightsBuilder: Highlights.Builder,
showHeader: Boolean = false,
) {
MarkdownCodeFence(content, node, style) { code, language, codeStyle ->
SafeMarkdownHighlightedCode(
code = code,
language = language,
style = codeStyle,
highlightsBuilder = highlightsBuilder,
showHeader = showHeader,
)
}
}
@Composable
internal fun SafeMarkdownHighlightedCodeBlock(
content: String,
node: ASTNode,
style: TextStyle = LocalMarkdownTypography.current.code,
highlightsBuilder: Highlights.Builder,
showHeader: Boolean = false,
) {
MarkdownCodeBlock(content, node, style) { code, language, codeStyle ->
SafeMarkdownHighlightedCode(
code = code,
language = language,
style = codeStyle,
highlightsBuilder = highlightsBuilder,
showHeader = showHeader,
)
}
}
@Composable
private fun SafeMarkdownHighlightedCode(
code: String,
language: String?,
style: TextStyle,
highlightsBuilder: Highlights.Builder,
showHeader: Boolean,
) {
val codeHighlights by produceState(
initialValue = AnnotatedString(code),
key1 = code,
key2 = language,
key3 = highlightsBuilder,
) {
value = withContext(Dispatchers.Default) {
buildSafeHighlightedAnnotatedString(code, language, highlightsBuilder)
}
}
val codeBackgroundCornerSize = LocalMarkdownDimens.current.codeBackgroundCornerSize
MarkdownCodeBackground(
color = LocalMarkdownColors.current.codeBackground,
shape = RoundedCornerShape(codeBackgroundCornerSize),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
showHeader = showHeader,
language = language,
code = code,
) {
MarkdownBasicText(
text = codeHighlights,
style = style,
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(LocalMarkdownPadding.current.codeBlock),
)
}
}
/**
* Converts Highlights ranges into Compose spans without trusting dependency
* offsets. Highlights 1.1.0 can return a reversed multiline-comment range
* when a multiline-comment closing delimiter precedes its opening delimiter.
* Streaming can expose that shape before a code block is complete.
*/
internal fun buildSafeHighlightedAnnotatedString(
code: String,
language: String?,
highlightsBuilder: Highlights.Builder,
): AnnotatedString {
val syntaxLanguage = language?.let(SyntaxLanguage::getByName)
val highlights = highlightsBuilder
.code(code)
.let { if (syntaxLanguage != null) it.language(syntaxLanguage) else it }
.build()
.getHighlights()
return buildAnnotatedString {
append(code)
highlights.forEach { highlight ->
val start = highlight.location.start.coerceIn(0, code.length)
val end = highlight.location.end.coerceIn(0, code.length)
if (start >= end) return@forEach
val style = when (highlight) {
is ColorHighlight -> SpanStyle(color = Color(highlight.rgb).copy(alpha = 1f))
is BoldHighlight -> SpanStyle(fontWeight = FontWeight.Bold)
}
addStyle(style = style, start = start, end = end)
}
}
}
@@ -19,15 +19,15 @@ data class SourceBadge(val label: String, val color: Color)
* The app's own chats — no badge (they're "your" conversations from this app /
* desktop / API, not a distinct gateway lane).
*/
private val OWN_CHAT_SOURCES = setOf("tui", "api_server", "cli", "local", "")
private val OWN_CHAT_SOURCES = setOf("tui", "api_server", "cli", "local", "webui", "")
/**
* Map a session `source` to a drawer badge, or null for the app's own chats and
* the phone **Thread** (which renders its own thread-spool chip). External
* gateways — discord / telegram / slack / cron / webhook / web / … — each get a
* small colored chip so the drawer reads like the desktop's per-channel tags.
* Confirmed live sources: tui, cli, api_server, web, discord, telegram, cron,
* webhook, phone.
* Confirmed live sources: tui, cli, api_server, webui, web, discord, telegram,
* cron, webhook, phone.
*/
fun sourceBadge(source: String?): SourceBadge? {
val s = source?.trim()?.lowercase() ?: return null
@@ -31,6 +31,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import com.hermesandroid.relay.viewmodel.VoiceState
import kotlin.math.PI
import kotlin.math.max
@@ -140,6 +141,8 @@ fun VoiceWaveform(
amplitude: Float,
state: VoiceState,
outputAudioActive: Boolean = false,
height: Dp = 56.dp,
compactBars: Boolean = false,
modifier: Modifier = Modifier,
) {
// No downstream smoothing. The VoiceViewModel already runs an
@@ -154,6 +157,7 @@ fun VoiceWaveform(
// doesn't look abrupt.
val dim = MaterialTheme.colorScheme.onSurfaceVariant
val errorColor = MaterialTheme.colorScheme.error
val compactColor = MaterialTheme.colorScheme.primary
val targetPrimary = when (state) {
VoiceState.Idle -> dim.copy(alpha = 0.3f)
@@ -198,16 +202,38 @@ fun VoiceWaveform(
)
val spinnerPhase = rememberProcessingSpinnerPhase(processing)
Canvas(
modifier = modifier
.fillMaxWidth()
.height(56.dp),
) {
val canvasModifier = if (compactBars) {
modifier.height(height)
} else {
modifier.fillMaxWidth().height(height)
}
Canvas(modifier = canvasModifier) {
val width = size.width
val height = size.height
if (width <= 0f || height <= 0f) return@Canvas
val centerY = height / 2f
if (compactBars) {
val barCount = 10
val gap = 3.dp.toPx()
val barWidth = 2.5.dp.toPx()
val totalWidth = barWidth * barCount + gap * (barCount - 1)
val startX = ((width - totalWidth) / 2f).coerceAtLeast(0f)
repeat(barCount) { index ->
val wave = ((sin(phases[0] + index * 0.82f) + 1f) * 0.5f)
val activeHeight = height * (0.22f + wave * (0.28f + displayAmplitude * 0.5f))
val x = startX + index * (barWidth + gap)
drawRoundRect(
color = compactColor.copy(alpha = if (index < 5) 0.96f else 0.22f),
topLeft = Offset(x, centerY - activeHeight / 2f),
size = Size(barWidth, activeHeight),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(barWidth / 2f, barWidth / 2f),
)
}
return@Canvas
}
val peakPixels = height * PEAK_FRACTION
val strokePx = STROKE_WIDTH_DP.dp.toPx()
val centerX = width / 2f
@@ -115,6 +115,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.radialNavyBackground
import com.hermesandroid.relay.network.upstream.ApiModelOption
import com.hermesandroid.relay.network.upstream.ChatMode
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.relay.RelayVoiceClient
@@ -195,8 +196,11 @@ import com.hermesandroid.relay.ui.components.ThinkingMatrixColor
import com.hermesandroid.relay.ui.components.ThinkingMatrixPattern
import com.hermesandroid.relay.ui.components.SessionDrawerContent
import com.hermesandroid.relay.ui.components.SlashCommand
import com.hermesandroid.relay.ui.components.StreamingDots
import com.hermesandroid.relay.ui.components.SubagentLane
import com.hermesandroid.relay.ui.components.ToolProgressCard
import com.hermesandroid.relay.ui.components.isVisibleForToolDisplay
import com.hermesandroid.relay.ui.components.showsImageGenerationPlaceholder
import com.hermesandroid.relay.ui.components.VoiceModeOverlay
import com.hermesandroid.relay.ui.LocalSnackbarHost
import com.hermesandroid.relay.ui.showHumanError
@@ -223,6 +227,23 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private const val DEFAULT_CHAR_LIMIT = 4096
internal fun resolveChatHeaderSubtitle(
isStreaming: Boolean,
statusText: String,
projectName: String?,
personalityName: String?,
modelName: String?,
): String = if (isStreaming) {
statusText
} else {
listOfNotNull(
projectName?.takeIf { it.isNotBlank() },
personalityName?.takeIf { it.isNotBlank() },
modelName?.takeIf { it.isNotBlank() },
).joinToString(" \u00B7 ").ifBlank { statusText }
}
/**
* A same-author run breaks into a new visual group once the gap to the
* neighboring message exceeds this — so a conversation resumed after a pause
@@ -524,6 +545,7 @@ fun ChatScreen(
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
val standardVoiceSignInRouteHint by
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
val dashboardRouteMovedHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
val apiReachable by connectionViewModel.apiServerReachable.collectAsState()
val chatMode by connectionViewModel.chatMode.collectAsState()
val error by chatViewModel.error.collectAsState()
@@ -551,11 +573,12 @@ fun ChatScreen(
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val serverModelName by chatViewModel.serverModelName.collectAsState()
val availableModels by chatViewModel.availableModels.collectAsState()
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
val modelProviders by chatViewModel.modelProviders.collectAsState()
val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState()
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
val gatewayProjectName by chatViewModel.gatewayProjectName.collectAsState()
val selectedReasoningEffort by chatViewModel.selectedReasoningEffort.collectAsState()
val showThinking by connectionViewModel.showThinking.collectAsState()
val toolDisplay by connectionViewModel.toolDisplay.collectAsState()
@@ -587,7 +610,7 @@ fun ChatScreen(
val voiceHintSeen by connectionViewModel.voiceHintSeen.collectAsState()
// Whether the NEXT turn would ride the gateway transport — gates the
// "Edit & resend" menu entry (conversation rewind needs the gateway).
// Per-turn steerability comes from [steerableTurn], which also covers
// Per-turn correction availability comes from [steerableTurn], which also covers
// the preflight-SSE-fallback window.
val streamingEndpointPref by connectionViewModel.streamingEndpoint.collectAsState()
val chatServerCapabilities by connectionViewModel.serverCapabilities.collectAsState()
@@ -1583,12 +1606,16 @@ fun ChatScreen(
// yet (server config still loading), fall back to the plain
// connection status \u2014 never the literal "None"/"Default"
// personality label.
val subtitleText = when {
!headerChatReady -> statusText
else -> listOfNotNull(
nonDefaultPersonality,
modelName?.takeIf { it.isNotBlank() },
).joinToString(" \u00B7 ").ifBlank { statusText }
val subtitleText = if (!headerChatReady) {
statusText
} else {
resolveChatHeaderSubtitle(
isStreaming = isStreaming,
statusText = statusText,
projectName = gatewayProjectName,
personalityName = nonDefaultPersonality,
modelName = modelName,
)
}
val subtitleColor = if (headerChatReady) {
MaterialTheme.colorScheme.onSurfaceVariant
@@ -1721,6 +1748,10 @@ fun ChatScreen(
transitionSpec = { loadedContentTransform() },
label = "chatHeaderSubtitle",
) { line ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = line,
style = MaterialTheme.typography.bodySmall,
@@ -1728,6 +1759,13 @@ fun ChatScreen(
maxLines = 1,
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
if (isStreaming && animationEnabled) {
StreamingDots(
color = subtitleColor,
modifier = Modifier.clearAndSetSemantics { },
)
}
}
}
}
}
@@ -1929,6 +1967,9 @@ fun ChatScreen(
chatReady = chatReady,
isLoadingHistory = isLoadingHistory,
isLoadingSessions = isLoadingSessions,
gatewayAvailability = chatGatewayAvailability,
dashboardRouteMovedHint = dashboardRouteMovedHint,
onNavigateToManage = onNavigateToManage,
onNavigateToConnections = onNavigateToConnections,
modifier = Modifier.fillMaxSize(),
)
@@ -2315,7 +2356,7 @@ fun ChatScreen(
)
}
// Steered sends live inside a server-side tool
// Legacy steered sends live inside a server-side tool
// result, not a user message — flag the local
// bubble so the scrollback explains itself.
if (message.role == MessageRole.USER && message.id.startsWith("steer-")) {
@@ -2332,12 +2373,21 @@ fun ChatScreen(
}
}
if (toolDisplay != "off" && !hasBackgroundTask) {
if (!hasBackgroundTask) {
// Subagent children (taskIndex != null) group
// into lanes after the top-level tool cards;
// the null group renders exactly as before.
val laneGroups = message.toolCalls.groupBy { it.taskIndex }
laneGroups[null]?.forEach { toolCall ->
// Image generation renders inside MessageBubble
// so the progress canvas and final media share
// one stable Surface and transition in place.
if (toolCall.showsImageGenerationPlaceholder()) {
return@forEach
}
if (!toolCall.isVisibleForToolDisplay(toolDisplay)) {
return@forEach
}
Spacer(modifier = Modifier.height(4.dp))
when (toolDisplay) {
"compact" -> CompactToolCall(toolCall = toolCall)
@@ -2347,12 +2397,14 @@ fun ChatScreen(
)
}
}
laneGroups.keys.filterNotNull().sorted().forEach { taskIndex ->
Spacer(modifier = Modifier.height(4.dp))
SubagentLane(
taskIndex = taskIndex,
calls = laneGroups.getValue(taskIndex),
)
if (toolDisplay != "off") {
laneGroups.keys.filterNotNull().sorted().forEach { taskIndex ->
Spacer(modifier = Modifier.height(4.dp))
SubagentLane(
taskIndex = taskIndex,
calls = laneGroups.getValue(taskIndex),
)
}
}
}
}
@@ -2680,7 +2732,7 @@ fun ChatScreen(
}
// Input bar — pill field with ONE trailing slot morphing
// Send / Voice / Stop / Steer / Queue. "+" taps the file picker,
// Send / Voice / Stop / Correct / Queue. "+" taps the file picker,
// long-press opens the CommandPalette (the dedicated "/" button
// is gone — typing "/" still surfaces InlineAutocomplete).
val hasContent = inputText.isNotBlank() || pendingAttachments.isNotEmpty()
@@ -2707,13 +2759,16 @@ fun ChatScreen(
val editBusyMessage = stringResource(R.string.chat_edit_busy_snackbar)
val stoppedMessage = stringResource(R.string.chat_stopped_snackbar)
val attachmentPlaceholder = stringResource(R.string.chat_attachment_placeholder)
val sseModelOptions = remember(availableModels, agentProfiles, selectedModelOverride) {
(availableModels.mapNotNull(AgentDisplay::displayModelName) +
agentProfiles.mapNotNull { AgentDisplay.displayModelName(it.model) } +
listOfNotNull(AgentDisplay.displayModelName(selectedModelOverride)))
.distinct()
val sseModelOptions = remember(apiModelOptions, agentProfiles, selectedModelOverride) {
(apiModelOptions +
agentProfiles.mapNotNull { profile ->
AgentDisplay.requestModelName(profile.model)?.let { ApiModelOption(it) }
} +
listOfNotNull(AgentDisplay.requestModelName(selectedModelOverride)?.let { ApiModelOption(it) }))
.distinctBy { it.id }
}
val currentModelForInput = AgentDisplay.displayModelName(selectedModelOverride)
val currentModelForInput = apiModelOptions.firstOrNull { it.id == selectedModelOverride }?.id
?: AgentDisplay.displayModelName(selectedModelOverride)
?: AgentDisplay.displayModelName(gatewayCurrentModel)
?: AgentDisplay.displayModelName(effectiveProfile?.model)
?: AgentDisplay.displayModelName(serverModelName)
@@ -2785,9 +2840,10 @@ fun ChatScreen(
sseModelOptions.forEach { model ->
add(
ChatInputPickerOption(
label = model,
value = model,
selected = selectedModelOverride == model,
label = AgentDisplay.displayModelName(model.id) ?: model.id,
value = model.id,
secondary = model.routeDetail,
selected = selectedModelOverride == model.id,
),
)
}
@@ -2932,7 +2988,11 @@ fun ChatScreen(
isDarkTheme = isDarkTheme,
modelControl = modelControl,
onModelOptionSelected = { option ->
chatViewModel.selectModel(option.value, option.provider)
if (option.provider == null && apiModelOptions.any { it.id == option.value }) {
option.value?.let(chatViewModel::selectApiModel)
} else {
chatViewModel.selectModel(option.value, option.provider)
}
},
effortControl = effortControl,
onEffortOptionSelected = { option ->
@@ -2949,7 +3009,11 @@ fun ChatScreen(
onRefresh = { chatViewModel.refreshModelOptions(refresh = true) },
onSelect = { option ->
showModelSheet = false
chatViewModel.selectModel(option.value, option.provider)
if (option.provider == null && apiModelOptions.any { it.id == option.value }) {
option.value?.let(chatViewModel::selectApiModel)
} else {
chatViewModel.selectModel(option.value, option.provider)
}
},
onDismiss = { showModelSheet = false },
)
@@ -3226,6 +3290,9 @@ private fun ChatColdStartLoadingState(
chatReady: Boolean,
isLoadingHistory: Boolean,
isLoadingSessions: Boolean,
gatewayAvailability: GatewayAvailability,
dashboardRouteMovedHint: String?,
onNavigateToManage: () -> Unit,
onNavigateToConnections: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -3288,14 +3355,51 @@ private fun ChatColdStartLoadingState(
)
}
ChatLoadingCommandPanel(
commands = commands,
onNavigateToConnections = onNavigateToConnections,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
)
val dashboardSignInRequired =
gatewayAvailability == GatewayAvailability.SignInRequired && !apiReachable
if (dashboardSignInRequired) {
ElevatedCard(
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
),
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(R.string.dashboard_signin_required_title),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = dashboardRouteMovedHint?.let { route ->
stringResource(R.string.dashboard_signin_route_hint, route)
} ?: stringResource(R.string.chat_settings_gateway_needs_signin_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = onNavigateToManage,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.voice_settings_sign_in_via_manage))
}
}
}
} else {
ChatLoadingCommandPanel(
commands = commands,
onNavigateToConnections = onNavigateToConnections,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
)
}
}
}
@@ -44,11 +44,16 @@ internal enum class DashboardActionKind {
EnableMcp,
DisableMcp,
TestMcp,
AuthenticateMcp,
RemoveMcp,
InstallMcpCatalog,
ViewProfileSoul,
ActivateProfile,
DeleteProfile,
EditCustomEndpoint,
ValidateCustomEndpoint,
ActivateCustomEndpoint,
DeleteCustomEndpoint,
// Input-backed kinds — intercepted before runAction and routed to a
// text-input or model-picker dialog instead of firing immediately.
@@ -2,6 +2,8 @@
package com.hermesandroid.relay.ui.screens
import android.content.Intent
import android.net.Uri
import android.webkit.CookieManager
import android.webkit.WebResourceRequest
import android.webkit.WebView
@@ -96,9 +98,12 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewmodel.compose.viewModel
import com.hermesandroid.relay.R
import com.hermesandroid.relay.network.upstream.EncryptedDashboardCookieStore
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.DashboardCustomEndpointDraft
import com.hermesandroid.relay.network.upstream.McpOAuthFlowCoordinator
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
@@ -114,7 +119,10 @@ import com.hermesandroid.relay.ui.theme.relayGridTexture
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
import com.hermesandroid.relay.ui.theme.relayPanel
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import com.hermesandroid.relay.viewmodel.DashboardManageOAuthViewModel
import com.hermesandroid.relay.viewmodel.PendingMcpOAuth
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonArray
@@ -123,6 +131,7 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.text.DateFormat
import java.util.Date
@@ -136,6 +145,7 @@ private enum class DashboardManagementSection(val path: String) {
Cron("/api/cron/jobs"),
Mcp("/api/mcp/servers"),
Catalog("/api/mcp/catalog"),
CustomEndpoints("/api/providers/custom-endpoints"),
Profiles("/api/profiles"),
Models("/api/model/info"),
Keys("/api/env"),
@@ -147,6 +157,7 @@ private enum class DashboardManagementSection(val path: String) {
Cron -> stringResource(R.string.dashboard_tab_cron)
Mcp -> stringResource(R.string.dashboard_tab_mcp)
Catalog -> stringResource(R.string.dashboard_tab_catalog)
CustomEndpoints -> stringResource(R.string.dashboard_tab_custom_endpoints)
Profiles -> stringResource(R.string.dashboard_tab_profiles)
Models -> stringResource(R.string.dashboard_tab_models)
Keys -> stringResource(R.string.dashboard_tab_keys)
@@ -160,6 +171,7 @@ private enum class DashboardManagementSection(val path: String) {
Cron -> stringResource(R.string.dashboard_tab_cron_lower)
Mcp -> stringResource(R.string.dashboard_tab_mcp_lower)
Catalog -> stringResource(R.string.dashboard_tab_catalog_lower)
CustomEndpoints -> stringResource(R.string.dashboard_tab_custom_endpoints_lower)
Profiles -> stringResource(R.string.dashboard_tab_profiles_lower)
Models -> stringResource(R.string.dashboard_tab_models_lower)
Keys -> stringResource(R.string.dashboard_tab_keys_lower)
@@ -169,6 +181,55 @@ private enum class DashboardManagementSection(val path: String) {
private val managementSections: List<DashboardManagementSection> = DashboardManagementSection.entries
internal fun dashboardSectionRequestPath(path: String, profile: String?): String {
if (profile.isNullOrBlank() || path !in setOf("/api/mcp/servers", "/api/mcp/catalog")) return path
val encoded = java.net.URLEncoder.encode(profile, Charsets.UTF_8.name()).replace("+", "%20")
return "$path?profile=$encoded"
}
internal fun resolveMcpOAuthDialogItem(
requested: DashboardSummaryItem?,
pending: PendingMcpOAuth?,
): DashboardSummaryItem? = pending?.let { flow ->
DashboardSummaryItem(
id = flow.serverName,
title = flow.serverName,
profile = flow.profile,
)
} ?: requested
internal fun canStartMcpOAuth(capabilitySupported: Boolean, pending: PendingMcpOAuth?): Boolean =
capabilitySupported && pending == null
/** Stable, non-secret owner identity for a dashboard-hosted OAuth flow. */
internal fun mcpOAuthRouteIdentity(connectionId: String, dashboardUrl: String): String? {
if (connectionId.isBlank()) return null
val url = dashboardUrl.trim().toHttpUrlOrNull() ?: return null
val normalized = url.newBuilder()
.username("")
.password("")
.query(null)
.fragment(null)
.build()
.toString()
.trimEnd('/')
return "$connectionId|$normalized"
}
internal fun canResumeMcpOAuth(pending: PendingMcpOAuth?, currentRouteIdentity: String?): Boolean =
pending != null && currentRouteIdentity != null && pending.routeIdentity == currentRouteIdentity
internal fun scopeDashboardManageItems(
sectionPath: String,
profile: String?,
items: List<DashboardSummaryItem>,
): List<DashboardSummaryItem> =
if (sectionPath == "/api/mcp/servers" || sectionPath == "/api/mcp/catalog") {
items.map { it.copy(profile = profile) }
} else {
items
}
private sealed interface DashboardPayloadState {
data object Idle : DashboardPayloadState
data object Loading : DashboardPayloadState
@@ -287,6 +348,7 @@ private enum class DashboardSectionAction {
CreateProfile,
BrowseSkillsHub,
UpdateSkillsHub,
AddCustomEndpoint,
}
/** Editor session for a profile's SOUL.md — content is the FULL file from GET. */
@@ -322,6 +384,7 @@ fun DashboardManagementScreen(
onNavigateToBridge: () -> Unit = {},
onNavigateToTerminal: () -> Unit = {},
onNavigateToSettings: () -> Unit = {},
oauthViewModel: DashboardManageOAuthViewModel = viewModel(),
) {
val context = LocalContext.current.applicationContext
val scope = rememberCoroutineScope()
@@ -331,6 +394,10 @@ fun DashboardManagementScreen(
// dashboard off the connection's persisted URL — drives the target line
// and the per-host sign-in explanation below.
val dashboardRouteHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
val effectiveProfileName by connectionViewModel.effectiveSessionProfileName.collectAsState()
val pendingMcpOAuth by oauthViewModel.pending.collectAsState()
val unsupportedOAuthRoutes by oauthViewModel.unsupportedRoutes.collectAsState()
val supportedOAuthRoutes by oauthViewModel.supportedRoutes.collectAsState()
var selectedTab by remember { mutableStateOf(0) }
var showingDetail by remember { mutableStateOf(false) }
var reloadNonce by remember { mutableStateOf(0) }
@@ -349,11 +416,23 @@ fun DashboardManagementScreen(
var expensiveModelConfirm by remember { mutableStateOf<ExpensiveModelConfirm?>(null) }
var showSkillsHub by remember { mutableStateOf(false) }
var soulEditor by remember { mutableStateOf<SoulEditorState?>(null) }
var oauthMcpItem by remember { mutableStateOf<DashboardSummaryItem?>(null) }
var oauthDialogHidden by remember(pendingMcpOAuth?.flowId) { mutableStateOf(false) }
var customEndpointEditor by remember { mutableStateOf<DashboardSummaryItem?>(null) }
var showCustomEndpointEditor by remember { mutableStateOf(false) }
val section = managementSections[selectedTab]
val connectionId = activeConnection?.id ?: "default"
val currentMcpOAuthRouteIdentity = mcpOAuthRouteIdentity(connectionId, dashboardUrl)
val oauthRouteKey = "${currentMcpOAuthRouteIdentity.orEmpty()}|${effectiveProfileName.orEmpty()}"
val mcpOAuthSupported = oauthRouteKey in supportedOAuthRoutes
val mcpOAuthStartAllowed = canStartMcpOAuth(mcpOAuthSupported, pendingMcpOAuth)
fun payloadKeyFor(targetSection: DashboardManagementSection): String =
dashboardPayloadKey(connectionId, dashboardUrl, targetSection.path)
dashboardPayloadKey(
connectionId,
dashboardUrl,
dashboardSectionRequestPath(targetSection.path, effectiveProfileName),
)
val payloadKey = payloadKeyFor(section)
val payloadState = payloadStates[payloadKey] ?: DashboardPayloadState.Idle
@@ -436,6 +515,7 @@ fun DashboardManagementScreen(
val nextState = fetchDashboardSectionState(
clientFactory = clientFactory,
targetSection = targetSection,
effectiveProfileName = effectiveProfileName,
preamble = preamble,
context = context,
recordStatus = { status: DashboardStatus?, session: DashboardAuthSession?, gatewayTicketAvailable: Boolean? ->
@@ -711,7 +791,7 @@ fun DashboardManagementScreen(
}
}
LaunchedEffect(dashboardUrl, selectedTab, reloadNonce, activeConnection?.id) {
LaunchedEffect(dashboardUrl, selectedTab, reloadNonce, activeConnection?.id, effectiveProfileName) {
val forceCurrent = forceReloadKey == payloadKey
loadDashboardSection(
targetSection = section,
@@ -724,7 +804,7 @@ fun DashboardManagementScreen(
}
}
LaunchedEffect(dashboardUrl, activeConnection?.id, payloadState) {
LaunchedEffect(dashboardUrl, activeConnection?.id, payloadState, effectiveProfileName) {
val loadedState = payloadState as? DashboardPayloadState.Loaded ?: return@LaunchedEffect
if (dashboardUrl.isBlank()) return@LaunchedEffect
if (loadedState.status?.authRequired == true && loadedState.session?.authenticated != true) {
@@ -759,6 +839,27 @@ fun DashboardManagementScreen(
}
}
LaunchedEffect(oauthRouteKey, payloadState) {
val loadedState = payloadState as? DashboardPayloadState.Loaded ?: return@LaunchedEffect
if (dashboardUrl.isBlank() || oauthRouteKey in supportedOAuthRoutes || oauthRouteKey in unsupportedOAuthRoutes) {
return@LaunchedEffect
}
if (loadedState.status?.authRequired == true && loadedState.session?.authenticated != true) {
return@LaunchedEffect
}
val capability = try {
withDashboardClient(clientFactory) { client -> client.supportsHostedMcpOAuth() }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
capability.onSuccess { supported ->
if (supported) oauthViewModel.markSupported(oauthRouteKey)
else oauthViewModel.markUnsupported(oauthRouteKey)
}
}
pendingAction?.let { pending ->
val isActivateProfile = pending.action.kind == DashboardActionKind.ActivateProfile
val actionLabel = dashboardActionLabel(pending.action)
@@ -959,6 +1060,58 @@ fun DashboardManagementScreen(
)
}
val oauthDialogItem = resolveMcpOAuthDialogItem(oauthMcpItem, pendingMcpOAuth)
if (oauthDialogItem != null &&
!oauthDialogHidden &&
(pendingMcpOAuth == null || canResumeMcpOAuth(pendingMcpOAuth, currentMcpOAuthRouteIdentity))
) {
McpOAuthDialog(
item = oauthDialogItem,
effectiveProfileName = effectiveProfileName,
pending = pendingMcpOAuth,
currentRouteIdentity = currentMcpOAuthRouteIdentity,
clientFactory = clientFactory,
onPending = { flow ->
oauthViewModel.remember(flow.flowId, flow.serverName, flow.profile, flow.routeIdentity)
},
onApproved = {
oauthViewModel.clear()
oauthMcpItem = null
forceReloadKey = payloadKey
reloadNonce += 1
actionMessage = context.getString(R.string.dashboard_mcp_oauth_approved)
},
onFlowFailed = { failure ->
oauthViewModel.clear()
oauthMcpItem = null
oauthDialogHidden = true
actionMessage = failure
},
onDismiss = {
oauthMcpItem = null
oauthDialogHidden = true
},
)
}
if (showCustomEndpointEditor) {
CustomEndpointDialog(
existing = customEndpointEditor,
clientFactory = clientFactory,
onSaved = {
showCustomEndpointEditor = false
customEndpointEditor = null
forceReloadKey = payloadKey
reloadNonce += 1
actionMessage = context.getString(R.string.dashboard_custom_endpoint_saved)
},
onDismiss = {
showCustomEndpointEditor = false
customEndpointEditor = null
},
)
}
if (confirmClearDashboardSession) {
val clearedMsg = stringResource(R.string.dashboard_session_cleared)
AlertDialog(
@@ -1139,11 +1292,23 @@ fun DashboardManagementScreen(
)
DashboardActionKind.EditProfileSoul ->
openSoulEditor(item)
DashboardActionKind.AuthenticateMcp ->
if (mcpOAuthStartAllowed) {
oauthDialogHidden = false
oauthMcpItem = item.copy(profile = effectiveProfileName)
}
DashboardActionKind.EditCustomEndpoint,
DashboardActionKind.ValidateCustomEndpoint -> {
customEndpointEditor = item
showCustomEndpointEditor = true
}
// Always confirm: this flips the
// server's persistent active agent for
// every client, unlike the ephemeral
// per-conversation switch in chat.
DashboardActionKind.ActivateProfile ->
DashboardActionKind.ActivateProfile,
DashboardActionKind.ActivateCustomEndpoint,
DashboardActionKind.DeleteCustomEndpoint ->
pendingAction = PendingDashboardAction(item, action)
else -> if (action.destructive) {
pendingAction = PendingDashboardAction(item, action)
@@ -1162,8 +1327,13 @@ fun DashboardManagementScreen(
showSkillsHub = true
DashboardSectionAction.UpdateSkillsHub ->
runUpdateSkillsHub()
DashboardSectionAction.AddCustomEndpoint -> {
customEndpointEditor = null
showCustomEndpointEditor = true
}
}
},
mcpOAuthSupported = mcpOAuthStartAllowed,
)
}
}
@@ -1233,6 +1403,11 @@ private fun manageTileSpec(section: DashboardManagementSection): ManageTileSpec
title = stringResource(R.string.dashboard_tile_catalog_title),
subtitle = stringResource(R.string.dashboard_tile_catalog_sub),
)
DashboardManagementSection.CustomEndpoints -> ManageTileSpec(
icon = Icons.Filled.Link,
title = stringResource(R.string.dashboard_tile_custom_endpoints_title),
subtitle = stringResource(R.string.dashboard_tile_custom_endpoints_sub),
)
DashboardManagementSection.Models -> ManageTileSpec(
icon = Icons.Filled.Tune,
title = stringResource(R.string.dashboard_tile_models_title),
@@ -1340,6 +1515,39 @@ private fun ManageOverviewBody(
onClearSession = onClearSession,
)
}
if (status?.nousSessionValid == "terminal") {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
),
) {
Text(
text = stringResource(R.string.dashboard_nous_terminal_warning),
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
}
if (status?.gatewayMode != null || status?.profiles?.isNotEmpty() == true) {
item {
val mode = status.gatewayMode ?: "unknown"
val profiles = status.profiles.joinToString().ifBlank { "—" }
val ports = status.gateways.flatMap { gateway ->
gateway.ports.map { (platform, port) -> "${gateway.profile}/$platform:$port" }
}.joinToString().ifBlank { "—" }
Text(
text = stringResource(R.string.dashboard_gateway_topology, mode, profiles, ports),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 4.dp),
)
}
}
val signInStatus = status
if (signInStatus?.authRequired == true && authenticated != true) {
item {
@@ -1406,6 +1614,14 @@ private fun ManageOverviewBody(
onClick = { onSelectSection(DashboardManagementSection.Models) },
)
}
item {
RelayNavTile(
icon = Icons.Filled.Link,
title = stringResource(R.string.dashboard_tile_custom_endpoints_title),
subtitle = stringResource(R.string.dashboard_tile_custom_endpoints_sub),
onClick = { onSelectSection(DashboardManagementSection.CustomEndpoints) },
)
}
item {
RelayNavTile(
icon = Icons.Filled.Key,
@@ -1622,6 +1838,7 @@ private suspend fun fetchDashboardPreamble(client: DashboardApiClient): Dashboar
private suspend fun fetchDashboardSectionState(
clientFactory: () -> DashboardApiClient,
targetSection: DashboardManagementSection,
effectiveProfileName: String? = null,
preamble: DashboardPreamble? = null,
recordStatus: (DashboardStatus?, DashboardAuthSession?, Boolean?) -> Unit = { _, _, _ -> },
context: android.content.Context,
@@ -1629,6 +1846,7 @@ private suspend fun fetchDashboardSectionState(
fetchDashboardSectionStateWith(
client = client,
targetSection = targetSection,
effectiveProfileName = effectiveProfileName,
preamble = preamble,
recordStatus = recordStatus,
context = context,
@@ -1639,6 +1857,7 @@ private suspend fun fetchDashboardSectionState(
private suspend fun fetchDashboardSectionStateWith(
client: DashboardApiClient,
targetSection: DashboardManagementSection,
effectiveProfileName: String? = null,
preamble: DashboardPreamble? = null,
recordStatus: (DashboardStatus?, DashboardAuthSession?, Boolean?) -> Unit = { _, _, _ -> },
context: android.content.Context,
@@ -1651,13 +1870,19 @@ private suspend fun fetchDashboardSectionStateWith(
status = resolved.status,
)
} else {
val result = client.getJsonElement(targetSection.path)
val result = client.getJsonElement(
dashboardSectionRequestPath(targetSection.path, effectiveProfileName),
)
result.fold(
onSuccess = { root ->
DashboardPayloadState.Loaded(
status = resolved.status,
session = resolved.session,
items = summarize(targetSection, root),
items = scopeDashboardManageItems(
targetSection.path,
effectiveProfileName,
summarize(targetSection, root),
),
rawSummary = summarizeRoot(root),
fetchedAtMillis = System.currentTimeMillis(),
)
@@ -1694,13 +1919,18 @@ internal suspend fun prewarmDashboardManage(
cookieStore: DashboardCookieStore,
connectionId: String,
dashboardUrl: String,
effectiveProfileName: String? = null,
/** When non-null, the sweep's results are mirrored to the disk cache. */
cacheDir: java.io.File? = null,
context: android.content.Context,
) {
if (dashboardUrl.isBlank()) return
fun needsWarm(targetSection: DashboardManagementSection): Boolean {
val key = dashboardPayloadKey(connectionId, dashboardUrl, targetSection.path)
val key = dashboardPayloadKey(
connectionId,
dashboardUrl,
dashboardSectionRequestPath(targetSection.path, effectiveProfileName),
)
return when (val existing = DashboardPayloadCache.states[key]) {
is DashboardPayloadState.Loading -> false
is DashboardPayloadState.Loaded ->
@@ -1736,13 +1966,14 @@ internal suspend fun prewarmDashboardManage(
val key = dashboardPayloadKey(
connectionId,
dashboardUrl,
targetSection.path,
dashboardSectionRequestPath(targetSection.path, effectiveProfileName),
)
if (!needsWarm(targetSection)) return@launch
val state = try {
fetchDashboardSectionStateWith(
client = client,
targetSection = targetSection,
effectiveProfileName = effectiveProfileName,
preamble = preamble,
context = context,
)
@@ -1942,12 +2173,14 @@ private fun LoadedBody(
actionMessage: String?,
onAction: (DashboardSummaryItem, DashboardItemAction) -> Unit,
onSectionAction: (DashboardSectionAction) -> Unit = {},
mcpOAuthSupported: Boolean = true,
) {
// Pre-resolve action labels outside LazyColumn's non-Composable lambda
val actionLabelChangeMainModel = stringResource(R.string.dashboard_section_action_change_main_model)
val actionLabelNewProfile = stringResource(R.string.dashboard_section_action_new_profile)
val actionLabelBrowseHub = stringResource(R.string.dashboard_section_action_browse_hub)
val actionLabelUpdateInstalled = stringResource(R.string.dashboard_section_action_update_installed)
val actionLabelAddEndpoint = stringResource(R.string.dashboard_custom_endpoint_add)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
@@ -1972,6 +2205,9 @@ private fun LoadedBody(
DashboardSectionAction.BrowseSkillsHub to actionLabelBrowseHub,
DashboardSectionAction.UpdateSkillsHub to actionLabelUpdateInstalled,
)
DashboardManagementSection.CustomEndpoints -> listOf(
DashboardSectionAction.AddCustomEndpoint to actionLabelAddEndpoint,
)
else -> emptyList()
}
if (sectionActions.isNotEmpty()) {
@@ -2015,10 +2251,13 @@ private fun LoadedBody(
}
} else {
items(state.items) { item ->
val renderedItem = if (!mcpOAuthSupported) {
item.copy(actions = item.actions.filterNot { it.kind == DashboardActionKind.AuthenticateMcp })
} else item
DashboardSummaryCard(
item = item,
item = renderedItem,
actionInFlight = actionInFlight,
onAction = { action -> onAction(item, action) },
onAction = { action -> onAction(renderedItem, action) },
)
}
}
@@ -2049,6 +2288,7 @@ private fun dashboardActionLabel(kind: DashboardActionKind): String = when (kind
DashboardActionKind.EnableMcp -> stringResource(R.string.dashboard_action_enable)
DashboardActionKind.DisableMcp -> stringResource(R.string.dashboard_action_disable)
DashboardActionKind.TestMcp -> stringResource(R.string.dashboard_action_test)
DashboardActionKind.AuthenticateMcp -> stringResource(R.string.dashboard_action_authenticate)
DashboardActionKind.RemoveMcp -> stringResource(R.string.dashboard_action_remove)
DashboardActionKind.ViewProfileSoul -> stringResource(R.string.dashboard_action_soul)
DashboardActionKind.EditProfileSoul -> stringResource(R.string.dashboard_action_edit_soul)
@@ -2058,6 +2298,10 @@ private fun dashboardActionLabel(kind: DashboardActionKind): String = when (kind
DashboardActionKind.EnableSkill -> stringResource(R.string.dashboard_action_enable)
DashboardActionKind.DisableSkill -> stringResource(R.string.dashboard_action_disable)
DashboardActionKind.DeleteProfile -> stringResource(R.string.dashboard_action_delete)
DashboardActionKind.EditCustomEndpoint -> stringResource(R.string.dashboard_action_edit)
DashboardActionKind.ValidateCustomEndpoint -> stringResource(R.string.dashboard_action_validate)
DashboardActionKind.ActivateCustomEndpoint -> stringResource(R.string.dashboard_action_use)
DashboardActionKind.DeleteCustomEndpoint -> stringResource(R.string.dashboard_action_delete)
}
/**
@@ -2078,6 +2322,7 @@ private fun dashboardActionLabel(context: android.content.Context, kind: Dashboa
DashboardActionKind.EnableMcp -> context.getString(R.string.dashboard_action_enable)
DashboardActionKind.DisableMcp -> context.getString(R.string.dashboard_action_disable)
DashboardActionKind.TestMcp -> context.getString(R.string.dashboard_action_test)
DashboardActionKind.AuthenticateMcp -> context.getString(R.string.dashboard_action_authenticate)
DashboardActionKind.RemoveMcp -> context.getString(R.string.dashboard_action_remove)
DashboardActionKind.ViewProfileSoul -> context.getString(R.string.dashboard_action_soul)
DashboardActionKind.EditProfileSoul -> context.getString(R.string.dashboard_action_edit_soul)
@@ -2087,6 +2332,10 @@ private fun dashboardActionLabel(context: android.content.Context, kind: Dashboa
DashboardActionKind.EnableSkill -> context.getString(R.string.dashboard_action_enable)
DashboardActionKind.DisableSkill -> context.getString(R.string.dashboard_action_disable)
DashboardActionKind.DeleteProfile -> context.getString(R.string.dashboard_action_delete)
DashboardActionKind.EditCustomEndpoint -> context.getString(R.string.dashboard_action_edit)
DashboardActionKind.ValidateCustomEndpoint -> context.getString(R.string.dashboard_action_validate)
DashboardActionKind.ActivateCustomEndpoint -> context.getString(R.string.dashboard_action_use)
DashboardActionKind.DeleteCustomEndpoint -> context.getString(R.string.dashboard_action_delete)
}
@OptIn(ExperimentalLayoutApi::class)
@@ -2564,6 +2813,10 @@ internal data class ModelProviderOption(
*/
internal fun parseModelOptions(root: JsonObject): List<ModelProviderOption> {
val providers = root["providers"] as? JsonArray ?: return emptyList()
val excludedProviders = (root["excluded_providers"] as? JsonArray).orEmpty()
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf(String::isNotBlank) }
.map { it.lowercase() }
.toSet()
return providers.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val id = obj.stringField("slug")
@@ -2571,6 +2824,10 @@ internal fun parseModelOptions(root: JsonObject): List<ModelProviderOption> {
?: obj.stringField("provider")
?: obj.stringField("name")
?: return@mapNotNull null
if (id.lowercase() in excludedProviders) return@mapNotNull null
if (obj.booleanField("enabled") == false || obj.booleanField("excluded") == true) {
return@mapNotNull null
}
val models = (obj["models"] as? JsonArray)?.mapNotNull { modelElement ->
when (modelElement) {
is JsonPrimitive -> modelElement.contentOrNull
@@ -3067,6 +3324,228 @@ private fun SkillsHubDialog(
}
}
@Composable
private fun McpOAuthDialog(
item: DashboardSummaryItem,
effectiveProfileName: String?,
pending: PendingMcpOAuth?,
currentRouteIdentity: String?,
clientFactory: () -> DashboardApiClient,
onPending: (PendingMcpOAuth) -> Unit,
onApproved: () -> Unit,
onFlowFailed: (String) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var running by remember(item, pending?.flowId) { mutableStateOf(pending != null) }
var message by remember(item) { mutableStateOf<String?>(null) }
LaunchedEffect(pending?.flowId, currentRouteIdentity) {
val flow = pending ?: return@LaunchedEffect
if (!canResumeMcpOAuth(flow, currentRouteIdentity)) return@LaunchedEffect
running = true
val result = try {
withDashboardClient(clientFactory) { client ->
McpOAuthFlowCoordinator(client).resume(flow.flowId)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
result.fold(
onSuccess = { onApproved() },
onFailure = { error ->
running = false
onFlowFailed(error.message ?: context.getString(R.string.dashboard_mcp_oauth_failed))
},
)
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.dashboard_mcp_oauth_title, item.title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.dashboard_mcp_oauth_body))
message?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (running) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
},
confirmButton = {
TextButton(
enabled = !running,
onClick = {
running = true
message = null
scope.launch {
val result = try {
withDashboardClient(clientFactory) { client ->
McpOAuthFlowCoordinator(client).start(
serverName = item.id.ifBlank { item.title },
profile = effectiveProfileName,
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
result.fold(
onSuccess = { started ->
if (started.status == "approved") {
onApproved()
return@fold
}
val authorizationUrl = McpOAuthFlowCoordinator.validatedAuthorizationUrl(started)
authorizationUrl.fold(
onSuccess = authorization@{ url ->
val serverName = started.serverName.ifBlank { item.id.ifBlank { item.title } }
val routeIdentity = currentRouteIdentity
if (routeIdentity == null) {
running = false
message = context.getString(R.string.dashboard_mcp_oauth_failed)
return@authorization
}
onPending(
PendingMcpOAuth(
started.flowId,
serverName,
effectiveProfileName,
routeIdentity,
),
)
val opened = runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}.isSuccess
if (!opened) {
onFlowFailed(context.getString(R.string.dashboard_mcp_oauth_no_browser))
}
},
onFailure = { error ->
running = false
message = error.message ?: context.getString(R.string.dashboard_mcp_oauth_failed)
},
)
},
onFailure = { error ->
running = false
message = error.message ?: context.getString(R.string.dashboard_mcp_oauth_failed)
},
)
}
},
) { Text(stringResource(R.string.dashboard_action_authenticate)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dashboard_cancel)) }
},
)
}
@Composable
private fun CustomEndpointDialog(
existing: DashboardSummaryItem?,
clientFactory: () -> DashboardApiClient,
onSaved: () -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var name by remember(existing) { mutableStateOf(existing?.title.orEmpty()) }
var baseUrl by remember(existing) { mutableStateOf(existing?.subtitle.orEmpty()) }
var model by remember(existing) {
mutableStateOf(existing?.meta?.substringAfter("model=", "")?.substringBefore(" · ").orEmpty())
}
var apiKey by remember(existing) { mutableStateOf("") }
var contextLength by remember(existing) {
mutableStateOf(existing?.meta?.substringAfter("context=", "")?.substringBefore(" · ").orEmpty())
}
var discoverModels by remember(existing) {
mutableStateOf(existing?.meta?.contains("discover=off") != true)
}
var busy by remember(existing) { mutableStateOf(false) }
var message by remember(existing) { mutableStateOf<String?>(null) }
fun draft() = DashboardCustomEndpointDraft(
id = existing?.id,
name = name.trim(),
baseUrl = baseUrl.trim(),
model = model.trim(),
apiKey = apiKey.takeIf { it.isNotBlank() },
contextLength = contextLength.toIntOrNull(),
discoverModels = discoverModels,
)
AlertDialog(
onDismissRequest = { if (!busy) onDismiss() },
title = { Text(stringResource(if (existing == null) R.string.dashboard_custom_endpoint_add else R.string.dashboard_custom_endpoint_edit)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(name, { name = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_name)) }, enabled = !busy)
OutlinedTextField(baseUrl, { baseUrl = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_url)) }, enabled = !busy)
OutlinedTextField(model, { model = it }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_model)) }, enabled = !busy)
OutlinedTextField(
apiKey,
{ apiKey = it },
label = { Text(stringResource(R.string.dashboard_custom_endpoint_key)) },
supportingText = { Text(stringResource(R.string.dashboard_custom_endpoint_key_help)) },
visualTransformation = PasswordVisualTransformation(),
enabled = !busy,
)
OutlinedTextField(contextLength, { contextLength = it.filter(Char::isDigit) }, label = { Text(stringResource(R.string.dashboard_custom_endpoint_context)) }, enabled = !busy)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(discoverModels, { discoverModels = it }, enabled = !busy)
Text(stringResource(R.string.dashboard_custom_endpoint_discover))
}
message?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
if (busy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
},
confirmButton = {
Row {
TextButton(
enabled = !busy && baseUrl.isNotBlank(),
onClick = {
busy = true
scope.launch {
val result = withDashboardClient(clientFactory) { it.validateCustomEndpoint(draft()) }
busy = false
message = result.fold(
onSuccess = { validation ->
validation.message.ifBlank {
context.getString(R.string.dashboard_custom_endpoint_valid, validation.models.size)
}
},
onFailure = { it.message ?: context.getString(R.string.dashboard_custom_endpoint_validate_failed) },
)
}
},
) { Text(stringResource(R.string.dashboard_action_validate)) }
TextButton(
enabled = !busy && name.isNotBlank() && baseUrl.isNotBlank() && model.isNotBlank(),
onClick = {
busy = true
scope.launch {
val result = withDashboardClient(clientFactory) { it.saveCustomEndpoint(draft()) }
result.fold(onSuccess = { onSaved() }, onFailure = {
busy = false
message = it.message ?: context.getString(R.string.dashboard_custom_endpoint_save_failed)
})
}
},
) { Text(stringResource(R.string.dashboard_save)) }
}
},
dismissButton = { TextButton(onClick = onDismiss, enabled = !busy) { Text(stringResource(R.string.dashboard_cancel)) } },
)
}
/**
* Full-file SOUL.md editor. The dashboard GET returns the complete file (no
* truncation), so saving the edited buffer back is a lossless round-trip.
@@ -3151,6 +3630,7 @@ private fun summarize(
DashboardManagementSection.Catalog -> root.arrayItems("entries", "catalog", "items")
?.mapIndexed { index, item -> summarizeObjectItem(item, "Catalog ${index + 1}") }
?: emptyList()
DashboardManagementSection.CustomEndpoints -> summarizeCustomEndpoints(root)
DashboardManagementSection.Profiles -> {
root.arrayItems("profiles", "items")
?.mapIndexed { index, item -> summarizeObjectItem(item, "Profile ${index + 1}") }
@@ -3252,7 +3732,7 @@ private fun summarizeObjectItem(
)
}
private fun dashboardActionsFor(obj: JsonObject): List<DashboardItemAction> {
internal fun dashboardActionsFor(obj: JsonObject): List<DashboardItemAction> {
val hasSchedule = obj["schedule"] != null || obj["cron"] != null || obj.stringField("next_run") != null
val hasTransport = obj.stringField("transport") != null ||
obj.stringField("command") != null ||
@@ -3290,6 +3770,9 @@ private fun dashboardActionsFor(obj: JsonObject): List<DashboardItemAction> {
} else {
add(DashboardItemAction("Disable", DashboardActionKind.DisableMcp))
}
if (obj.stringField("auth") == "oauth") {
add(DashboardItemAction("Authenticate", DashboardActionKind.AuthenticateMcp))
}
add(DashboardItemAction("Test", DashboardActionKind.TestMcp))
add(DashboardItemAction("Remove", DashboardActionKind.RemoveMcp, destructive = true))
}
@@ -3329,6 +3812,31 @@ private fun dashboardActionsFor(obj: JsonObject): List<DashboardItemAction> {
}
}
internal fun summarizeCustomEndpoints(root: JsonElement): List<DashboardSummaryItem> =
root.arrayItems("endpoints")?.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
val id = obj.stringField("id") ?: return@mapNotNull null
val model = obj.stringField("model").orEmpty()
DashboardSummaryItem(
id = id,
title = obj.stringField("name") ?: id,
subtitle = obj.stringField("base_url"),
meta = listOfNotNull(
model.takeIf { it.isNotBlank() }?.let { "model=$it" },
obj.booleanField("is_current")?.takeIf { it }?.let { "active" },
obj.booleanField("has_api_key")?.takeIf { it }?.let { "credential set" },
obj.intField("context_length")?.let { "context=$it" },
obj.booleanField("discover_models")?.takeIf { !it }?.let { "discover=off" },
).joinToString(" · ").takeIf { it.isNotBlank() },
actions = listOf(
DashboardItemAction("Edit", DashboardActionKind.EditCustomEndpoint),
DashboardItemAction("Validate", DashboardActionKind.ValidateCustomEndpoint),
DashboardItemAction("Use", DashboardActionKind.ActivateCustomEndpoint, destructive = true),
DashboardItemAction("Delete", DashboardActionKind.DeleteCustomEndpoint, destructive = true),
),
)
} ?: emptyList()
private fun summarizeRoot(root: JsonElement): String {
return when (root) {
is JsonObject -> {
@@ -3382,7 +3890,8 @@ private fun DashboardSummaryItem.optimisticAfter(action: DashboardItemAction): D
)
DashboardActionKind.DeleteCron,
DashboardActionKind.RemoveMcp,
DashboardActionKind.DeleteProfile -> null
DashboardActionKind.DeleteProfile,
DashboardActionKind.DeleteCustomEndpoint -> null
DashboardActionKind.InstallMcpCatalog -> copy(
meta = appendMeta(meta, "installed"),
actions = emptyList(),
@@ -3394,6 +3903,7 @@ private fun DashboardSummaryItem.optimisticAfter(action: DashboardItemAction): D
DashboardActionKind.ViewCronRuns,
DashboardActionKind.TriggerCron,
DashboardActionKind.TestMcp,
DashboardActionKind.AuthenticateMcp,
DashboardActionKind.ViewProfileSoul,
DashboardActionKind.ActivateProfile,
DashboardActionKind.SetEnvKey,
@@ -3401,6 +3911,9 @@ private fun DashboardSummaryItem.optimisticAfter(action: DashboardItemAction): D
DashboardActionKind.EditProfileDescription,
DashboardActionKind.SetProfileModel,
DashboardActionKind.EditProfileSoul -> this
DashboardActionKind.EditCustomEndpoint,
DashboardActionKind.ValidateCustomEndpoint,
DashboardActionKind.ActivateCustomEndpoint -> this
}
}
@@ -3513,14 +4026,19 @@ private suspend fun DashboardApiClient.runDashboardAction(
DashboardActionKind.ResumeCron -> resumeCronJob(id, profile = item.profile)
DashboardActionKind.TriggerCron -> triggerCronJob(id, profile = item.profile)
DashboardActionKind.DeleteCron -> deleteCronJob(id, profile = item.profile)
DashboardActionKind.EnableMcp -> setMcpServerEnabled(id, enabled = true)
DashboardActionKind.DisableMcp -> setMcpServerEnabled(id, enabled = false)
DashboardActionKind.TestMcp -> testMcpServer(id)
DashboardActionKind.RemoveMcp -> removeMcpServer(id)
DashboardActionKind.InstallMcpCatalog -> installMcpCatalogEntry(id)
DashboardActionKind.EnableMcp -> setMcpServerEnabled(id, enabled = true, profile = item.profile)
DashboardActionKind.DisableMcp -> setMcpServerEnabled(id, enabled = false, profile = item.profile)
DashboardActionKind.TestMcp -> testMcpServer(id, profile = item.profile)
DashboardActionKind.AuthenticateMcp ->
Result.failure(IllegalStateException("Authenticate requires hosted OAuth flow"))
DashboardActionKind.RemoveMcp -> removeMcpServer(id, profile = item.profile)
DashboardActionKind.InstallMcpCatalog -> installMcpCatalogEntry(id, profile = item.profile)
DashboardActionKind.ViewProfileSoul -> getProfileSoul(id)
DashboardActionKind.ActivateProfile -> setActiveProfile(id)
DashboardActionKind.DeleteProfile -> deleteProfile(id)
DashboardActionKind.ActivateCustomEndpoint -> activateCustomEndpoint(id)
DashboardActionKind.DeleteCustomEndpoint ->
deleteCustomEndpoint(id).map { JsonObject(emptyMap()) }
DashboardActionKind.RevealEnvKey -> revealEnvVar(id)
DashboardActionKind.ClearEnvKey -> deleteEnvVar(id)
// Input-backed kinds are intercepted at the onAction layer and routed
@@ -3530,6 +4048,9 @@ private suspend fun DashboardApiClient.runDashboardAction(
DashboardActionKind.SetProfileModel,
DashboardActionKind.EditProfileSoul ->
Result.failure(IllegalStateException("${action.label} requires input"))
DashboardActionKind.EditCustomEndpoint,
DashboardActionKind.ValidateCustomEndpoint ->
Result.failure(IllegalStateException("${action.label} requires input"))
}
}
@@ -81,6 +81,7 @@ fun DiagnosticsScreen(
val relayInfo by connectionViewModel.relayInfo.collectAsState()
val effectiveSessionProfileName by
connectionViewModel.effectiveSessionProfileName.collectAsState()
val toolsets by connectionViewModel.toolsetInventory.collectAsState()
val checkedAt by connectionViewModel.diagnosticsCheckedAt.collectAsState()
val refreshing by connectionViewModel.diagnosticsRefreshing.collectAsState()
val voiceReady by connectionViewModel.voiceReady.collectAsState()
@@ -182,6 +183,18 @@ fun DiagnosticsScreen(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
info.gatewayHeartbeat?.let { heartbeat ->
val detail = heartbeat.ageSeconds?.let { " · ${it}s" }.orEmpty()
Text(
text = stringResource(R.string.diag_gateway_heartbeat, heartbeat.status, detail),
style = MaterialTheme.typography.bodySmall,
color = if (heartbeat.status in setOf("stale", "malformed", "pid_mismatch", "start_mismatch")) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
val profileKey = effectiveSessionProfileName ?: "(default)"
val profileState = info.profiles.firstOrNull { it.name == profileKey }?.relayState
?: stringResource(R.string.diag_check_not_checked)
@@ -191,6 +204,22 @@ fun DiagnosticsScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
toolsets?.let { inventory ->
val enabled = inventory.count { it.enabled }
val relayVisible = inventory.any { item ->
item.tools.any { it.startsWith("relay_") || it.startsWith("android_") }
}
Text(
text = stringResource(
R.string.diag_toolsets_inventory,
enabled,
inventory.size,
if (relayVisible) stringResource(R.string.diag_yes) else stringResource(R.string.diag_no),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
StatusCheckTimeline(
checks = checks,
File diff suppressed because it is too large Load Diff
@@ -227,6 +227,18 @@ private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?
val msg = t.message.orEmpty().lowercase()
// Upstream rejects new API work with this stable code while an intentional
// shutdown/external drain is in progress. Keep it distinct from provider
// 503s: the server is healthy and will accept work after the drain clears.
if (msg.startsWith("api error 503: gateway_draining:")) {
return HumanError(
title = "Hermes is restarting",
body = "Hermes is draining active work. Wait a moment, then retry this message.",
retryable = true,
actionLabel = ctx?.getString(R.string.error_classify_retry) ?: "Retry",
)
}
// Typed exceptions are checked first because an IOException message scan
// would otherwise swallow SSL/timeout/connect errors whose messages
// happen to contain HTTP-ish substrings. Only fall through to the
@@ -46,6 +46,7 @@ import com.hermesandroid.relay.network.upstream.GatewayAskExpiry
import com.hermesandroid.relay.network.upstream.GatewayAskResponse
import com.hermesandroid.relay.network.upstream.GatewayBackgroundTurnCompletion
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.GatewayCompressResult
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.network.upstream.GatewayInboundTurnRegistration
import com.hermesandroid.relay.network.upstream.GatewayModelProvider
@@ -57,6 +58,7 @@ import com.hermesandroid.relay.network.upstream.GatewayAttachment
import com.hermesandroid.relay.network.upstream.GatewayRpcException
import com.hermesandroid.relay.network.upstream.GatewayTurnCallbacks
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.network.upstream.ApiModelOption
import com.hermesandroid.relay.network.relay.ProactiveMessage
import com.hermesandroid.relay.network.relay.RelayHttpClient
import com.hermesandroid.relay.network.relay.RealtimeVoiceEvent
@@ -105,6 +107,7 @@ import kotlinx.serialization.json.contentOrNull
import okhttp3.sse.EventSource
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
@@ -197,6 +200,12 @@ class ChatViewModel : ViewModel() {
val startedAt: Long,
)
private data class QueuedRecoveryHandoff(
val checkpoint: ChatTurnCheckpoint,
val completedHistory: List<MessageItem>,
val queuedUserText: String,
)
/** Test seam for the recovery poll cadence — production uses the defaults. */
internal var recoveryTimingOverride: ChatStreamRecovery.Timing? = null
@@ -213,6 +222,7 @@ class ChatViewModel : ViewModel() {
private var backgroundProcessSessionJob: Job? = null
private var connectionSwitchJob: Job? = null
private var sessionRefreshJob: Job? = null
private var imageActivityJob: Job? = null
private val historyLoadGeneration = AtomicInteger(0)
private val sessionRefreshGeneration = AtomicInteger(0)
private val realtimeAgentUserMessages = mutableMapOf<String, String>()
@@ -465,6 +475,8 @@ class ChatViewModel : ViewModel() {
/** Flat model ids from `GET /v1/models` (SSE fallback source; gateway uses [modelProviders]). */
private val _availableModels = MutableStateFlow<List<String>>(emptyList())
val availableModels: StateFlow<List<String>> = _availableModels.asStateFlow()
private val _apiModelOptions = MutableStateFlow<List<ApiModelOption>>(emptyList())
val apiModelOptions: StateFlow<List<ApiModelOption>> = _apiModelOptions.asStateFlow()
/**
* Curated provider→model groups from the gateway `model.options` RPC — the
@@ -600,7 +612,11 @@ class ChatViewModel : ViewModel() {
fun fetchModels() {
val client = apiClient ?: return
viewModelScope.launch { _availableModels.value = client.getModels() }
viewModelScope.launch {
val options = client.getModelOptions()
_apiModelOptions.value = options
_availableModels.value = options.map { it.id }
}
}
/**
@@ -720,6 +736,13 @@ class ChatViewModel : ViewModel() {
refreshActiveAgentName()
}
/** Select a `/v1/models` request id verbatim, including route aliases. */
fun selectApiModel(modelId: String) {
_selectedModelOverride.value = modelId.trim().takeIf { it.isNotEmpty() }
_selectedProviderOverride.value = null
refreshActiveAgentName()
}
/**
* Switch the gateway reasoning effort for this session through `config.set`.
* The chip owns optimistic UI; failures are surfaced in chat.
@@ -1085,10 +1108,11 @@ class ChatViewModel : ViewModel() {
val model = _selectedModelOverride.value?.takeIf { it.isNotBlank() }
val provider = _selectedProviderOverride.value?.takeIf { it.isNotBlank() }
val effort = _selectedReasoningEffort.value?.takeIf { it.isNotBlank() }
// Only pin fast when explicitly ON (upstream treats fast=false the
// same as omitting → profile default tier), so a null/false leaves
// the profile's own service tier intact.
val fast = _fastEnabled.value?.takeIf { it }
// Contract v4 distinguishes all three states: null omits the field
// and inherits the profile tier, true pins priority, and false pins
// normal. Do not filter false here or a user's explicit Fast-off
// pick would silently inherit a priority-by-default profile.
val fast = _fastEnabled.value
if (model != null || effort != null || fast != null) {
GatewaySessionModel(
model = model,
@@ -1195,6 +1219,7 @@ class ChatViewModel : ViewModel() {
private fun createGatewayInboundTurnRegistration(
client: GatewayChatClient,
storedSessionId: String,
queuedRecovery: QueuedRecoveryHandoff? = null,
): GatewayInboundTurnRegistration? {
val handler = chatHandler ?: return null
fun matchesAdmissionContext(): Boolean =
@@ -1204,6 +1229,7 @@ class ChatViewModel : ViewModel() {
handler.currentSessionId.value == storedSessionId
val messageId = "gateway-inbound-${UUID.randomUUID()}"
val queuedUserMessageId = "gateway-queued-user-${UUID.randomUUID()}"
var baselineAssistantCount = 0
var started = false
var accepted = false
@@ -1356,7 +1382,26 @@ class ChatViewModel : ViewModel() {
return GatewayInboundTurnRegistration(
callbacks = callbacks,
onHandle = { handle ->
if (matchesAdmissionContext() && activeStream == null && !handler.isStreaming.value) {
val replaceRecoveredCheckpoint = queuedRecovery != null && handler.isStreaming.value
val canReplaceRecoveredCheckpoint = queuedRecovery != null && activeStream == null
if (matchesAdmissionContext() && activeStream == null &&
(!handler.isStreaming.value || canReplaceRecoveredCheckpoint)
) {
queuedRecovery?.takeIf { replaceRecoveredCheckpoint }?.let { handoff ->
if (handoff.completedHistory.isNotEmpty()) {
handler.loadMessageHistory(handoff.completedHistory)
}
val durableAssistantCount = handoff.completedHistory.count {
it.role.equals("assistant", ignoreCase = true)
}
if (durableAssistantCount <= handoff.checkpoint.baselineAssistantCount) {
// Persistence can trail the resume snapshot. Preserve
// the prior checkpoint as a completed partial turn
// instead of deleting it or appending the next answer.
handler.restoreInFlightTurn(handoff.checkpoint)
}
finalizeTurnSideEffects(handler, handoff.checkpoint.assistant.id)
}
// Admission and its transcript baseline are both captured
// on the callback/main dispatcher. Reading activeStream or
// messages on the WebSocket thread can observe a local
@@ -1369,6 +1414,45 @@ class ChatViewModel : ViewModel() {
activeStream = handle
activeStreamIsGateway = true
_steerableTurn.value = true
queuedRecovery?.let { handoff ->
val now = System.currentTimeMillis()
handler.activeAgentName = currentAgentDisplayName()
handler.addUserMessage(
ChatMessage(
id = queuedUserMessageId,
role = MessageRole.USER,
content = handoff.queuedUserText,
timestamp = now,
),
)
handler.setLastSentMessage(handoff.queuedUserText)
handler.addPlaceholderMessage(
ChatMessage(
id = messageId,
role = MessageRole.ASSISTANT,
content = "",
timestamp = now + 1L,
isStreaming = true,
agentName = handler.activeAgentName,
),
)
started = true
beginTurnCheckpoint(
handler = handler,
sessionId = storedSessionId,
transport = "gateway",
userMessageId = queuedUserMessageId,
userText = handoff.queuedUserText,
assistantMessageId = messageId,
assistantTimestamp = now + 1L,
)
activeTurnCheckpointSeed?.liveSessionId =
client.currentLiveSessionId(storedSessionId)
handler.setTurnStatus(
"Reconnected — queued: “${queuedPromptPreview(handoff.queuedUserText)}”",
)
scheduleCheckpointWrite(immediate = true)
}
true
} else {
false
@@ -1575,14 +1659,14 @@ class ChatViewModel : ViewModel() {
/**
* True while the in-flight turn is actually running on the gateway
* transport (not an SSE fallback) — the only state in which
* `session.steer` can land. Drives the STEER trailing slot.
* `session.redirect` can land. Drives the correction trailing slot.
*/
private val _steerableTurn = MutableStateFlow(false)
val steerableTurn: StateFlow<Boolean> = _steerableTurn.asStateFlow()
/**
* One-line caption feedback after a steer attempt fell back to the
* queue ("Queued — delivers after this turn"). Cleared at turn end.
* One-line caption feedback after an active-turn correction fell back to the
* queue ("Queued — sends after this turn"). Cleared at turn end.
*/
private val _steerNotice = MutableStateFlow<String?>(null)
val steerNotice: StateFlow<String?> = _steerNotice.asStateFlow()
@@ -1677,6 +1761,9 @@ class ChatViewModel : ViewModel() {
}
private var selectedProfileProvider: () -> Profile? = { null }
private var isolatedProfileApiProvider: () -> Boolean = {
selectedProfileProvider()?.hasIsolatedApi == true
}
private var sessionProfileNameProvider: () -> String? = {
AgentDisplay.profileRequestName(selectedProfileProvider()?.name)
}
@@ -1708,6 +1795,10 @@ class ChatViewModel : ViewModel() {
refreshActiveAgentName()
}
fun setIsolatedProfileApiProvider(provider: () -> Boolean) {
isolatedProfileApiProvider = provider
}
/**
* Supplies the single effective namespace for Gateway session.create/resume.
* This is separate from [selectedProfileProvider] because a null selection
@@ -1878,6 +1969,9 @@ class ChatViewModel : ViewModel() {
*/
private var lastSurfacedCredentialWarning: String? = null
private val _gatewayProjectName = MutableStateFlow<String?>(null)
val gatewayProjectName: StateFlow<String?> = _gatewayProjectName.asStateFlow()
/**
* Track the active client's `session.info`-derived state (personality, model,
* provider, reasoning effort, credential warning, YOLO, fast) so a change made
@@ -1960,6 +2054,12 @@ class ChatViewModel : ViewModel() {
}
}
}
launch {
client.serverProject.collect { project ->
if (gatewayClient !== client) return@collect
_gatewayProjectName.value = project?.name
}
}
}
}
@@ -2545,6 +2645,7 @@ class ChatViewModel : ViewModel() {
_defaultPersonality.value = ""
personalityPrompts = emptyMap()
_availableModels.value = emptyList()
_apiModelOptions.value = emptyList()
_serverCommands.value = emptyList()
_modelProviders.value = emptyList()
_gatewayCurrentModel.value = ""
@@ -2669,7 +2770,7 @@ class ChatViewModel : ViewModel() {
viewModelScope.launch {
val selectedProfile = selectedProfileProvider()
val useIsolatedProfileApi = selectedProfile?.hasIsolatedApi == true
val useIsolatedProfileApi = isolatedProfileApiProvider()
client.createSessionResult(
profileName = if (useIsolatedProfileApi) null else selectedProfile?.name,
model = if (useIsolatedProfileApi) {
@@ -2961,7 +3062,7 @@ class ChatViewModel : ViewModel() {
// slash.exec / command.dispatch instead of becoming a prompt.
if (activeStream == null && maybeHandleServerSlashCommand(text.trim())) return
// Mid-turn: steer on the gateway transport, queue everywhere else.
// Mid-turn: redirect on the gateway transport, queue everywhere else.
if (activeStream != null) {
if (_steerableTurn.value && streamingEndpoint == "gateway") {
steerActiveTurn(text.trim())
@@ -2975,12 +3076,10 @@ class ChatViewModel : ViewModel() {
}
/**
* Inject [text] into the in-flight gateway turn via `session.steer`.
* Accepted steers land in the next tool batch's last result — the
* server records them inside a tool result, NOT as a user message, so
* we add a local `steer-` bubble that [ChatHandler.loadMessageHistory]
* preserves. Rejected/failed steers fall back to the existing queue
* with a one-line caption so the send never silently vanishes.
* Correct the in-flight gateway turn via `session.redirect`, falling back
* to legacy `session.steer` only on older gateways. Current upstream records
* a redirect as durable session state; the local echo is ordinary so the
* post-turn history reconcile can replace it with the server-owned row.
*/
private fun steerActiveTurn(text: String) {
val handler = chatHandler ?: return
@@ -2994,23 +3093,19 @@ class ChatViewModel : ViewModel() {
SteerResult.Queued -> {
handler.addUserMessage(
ChatMessage(
id = "steer-${UUID.randomUUID()}",
id = "redirect-${UUID.randomUUID()}",
role = MessageRole.USER,
content = text,
timestamp = System.currentTimeMillis(),
// Steered text lands in a server-side tool result,
// never as a user message, so this echo has no server
// row — keep it across the post-turn reload.
clientOnly = true,
)
)
}
SteerResult.Rejected, SteerResult.Failed -> {
if (activeStream != null) {
_queuedMessages.update { it + text }
_steerNotice.value = "Queued — delivers after this turn"
_steerNotice.value = "Queued — sends after this turn"
} else {
// Turn ended while the steer RPC was in flight —
// Turn ended while the correction RPC was in flight —
// send it as a normal next-turn prompt instead.
val client = apiClient
if (gatewayClient != null || client != null) {
@@ -3500,6 +3595,14 @@ class ChatViewModel : ViewModel() {
return true
}
if (normalizedName == "compress" || normalizedName == "compact") {
val focusTopic = text.substringAfter(' ', "").trim().takeIf { it.isNotBlank() }
viewModelScope.launch {
runServerCompressCommand(gateway, handler, focusTopic)
}
return true
}
viewModelScope.launch {
val commands = currentOrRefreshedServerCommands(gateway, handler) ?: return@launch
val known = commands.any {
@@ -3530,6 +3633,68 @@ class ChatViewModel : ViewModel() {
return parsed
}
private suspend fun runServerCompressCommand(
gateway: GatewayChatClient,
handler: ChatHandler,
focusTopic: String?,
) {
val invokedSessionId = handler.currentSessionId.value
handler.addSystemNotice("Compressing conversation context…")
gateway.compressSession(focusTopic).fold(
onSuccess = { result ->
if (
invokedSessionId != null &&
handler.currentSessionId.value == invokedSessionId &&
result.isAuthoritative
) {
handler.loadMessageHistory(result.messages)
}
result.effectiveUsage?.let(::applyCompressUsage)
invokedSessionId?.let { sessionId ->
result.title?.let { title -> handler.renameSessionLocal(sessionId, title) }
}
val summary = when (result.status.lowercase()) {
"aborted" -> "Compression aborted."
"noop", "no_op" -> "Nothing to compress."
"legacy" -> result.output ?: "Compression command sent through legacy slash support."
else -> result.output ?: compressionSummary(result)
}
if (summary.isNotBlank()) handler.addSystemNotice(summary)
refreshSessions()
},
onFailure = { e ->
handler.addSystemNotice("/compress failed: ${e.message ?: "unknown error"}")
},
)
}
private fun compressionSummary(result: GatewayCompressResult): String {
val before = result.beforeMessages
val after = result.afterMessages
val removed = result.removed
return when {
before != null && after != null ->
"Context compressed — messages $before → $after."
removed != null && removed > 0 ->
"Context compressed — removed $removed messages."
else -> "Context compressed."
}
}
private fun applyCompressUsage(usage: UsageInfo) {
val ctxMax = usage.contextMax
if (ctxMax != null && ctxMax > 0) {
usage.contextUsed?.let { used ->
_contextUsage.value = (used.toFloat() / ctxMax).coerceIn(0f, 1f)
_contextWindow.value = ContextWindowUsage(usedTokens = used, maxTokens = ctxMax)
return
}
usage.contextPercent?.let { percent ->
_contextUsage.value = (percent / 100f).coerceIn(0f, 1f)
}
}
}
/**
* Upstream dispatch order: `slash.exec` first; error 4018 (pending-input
* / skill / blocked command) falls through to `command.dispatch`, whose
@@ -4001,29 +4166,60 @@ class ChatViewModel : ViewModel() {
)
if (checkpoint.transport == "gateway" && client != null) {
val callbacks = recoveredGatewayCallbacks(handler, checkpoint)
val queuedSuccessorPending = AtomicBoolean(false)
val callbacks = recoveredGatewayCallbacks(handler, checkpoint, queuedSuccessorPending)
val recovery = client.recoverTurn(
storedId = sessionId,
preferredLiveId = checkpoint.liveSessionId,
callbacks = callbacks,
queuedTurnProvider = { queued ->
queuedSuccessorPending.set(true)
createGatewayInboundTurnRegistration(
client = client,
storedSessionId = sessionId,
queuedRecovery = QueuedRecoveryHandoff(
checkpoint = checkpoint,
completedHistory = initialHistory,
queuedUserText = queued.user,
),
)
},
).getOrNull()
if (recovery?.queued != null && !recovery.running && recovery.handle != null) {
val ownsQueuedHandoff =
chatHandler === handler &&
handler.currentSessionId.value == sessionId &&
activeProfileContextKey == contextKey &&
activeStream === recovery.handle
if (!ownsQueuedHandoff) recovery.handle.detach()
if (ownsQueuedHandoff) {
gatewayProcessController.sessionReady(sessionId)
}
return true
}
if (!ownsTurnCheckpoint(checkpoint, handler)) {
recovery?.handle?.detach()
return true
}
if (recovery?.running == true && recovery.handle != null) {
if (recovery?.hasPendingWork == true && recovery.handle != null) {
activeStream = recovery.handle
activeStreamIsGateway = true
activeTurnCheckpointSeed?.liveSessionId = recovery.liveSessionId
handler.restoreInFlightTurn(
checkpoint = checkpoint,
upstreamAssistantText = recovery.inflight?.assistant,
)
handler.setTurnStatus(
checkpoint.turnStatus?.takeIf { it.isNotBlank() }
?: "Reconnected — Hermes is still working…",
)
restorePendingAsk(handler, checkpoint)
if (recovery.running) {
handler.restoreInFlightTurn(
checkpoint = checkpoint,
upstreamAssistantText = recovery.inflight?.assistant,
)
handler.setTurnStatus(
if (recovery.queued != null) {
"Reconnected — Hermes is working · queued: “${queuedPromptPreview(recovery.queued.user)}”"
} else {
checkpoint.turnStatus?.takeIf { it.isNotBlank() }
?: "Reconnected — Hermes is still working…"
},
)
restorePendingAsk(handler, checkpoint)
}
scheduleCheckpointWrite(immediate = true)
gatewayProcessController.sessionReady(sessionId)
return true
@@ -4056,6 +4252,7 @@ class ChatViewModel : ViewModel() {
private fun recoveredGatewayCallbacks(
handler: ChatHandler,
checkpoint: ChatTurnCheckpoint,
queuedSuccessorPending: AtomicBoolean,
): GatewayTurnCallbacks {
val messageId = checkpoint.assistant.id
fun owns(): Boolean = ownsTurnCheckpoint(checkpoint, handler)
@@ -4103,15 +4300,17 @@ class ChatViewModel : ViewModel() {
onComplete = {
if (owns()) {
finalizeTurnSideEffects(handler, messageId)
val expectedSessionId = checkpoint.sessionId
viewModelScope.launch {
val history = loadSessionHistory(expectedSessionId)
if (handler.currentSessionId.value == expectedSessionId && history.isNotEmpty()) {
handler.loadMessageHistory(history)
refreshSessions()
scheduleTitleReconcile(expectedSessionId)
if (!queuedSuccessorPending.get()) {
val expectedSessionId = checkpoint.sessionId
viewModelScope.launch {
val history = loadSessionHistory(expectedSessionId)
if (handler.currentSessionId.value == expectedSessionId && history.isNotEmpty()) {
handler.loadMessageHistory(history)
refreshSessions()
scheduleTitleReconcile(expectedSessionId)
}
drainQueue()
}
drainQueue()
}
}
},
@@ -4128,11 +4327,21 @@ class ChatViewModel : ViewModel() {
},
onError = { error ->
if (owns()) {
activeStream = null
_steerableTurn.value = false
_steerNotice.value = null
val latest = buildTurnCheckpoint() ?: checkpoint
startCheckpointHistoryRecovery(handler, latest, error)
if (queuedSuccessorPending.get()) {
AppAnalytics.onStreamError()
handler.onStreamError(error)
clearTurnCheckpoint()
activeStream = null
_steerableTurn.value = false
_steerNotice.value = null
clearPendingAsk(approvalStamp = "Resolved")
} else {
activeStream = null
_steerableTurn.value = false
_steerNotice.value = null
val latest = buildTurnCheckpoint() ?: checkpoint
startCheckpointHistoryRecovery(handler, latest, error)
}
}
},
onToolGenerating = { name ->
@@ -4567,7 +4776,7 @@ class ChatViewModel : ViewModel() {
}
viewModelScope.launch {
val selectedProfile = selectedProfileProvider()
val useIsolatedProfileApi = selectedProfile?.hasIsolatedApi == true
val useIsolatedProfileApi = isolatedProfileApiProvider()
client.createSessionResult(
profileName = if (useIsolatedProfileApi) null else selectedProfile?.name,
model = if (useIsolatedProfileApi) {
@@ -5423,7 +5632,7 @@ class ChatViewModel : ViewModel() {
return composeInjectedContext(
interfaceContextPrompt = null,
selectedProfile = profile,
useIsolatedProfileApi = profile?.hasIsolatedApi == true,
useIsolatedProfileApi = isolatedProfileApiProvider(),
relayServerBlocks = relayBlocks,
)
}
@@ -5441,7 +5650,9 @@ class ChatViewModel : ViewModel() {
// Resolve the active profile pick once — used below for both
// modelOverride and the system_message precedence rule.
val selectedProfile = selectedProfileProvider()
val useIsolatedProfileApi = selectedProfile?.hasIsolatedApi == true
val useIsolatedProfileApi = isolatedProfileApiProvider()
val imageActivityProfile =
AgentDisplay.profileRequestName(selectedProfile?.name) ?: "default"
// The injected system_message (persona + phone-status + per-turn
// context) is built by composeInjectedContext so the chat-screen audit
@@ -5480,6 +5691,7 @@ class ChatViewModel : ViewModel() {
// Track the current message ID — starts with our generated ID,
// but updates when the server sends message.started with its own ID.
var currentMessageId = assistantMessageId
var gatewayInterimSealedCurrentMessage = false
// The SSE endpoint this turn actually dispatched on (null on a gateway
// dispatch) — set by dispatchSse below. onErrorCb keys the dropped-
@@ -5525,6 +5737,26 @@ class ChatViewModel : ViewModel() {
)
activeStreamDeltas = streamDeltas
fun ensurePostInterimMessage() {
if (!gatewayInterimSealedCurrentMessage) return
streamDeltas.flushNow()
val nextMessageId = UUID.randomUUID().toString()
currentMessageId = nextMessageId
gatewayInterimSealedCurrentMessage = false
handler.addPlaceholderMessage(
ChatMessage(
id = nextMessageId,
role = MessageRole.ASSISTANT,
content = "",
timestamp = System.currentTimeMillis(),
isStreaming = true,
agentName = handler.activeAgentName,
badges = if (interfaceContextPrompt != null) listOf("Voice") else emptyList(),
),
)
updateTurnCheckpointAssistantId(nextMessageId)
}
fun flushAndReleaseStreamDeltas() {
streamDeltas.flushNow()
if (activeStreamDeltas === streamDeltas) activeStreamDeltas = null
@@ -5541,6 +5773,7 @@ class ChatViewModel : ViewModel() {
updateTurnCheckpointAssistantId(serverMsgId)
}
val onTextDeltaCb = { delta: String ->
ensurePostInterimMessage()
if (!firstTokenNotified) {
firstTokenNotified = true
AppAnalytics.onFirstTokenReceived()
@@ -5548,19 +5781,44 @@ class ChatViewModel : ViewModel() {
streamDeltas.appendText(delta)
}
val onThinkingDeltaCb = { delta: String ->
ensurePostInterimMessage()
streamDeltas.appendThinking(delta)
}
val onToolCallStartCb = { toolCallId: String, toolName: String ->
val onInterimMessageCb = { text: String, alreadyStreamed: Boolean ->
streamDeltas.flushNow()
handler.onToolCallStart(currentMessageId, toolCallId, toolName)
if (!alreadyStreamed && text.isNotBlank()) {
handler.onTextDelta(currentMessageId, text)
}
handler.onTurnComplete(currentMessageId)
gatewayInterimSealedCurrentMessage = true
scheduleCheckpointWrite(immediate = true)
}
val observedImageToolStates = mutableMapOf<String, String>()
val onToolCallStartCb = { toolCallId: String, toolName: String ->
ensurePostInterimMessage()
streamDeltas.flushNow()
val alreadyObserved =
toolName == "image_generate" &&
observedImageToolStates.putIfAbsent(toolCallId, "running") != null
if (!alreadyObserved) {
handler.onToolCallStart(currentMessageId, toolCallId, toolName)
}
scheduleCheckpointWrite(immediate = true)
}
val onToolCallDoneCb = { toolCallId: String, resultPreview: String? ->
ensurePostInterimMessage()
streamDeltas.flushNow()
handler.onToolCallComplete(currentMessageId, toolCallId, resultPreview)
val alreadyCompleted = observedImageToolStates[toolCallId] == "completed"
if (observedImageToolStates.containsKey(toolCallId)) {
observedImageToolStates[toolCallId] = "completed"
}
if (!alreadyCompleted) {
handler.onToolCallComplete(currentMessageId, toolCallId, resultPreview)
}
scheduleCheckpointWrite(immediate = true)
}
val onToolCallFailedCb = { toolCallId: String, errorMsg: String? ->
ensurePostInterimMessage()
streamDeltas.flushNow()
handler.onToolCallFailed(currentMessageId, toolCallId, errorMsg)
scheduleCheckpointWrite(immediate = true)
@@ -5571,7 +5829,52 @@ class ChatViewModel : ViewModel() {
handler.onTurnComplete(currentMessageId)
scheduleCheckpointWrite(immediate = true)
}
fun stopImageActivityBridge() {
imageActivityJob?.cancel()
imageActivityJob = null
}
fun startImageActivityBridge() {
val relay = relayHttpClient ?: return
stopImageActivityBridge()
imageActivityJob = viewModelScope.launch {
var consecutiveErrors = 0
while (true) {
val activeSessionId = handler.currentSessionId.value
if (activeSessionId.isNullOrBlank()) {
delay(250)
continue
}
val result = relay.fetchImageActivity(
profile = imageActivityProfile,
sessionId = activeSessionId,
sinceEpochSeconds = (assistantTimestamp / 1000.0) - 1.0,
)
if (result.isFailure) {
consecutiveErrors += 1
if (consecutiveErrors >= 3) break
delay(1_000)
continue
}
consecutiveErrors = 0
val snapshot = result.getOrNull() ?: break
snapshot.activities.forEach { activity ->
val prior = observedImageToolStates[activity.callId]
if (prior == null) {
onToolCallStartCb(activity.callId, activity.toolName)
}
if (
activity.state == "completed" &&
observedImageToolStates[activity.callId] != "completed"
) {
onToolCallDoneCb(activity.callId, null)
}
}
delay(750)
}
}
}
val onCompleteCb = {
stopImageActivityBridge()
flushAndReleaseStreamDeltas()
// Double-finalize guard: if a straggler completion arrives while
// the answer-recovery poller is running, the normal completion
@@ -5682,6 +5985,7 @@ class ChatViewModel : ViewModel() {
}
}
val onErrorCb = { errorMsg: String ->
stopImageActivityBridge()
flushAndReleaseStreamDeltas()
val errorSessionId = handler.currentSessionId.value
if (intentionallyCancelled) {
@@ -6000,6 +6304,7 @@ class ChatViewModel : ViewModel() {
dispatchSse(resolveSseFallback(handler))
else -> {
startImageActivityBridge()
val isNewSession = handler.currentSessionId.value == null
val autoTitle = message.take(50).let { if (message.length > 50) "$it..." else it }
// The gateway carries NO phone-context preamble: prompt.submit
@@ -6040,11 +6345,13 @@ class ChatViewModel : ViewModel() {
},
onStart = { },
onTextDelta = onTextDeltaCb,
onInterimMessage = onInterimMessageCb,
onThinkingDelta = onThinkingDeltaCb,
onToolCallStart = onToolCallStartCb,
onToolCallDone = onToolCallDoneCb,
onToolCallFailed = onToolCallFailedCb,
onToolOutputRisk = { risk ->
ensurePostInterimMessage()
streamDeltas.flushNow()
handler.onToolOutputRisk(currentMessageId, risk)
scheduleCheckpointWrite(immediate = true)
@@ -6057,11 +6364,13 @@ class ChatViewModel : ViewModel() {
onUsage = onUsageCb,
onError = onErrorCb,
onToolGenerating = { name ->
ensurePostInterimMessage()
streamDeltas.flushNow()
handler.onToolGenerating(currentMessageId, name)
scheduleCheckpointWrite(immediate = true)
},
onSubagentEvent = { event ->
ensurePostInterimMessage()
streamDeltas.flushNow()
handler.onSubagentEvent(currentMessageId, event)
scheduleCheckpointWrite(immediate = true)
@@ -6188,6 +6497,8 @@ class ChatViewModel : ViewModel() {
activeStreamDeltas = null
activeStream?.cancel()
activeStream = null
imageActivityJob?.cancel()
imageActivityJob = null
_queuedMessages.value = emptyList()
_steerableTurn.value = false
_steerNotice.value = null
@@ -6720,6 +7031,8 @@ class ChatViewModel : ViewModel() {
}
override fun onCleared() {
imageActivityJob?.cancel()
imageActivityJob = null
activeStreamDeltas?.flushNow()
activeStreamDeltas = null
flushTurnCheckpointForTeardown()
@@ -6776,6 +7089,11 @@ class ChatViewModel : ViewModel() {
}
}
private fun queuedPromptPreview(prompt: String, maxChars: Int = 96): String {
val compact = prompt.replace(Regex("\\s+"), " ").trim()
return if (compact.length <= maxChars) compact else compact.take(maxChars - 1).trimEnd() + "…"
}
/** Production adapter for the independently-testable process controller. */
private class GatewayChatProcessSource(
private val client: GatewayChatClient,
@@ -24,12 +24,14 @@ import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.DataManager
import com.hermesandroid.relay.data.DemoContent
import com.hermesandroid.relay.data.DemoMode
import com.hermesandroid.relay.data.DashboardEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.data.displayLabel
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.MediaSettingsRepository
import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.data.RelayEndpoint
import com.hermesandroid.relay.data.primaryRouteUrl
import com.hermesandroid.relay.data.routeAuthority
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.capabilities
@@ -62,9 +64,11 @@ import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.DashboardChatDisplaySettings
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.SessionItem
import com.hermesandroid.relay.network.upstream.mirrorDashboardSessionCookies
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
import com.hermesandroid.relay.network.upstream.DashboardCookieStore
import com.hermesandroid.relay.network.upstream.DashboardStatus
import com.hermesandroid.relay.network.upstream.ToolsetInfo
import com.hermesandroid.relay.network.shared.EndpointResolver
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
@@ -92,6 +96,7 @@ import com.hermesandroid.relay.viewmodel.connection.PairingController
import com.hermesandroid.relay.viewmodel.connection.ProfileController
import com.hermesandroid.relay.viewmodel.connection.UpstreamTransportController
import okhttp3.OkHttpClient
import java.net.URI
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -100,12 +105,12 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.flatMapLatest
@@ -188,6 +193,29 @@ internal fun isChatTransportReady(
internal fun hasConfiguredHermesConnection(connection: Connection?): Boolean =
connection?.capabilities?.anySurfaceConfigured == true
/**
* Resolve the Dashboard/Gateway surface for the route the resolver selected.
*
* A saved dashboard URL describes the primary route; it must not pin every
* runtime route to that host. When a LAN connection has an explicit dashboard
* URL and the resolver selects Tailscale, keeping the saved LAN URL makes
* Gateway sessions, Manage, and standard voice all appear offline even though
* the selected Tailscale candidate is reachable.
*/
internal fun resolveEffectiveDashboardUrl(
connection: Connection?,
endpoint: EndpointCandidate?,
): String {
if (connection == null) return ""
endpoint?.dashboard?.url
?.takeIf { it.isNotBlank() }
?.let { return it }
endpoint?.api?.url
?.let(Connection::deriveDefaultDashboardUrl)
?.let { return it }
return connection.resolvedDashboardUrl
}
/**
* Add Relay transport metadata to the connection's existing standard routes
* without adopting the Relay QR's API/Dashboard identity.
@@ -195,12 +223,65 @@ internal fun hasConfiguredHermesConnection(connection: Connection?): Boolean =
internal fun mergeRelayTransportIntoStandardRoutes(
standardRoutes: List<EndpointCandidate>,
relayRoutes: List<EndpointCandidate>,
): List<EndpointCandidate> = standardRoutes.map { standard ->
val relay = relayRoutes
.firstOrNull { it.role.equals(standard.role, ignoreCase = true) }
?.relay
?: return@map standard
standard.copy(relay = relay)
): List<EndpointCandidate> {
if (standardRoutes.isEmpty()) {
return relayRoutes.sortedWith(
compareBy<EndpointCandidate> { it.priority }.thenBy { it.role },
)
}
val standardRoles = standardRoutes.map { it.role.lowercase() }.toSet()
val merged = standardRoutes.map { standard ->
val paired = relayRoutes
.firstOrNull { it.role.equals(standard.role, ignoreCase = true) }
?: return@map standard
val sameHost = standard.routeHost()?.equals(paired.routeHost(), ignoreCase = true) == true
// The Standard connection remains authoritative for any surface it
// already configured. A Relay QR fills missing surfaces and refreshes
// its own transport, so a manually-added dashboard-only Tailscale
// route gains the QR's same-host API fallback instead of remaining
// Gateway-only. Never graft a QR API from a different host.
standard.copy(
api = standard.api ?: paired.api.takeIf { sameHost },
dashboard = standard.dashboard ?: paired.dashboard.takeIf { sameHost },
relay = paired.relay ?: standard.relay,
proxy = standard.proxy ?: paired.proxy,
security = standard.security ?: paired.security,
recommended = standard.recommended || paired.recommended,
)
}
// Relay-only pairing used to map over Standard's existing roles and drop
// every unmatched QR role. The common LAN-only Standard + LAN/Tailscale QR
// therefore looked paired while it had no remote route. Preserve the full
// signed QR route set, appending roles Standard did not know yet.
val missingQrRoles = relayRoutes.filterNot {
it.role.lowercase() in standardRoles
}
return (merged + missingQrRoles)
.map(EndpointCandidate::withDerivedDashboard)
.distinctBy { "${it.role.lowercase()}|${it.routeAuthority()}" }
.sortedWith(compareBy<EndpointCandidate> { it.priority }.thenBy { it.role })
}
private fun EndpointCandidate.withDerivedDashboard(): EndpointCandidate {
if (dashboard != null) return this
val derived = api?.url
?.let(Connection::deriveDefaultDashboardUrl)
?.let(::DashboardEndpoint)
?: return this
return copy(dashboard = derived)
}
private fun EndpointCandidate.routeHost(): String? {
val raw = primaryRouteUrl() ?: return null
val normalized = when {
raw.startsWith("ws://", ignoreCase = true) ->
"http://${raw.substringAfter("://")}"
raw.startsWith("wss://", ignoreCase = true) ->
"https://${raw.substringAfter("://")}"
else -> raw
}
return runCatching { URI(normalized).host }.getOrNull()
}
/** Pure persistence policy used by Relay-only QR apply and its regression tests. */
@@ -631,12 +712,25 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
/** Currently-active endpoint, for the Endpoints card. */
val activeEndpoint = connectionManager.activeEndpoint
private fun activeRouteCandidatesSnapshot(): List<EndpointCandidate> {
private suspend fun activeRouteCandidatesSnapshot(): List<EndpointCandidate> {
val activeId = connectionStore.activeConnectionId.value ?: return emptyList()
return connectionStore.connections.value
val current = connectionStore.connections.value
.firstOrNull { it.id == activeId }
?.routeCandidates
.orEmpty()
?: return emptyList()
val deviceId = runCatching { authManager.getOrCreateDeviceId() }.getOrNull()
val pairedRoutes = deviceId?.let { id ->
runCatching {
PairingPreferences.getDeviceEndpoints(getApplication(), id).first()
}.getOrDefault(emptyList())
}.orEmpty()
val recovered = mergeRelayTransportIntoStandardRoutes(
standardRoutes = current.routeCandidates,
relayRoutes = pairedRoutes,
)
if (recovered != current.routeCandidates) {
connectionStore.updateConnection(current.copy(routeCandidates = recovered))
}
return recovered
}
private fun normalizeStandardRouteCandidates(
@@ -986,22 +1080,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}.stateIn(viewModelScope, SharingStarted.Eagerly, false)
/**
* Runtime dashboard route. Explicit dashboard overrides stay on the
* persisted connection, but auto-managed dashboard URLs follow the active
* API route so Manage can move from LAN to Tailscale with Chat.
* Runtime dashboard route. A selected endpoint's Dashboard/Gateway URL
* wins because it is part of that route just like API and Relay. When an
* older candidate has only API metadata, auto-managed dashboards can still
* derive the conventional Dashboard URL; otherwise the saved URL remains
* the fallback.
*/
val effectiveDashboardUrl: StateFlow<String> = combine(
activeConnection,
connectionManager.activeEndpoint,
) { connection, endpoint ->
when {
connection == null -> ""
endpoint != null &&
Connection.isAutoManagedDashboardUrl(connection.dashboardUrl, connection.apiServerUrl) ->
endpoint.dashboard?.url
?: endpoint.api?.url?.let(Connection::deriveDefaultDashboardUrl).orEmpty()
else -> connection.resolvedDashboardUrl
}
resolveEffectiveDashboardUrl(connection, endpoint)
}.stateIn(viewModelScope, SharingStarted.Eagerly, "")
/**
@@ -1056,7 +1145,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
* persisted URL. Dashboard session cookies are host-scoped, so a sign-in
* performed on the LAN host does not authenticate the Tailscale host —
* every dashboard-riding surface (Manage, standard voice) uses this to
* say *which* route it's on and why sign-in state didn't follow.
* say *which* route rejected the saved shared session.
*/
val dashboardRouteMovedHint: StateFlow<String?> = combine(
effectiveDashboardUrl,
@@ -1993,6 +2082,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
private val _relayInfo = MutableStateFlow<RelayHttpClient.RelayInfo?>(null)
val relayInfo: StateFlow<RelayHttpClient.RelayInfo?> = _relayInfo.asStateFlow()
private val _toolsetInventory = MutableStateFlow<List<ToolsetInfo>?>(null)
val toolsetInventory: StateFlow<List<ToolsetInfo>?> = _toolsetInventory.asStateFlow()
private val _diagnosticsCheckedAt = MutableStateFlow<Long?>(null)
val diagnosticsCheckedAt: StateFlow<Long?> = _diagnosticsCheckedAt.asStateFlow()
private val _diagnosticsRefreshing = MutableStateFlow(false)
@@ -2013,6 +2104,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
viewModelScope.launch {
val info = relayHttpClient.fetchRelayInfo().getOrNull()
_relayInfo.value = info
// Toolsets are profile-scoped upstream. Use the same routed client
// as Chat so Diagnostics cannot pair a selected profile's Relay
// state with the base/default profile's tool inventory.
_toolsetInventory.value = _chatApiClient.value?.getToolsets()?.getOrNull()
relayHttpClient.fetchUpdateCheck().onSuccess { _relayUpdateInfo.value = it }
_diagnosticsCheckedAt.value = System.currentTimeMillis()
_diagnosticsRefreshing.value = false
@@ -3799,6 +3894,24 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}
// Dashboard/Gateway can move independently of the optional API
// fallback (for example, a custom dashboard-only route). Do not carry
// the previous host's availability or cookie-backed status into the
// new route while its probe is in flight. collectLatest cancels an
// obsolete probe if the resolver changes routes again.
viewModelScope.launch {
effectiveDashboardUrl
.drop(1)
.distinctUntilChanged()
.collectLatest {
_standardVoiceAvailability.value = StandardVoiceAvailability.Unknown
_standardAudioApiReachable.value = false
_serverChatDisplaySettings.value = null
updateGatewayAvailability(GatewayAvailability.Unknown)
probeStandardVoice()
}
}
// Apply a route change that was deferred because a turn was streaming.
// (StateFlow already conflates/dedups, so no distinctUntilChanged.)
viewModelScope.launch {
@@ -4093,10 +4206,18 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
updateGatewayAvailability(GatewayAvailability.Unknown)
return
}
withContext(Dispatchers.IO) {
mirrorDashboardSessionCookies(
store = dashboardCookieStoreFor(connectionId),
targetUrl = dashboardUrl.orEmpty(),
trustedHosts = trustedDashboardHosts(connectionId),
)
}
val client = upstreamTransport.dashboardClientForActive(dashboardUrl)
try {
val status = client.getStatus().getOrNull()
if (status == null) {
updateDashboardTopology(connectionId, null)
_standardVoiceAvailability.value = StandardVoiceAvailability.Unreachable
_standardAudioApiReachable.value = false
_serverChatDisplaySettings.value = null
@@ -4104,6 +4225,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
recordDashboardStatusIfChanged(connectionId, status = null, session = null)
return
}
updateDashboardTopology(connectionId, status)
val session = if (status.authRequired) client.currentSession().getOrNull() else null
val authed = !status.authRequired || session?.authenticated == true
recordDashboardStatusIfChanged(connectionId, status, session)
@@ -4125,8 +4247,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Warning,
title = ctx.getString(R.string.conn_status_voice_signin),
detail = "Dashboard sessions are per-host — a sign-in from another " +
"route does not carry over; sign in once via Manage on this one",
detail = "The encrypted dashboard session could not be reused on this " +
"trusted route; open Manage to refresh the session",
url = dashboardUrl,
)
}
@@ -4147,6 +4269,60 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}
private fun trustedDashboardHosts(connectionId: String): Set<String> {
val connection = connectionStore.connections.value
.firstOrNull { it.id == connectionId }
?: return emptySet()
val urls = buildList {
connection.dashboardUrl?.let(::add)
Connection.deriveDefaultDashboardUrl(connection.apiServerUrl)
?.takeIf { it.isNotBlank() }
?.let(::add)
connection.routeCandidates.forEach { candidate ->
candidate.dashboard?.url?.let(::add)
candidate.api?.url
?.let(Connection::deriveDefaultDashboardUrl)
?.takeIf { it.isNotBlank() }
?.let(::add)
}
}
return urls.mapNotNullTo(mutableSetOf()) { url ->
runCatching { URI(url).host?.lowercase() }.getOrNull()
}
}
private var topologyConnectionId: String? = null
private var topologyGatewayMode: String? = null
private var topologyProfiles: List<String> = emptyList()
fun selectedProfileUsesIsolatedApiRoute(): Boolean {
val profile = profileController.selectedProfile.value ?: return false
if (profile.hasIsolatedApi) return true
val activeConnectionId = connectionStore.activeConnectionId.value
val persisted = connectionStore.connections.value
.firstOrNull { it.id == activeConnectionId }
?.dashboardLastStatus
val liveTopology = topologyConnectionId == activeConnectionId
val mode = if (liveTopology) topologyGatewayMode else persisted?.gatewayMode
val profiles = if (liveTopology) topologyProfiles else persisted?.profiles.orEmpty()
return mode.equals("multiplex", ignoreCase = true) && profile.name in profiles
}
/** Keep chat routing synchronized with the latest public dashboard topology. */
private suspend fun updateDashboardTopology(connectionId: String, status: DashboardStatus?) {
val nextMode = status?.gatewayMode
val nextProfiles = status?.profiles.orEmpty()
val changed = topologyConnectionId != connectionId ||
topologyGatewayMode != nextMode ||
topologyProfiles != nextProfiles
topologyConnectionId = connectionId
topologyGatewayMode = nextMode
topologyProfiles = nextProfiles
if (changed && connectionStore.activeConnectionId.value == connectionId) {
rebuildChatApiClient()
}
}
private suspend fun refreshChatDisplaySettings(
client: DashboardApiClient,
authenticated: Boolean,
@@ -4178,7 +4354,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val materiallySame = previous != null &&
previous.reachable == reachable &&
previous.authRequired == status?.authRequired &&
previous.authenticated == session?.authenticated
previous.authenticated == session?.authenticated &&
previous.gatewayMode == status?.gatewayMode &&
previous.profiles == status?.profiles.orEmpty()
if (!materiallySame) {
recordDashboardStatus(status = status, session = session, reachable = reachable)
}
@@ -4723,6 +4901,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
authProvider = session?.provider,
gatewayTicketAvailable = gatewayTicketAvailable,
message = message,
gatewayMode = status?.gatewayMode,
profiles = status?.profiles.orEmpty(),
),
)
}
@@ -4755,6 +4935,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
authProvider = null,
gatewayTicketAvailable = false,
message = "Dashboard session cleared",
gatewayMode = active?.dashboardLastStatus?.gatewayMode,
profiles = active?.dashboardLastStatus?.profiles.orEmpty(),
),
)
probeStandardVoice()
@@ -5255,9 +5437,18 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
private suspend fun rebuildChatApiClient() {
val baseApiUrl = ProfileApiUrlResolver.normalize(effectiveApiServerUrlSnapshot())
val profileApiUrl = ProfileApiUrlResolver.resolveForConnection(
profileApiUrl = profileController.selectedProfile.value?.apiServerUrl,
val selectedProfile = profileController.selectedProfile.value
val activeConnectionId = connectionStore.activeConnectionId.value
val topology = connectionStore.connections.value
.firstOrNull { it.id == activeConnectionId }
?.dashboardLastStatus
val liveTopology = topologyConnectionId == activeConnectionId
val profileApiUrl = ProfileApiUrlResolver.resolveChatBase(
profileApiUrl = selectedProfile?.apiServerUrl,
baseApiUrl = baseApiUrl,
selectedProfileName = selectedProfile?.name,
gatewayMode = if (liveTopology) topologyGatewayMode else topology?.gatewayMode,
servedProfiles = if (liveTopology) topologyProfiles else topology?.profiles.orEmpty(),
)
val baseClient = _apiClient.value
val key = apiKeyForClientBuild()
@@ -5401,19 +5592,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
fun observeDeviceEndpoints(): kotlinx.coroutines.flow.Flow<List<EndpointCandidate>> {
return activeConnection.flatMapLatest { connection ->
val savedRoutes = connection?.routeCandidates.orEmpty()
if (savedRoutes.isNotEmpty()) {
kotlinx.coroutines.flow.flowOf(savedRoutes)
} else {
kotlinx.coroutines.flow.flow {
val deviceId = runCatching { authManager.getOrCreateDeviceId() }.getOrNull()
if (deviceId == null) {
emit(emptyList())
return@flow
}
emitAll(
PairingPreferences.getDeviceEndpoints(getApplication(), deviceId)
)
}
kotlinx.coroutines.flow.flow {
val deviceId = runCatching { authManager.getOrCreateDeviceId() }.getOrNull()
val pairedRoutes = deviceId?.let { id ->
runCatching {
PairingPreferences.getDeviceEndpoints(getApplication(), id).first()
}.getOrDefault(emptyList())
}.orEmpty()
emit(
mergeRelayTransportIntoStandardRoutes(
standardRoutes = savedRoutes,
relayRoutes = pairedRoutes,
),
)
}
}
}
@@ -5422,7 +5613,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
* Add or replace an extra fallback route on the active connection — the
* standard path's manual equivalent of a v3 pairing QR's `endpoints`
* array. The primary route (priority 0) mirrors the connection's main
* Dashboard/Gateway route and is edited through the connection detail,
* Dashboard/Gateway address and is edited through the connection detail,
* never here.
*
* Legacy candidate sources (per-device PairingPreferences from old QR
@@ -5436,12 +5627,12 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
*/
fun saveExtraRoute(
role: String,
apiUrl: String,
dashboardUrl: String,
original: EndpointCandidate? = null,
onResult: (String?) -> Unit,
) {
if (original?.priority == 0) {
onResult("The primary route mirrors the connection's API URL — edit that instead")
onResult("The primary route mirrors the connection's Dashboard/Gateway address — edit that instead")
return
}
viewModelScope.launch {
@@ -5452,7 +5643,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
// Accept bare hosts/IPs — http:// is assumed and the standard
// Dashboard/Gateway port defaults to 9119.
val trimmedUrl = Connection.normalizeDashboardUrlInput(apiUrl)
val trimmedUrl = Connection.normalizeDashboardUrlInput(dashboardUrl)
val existing = seedRouteCandidates(current)
if (existing.isEmpty()) {
onResult("Set the connection's Dashboard/Gateway URL first")
@@ -5468,6 +5659,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
priority = original?.priority
?: ((withoutOriginal.maxOfOrNull { it.priority } ?: 0) + 1),
dashboardUrl = trimmedUrl,
apiServerUrl = Connection.deriveDefaultApiUrl(trimmedUrl),
relayUrl = if (
current.relayUrl.isNotBlank() ||
original?.relay != null ||
existing.any { it.relay != null }
) {
Connection.deriveDefaultApiUrl(trimmedUrl)
?.let(Connection::deriveDefaultRelayUrl)
} else {
null
},
)
if (candidate == null) {
onResult(
@@ -5501,13 +5703,13 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
/**
* Remove an extra fallback route. The primary route (priority 0) is
* protected — it mirrors the connection's API URL. Clears a preferred-
* protected — it mirrors the connection's Dashboard/Gateway address. Clears a preferred-
* route override that pointed at the removed route, mirroring
* [persistActiveConnectionUrls]' stale-preference handling.
*/
fun removeExtraRoute(candidate: EndpointCandidate, onResult: (String?) -> Unit = {}) {
if (candidate.priority == 0) {
onResult("The primary route can't be removed — edit the connection's API URL instead")
onResult("The primary route can't be removed — edit the connection's Dashboard/Gateway address instead")
return
}
viewModelScope.launch {
@@ -5553,12 +5755,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
* Routes card is currently displaying is what an edit starts from.
*/
private suspend fun seedRouteCandidates(current: Connection): List<EndpointCandidate> {
current.routeCandidates.takeIf { it.isNotEmpty() }?.let { return it }
val fromPairing = runCatching {
val deviceId = authManager.getOrCreateDeviceId()
PairingPreferences.getDeviceEndpoints(getApplication(), deviceId).first()
}.getOrDefault(emptyList())
if (fromPairing.isNotEmpty()) return fromPairing
val recovered = mergeRelayTransportIntoStandardRoutes(
standardRoutes = current.routeCandidates,
relayRoutes = fromPairing,
)
if (recovered.isNotEmpty()) return recovered
val dashboardUrl = current.resolvedDashboardUrl.takeIf { it.isNotBlank() }
?: return emptyList()
return listOfNotNull(
@@ -0,0 +1,105 @@
package com.hermesandroid.relay.viewmodel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
/** Non-secret handle required to resume a dashboard-owned MCP OAuth flow. */
data class PendingMcpOAuth(
val flowId: String,
val serverName: String,
val profile: String?,
/** Non-secret connection + normalized dashboard identity that owns this flow. */
val routeIdentity: String,
)
/**
* Process-recreatable Manage OAuth state. Authorization URLs, OAuth codes,
* callback state, and tokens are deliberately never written here.
*/
class DashboardManageOAuthViewModel(
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val flowId = savedStateHandle.getStateFlow<String?>(KEY_FLOW_ID, null)
private val serverName = savedStateHandle.getStateFlow<String?>(KEY_SERVER_NAME, null)
private val profile = savedStateHandle.getStateFlow<String?>(KEY_PROFILE, null)
private val routeIdentity = savedStateHandle.getStateFlow<String?>(KEY_ROUTE_IDENTITY, null)
val pending: StateFlow<PendingMcpOAuth?> = combine(
flowId,
serverName,
profile,
routeIdentity,
) { id, server, scope, route ->
if (id.isNullOrBlank() || server.isNullOrBlank() || route.isNullOrBlank()) null
else PendingMcpOAuth(id, server, scope, route)
}.stateIn(viewModelScope, SharingStarted.Eagerly, currentPending())
val unsupportedRoutes: StateFlow<ArrayList<String>> =
savedStateHandle.getStateFlow(KEY_UNSUPPORTED_ROUTES, arrayListOf())
val supportedRoutes: StateFlow<ArrayList<String>> =
savedStateHandle.getStateFlow(KEY_SUPPORTED_ROUTES, arrayListOf())
fun remember(flowId: String, serverName: String, profile: String?, routeIdentity: String) {
if (routeIdentity.isBlank()) return
savedStateHandle[KEY_FLOW_ID] = flowId
savedStateHandle[KEY_SERVER_NAME] = serverName
savedStateHandle[KEY_PROFILE] = profile
savedStateHandle[KEY_ROUTE_IDENTITY] = routeIdentity
}
fun clear() {
savedStateHandle[KEY_FLOW_ID] = null
savedStateHandle[KEY_SERVER_NAME] = null
savedStateHandle[KEY_PROFILE] = null
savedStateHandle[KEY_ROUTE_IDENTITY] = null
}
fun markUnsupported(routeKey: String) {
if (routeKey.isBlank()) return
val next = ArrayList(unsupportedRoutes.value)
if (routeKey !in next) {
next += routeKey
savedStateHandle[KEY_UNSUPPORTED_ROUTES] = next
}
removeRoute(KEY_SUPPORTED_ROUTES, supportedRoutes.value, routeKey)
}
fun markSupported(routeKey: String) {
if (routeKey.isBlank()) return
val next = ArrayList(supportedRoutes.value)
if (routeKey !in next) {
next += routeKey
savedStateHandle[KEY_SUPPORTED_ROUTES] = next
}
removeRoute(KEY_UNSUPPORTED_ROUTES, unsupportedRoutes.value, routeKey)
}
private fun removeRoute(key: String, routes: ArrayList<String>, routeKey: String) {
if (routeKey !in routes) return
val next = ArrayList(routes)
next.remove(routeKey)
savedStateHandle[key] = next
}
private fun currentPending(): PendingMcpOAuth? {
val id = savedStateHandle.get<String>(KEY_FLOW_ID)
val server = savedStateHandle.get<String>(KEY_SERVER_NAME)
val route = savedStateHandle.get<String>(KEY_ROUTE_IDENTITY)
if (id.isNullOrBlank() || server.isNullOrBlank() || route.isNullOrBlank()) return null
return PendingMcpOAuth(id, server, savedStateHandle.get(KEY_PROFILE), route)
}
private companion object {
const val KEY_FLOW_ID = "manage_mcp_oauth_flow_id"
const val KEY_SERVER_NAME = "manage_mcp_oauth_server"
const val KEY_PROFILE = "manage_mcp_oauth_profile"
const val KEY_ROUTE_IDENTITY = "manage_mcp_oauth_route_identity"
const val KEY_UNSUPPORTED_ROUTES = "manage_mcp_oauth_unsupported_routes"
const val KEY_SUPPORTED_ROUTES = "manage_mcp_oauth_supported_routes"
}
}
@@ -43,6 +43,8 @@ import kotlinx.coroutines.launch
* live here.
*/
data class VoiceConfigUiState(
val isLoading: Boolean = false,
val hasLoaded: Boolean = false,
val voiceConfig: VoiceConfig? = null,
val voiceConfigError: String? = null,
val voiceOutputConfig: VoiceOutputConfig? = null,
@@ -107,6 +109,7 @@ class VoiceSettingsViewModel(application: Application) : AndroidViewModel(applic
val configErrorEvents: SharedFlow<HumanError> = _configErrorEvents.asSharedFlow()
private var loadJob: Job? = null
private var loadGeneration: Long = 0L
fun setBargeInEnabled(enabled: Boolean) {
viewModelScope.launch { bargeInRepo.setEnabled(enabled) }
@@ -132,65 +135,78 @@ class VoiceSettingsViewModel(application: Application) : AndroidViewModel(applic
*/
fun loadVoiceConfig(client: RelayVoiceClient?, relayVoiceReady: Boolean) {
loadJob?.cancel()
val generation = ++loadGeneration
if (client == null || !relayVoiceReady) {
_configState.value = VoiceConfigUiState()
return
}
_configState.value = VoiceConfigUiState(isLoading = true)
loadJob = viewModelScope.launch {
val voiceResult = client.getVoiceConfig()
if (voiceResult.isSuccess) {
_configState.update {
it.copy(voiceConfig = voiceResult.getOrNull(), voiceConfigError = null)
try {
val voiceResult = client.getVoiceConfig()
if (generation != loadGeneration) return@launch
if (voiceResult.isSuccess) {
_configState.update {
it.copy(voiceConfig = voiceResult.getOrNull(), voiceConfigError = null)
}
} else {
val human = classifyError(voiceResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
_configState.update { it.copy(voiceConfigError = human.body) }
_configErrorEvents.tryEmit(human)
}
} else {
val human = classifyError(voiceResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
_configState.update { it.copy(voiceConfigError = human.body) }
_configErrorEvents.tryEmit(human)
}
val outputResult = client.getVoiceOutputConfig()
if (outputResult.isSuccess) {
val config = outputResult.getOrNull()
_configState.update {
it.copy(voiceOutputConfig = config, voiceOutputConfigError = null)
}
config?.default_provider?.takeIf { id -> id.isNotBlank() }?.let { providerId ->
val optionsResult = client.getVoiceOutputProviderOptions(providerId)
optionsResult.getOrNull()?.provider?.let { provider ->
_configState.update {
it.copy(
voiceOutputProviderOptions =
it.voiceOutputProviderOptions + (provider.id to provider),
)
val outputResult = client.getVoiceOutputConfig()
if (generation != loadGeneration) return@launch
if (outputResult.isSuccess) {
val config = outputResult.getOrNull()
_configState.update {
it.copy(voiceOutputConfig = config, voiceOutputConfigError = null)
}
config?.default_provider?.takeIf { id -> id.isNotBlank() }?.let { providerId ->
val optionsResult = client.getVoiceOutputProviderOptions(providerId)
if (generation != loadGeneration) return@launch
optionsResult.getOrNull()?.provider?.let { provider ->
_configState.update {
it.copy(
voiceOutputProviderOptions =
it.voiceOutputProviderOptions + (provider.id to provider),
)
}
}
}
} else {
val human = classifyError(outputResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
_configState.update { it.copy(voiceOutputConfigError = human.body) }
_configErrorEvents.tryEmit(human)
}
} else {
val human = classifyError(outputResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
_configState.update { it.copy(voiceOutputConfigError = human.body) }
_configErrorEvents.tryEmit(human)
}
val realtimeResult = client.getRealtimeAgentConfig()
if (realtimeResult.isSuccess) {
val config = realtimeResult.getOrNull()
_configState.update {
it.copy(realtimeConfig = config, realtimeConfigError = null)
}
config?.default_provider?.takeIf { id -> id.isNotBlank() }?.let { providerId ->
val optionsResult = client.getRealtimeAgentProviderOptions(providerId)
optionsResult.getOrNull()?.provider?.let { provider ->
_configState.update {
it.copy(
realtimeProviderOptions =
it.realtimeProviderOptions + (provider.id to provider),
)
val realtimeResult = client.getRealtimeAgentConfig()
if (generation != loadGeneration) return@launch
if (realtimeResult.isSuccess) {
val config = realtimeResult.getOrNull()
_configState.update {
it.copy(realtimeConfig = config, realtimeConfigError = null)
}
config?.default_provider?.takeIf { id -> id.isNotBlank() }?.let { providerId ->
val optionsResult = client.getRealtimeAgentProviderOptions(providerId)
if (generation != loadGeneration) return@launch
optionsResult.getOrNull()?.provider?.let { provider ->
_configState.update {
it.copy(
realtimeProviderOptions =
it.realtimeProviderOptions + (provider.id to provider),
)
}
}
}
} else {
val human = classifyError(realtimeResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
_configState.update { it.copy(realtimeConfig = null, realtimeConfigError = human.body) }
}
} finally {
if (generation == loadGeneration) {
_configState.update { it.copy(isLoading = false, hasLoaded = true) }
}
} else {
val human = classifyError(realtimeResult.exceptionOrNull(), context = "voice_config", ctx = getApplication())
_configState.update { it.copy(realtimeConfig = null, realtimeConfigError = human.body) }
}
}
}
@@ -36,6 +36,10 @@ import com.hermesandroid.relay.network.relay.RealtimeVoiceEvent
import com.hermesandroid.relay.network.relay.VoiceHandoffEvent
import com.hermesandroid.relay.network.shared.VoiceAudioClient
import com.hermesandroid.relay.network.shared.LocalDispatchResult
import com.hermesandroid.relay.network.shared.VoiceSpeechStream
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamCallbacks
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamOutcome
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamStatus
import com.hermesandroid.relay.util.HumanError
import com.hermesandroid.relay.util.classifyError
import com.hermesandroid.relay.voice.VoiceIntentSyncBuilder
@@ -53,6 +57,7 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -68,6 +73,7 @@ import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.contentOrNull
import java.io.File
import java.io.IOException
import java.util.Base64
import java.util.Collections
import java.util.concurrent.atomic.AtomicBoolean
@@ -82,6 +88,17 @@ import com.hermesandroid.relay.data.VoiceAudioRoute
*/
enum class VoiceState { Idle, Listening, Transcribing, Thinking, Speaking, Error }
private enum class StandardSpeechStreamState {
Idle,
Opening,
Streaming,
LegacyFallback,
Consumed,
}
internal fun shouldFallbackStandardSpeech(outcome: VoiceSpeechStreamOutcome): Boolean =
!outcome.audioStarted && outcome.status != VoiceSpeechStreamStatus.Stopped
internal fun realtimeTranscriptState(micCaptureActive: Boolean): VoiceState =
if (micCaptureActive) VoiceState.Listening else VoiceState.Transcribing
@@ -155,6 +172,16 @@ data class VoiceUiState(
val backgroundRun: BackgroundRunState? = null,
)
data class VoicePreviewUiState(
val selectionKey: String? = null,
val isLoading: Boolean = false,
val isPlaying: Boolean = false,
val amplitude: Float = 0f,
val error: String? = null,
) {
val isActive: Boolean get() = isLoading || isPlaying
}
/** ADR 33 background/promoted Hermes run surface for the voice overlay. */
data class BackgroundRunState(
val runId: String? = null,
@@ -579,6 +606,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(VoiceUiState())
val uiState: StateFlow<VoiceUiState> = _uiState.asStateFlow()
private val _voicePreviewState = MutableStateFlow(VoicePreviewUiState())
val voicePreviewState: StateFlow<VoicePreviewUiState> = _voicePreviewState.asStateFlow()
private var voicePreviewJob: Job? = null
private var voicePreviewGeneration: Long = 0L
// Rolling voice pipeline telemetry for StatsForNerds → Voice section.
// Updated on discrete lifecycle events (turn start/stop, STT call
// complete, TTS call complete, barge-in fire, threshold read, state
@@ -658,6 +690,15 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private var pendingRawDelta: StringBuilder = StringBuilder()
private var streamObserverJob: Job? = null
private var standardSpeechStreamJob: Job? = null
private var standardSpeechStream: VoiceSpeechStream? = null
private var standardSpeechStreamGeneration: Long = 0L
private var standardSpeechStreamState: StandardSpeechStreamState = StandardSpeechStreamState.Idle
private var standardSpeechStreamText: StringBuilder = StringBuilder()
private var standardSpeechStreamFinishRequested: Boolean = false
private val standardSpeechStreamAudioSeen = AtomicBoolean(false)
private val standardSpeechStreamAudioBytes = AtomicInteger(0)
private val standardSpeechStreamBargeInStarted = AtomicBoolean(false)
private var ttsConsumerJob: Job? = null
private var realtimeTtsConsumerJob: Job? = null
private var amplitudeBridgeJob: Job? = null
@@ -928,6 +969,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
voiceRelayPreflight: (suspend () -> Result<Unit>)? = null,
voiceHandoffReporter: ((VoiceHandoffEvent) -> Unit)? = null,
) {
cancelStandardSpeechStream("voice dependencies rewired")
this.voiceClient = voiceClient
this.voiceAudioClient = voiceAudioClient ?: RelayVoiceAudioClientAdapter(voiceClient)
this.chatViewModel = chatViewModel
@@ -1647,6 +1689,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// B4: tear down the barge-in listener + timers before we kill the
// player so AEC doesn't try to track a released audio session.
stopBargeInListener()
cancelStandardSpeechStream("voice mode exited")
duckingWatchdog?.cancel(); duckingWatchdog = null
resumeWatchdog?.cancel(); resumeWatchdog = null
handoffStatusClearJob?.cancel(); handoffStatusClearJob = null
@@ -1742,6 +1785,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// listener and cancel any pending resume. Normal "tap mic to
// talk" path also lands here, so we always leave Speaking cleanly.
stopBargeInListener()
cancelStandardSpeechStream("microphone capture started")
resumeWatchdog?.cancel(); resumeWatchdog = null
lastInterruptedAtChunkIndex = null
clearSpokenChunksState()
@@ -2053,6 +2097,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// bargeInDetected while the resume watchdog is deliberating.
// stopBargeInListener is null-safe.
stopBargeInListener()
cancelStandardSpeechStream("speech interrupted")
// 2026-04-18: the silence watchdog only runs during Listening, but
// cancel defensively so a stale job from the prior turn can't
// fire stopListening() after we've already returned to Idle.
@@ -2254,6 +2299,222 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
}
}
/**
* Plays an ephemeral Relay voice-output session using the editor's draft
* selection. None of these values are persisted; saving remains an
* explicit, separate settings action.
*
* Calling this with the currently-playing key acts as a pause/stop toggle.
* Starting another key always stops the previous preview first, so two
* providers can never talk over one another.
*/
fun previewVoiceOutput(
selectionKey: String,
provider: String,
model: String,
voice: String,
sampleRate: Int,
language: String,
sample: String = "Hello, this is Hermes. This is how this voice sounds.",
onResult: (Result<Unit>) -> Unit = {},
) {
if (_voicePreviewState.value.isActive &&
_voicePreviewState.value.selectionKey == selectionKey
) {
stopVoicePreview()
return
}
val client = voiceClient
val pcmPlayer = realtimePcmPlayer
if (client == null || pcmPlayer == null) {
val error = IllegalStateException("Relay voice preview is not available")
_voicePreviewState.value = VoicePreviewUiState(error = error.message)
onResult(Result.failure(error))
return
}
val previousJob = voicePreviewJob
val generation = ++voicePreviewGeneration
_voicePreviewState.value = VoicePreviewUiState(
selectionKey = selectionKey,
isLoading = true,
)
voicePreviewJob = viewModelScope.launch {
val audioBytes = AtomicInteger(0)
try {
previousJob?.cancelAndJoin()
if (generation != voicePreviewGeneration) return@launch
pcmPlayer.stop()
val result = client.runVoiceOutput(
text = sample,
renderMode = "verbatim",
provider = provider,
model = model,
voice = voice,
sampleRate = sampleRate,
language = language,
) { event ->
if (!event.isAudioDelta) return@runVoiceOutput
val encoded = event.audioBase64 ?: return@runVoiceOutput
val audio = try {
Base64.getDecoder().decode(encoded)
} catch (_: Exception) {
return@runVoiceOutput
}
if (audio.isEmpty()) return@runVoiceOutput
if (generation != voicePreviewGeneration) return@runVoiceOutput
val level = pcmPlayer.write(audio, event.sampleRate ?: sampleRate)
audioBytes.addAndGet(audio.size)
_voicePreviewState.update { state ->
if (state.selectionKey == selectionKey && generation == voicePreviewGeneration) {
state.copy(amplitude = level, isLoading = false, isPlaying = true, error = null)
} else {
state
}
}
}
val finalResult = result.fold(
onSuccess = {
if (audioBytes.get() <= 0) {
Result.failure(IllegalStateException("Voice preview returned no audio"))
} else {
val drainMs = pcmPlayer.flushBufferedPlayback().coerceIn(250L, 4_500L)
delay(drainMs)
Result.success(Unit)
}
},
onFailure = { Result.failure(it) },
)
if (generation == voicePreviewGeneration) {
onResult(finalResult)
_voicePreviewState.value = VoicePreviewUiState(
error = finalResult.exceptionOrNull()?.message,
)
}
} catch (cancelled: CancellationException) {
throw cancelled
} finally {
if (generation == voicePreviewGeneration) {
pcmPlayer.stop()
_voicePreviewState.update { state ->
if (state.selectionKey == selectionKey) {
state.copy(isLoading = false, isPlaying = false, amplitude = 0f)
} else {
state
}
}
}
}
}
}
/**
* Opens a one-shot provider-native session with the Realtime editor's
* unsaved provider/model/voice selection. The session endpoint accepts
* these as request-scoped overrides, so auditioning never mutates relay
* configuration or the active chat session.
*/
fun previewRealtimeAgent(
selectionKey: String,
provider: String,
model: String,
voice: String,
sampleRate: Int,
sample: String = "Introduce this voice in one short sentence.",
onResult: (Result<Unit>) -> Unit = {},
) {
if (_voicePreviewState.value.isActive && _voicePreviewState.value.selectionKey == selectionKey) {
stopVoicePreview()
return
}
val client = voiceClient
val pcmPlayer = realtimePcmPlayer
if (client == null || pcmPlayer == null) {
val error = IllegalStateException("Realtime voice preview is not available")
_voicePreviewState.value = VoicePreviewUiState(error = error.message)
onResult(Result.failure(error))
return
}
val previousJob = voicePreviewJob
val generation = ++voicePreviewGeneration
_voicePreviewState.value = VoicePreviewUiState(selectionKey = selectionKey, isLoading = true)
voicePreviewJob = viewModelScope.launch {
val audioBytes = AtomicInteger(0)
try {
previousJob?.cancelAndJoin()
if (generation != voicePreviewGeneration) return@launch
pcmPlayer.stop()
val result = client.runRealtimeAgent(
prompt = sample,
inputPcm = ByteArray(0),
inputSampleRate = 16_000,
provider = provider,
model = model,
voice = voice,
sampleRate = sampleRate,
) { event, _ ->
if (!event.isAudioDelta) return@runRealtimeAgent
val encoded = event.audioBase64 ?: return@runRealtimeAgent
val audio = try {
Base64.getDecoder().decode(encoded)
} catch (_: Exception) {
return@runRealtimeAgent
}
if (audio.isEmpty()) return@runRealtimeAgent
if (generation != voicePreviewGeneration) return@runRealtimeAgent
val level = pcmPlayer.write(audio, event.sampleRate ?: sampleRate)
audioBytes.addAndGet(audio.size)
_voicePreviewState.update { state ->
if (state.selectionKey == selectionKey && generation == voicePreviewGeneration) {
state.copy(amplitude = level, isLoading = false, isPlaying = true, error = null)
} else {
state
}
}
}
val finalResult = result.fold(
onSuccess = {
if (audioBytes.get() <= 0) {
Result.failure(IllegalStateException("Realtime preview returned no audio"))
} else {
val drainMs = pcmPlayer.flushBufferedPlayback().coerceIn(250L, 4_500L)
delay(drainMs)
Result.success(Unit)
}
},
onFailure = { Result.failure(it) },
)
if (generation == voicePreviewGeneration) {
onResult(finalResult)
_voicePreviewState.value = VoicePreviewUiState(error = finalResult.exceptionOrNull()?.message)
}
} catch (cancelled: CancellationException) {
throw cancelled
} finally {
if (generation == voicePreviewGeneration) {
pcmPlayer.stop()
_voicePreviewState.update { state ->
if (state.selectionKey == selectionKey) {
state.copy(isLoading = false, isPlaying = false, amplitude = 0f)
} else {
state
}
}
}
}
}
}
fun stopVoicePreview() {
voicePreviewGeneration += 1
voicePreviewJob?.cancel()
voicePreviewJob = null
realtimePcmPlayer?.stop()
_voicePreviewState.value = VoicePreviewUiState()
}
fun testRealtimeAgent(
sample: String = "Say a short confirmation that Hermes Realtime Agent is working.",
onResult: (Result<Unit>) -> Unit = {},
@@ -2820,6 +3081,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
ignoreAssistantId = chatVm.messages.value
.lastOrNull { it.role == MessageRole.ASSISTANT }?.id
prepareStandardSpeechStream()
// Kick off streaming observer BEFORE sending the message so we don't
// miss early deltas that arrive synchronously from the callback.
startStreamObserver(chatVm)
@@ -2874,6 +3137,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
fun sessionIsCurrent(): Boolean =
realtimeSessionGeneration.get() == sessionGeneration
if (!prewarm) providerRealtimeAgentTurnActive.set(true)
cancelStandardSpeechStream("provider-native realtime turn")
// New turn requested → allow this response's audio through again.
realtimeAudioSuppressed = false
streamObserverJob?.cancel()
@@ -3778,6 +4042,186 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// Sentence-boundary streaming from ChatViewModel
// ---------------------------------------------------------------------
/**
* Optimistically open upstream's per-reply PCM socket while Hermes starts
* the chat turn. Deltas are buffered until the fresh single-use dashboard
* ticket is minted and the WebSocket opens. Older hosts, expired auth, and
* non-streaming providers all converge on [activateStandardSpeechFallback]
* before any PCM is heard, preserving the existing POST pipeline.
*/
private fun prepareStandardSpeechStream() {
cancelStandardSpeechStream("new Standard voice turn")
val client = voiceAudioClient ?: return
if (client.effectiveRoute != VoiceAudioRoute.Standard || realtimePcmPlayer == null) return
val generation = ++standardSpeechStreamGeneration
standardSpeechStreamState = StandardSpeechStreamState.Opening
standardSpeechStreamText = StringBuilder()
standardSpeechStreamFinishRequested = false
standardSpeechStreamAudioSeen.set(false)
standardSpeechStreamAudioBytes.set(0)
standardSpeechStreamBargeInStarted.set(false)
standardSpeechStreamJob = viewModelScope.launch {
var openedSession: VoiceSpeechStream? = null
try {
val result = client.openSpeechStream(
VoiceSpeechStreamCallbacks(
onStart = { sampleRate, channels ->
Log.i(
TAG,
"Standard speech stream ready sampleRate=$sampleRate channels=$channels",
)
},
onPcm = { pcm, sampleRate ->
viewModelScope.launch {
if (generation != standardSpeechStreamGeneration ||
standardSpeechStreamState == StandardSpeechStreamState.Idle ||
standardSpeechStreamState == StandardSpeechStreamState.LegacyFallback
) {
return@launch
}
handleStandardSpeechPcm(pcm, sampleRate)
}
},
),
)
if (generation != standardSpeechStreamGeneration) {
result.getOrNull()?.stop()
return@launch
}
openedSession = result.getOrNull()
if (openedSession == null) {
activateStandardSpeechFallback(
generation,
result.exceptionOrNull() ?: IOException("Streaming voice unavailable"),
)
return@launch
}
standardSpeechStream = openedSession
standardSpeechStreamState = StandardSpeechStreamState.Streaming
val buffered = standardSpeechStreamText.toString()
if (buffered.isNotEmpty()) openedSession.append(buffered)
if (standardSpeechStreamFinishRequested) openedSession.finish()
val outcome = openedSession.awaitOutcome()
if (generation != standardSpeechStreamGeneration) return@launch
standardSpeechStream = null
if (shouldFallbackStandardSpeech(outcome)) {
activateStandardSpeechFallback(generation, outcome.error)
return@launch
}
standardSpeechStreamState = StandardSpeechStreamState.Consumed
if (outcome.audioStarted) {
realtimePcmPlayer?.flushBufferedPlayback()
Log.i(
TAG,
"Standard speech stream finished status=${outcome.status} " +
"bytes=${standardSpeechStreamAudioBytes.get()}",
)
}
scheduleAgentAudioCompletionCheck()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (error: Throwable) {
if (generation == standardSpeechStreamGeneration) {
activateStandardSpeechFallback(generation, error)
}
} finally {
if (generation != standardSpeechStreamGeneration) openedSession?.stop()
if (generation == standardSpeechStreamGeneration) standardSpeechStreamJob = null
}
}
}
/** Returns true when the Standard WebSocket owns this text delta. */
private fun offerStandardSpeechText(text: String): Boolean {
if (text.isEmpty()) return standardSpeechStreamOwnsReply()
return when (standardSpeechStreamState) {
StandardSpeechStreamState.Opening -> {
standardSpeechStreamText.append(text)
true
}
StandardSpeechStreamState.Streaming -> {
standardSpeechStreamText.append(text)
standardSpeechStream?.append(text)
true
}
StandardSpeechStreamState.Consumed -> true
StandardSpeechStreamState.Idle,
StandardSpeechStreamState.LegacyFallback,
-> false
}
}
private fun standardSpeechStreamOwnsReply(): Boolean =
standardSpeechStreamState == StandardSpeechStreamState.Opening ||
standardSpeechStreamState == StandardSpeechStreamState.Streaming ||
standardSpeechStreamState == StandardSpeechStreamState.Consumed
/** Returns true when stream completion is owned by the Standard socket. */
private fun finishStandardSpeechStream(): Boolean {
return when (standardSpeechStreamState) {
StandardSpeechStreamState.Opening -> {
standardSpeechStreamFinishRequested = true
true
}
StandardSpeechStreamState.Streaming -> {
standardSpeechStreamFinishRequested = true
standardSpeechStream?.finish()
true
}
StandardSpeechStreamState.Consumed -> true
StandardSpeechStreamState.Idle,
StandardSpeechStreamState.LegacyFallback,
-> false
}
}
private fun activateStandardSpeechFallback(generation: Long, error: Throwable?) {
if (generation != standardSpeechStreamGeneration ||
standardSpeechStreamState == StandardSpeechStreamState.LegacyFallback ||
standardSpeechStreamState == StandardSpeechStreamState.Idle
) {
return
}
Log.i(TAG, "Standard speech streaming unavailable; using POST fallback: ${error?.message}")
standardSpeechStream?.stop()
standardSpeechStream = null
standardSpeechStreamState = StandardSpeechStreamState.LegacyFallback
val buffered = standardSpeechStreamText.toString()
standardSpeechStreamText = StringBuilder()
if (buffered.isNotEmpty()) {
appendSanitizedDelta(buffered)
if (standardSpeechStreamFinishRequested) {
flushRemainingBuffer()
} else {
drainSentences()
rearmIdleFlush()
}
}
scheduleAgentAudioCompletionCheck()
}
private fun cancelStandardSpeechStream(reason: String) {
if (standardSpeechStreamState != StandardSpeechStreamState.Idle) {
Log.i(TAG, "Stopping Standard speech stream: $reason")
}
standardSpeechStreamGeneration += 1
standardSpeechStream?.stop()
standardSpeechStream = null
standardSpeechStreamJob?.cancel()
standardSpeechStreamJob = null
standardSpeechStreamState = StandardSpeechStreamState.Idle
standardSpeechStreamText = StringBuilder()
standardSpeechStreamFinishRequested = false
standardSpeechStreamAudioSeen.set(false)
standardSpeechStreamAudioBytes.set(0)
standardSpeechStreamBargeInStarted.set(false)
}
/**
* Observe [ChatViewModel.messages]. When the last assistant message
* grows (isStreaming=true), diff the content against our last snapshot,
@@ -3806,7 +4250,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
} else if (lastObservedMessageId != msgId) {
// A new assistant turn appeared — flush whatever's left
// from the previous one, then switch tracking.
flushRemainingBuffer()
if (!finishStandardSpeechStream()) flushRemainingBuffer()
resetBrokeredToolSpeechState()
lastObservedMessageId = msgId
lastObservedContentLength = 0
@@ -3829,7 +4273,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
streamComplete = true
idleFlushJob?.cancel()
idleFlushJob = null
flushRemainingBuffer()
if (!finishStandardSpeechStream()) flushRemainingBuffer()
// Speaking state will naturally end when TTS queue drains.
// We can't easily wait here without blocking the collector;
// the TTS consumer transitions back to Idle.
@@ -3848,7 +4292,6 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
* (the chat surface has its own renderer and wants the markdown).
*/
private fun onStreamDelta(delta: String, fullContent: String) {
appendSanitizedDelta(delta)
_uiState.update {
it.copy(
state = VoiceState.Speaking,
@@ -3856,6 +4299,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
responseText = fullContent,
)
}
if (offerStandardSpeechText(delta)) return
appendSanitizedDelta(delta)
drainSentences()
rearmIdleFlush()
}
@@ -3894,7 +4339,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private fun enqueueBrokeredToolStatus(status: String) {
if (status.isBlank()) return
if (enqueueSentenceForTts(status, immediate = true)) {
val offeredToStandardStream = offerStandardSpeechText("$status ")
if (offeredToStandardStream || enqueueSentenceForTts(status, immediate = true)) {
_uiState.update { state ->
state.copy(
state = VoiceState.Speaking,
@@ -4287,7 +4733,40 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
} catch (_: Exception) {
return
}
if (audio.isEmpty()) return
handlePcmAudioChunk(
audio = audio,
sampleRate = event.sampleRate ?: 24_000,
source = "Realtime",
pcmPlayer = pcmPlayer,
audioSeen = audioSeen,
audioBytes = audioBytes,
bargeInStarted = bargeInStarted,
)
}
private fun handleStandardSpeechPcm(audio: ByteArray, sampleRate: Int) {
val pcmPlayer = realtimePcmPlayer ?: return
handlePcmAudioChunk(
audio = audio,
sampleRate = sampleRate,
source = "Standard",
pcmPlayer = pcmPlayer,
audioSeen = standardSpeechStreamAudioSeen,
audioBytes = standardSpeechStreamAudioBytes,
bargeInStarted = standardSpeechStreamBargeInStarted,
)
}
private fun handlePcmAudioChunk(
audio: ByteArray,
sampleRate: Int,
source: String,
pcmPlayer: RealtimePcmPlayer,
audioSeen: AtomicBoolean,
audioBytes: AtomicInteger,
bargeInStarted: AtomicBoolean,
) {
if (audio.isEmpty() || sampleRate <= 0) return
audioSeen.set(true)
audioBytes.addAndGet(audio.size)
lastRealtimeAudioDeltaAtMs = System.currentTimeMillis()
@@ -4298,11 +4777,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
deliveringChipClearJob?.cancel()
settleBackgroundRunChip(reason = "summary_audio_started")
}
val sampleRate = event.sampleRate ?: 24_000
Log.i(
TAG,
"Realtime audio delta event=${event.audioEventId ?: 0} bytes=${audio.size} " +
"sampleRate=$sampleRate rms=${event.rmsLevel ?: -1f} peak=${event.peakLevel ?: -1f}",
"$source audio delta bytes=${audio.size} sampleRate=$sampleRate",
)
val level = pcmPlayer.write(audio, sampleRate)
scheduleRealtimeAmplitudeRelease(audio.size, sampleRate, lastRealtimeAudioDeltaAtMs)
@@ -4727,7 +5204,14 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
return decideAgentAudioCompletion(
voiceMode = _uiState.value.voiceMode,
observerStopped = streamObserverJob?.isActive != true,
pendingTtsWork = pendingInTtsQueue.get(),
pendingTtsWork = pendingInTtsQueue.get() +
if (standardSpeechStreamState == StandardSpeechStreamState.Opening ||
standardSpeechStreamState == StandardSpeechStreamState.Streaming
) {
1
} else {
0
},
hasPendingSynthFiles = pendingTtsFiles.isNotEmpty(),
realtimePlaybackRemainingMs = effectiveRemaining,
realtimeTailGuardRemainingMs = continuousResumeTailGuardRemainingMs(),
@@ -4737,6 +5221,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
private fun finishAgentAudioOutput() {
continuousResumeJob = null
stopBargeInListener()
cancelStandardSpeechStream("audio output finished")
realtimeAmplitudeDecayJob?.cancel()
realtimeAmplitudeDecayJob = null
firstFrameWatchdogJob?.cancel()
@@ -5444,10 +5929,13 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
override fun onCleared() {
super.onCleared()
voicePreviewJob?.cancel()
voicePreviewJob = null
closeRealtimeSession()
// B4: release the listener + VAD engine native resources before
// anything else. stopBargeInListener is defensive / idempotent.
stopBargeInListener()
cancelStandardSpeechStream("view model cleared")
duckingWatchdog?.cancel()
resumeWatchdog?.cancel()
silenceWatchdogJob?.cancel()
+44 -7
View File
@@ -64,7 +64,7 @@
<string name="chat_input_start_voice">Iniciar conversa por voz</string>
<string name="chat_input_voice_setup_needed">Conversa por voz — configuração necessária</string>
<string name="chat_input_stop_streaming">Parar transmissão</string>
<string name="chat_input_steer_response">Direcionar a resposta</string>
<string name="chat_input_steer_response">Corrigir a resposta</string>
<string name="chat_input_queue_message">Colocar mensagem na fila</string>
<!-- Bridge return labels -->
<string name="bridge_return_chat_label">Chat</string>
@@ -90,7 +90,7 @@
<string name="demo_feature_manage">Gerenciar</string>
<!-- Chat screen placeholders -->
<string name="chat_placeholder_edit">Edite sua mensagem…</string>
<string name="chat_placeholder_steer">Direcione a resposta…</string>
<string name="chat_placeholder_steer">Corrija a resposta…</string>
<string name="chat_placeholder_queue">Coloque uma mensagem na fila…</string>
<string name="chat_placeholder_message">Mensagem…</string>
<string name="chat_edit_busy_snackbar">Não é possível editar agora — aguarde o turno atual terminar</string>
@@ -330,7 +330,7 @@
<string name="cw_api_url_placeholder">192.168.1.10 ou http://your-server:8642</string>
<string name="cw_api_url_supporting">API do Hermes usada pelo Chat e pelas sessões — a porta 8642 da API e http:// são presumidos para hosts sem esquema (a porta 9119 do painel é determinada separadamente)</string>
<string name="cw_scan_message">Procurando o painel/a API do Hermes nesta LAN…</string>
<string name="cw_dashboard_signin_hint">Entre pelo painel para liberar Gerenciar e a voz — a chave da API é opcional para o Chat.</string>
<string name="cw_dashboard_signin_hint">Entre pelo painel para liberar Gerenciar e voz — a chave da API é usada apenas no fallback opcional pela API direta.</string>
<string name="cw_pair_relay_section">Parear o Relay (opcional)</string>
<string name="cw_pair_relay_section_desc">O plugin do Relay já está em execução? Faça o pareamento aqui para ativar Terminal, Bridge e permissões de canais.</string>
<string name="cw_pair_relay_url_label">URL do Relay</string>
@@ -1412,7 +1412,7 @@
<string name="voice_settings_route_relay">Relay</string>
<string name="voice_settings_route_relay_desc">Voz do plugin do Relay — provedores específicos por perfil e saída de voz em streaming.</string>
<string name="voice_settings_optional">Opcional</string>
<string name="voice_settings_signin_route_hint">Você está conectado pela rota %1$s, e os logins do painel são específicos por host — o login feito na sua rede doméstica não é transferido. Entre uma vez em Gerenciar enquanto estiver nesta rota para liberar a voz aqui também.</string>
<string name="voice_settings_signin_route_hint">Não foi possível reutilizar a sessão salva do painel pela rota %1$s. Abra Gerenciar para entrar novamente e liberar a voz.</string>
<string name="voice_settings_signin_default_hint">Seu painel do Hermes exige login antes que a voz padrão possa transcrever ou falar. Entrar uma vez em Gerenciar libera a voz para esta conexão.</string>
<string name="voice_settings_sign_in_via_manage">Entrar por Gerenciar</string>
<string name="voice_settings_unsupported_build_body">Esta versão do servidor Hermes ainda não oferece as rotas de áudio do painel. Atualize o hermes-agent no servidor ou pareie o Relay para usar a voz do Relay.</string>
@@ -1642,7 +1642,7 @@
<!-- Sign-in card -->
<string name="dashboard_signin_required_title">É necessário entrar no painel</string>
<string name="dashboard_signin_required_body">O Gerenciar usa a sessão do painel do Hermes em %1$s.</string>
<string name="dashboard_signin_route_hint">Você está na rota %1$s. Os logins do painel são específicos por host, então o login da outra rota não é transferido — entre uma vez aqui, e o app manterá as duas sessões.</string>
<string name="dashboard_signin_route_hint">Não foi possível reutilizar a sessão salva do painel pela rota %1$s. Entre novamente para atualizá-la nas rotas confiáveis desta conexão.</string>
<string name="dashboard_signin_with_provider">Entrar com %1$s</string>
<string name="dashboard_username_password">Nome de usuário e senha</string>
<string name="dashboard_username">Nome de usuário</string>
@@ -1688,6 +1688,31 @@
<string name="dashboard_action_use">Usar</string>
<string name="dashboard_action_describe">Descrever</string>
<string name="dashboard_action_model">Modelo</string>
<string name="dashboard_tab_custom_endpoints">Endpoints</string>
<string name="dashboard_tab_custom_endpoints_lower">endpoints</string>
<string name="dashboard_tile_custom_endpoints_title">Endpoints personalizados</string>
<string name="dashboard_tile_custom_endpoints_sub">Provedores compatíveis com OpenAI</string>
<string name="dashboard_action_authenticate">Autenticar</string>
<string name="dashboard_action_validate">Validar</string>
<string name="dashboard_action_edit">Editar</string>
<string name="dashboard_mcp_oauth_title">Autenticar %1$s</string>
<string name="dashboard_mcp_oauth_body">O Hermes abrirá o provedor no navegador. Volte aqui depois de aprovar o acesso; as credenciais permanecem no servidor Hermes.</string>
<string name="dashboard_mcp_oauth_approved">Autenticação MCP aprovada</string>
<string name="dashboard_mcp_oauth_failed">Falha na autenticação MCP</string>
<string name="dashboard_mcp_oauth_no_browser">Nenhum navegador está disponível para concluir a autenticação MCP.</string>
<string name="dashboard_custom_endpoint_add">Adicionar endpoint</string>
<string name="dashboard_custom_endpoint_edit">Editar endpoint</string>
<string name="dashboard_custom_endpoint_name">Nome</string>
<string name="dashboard_custom_endpoint_url">URL base</string>
<string name="dashboard_custom_endpoint_model">Modelo padrão</string>
<string name="dashboard_custom_endpoint_key">Chave de API (opcional)</string>
<string name="dashboard_custom_endpoint_key_help">Deixe em branco para preservar uma chave existente ao salvar. A validação usa somente a chave inserida aqui; o Hermes não expõe nem apaga as chaves salvas.</string>
<string name="dashboard_custom_endpoint_context">Tamanho do contexto (opcional)</string>
<string name="dashboard_custom_endpoint_discover">Descobrir modelos em /models</string>
<string name="dashboard_custom_endpoint_valid">Endpoint acessível · %1$d modelo(s)</string>
<string name="dashboard_custom_endpoint_validate_failed">Falha ao validar o endpoint</string>
<string name="dashboard_custom_endpoint_save_failed">Falha ao salvar o endpoint</string>
<string name="dashboard_custom_endpoint_saved">Endpoint personalizado salvo</string>
<string name="dashboard_action_completed">%1$s concluído</string>
<string name="dashboard_action_failed">Falha em %1$s</string>
<string name="dashboard_more">Mais</string>
@@ -1984,7 +2009,7 @@
<string name="active_section_api_key_already_set">A chave da API já está definida</string>
<string name="active_section_api_key_needed_hint">Use o API_SERVER_KEY criado no seu servidor Hermes. O aplicativo não fornece essa chave.</string>
<string name="active_section_api_key_not_configured">Chave da API não configurada</string>
<string name="active_section_api_key_optional">Chave da API (opcional)</string>
<string name="active_section_api_key_optional">Chave da API direta</string>
<string name="active_section_api_key_stored_hint">A chave está armazenada com segurança.</string>
<string name="active_section_api_reachable_voice_review">API acessível — revise a configuração de voz</string>
<string name="active_section_api_relay_voice_reachable">API acessível — a voz do Relay está configurada</string>
@@ -2343,6 +2368,7 @@
<string name="tool_progress_status_completed">concluída</string>
<string name="tool_progress_status_failed">falhou</string>
<string name="tool_progress_status_running">em execução</string>
<string name="image_generation_rendering">Gerando imagem</string>
<string name="tool_progress_cd_collapse">Recolher</string>
<string name="tool_progress_cd_expand">Expandir</string>
<!-- AgentIconRow -->
@@ -3060,7 +3086,7 @@
<string name="active_section_optional_api_fallback">Fallback opcional pela API direta</string>
<string name="active_section_api_not_required">Não é necessário quando esta conexão usa o Hermes Dashboard.</string>
<string name="active_section_where_api_key">Onde obtenho essa chave?</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY é criada no seu servidor Hermes; este aplicativo não fornece a chave. Configure-a somente ao ativar o servidor de API opcional e insira aqui o mesmo valor.</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY é criada no seu servidor Hermes; este aplicativo não fornece a chave. Configure-a somente ao ativar o servidor de API opcional, que exige uma chave utilizável, e insira aqui o mesmo valor.</string>
<string name="active_section_scan_relay_qr">Escanear QR do Relay</string>
<string name="active_section_other_relay_methods">Outros métodos de pareamento</string>
<string name="cw_pair_relay_for">Emparelhar Relay com %1$s</string>
@@ -3128,4 +3154,15 @@
<string name="active_section_dashboard_home_lan">Dashboard · LAN doméstica</string>
<string name="endpoints_tailscale_setup_hint">O Tailscale deve estar conectado nos dois dispositivos, e o Hermes Dashboard deve estar acessível na porta 9119.</string>
<string name="endpoints_setup_help">Ajuda para configurar o Tailscale</string>
<string name="voice_settings_provider_desc">Onde a fala e gerada. As opcoes disponiveis vêm deste host Hermes e dos provedores instalados.</string>
<string name="voice_settings_model_desc">Latest acompanha atualizacoes do provedor. Escolha um modelo versionado para manter o comportamento de voz fixo.</string>
<string name="voice_settings_voice_desc">Escolha como as respostas soam. Previsualizar uma voz nao salva a selecao.</string>
<string name="voice_settings_language_desc">Automatico deixa o provedor detectar o idioma falado. Escolha um idioma somente quando a deteccao nao for confiavel.</string>
<string name="voice_settings_language_auto">Automatico</string>
<string name="voice_settings_provider_options_title">Opcoes de voz</string>
<string name="voice_settings_provider_options_desc">Somente configuracoes aceitas pelo provedor selecionado sao mostradas.</string>
<string name="voice_settings_auto_speak">Ler respostas em voz alta</string>
<string name="voice_settings_auto_speak_desc">Fala automaticamente as respostas do assistente em superficies Hermes que respeitam esta configuracao do host.</string>
<string name="voice_settings_realtime_model_desc">Controla a sessao de fala ao vivo, nao o modelo de chat Hermes. Latest acompanha atualizacoes do provedor; um modelo versionado fica fixo.</string>
<string name="voice_settings_realtime_voice_desc">A voz usada dentro da sessao ao vivo. Vozes integradas e personalizadas aparecem quando o provedor as anuncia.</string>
</resources>
+44 -7
View File
@@ -70,7 +70,7 @@
<string name="chat_input_start_voice">开始语音对话</string>
<string name="chat_input_voice_setup_needed">语音对话——需要先设置</string>
<string name="chat_input_stop_streaming">停止流式响应</string>
<string name="chat_input_steer_response">引导回复方向</string>
<string name="chat_input_steer_response">修正回复</string>
<string name="chat_input_queue_message">排队发送消息</string>
<!-- Bridge 返回标签 -->
@@ -101,7 +101,7 @@
<!-- 聊天界面占位符 -->
<string name="chat_placeholder_edit">编辑你的消息…</string>
<string name="chat_placeholder_steer">引导回复方向…</string>
<string name="chat_placeholder_steer">修正回复…</string>
<string name="chat_placeholder_queue">排队发送消息…</string>
<string name="chat_placeholder_message">输入消息…</string>
<string name="chat_edit_busy_snackbar">暂时无法编辑——请等当前这一轮结束</string>
@@ -353,7 +353,7 @@
<string name="cw_api_url_placeholder">192.168.1.10 或 http://你的服务器:8642</string>
<string name="cw_api_url_supporting">聊天和会话使用的 Hermes API——裸主机名默认使用 API 端口 8642 和 http://(仪表盘的 9119 端口单独推导)</string>
<string name="cw_scan_message">正在扫描本局域网寻找 Hermes 仪表盘/API…</string>
<string name="cw_dashboard_signin_hint">通过仪表盘登录以解锁管理和语音——API 密钥对聊天是可选的。</string>
<string name="cw_dashboard_signin_hint">通过仪表盘登录以解锁管理和语音——API 密钥仅用于可选的直接 API 回退。</string>
<string name="cw_pair_relay_section">配对 Relay(可选)</string>
<string name="cw_pair_relay_for">为 %1$s 配对 Relay</string>
<string name="cw_pair_relay_scoped_desc">为这个已保存的 Hermes 连接添加可选的 Relay 扩展。这不会添加或替换服务器。</string>
@@ -1473,7 +1473,7 @@
<string name="voice_settings_route_relay">Relay</string>
<string name="voice_settings_route_relay_desc">Relay 插件语音——支持按个人资料的提供商和流式语音输出。</string>
<string name="voice_settings_optional">可选</string>
<string name="voice_settings_signin_route_hint">您正通过 %1$s 路由连接,而仪表板登录是按主机区分的——家庭网络上的登录不会延续到此。请在此路由上在 Manage 中登录一次,以在此处解锁语音。</string>
<string name="voice_settings_signin_route_hint">无法在 %1$s 路由上复用已保存的仪表板会话。请打开“管理”重新登录并解锁语音。</string>
<string name="voice_settings_signin_default_hint">您的 Hermes 仪表板需要先登录,标准语音才能转录或朗读。在 Manage 中登录一次即可为此连接解锁。</string>
<string name="voice_settings_sign_in_via_manage">通过 Manage 登录</string>
<string name="voice_settings_unsupported_build_body">此 Hermes 服务器构建尚未公开仪表板音频路由。请在服务器上更新 hermes-agent,或配对 Relay 以使用 Relay 语音。</string>
@@ -1712,7 +1712,7 @@
<!-- Sign-in card -->
<string name="dashboard_signin_required_title">需要登录仪表盘</string>
<string name="dashboard_signin_required_body">管理功能使用位于 %1$s 的 Hermes 仪表盘会话。</string>
<string name="dashboard_signin_route_hint">你当前使用 %1$s 路由。仪表盘登录按主机区分,所以在另一条路由上的登录不会自动延续——在这里登录一次,应用会同时保留两个会话。</string>
<string name="dashboard_signin_route_hint">无法在 %1$s 路由上复用已保存的仪表板会话。请重新登录,以便在此连接的可信路由之间刷新会话。</string>
<string name="dashboard_signin_with_provider">使用 %1$s 登录</string>
<string name="dashboard_username_password">用户名与密码</string>
<string name="dashboard_username">用户名</string>
@@ -1763,6 +1763,31 @@
<string name="dashboard_action_use">使用</string>
<string name="dashboard_action_describe">描述</string>
<string name="dashboard_action_model">模型</string>
<string name="dashboard_tab_custom_endpoints">端点</string>
<string name="dashboard_tab_custom_endpoints_lower">端点</string>
<string name="dashboard_tile_custom_endpoints_title">自定义端点</string>
<string name="dashboard_tile_custom_endpoints_sub">OpenAI 兼容提供商</string>
<string name="dashboard_action_authenticate">认证</string>
<string name="dashboard_action_validate">验证</string>
<string name="dashboard_action_edit">编辑</string>
<string name="dashboard_mcp_oauth_title">认证 %1$s</string>
<string name="dashboard_mcp_oauth_body">Hermes 将在浏览器中打开提供商。批准访问后请返回此页面;凭据将保留在 Hermes 服务器上。</string>
<string name="dashboard_mcp_oauth_approved">MCP 认证已获批准</string>
<string name="dashboard_mcp_oauth_failed">MCP 认证失败</string>
<string name="dashboard_mcp_oauth_no_browser">没有可用于完成 MCP 认证的浏览器。</string>
<string name="dashboard_custom_endpoint_add">添加端点</string>
<string name="dashboard_custom_endpoint_edit">编辑端点</string>
<string name="dashboard_custom_endpoint_name">名称</string>
<string name="dashboard_custom_endpoint_url">基础 URL</string>
<string name="dashboard_custom_endpoint_model">默认模型</string>
<string name="dashboard_custom_endpoint_key">API 密钥(可选)</string>
<string name="dashboard_custom_endpoint_key_help">保存时留空可保留现有密钥。验证仅使用在此输入的密钥;Hermes 不会显示或清除已保存的密钥。</string>
<string name="dashboard_custom_endpoint_context">上下文长度(可选)</string>
<string name="dashboard_custom_endpoint_discover">从 /models 发现模型</string>
<string name="dashboard_custom_endpoint_valid">端点可访问 · %1$d 个模型</string>
<string name="dashboard_custom_endpoint_validate_failed">端点验证失败</string>
<string name="dashboard_custom_endpoint_save_failed">端点保存失败</string>
<string name="dashboard_custom_endpoint_saved">自定义端点已保存</string>
<string name="dashboard_action_completed">%1$s 已完成</string>
<string name="dashboard_action_failed">%1$s 失败</string>
<string name="dashboard_more">更多</string>
@@ -2077,7 +2102,7 @@
<string name="active_section_api_key_already_set">API 密钥已设置</string>
<string name="active_section_api_key_needed_hint">使用在 Hermes 服务器上创建的 API_SERVER_KEY。此密钥并非由应用提供。</string>
<string name="active_section_api_key_not_configured">API 密钥未配置</string>
<string name="active_section_api_key_optional">API 密钥(可选)</string>
<string name="active_section_api_key_optional">直接 API 的 API 密钥</string>
<string name="active_section_api_key_stored_hint">密钥已安全存储。</string>
<string name="active_section_api_reachable_voice_review">API 可达——请检查语音配置</string>
<string name="active_section_api_relay_voice_reachable">API 可达——Relay 语音已配置</string>
@@ -2452,6 +2477,7 @@
<string name="tool_progress_status_completed">已完成</string>
<string name="tool_progress_status_failed">失败</string>
<string name="tool_progress_status_running">运行中</string>
<string name="image_generation_rendering">正在生成图像</string>
<string name="tool_progress_cd_collapse">折叠</string>
<string name="tool_progress_cd_expand">展开</string>
@@ -3217,9 +3243,20 @@
<string name="active_section_optional_api_fallback">可选的直接 API 回退</string>
<string name="active_section_api_not_required">此连接使用 Hermes Dashboard 时不需要配置。</string>
<string name="active_section_where_api_key">从哪里获取此密钥?</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY 需要在 Hermes 服务器上创建,并非由此应用提供。仅在启用可选 API 服务器时进行配置,并在此输入相同的值。</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY 需要在 Hermes 服务器上创建,并非由此应用提供。仅在启用可选 API 服务器时配置;该服务器需要可用密钥,并在此输入相同的值。</string>
<string name="active_section_scan_relay_qr">扫描 Relay 二维码</string>
<string name="active_section_other_relay_methods">其他配对方式</string>
<string name="endpoints_tailscale_setup_hint">两台设备都必须连接 Tailscale,并且 Hermes Dashboard 必须可通过端口 9119 访问。</string>
<string name="endpoints_setup_help">Tailscale 设置帮助</string>
<string name="voice_settings_provider_desc">语音生成的位置。可用选项来自此 Hermes 主机及其已安装的提供商。</string>
<string name="voice_settings_model_desc">Latest 会跟随提供商升级。选择带版本的模型可固定语音行为。</string>
<string name="voice_settings_voice_desc">选择回复的声音。预览语音不会保存选择。</string>
<string name="voice_settings_language_desc">自动会让提供商检测口语语言。仅在检测不可靠时选择语言。</string>
<string name="voice_settings_language_auto">自动</string>
<string name="voice_settings_provider_options_title">语音选项</string>
<string name="voice_settings_provider_options_desc">只显示所选提供商支持的设置。</string>
<string name="voice_settings_auto_speak">朗读回复</string>
<string name="voice_settings_auto_speak_desc">在遵循此主机设置的 Hermes 界面上自动朗读助手回复。</string>
<string name="voice_settings_realtime_model_desc">控制实时语音会话,而不是 Hermes 聊天模型。Latest 会跟随提供商升级;带版本的模型会保持固定。</string>
<string name="voice_settings_realtime_voice_desc">实时会话中使用的语音。当提供商公布内置或自定义语音时,它们会显示出来。</string>
</resources>
+44 -7
View File
@@ -70,7 +70,7 @@
<string name="chat_input_start_voice">Sprachgespräch starten</string>
<string name="chat_input_voice_setup_needed">Sprachgespräch — Einrichtung erforderlich</string>
<string name="chat_input_stop_streaming">Streaming stoppen</string>
<string name="chat_input_steer_response">Antwort steuern</string>
<string name="chat_input_steer_response">Antwort korrigieren</string>
<string name="chat_input_queue_message">Nachricht einreihen</string>
<!-- Bridge return labels -->
@@ -101,7 +101,7 @@
<!-- Chat screen placeholders -->
<string name="chat_placeholder_edit">Nachricht bearbeiten…</string>
<string name="chat_placeholder_steer">Antwort steuern…</string>
<string name="chat_placeholder_steer">Antwort korrigieren…</string>
<string name="chat_placeholder_queue">Nachricht einreihen…</string>
<string name="chat_placeholder_message">Nachricht…</string>
<string name="chat_edit_busy_snackbar">Bearbeiten derzeit nicht möglich — warte, bis der aktuelle Durchlauf beendet ist</string>
@@ -353,7 +353,7 @@
<string name="cw_api_url_placeholder">192.168.1.10 oder http://dein-server:8642</string>
<string name="cw_api_url_supporting">Von Chat und Sitzungen verwendete Hermes-API — bei reinen Hosts werden API-Port 8642 und http:// angenommen (Dashboard-Port 9119 wird separat abgeleitet)</string>
<string name="cw_scan_message">Dieses LAN wird nach Hermes-Dashboard/API durchsucht…</string>
<string name="cw_dashboard_signin_hint">Melde dich über das Dashboard an, um Verwaltung und Sprache freizuschalten — der API-Schlüssel ist für Chat optional.</string>
<string name="cw_dashboard_signin_hint">Melde dich über das Dashboard an, um Verwaltung und Sprache freizuschalten — der API-Schlüssel gilt nur für den optionalen direkten API-Fallback.</string>
<string name="cw_pair_relay_section">Relay koppeln (optional)</string>
<string name="cw_pair_relay_for">Relay mit %1$s koppeln</string>
<string name="cw_pair_relay_scoped_desc">Füge dieser gespeicherten Hermes-Verbindung die optionale Relay-Erweiterung hinzu. Dadurch wird kein Server hinzugefügt oder ersetzt.</string>
@@ -1476,7 +1476,7 @@
<string name="voice_settings_route_relay">Relay</string>
<string name="voice_settings_route_relay_desc">Sprachfunktion des Relay-Plugins — profilbezogene Anbieter und Streaming-Sprachausgabe.</string>
<string name="voice_settings_optional">Optional</string>
<string name="voice_settings_signin_route_hint">Du bist über die Route %1$s verbunden, und Dashboard-Anmeldungen gelten pro Host — eine Anmeldung aus deinem Heimnetz wird nicht übernommen. Melde dich unter Verwalten einmal über diese Route an, um auch hier Sprache freizuschalten.</string>
<string name="voice_settings_signin_route_hint">Die gespeicherte Dashboard-Sitzung konnte über die Route %1$s nicht wiederverwendet werden. Öffne Verwalten, um dich erneut anzumelden und Sprache freizuschalten.</string>
<string name="voice_settings_signin_default_hint">Dein Hermes-Dashboard erfordert eine Anmeldung, bevor Standardsprache transkribieren oder sprechen kann. Eine einmalige Anmeldung unter Verwalten schaltet sie für diese Verbindung frei.</string>
<string name="voice_settings_sign_in_via_manage">Über Verwalten anmelden</string>
<string name="voice_settings_unsupported_build_body">Dieser Hermes-Server-Build stellt die Dashboard-Audiorouten noch nicht bereit. Aktualisiere hermes-agent auf dem Server oder kopple Relay, um Relay-Sprache zu verwenden.</string>
@@ -1715,7 +1715,7 @@
<!-- Sign-in card -->
<string name="dashboard_signin_required_title">Dashboard-Anmeldung erforderlich</string>
<string name="dashboard_signin_required_body">Verwalten verwendet die Hermes-Dashboard-Sitzung unter %1$s.</string>
<string name="dashboard_signin_route_hint">Du verwendest die Route %1$s. Dashboard-Anmeldungen gelten pro Host, daher wird deine Anmeldung über die andere Route nicht übernommen — melde dich hier einmal an, dann behält die App beide Sitzungen.</string>
<string name="dashboard_signin_route_hint">Die gespeicherte Dashboard-Sitzung konnte über die Route %1$s nicht wiederverwendet werden. Melde dich erneut an, um sie für die vertrauenswürdigen Routen dieser Verbindung zu aktualisieren.</string>
<string name="dashboard_signin_with_provider">Mit %1$s anmelden</string>
<string name="dashboard_username_password">Benutzername &amp; Passwort</string>
<string name="dashboard_username">Benutzername</string>
@@ -1766,6 +1766,31 @@
<string name="dashboard_action_use">Verwenden</string>
<string name="dashboard_action_describe">Beschreiben</string>
<string name="dashboard_action_model">Modell</string>
<string name="dashboard_tab_custom_endpoints">Endpunkte</string>
<string name="dashboard_tab_custom_endpoints_lower">Endpunkte</string>
<string name="dashboard_tile_custom_endpoints_title">Benutzerdefinierte Endpunkte</string>
<string name="dashboard_tile_custom_endpoints_sub">OpenAI-kompatible Anbieter</string>
<string name="dashboard_action_authenticate">Authentifizieren</string>
<string name="dashboard_action_validate">Validieren</string>
<string name="dashboard_action_edit">Bearbeiten</string>
<string name="dashboard_mcp_oauth_title">%1$s authentifizieren</string>
<string name="dashboard_mcp_oauth_body">Hermes öffnet den Anbieter in Ihrem Browser. Kehren Sie nach der Genehmigung des Zugriffs hierher zurück; die Anmeldedaten verbleiben auf dem Hermes-Server.</string>
<string name="dashboard_mcp_oauth_approved">MCP-Authentifizierung genehmigt</string>
<string name="dashboard_mcp_oauth_failed">MCP-Authentifizierung fehlgeschlagen</string>
<string name="dashboard_mcp_oauth_no_browser">Zum Abschluss der MCP-Authentifizierung ist kein Browser verfügbar.</string>
<string name="dashboard_custom_endpoint_add">Endpunkt hinzufügen</string>
<string name="dashboard_custom_endpoint_edit">Endpunkt bearbeiten</string>
<string name="dashboard_custom_endpoint_name">Name</string>
<string name="dashboard_custom_endpoint_url">Basis-URL</string>
<string name="dashboard_custom_endpoint_model">Standardmodell</string>
<string name="dashboard_custom_endpoint_key">API-Schlüssel (optional)</string>
<string name="dashboard_custom_endpoint_key_help">Lassen Sie das Feld leer, um beim Speichern einen vorhandenen Schlüssel beizubehalten. Für die Validierung wird nur ein hier eingegebener Schlüssel verwendet; Hermes legt gespeicherte Schlüssel weder offen noch löscht es sie.</string>
<string name="dashboard_custom_endpoint_context">Kontextlänge (optional)</string>
<string name="dashboard_custom_endpoint_discover">Modelle über /models ermitteln</string>
<string name="dashboard_custom_endpoint_valid">Endpunkt erreichbar · %1$d Modell(e)</string>
<string name="dashboard_custom_endpoint_validate_failed">Endpunktvalidierung fehlgeschlagen</string>
<string name="dashboard_custom_endpoint_save_failed">Speichern des Endpunkts fehlgeschlagen</string>
<string name="dashboard_custom_endpoint_saved">Benutzerdefinierter Endpunkt gespeichert</string>
<string name="dashboard_action_completed">%1$s abgeschlossen</string>
<string name="dashboard_action_failed">%1$s fehlgeschlagen</string>
<string name="dashboard_more">Mehr</string>
@@ -2080,7 +2105,7 @@
<string name="active_section_api_key_already_set">API-Schlüssel ist bereits festgelegt</string>
<string name="active_section_api_key_needed_hint">Verwende den auf deinem Hermes-Server erstellten API_SERVER_KEY. Die App stellt diesen Schlüssel nicht aus.</string>
<string name="active_section_api_key_not_configured">API-Schlüssel nicht konfiguriert</string>
<string name="active_section_api_key_optional">API-Schlüssel (optional)</string>
<string name="active_section_api_key_optional">API-Schlüssel für direkte API</string>
<string name="active_section_api_key_stored_hint">Schlüssel ist sicher gespeichert.</string>
<string name="active_section_api_reachable_voice_review">API erreichbar — Sprachkonfiguration prüfen</string>
<string name="active_section_api_relay_voice_reachable">API erreichbar — Relay-Sprache ist konfiguriert</string>
@@ -2455,6 +2480,7 @@
<string name="tool_progress_status_completed">abgeschlossen</string>
<string name="tool_progress_status_failed">fehlgeschlagen</string>
<string name="tool_progress_status_running">läuft</string>
<string name="image_generation_rendering">Bild wird erstellt</string>
<string name="tool_progress_cd_collapse">Einklappen</string>
<string name="tool_progress_cd_expand">Ausklappen</string>
@@ -3283,9 +3309,20 @@
<string name="active_section_optional_api_fallback">Optionaler direkter API-Fallback</string>
<string name="active_section_api_not_required">Nicht erforderlich, wenn diese Verbindung das Hermes Dashboard verwendet.</string>
<string name="active_section_where_api_key">Wo erhalte ich diesen Schlüssel?</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY wird auf deinem Hermes-Server erstellt und nicht von dieser App bereitgestellt. Konfiguriere ihn nur für den optionalen API-Server und gib hier denselben Wert ein.</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY wird auf deinem Hermes-Server erstellt und nicht von dieser App bereitgestellt. Konfiguriere ihn nur für den optionalen API-Server, der einen verwendbaren Schlüssel verlangt, und gib hier denselben Wert ein.</string>
<string name="active_section_scan_relay_qr">Relay-QR scannen</string>
<string name="active_section_other_relay_methods">Andere Kopplungsmethoden</string>
<string name="endpoints_tailscale_setup_hint">Tailscale muss auf beiden Geräten verbunden und Hermes Dashboard auf Port 9119 erreichbar sein.</string>
<string name="endpoints_setup_help">Tailscale-Einrichtungshilfe</string>
<string name="voice_settings_provider_desc">Wo Sprache erzeugt wird. Verfuegbare Optionen kommen von diesem Hermes-Host und seinen installierten Anbietern.</string>
<string name="voice_settings_model_desc">Latest folgt Anbieter-Upgrades. Waehle ein versioniertes Modell, um das Sprachverhalten festzuhalten.</string>
<string name="voice_settings_voice_desc">Waehle, wie Antworten klingen. Eine Stimmvorschau speichert die Auswahl nicht.</string>
<string name="voice_settings_language_desc">Automatisch laesst den Anbieter die gesprochene Sprache erkennen. Waehle nur dann eine Sprache, wenn die Erkennung unzuverlaessig ist.</string>
<string name="voice_settings_language_auto">Automatisch</string>
<string name="voice_settings_provider_options_title">Sprachoptionen</string>
<string name="voice_settings_provider_options_desc">Nur Einstellungen, die der ausgewaehlte Anbieter unterstuetzt, werden angezeigt.</string>
<string name="voice_settings_auto_speak">Antworten vorlesen</string>
<string name="voice_settings_auto_speak_desc">Spricht Assistentenantworten automatisch auf Hermes-Oberflaechen, die diese Host-Einstellung beachten.</string>
<string name="voice_settings_realtime_model_desc">Steuert die Live-Sprachsitzung, nicht das Hermes-Chatmodell. Latest folgt Anbieter-Upgrades; ein versioniertes Modell bleibt festgelegt.</string>
<string name="voice_settings_realtime_voice_desc">Die Stimme innerhalb der Live-Sitzung. Eingebaute und benutzerdefinierte Stimmen erscheinen, wenn der Anbieter sie meldet.</string>
</resources>
+44 -7
View File
@@ -63,7 +63,7 @@
<string name="chat_input_start_voice">Iniciar conversación de voz</string>
<string name="chat_input_voice_setup_needed">Conversación de voz: se necesita configuración</string>
<string name="chat_input_stop_streaming">dejar de transmitir</string>
<string name="chat_input_steer_response">Dirigir la respuesta</string>
<string name="chat_input_steer_response">Corregir la respuesta</string>
<string name="chat_input_queue_message">mensaje de cola</string>
<string name="bridge_return_chat_label">Charlar</string>
<string name="bridge_return_manage_label">Administrar</string>
@@ -84,7 +84,7 @@
<string name="status_profile_format">perfil: %1$s</string>
<string name="demo_feature_manage">Administrar</string>
<string name="chat_placeholder_edit">Edita tu mensaje...</string>
<string name="chat_placeholder_steer">Dirige la respuesta...</string>
<string name="chat_placeholder_steer">Corrige la respuesta...</string>
<string name="chat_placeholder_queue">Poner en cola un mensaje...</string>
<string name="chat_placeholder_message">Mensaje…</string>
<string name="chat_edit_busy_snackbar">No se puede editar en este momento: espera a que termine el turno actual</string>
@@ -311,7 +311,7 @@
<string name="cw_api_url_placeholder">192.168.1.10 o http://your-server:8642</string>
<string name="cw_api_url_supporting">Hermes API utilizado por Chat y sesiones — API puerto 8642 y http:// asumido para hosts desnudos (el 9119 del tablero se deriva por separado)</string>
<string name="cw_scan_message">Escaneando esta LAN en busca de Hermes dashboard/API…</string>
<string name="cw_dashboard_signin_hint">Inicie sesión a través del panel para desbloquear Administrar y voz: la clave API es opcional para Chat.</string>
<string name="cw_dashboard_signin_hint">Inicie sesión a través del panel para desbloquear Administrar y voz: la clave API solo se usa para la alternativa directa opcional mediante API.</string>
<string name="cw_pair_relay_section">Emparejar Relay (opcional)</string>
<string name="cw_pair_relay_section_desc">¿Ya estás ejecutando el complemento Relay? Emparéjelo aquí para habilitar Terminal, Bridge y concesiones de canales.</string>
<string name="cw_pair_relay_url_label">URL de Relay</string>
@@ -1352,7 +1352,7 @@
<string name="voice_settings_route_relay">Relay</string>
<string name="voice_settings_route_relay_desc">Complemento de voz Relay: proveedores con reconocimiento de perfiles y salida de voz en streaming.</string>
<string name="voice_settings_optional">Opcional</string>
<string name="voice_settings_signin_route_hint">Estás conectado a través de la ruta %1$s y los inicios de sesión en el panel de control son por anfitrión: un inicio de sesión desde tu red doméstica no se transfiere. Inicia sesión una vez en Gestionar mientras estás en esta ruta para desbloquear la voz aquí también.</string>
<string name="voice_settings_signin_route_hint">No se pudo reutilizar la sesión guardada del panel en la ruta %1$s. Abre Gestionar para volver a iniciar sesión y desbloquear la voz.</string>
<string name="voice_settings_signin_default_hint">Su panel Hermes requiere iniciar sesión antes de que la voz estándar pueda transcribir o hablar. Iniciar sesión una vez en Administrar lo desbloquea para esta conexión.</string>
<string name="voice_settings_sign_in_via_manage">Iniciar sesión a través de Administrar</string>
<string name="voice_settings_unsupported_build_body">Esta compilación del servidor Hermes aún no expone las rutas de audio del panel. Actualice el agente hermes en el servidor o empareje Relay para usar la voz Relay.</string>
@@ -1571,7 +1571,7 @@
<string name="dashboard_hours_ago">Hace %1$dh</string>
<string name="dashboard_signin_required_title">Es necesario iniciar sesión en el panel</string>
<string name="dashboard_signin_required_body">Administrar utiliza la sesión del panel Hermes en %1$s.</string>
<string name="dashboard_signin_route_hint">Estás en la ruta %1$s. Los inicios de sesión en el panel de control son por anfitrión, por lo que tu inicio de sesión desde la otra ruta no se transfiere. Inicia sesión una vez aquí y la aplicación mantendrá ambas sesiones.</string>
<string name="dashboard_signin_route_hint">No se pudo reutilizar la sesión guardada del panel en la ruta %1$s. Vuelve a iniciar sesión para actualizarla en las rutas de confianza de esta conexión.</string>
<string name="dashboard_signin_with_provider">Iniciar sesión con %1$s</string>
<string name="dashboard_username_password">Nombre de usuario y contraseña</string>
<string name="dashboard_username">Nombre de usuario</string>
@@ -1612,6 +1612,31 @@
<string name="dashboard_action_use">Usar</string>
<string name="dashboard_action_describe">Describir</string>
<string name="dashboard_action_model">Modelo</string>
<string name="dashboard_tab_custom_endpoints">Puntos de conexión</string>
<string name="dashboard_tab_custom_endpoints_lower">puntos de conexión</string>
<string name="dashboard_tile_custom_endpoints_title">Puntos de conexión personalizados</string>
<string name="dashboard_tile_custom_endpoints_sub">Proveedores compatibles con OpenAI</string>
<string name="dashboard_action_authenticate">Autenticar</string>
<string name="dashboard_action_validate">Validar</string>
<string name="dashboard_action_edit">Editar</string>
<string name="dashboard_mcp_oauth_title">Autenticar %1$s</string>
<string name="dashboard_mcp_oauth_body">Hermes abrirá el proveedor en el navegador. Regrese aquí después de aprobar el acceso; las credenciales permanecen en el servidor de Hermes.</string>
<string name="dashboard_mcp_oauth_approved">Autenticación MCP aprobada</string>
<string name="dashboard_mcp_oauth_failed">Error de autenticación MCP</string>
<string name="dashboard_mcp_oauth_no_browser">No hay ningún navegador disponible para completar la autenticación MCP.</string>
<string name="dashboard_custom_endpoint_add">Agregar punto de conexión</string>
<string name="dashboard_custom_endpoint_edit">Editar punto de conexión</string>
<string name="dashboard_custom_endpoint_name">Nombre</string>
<string name="dashboard_custom_endpoint_url">URL base</string>
<string name="dashboard_custom_endpoint_model">Modelo predeterminado</string>
<string name="dashboard_custom_endpoint_key">Clave de API (opcional)</string>
<string name="dashboard_custom_endpoint_key_help">Déjelo en blanco para conservar una clave existente al guardar. La validación solo usa la clave introducida aquí; Hermes no muestra ni borra las claves guardadas.</string>
<string name="dashboard_custom_endpoint_context">Longitud de contexto (opcional)</string>
<string name="dashboard_custom_endpoint_discover">Detectar modelos desde /models</string>
<string name="dashboard_custom_endpoint_valid">Punto de conexión accesible · %1$d modelo(s)</string>
<string name="dashboard_custom_endpoint_validate_failed">Error al validar el punto de conexión</string>
<string name="dashboard_custom_endpoint_save_failed">Error al guardar el punto de conexión</string>
<string name="dashboard_custom_endpoint_saved">Punto de conexión personalizado guardado</string>
<string name="dashboard_action_completed">%1$s completado</string>
<string name="dashboard_action_failed">%1$s falló</string>
<string name="dashboard_more">Más</string>
@@ -1890,7 +1915,7 @@
<string name="active_section_api_key_already_set">La clave API ya está configurada</string>
<string name="active_section_api_key_needed_hint">Use el API_SERVER_KEY creado en su servidor Hermes. La aplicación no proporciona esta clave.</string>
<string name="active_section_api_key_not_configured">Clave API no configurada</string>
<string name="active_section_api_key_optional">Clave API (opcional)</string>
<string name="active_section_api_key_optional">Clave API para API directa</string>
<string name="active_section_api_key_stored_hint">La clave se almacena de forma segura.</string>
<string name="active_section_api_reachable_voice_review">API accesible: revisar la configuración de voz</string>
<string name="active_section_api_relay_voice_reachable">API accesible: la voz Relay está configurada</string>
@@ -2229,6 +2254,7 @@
<string name="tool_progress_status_completed">terminado</string>
<string name="tool_progress_status_failed">fallido</string>
<string name="tool_progress_status_running">correr</string>
<string name="image_generation_rendering">Generando imagen</string>
<string name="tool_progress_cd_collapse">Colapsar</string>
<string name="tool_progress_cd_expand">Expandir</string>
<string name="agent_icon_title">Icono de agente</string>
@@ -2966,11 +2992,22 @@
<string name="active_section_optional_api_fallback">Alternativa directa opcional mediante API</string>
<string name="active_section_api_not_required">No es necesaria cuando esta conexión usa Hermes Dashboard.</string>
<string name="active_section_where_api_key">¿Dónde obtengo esta clave?</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY se crea en su servidor Hermes; esta aplicación no la proporciona. Configúrela solo al habilitar el servidor API opcional e introduzca aquí el mismo valor.</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY se crea en su servidor Hermes; esta aplicación no la proporciona. Configúrela solo al habilitar el servidor API opcional, que requiere una clave utilizable, e introduzca aquí el mismo valor.</string>
<string name="active_section_choose_how_phone_reaches_named">Cómo accede este teléfono a %1$s</string>
<string name="active_section_dashboard_home_lan">Dashboard · Red LAN doméstica</string>
<string name="active_section_scan_relay_qr">Escanear QR de Relay</string>
<string name="active_section_other_relay_methods">Otros métodos de vinculación</string>
<string name="endpoints_tailscale_setup_hint">Tailscale debe estar conectado en ambos dispositivos y Hermes Dashboard debe ser accesible por el puerto 9119.</string>
<string name="endpoints_setup_help">Ayuda para configurar Tailscale</string>
<string name="voice_settings_provider_desc">Dónde se genera la voz. Las opciones disponibles vienen de este host Hermes y sus proveedores instalados.</string>
<string name="voice_settings_model_desc">Latest sigue las actualizaciones del proveedor. Elige un modelo con versión para mantener fija la voz.</string>
<string name="voice_settings_voice_desc">Elige cómo suenan las respuestas. La vista previa de una voz no guarda la selección.</string>
<string name="voice_settings_language_desc">Automático permite que el proveedor detecte el idioma hablado. Elige un idioma solo si la detección no es fiable.</string>
<string name="voice_settings_language_auto">Automático</string>
<string name="voice_settings_provider_options_title">Opciones de voz</string>
<string name="voice_settings_provider_options_desc">Solo se muestran los ajustes compatibles con el proveedor seleccionado.</string>
<string name="voice_settings_auto_speak">Leer respuestas en voz alta</string>
<string name="voice_settings_auto_speak_desc">Habla automáticamente las respuestas del asistente en las superficies de Hermes que respetan este ajuste del host.</string>
<string name="voice_settings_realtime_model_desc">Controla la sesión de voz en vivo, no el modelo de chat de Hermes. Latest sigue las actualizaciones del proveedor; un modelo con versión queda fijo.</string>
<string name="voice_settings_realtime_voice_desc">La voz usada dentro de la sesión en vivo. Las voces integradas y personalizadas aparecen cuando el proveedor las anuncia.</string>
</resources>
+44 -7
View File
@@ -70,7 +70,7 @@
<string name="chat_input_start_voice">音声会話を開始する</string>
<string name="chat_input_voice_setup_needed">音声会話 - 設定が必要です</string>
<string name="chat_input_stop_streaming">ストリーミングを停止する</string>
<string name="chat_input_steer_response">応答を制御する</string>
<string name="chat_input_steer_response">応答を修正</string>
<string name="chat_input_queue_message">キューメッセージ</string>
<!-- Bridge return labels -->
@@ -101,7 +101,7 @@
<!-- Chat screen placeholders -->
<string name="chat_placeholder_edit">メッセージを編集してください…</string>
<string name="chat_placeholder_steer">応答を制御します…</string>
<string name="chat_placeholder_steer">応答を修正…</string>
<string name="chat_placeholder_queue">メッセージをキューに入れます…</string>
<string name="chat_placeholder_message">メッセージ…</string>
<string name="chat_edit_busy_snackbar">現在編集できません - 現在のターンが終了するまで待ちます</string>
@@ -353,7 +353,7 @@
<string name="cw_api_url_placeholder">192.168.1.10 または http://your-server:8642</string>
<string name="cw_api_url_supporting">Hermes API チャットとセッションで使用されます — API ポート 8642 および http:// はベア ホストとして想定されます (ダッシュボードの 9119 は個別に派生します)</string>
<string name="cw_scan_message">この LAN をスキャンして Hermes ダッシュボード/API を探しています…</string>
<string name="cw_dashboard_signin_hint">ダッシュボード経由でサインインして、管理と音声のロックを解除します。チャットでは API キーはオプションです。</string>
<string name="cw_dashboard_signin_hint">ダッシュボード経由でサインインして、管理と音声のロックを解除します。API キーは任意の直接 API フォールバックでのみ使います。</string>
<string name="cw_pair_relay_section">Relay のペア (オプション)</string>
<string name="cw_pair_relay_section_desc">すでに Relay プラグインを実行していますか?ここでペアリングすると、ターミナル、Bridge、およびチャネル許可が有効になります。</string>
<string name="cw_pair_relay_url_label">Relay URL</string>
@@ -1489,7 +1489,7 @@
<string name="voice_settings_route_relay">Relay</string>
<string name="voice_settings_route_relay_desc">Relay プラグイン音声 — プロファイル対応プロバイダーとストリーミング音声出力。</string>
<string name="voice_settings_optional">オプション</string>
<string name="voice_settings_signin_route_hint">%1$s ルート経由で接続されており、ダッシュボードのサインインはホストごとに行われます。ホーム ネットワークからのサインインは引き継がれません。このルートの途中で [管理] に一度サインインすると、ここでも音声のロックが解除されます。</string>
<string name="voice_settings_signin_route_hint">%1$s ルートで保存済みのダッシュボード セッションを再利用できませんでした。[管理] を開いて再度サインインし、音声を有効にしてください。</string>
<string name="voice_settings_signin_default_hint">Hermes ダッシュボードでは、標準音声を書き起こしたり話したりする前にサインインする必要があります。管理に一度サインインすると、この接続のロックが解除されます。</string>
<string name="voice_settings_sign_in_via_manage">「管理」経由でサインインする</string>
<string name="voice_settings_unsupported_build_body">この Hermes サーバー ビルドでは、ダッシュボードのオーディオ ルートがまだ公開されていません。サーバー上の hermes-agent を更新するか、Relay をペアにして Relay 音声を使用します。</string>
@@ -1728,7 +1728,7 @@
<!-- Sign-in card -->
<string name="dashboard_signin_required_title">ダッシュボードへのサインインが必要です</string>
<string name="dashboard_signin_required_body">管理は、%1$s で Hermes ダッシュボード セッションを使用します。</string>
<string name="dashboard_signin_route_hint">あなたは %1$s ルート上にいます。ダッシュボードのサインインはホストごとに行われるため、他のルートからのサインインは引き継がれません。ここで 1 回サインインすると、アプリは両方のセッションを維持します。</string>
<string name="dashboard_signin_route_hint">%1$s ルートで保存済みのダッシュボード セッションを再利用できませんでした。再度サインインして、この接続の信頼済みルート全体でセッションを更新してください。</string>
<string name="dashboard_signin_with_provider">%1$s でサインインします</string>
<string name="dashboard_username_password">ユーザー名 &amp; パスワード</string>
<string name="dashboard_username">ユーザー名</string>
@@ -1779,6 +1779,31 @@
<string name="dashboard_action_use">使用</string>
<string name="dashboard_action_describe">説明する</string>
<string name="dashboard_action_model">モデル</string>
<string name="dashboard_tab_custom_endpoints">エンドポイント</string>
<string name="dashboard_tab_custom_endpoints_lower">エンドポイント</string>
<string name="dashboard_tile_custom_endpoints_title">カスタムエンドポイント</string>
<string name="dashboard_tile_custom_endpoints_sub">OpenAI 互換プロバイダー</string>
<string name="dashboard_action_authenticate">認証</string>
<string name="dashboard_action_validate">検証</string>
<string name="dashboard_action_edit">編集</string>
<string name="dashboard_mcp_oauth_title">%1$s を認証</string>
<string name="dashboard_mcp_oauth_body">Hermes はブラウザでプロバイダーを開きます。アクセスを承認したら、この画面に戻ってください。認証情報は Hermes サーバーに保存されます。</string>
<string name="dashboard_mcp_oauth_approved">MCP 認証が承認されました</string>
<string name="dashboard_mcp_oauth_failed">MCP 認証に失敗しました</string>
<string name="dashboard_mcp_oauth_no_browser">MCP 認証を完了するためのブラウザを利用できません。</string>
<string name="dashboard_custom_endpoint_add">エンドポイントを追加</string>
<string name="dashboard_custom_endpoint_edit">エンドポイントを編集</string>
<string name="dashboard_custom_endpoint_name">名前</string>
<string name="dashboard_custom_endpoint_url">ベース URL</string>
<string name="dashboard_custom_endpoint_model">デフォルトモデル</string>
<string name="dashboard_custom_endpoint_key">API キー(任意)</string>
<string name="dashboard_custom_endpoint_key_help">保存時に既存のキーを保持するには空欄にしてください。検証にはここで入力したキーのみが使用されます。Hermes が保存済みのキーを表示または削除することはありません。</string>
<string name="dashboard_custom_endpoint_context">コンテキスト長(任意)</string>
<string name="dashboard_custom_endpoint_discover">/models からモデルを検出</string>
<string name="dashboard_custom_endpoint_valid">エンドポイントに接続できました · %1$d モデル</string>
<string name="dashboard_custom_endpoint_validate_failed">エンドポイントの検証に失敗しました</string>
<string name="dashboard_custom_endpoint_save_failed">エンドポイントの保存に失敗しました</string>
<string name="dashboard_custom_endpoint_saved">カスタムエンドポイントを保存しました</string>
<string name="dashboard_action_completed">%1$s 完了</string>
<string name="dashboard_action_failed">%1$s が失敗しました</string>
<string name="dashboard_more">もっと</string>
@@ -2093,7 +2118,7 @@
<string name="active_section_api_key_already_set">API キーはすでに設定されています</string>
<string name="active_section_api_key_needed_hint">Hermes サーバーで作成した API_SERVER_KEY を使用します。このキーはアプリから発行されません。</string>
<string name="active_section_api_key_not_configured">API キーが構成されていません</string>
<string name="active_section_api_key_optional">API キー (オプション)</string>
<string name="active_section_api_key_optional">直接 API の API キー</string>
<string name="active_section_api_key_stored_hint">鍵は安全に保管されます。</string>
<string name="active_section_api_reachable_voice_review">API に到達可能 — 音声設定を確認してください</string>
<string name="active_section_api_relay_voice_reachable">API 到達可能 — Relay 音声が設定されています</string>
@@ -2466,6 +2491,7 @@
<string name="tool_progress_status_completed">完成した</string>
<string name="tool_progress_status_failed">失敗した</string>
<string name="tool_progress_status_running">走っている</string>
<string name="image_generation_rendering">画像を生成中</string>
<string name="tool_progress_cd_collapse">崩壊</string>
<string name="tool_progress_cd_expand">拡大する</string>
@@ -3283,9 +3309,20 @@
<string name="active_section_optional_api_fallback">任意の直接 API フォールバック</string>
<string name="active_section_api_not_required">この接続が Hermes Dashboard を使用する場合は必要ありません。</string>
<string name="active_section_where_api_key">このキーはどこで入手しますか?</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY は Hermes サーバー上で作成します。このアプリからは発行されません。任意の API サーバーを有効にする場合のみ設定し、同じ値をここに入力してください。</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY は Hermes サーバー上で作成します。このアプリからは発行されません。任意の API サーバーを有効にする場合のみ設定します。このサーバーには使用可能なキーが必要です。同じ値をここに入力してください。</string>
<string name="active_section_scan_relay_qr">Relay QR をスキャン</string>
<string name="active_section_other_relay_methods">その他のペアリング方法</string>
<string name="endpoints_tailscale_setup_hint">両方のデバイスで Tailscale に接続し、Hermes Dashboard がポート 9119 で到達可能である必要があります。</string>
<string name="endpoints_setup_help">Tailscale セットアップヘルプ</string>
<string name="voice_settings_provider_desc">音声を生成する場所です。利用可能な選択肢は、この Hermes ホストとインストール済みプロバイダーから取得されます。</string>
<string name="voice_settings_model_desc">Latest はプロバイダーの更新に追従します。音声の動作を固定するには、バージョン付きモデルを選択します。</string>
<string name="voice_settings_voice_desc">返信の声を選択します。音声をプレビューしても選択は保存されません。</string>
<string name="voice_settings_language_desc">自動では、プロバイダーが話し言葉を検出します。検出が不安定な場合だけ言語を選択します。</string>
<string name="voice_settings_language_auto">自動</string>
<string name="voice_settings_provider_options_title">音声オプション</string>
<string name="voice_settings_provider_options_desc">選択したプロバイダーが対応する設定だけを表示します。</string>
<string name="voice_settings_auto_speak">返信を読み上げる</string>
<string name="voice_settings_auto_speak_desc">このホスト設定に対応する Hermes 画面で、アシスタントの返信を自動的に読み上げます。</string>
<string name="voice_settings_realtime_model_desc">Hermes チャットモデルではなく、ライブ音声セッションを制御します。Latest はプロバイダーの更新に追従し、バージョン付きモデルは固定されます。</string>
<string name="voice_settings_realtime_voice_desc">ライブセッション内で使う音声です。プロバイダーが公開している場合、組み込み音声とカスタム音声が表示されます。</string>
</resources>
+63 -20
View File
@@ -70,7 +70,7 @@
<string name="chat_input_start_voice">Start voice conversation</string>
<string name="chat_input_voice_setup_needed">Voice conversation — setup needed</string>
<string name="chat_input_stop_streaming">Stop streaming</string>
<string name="chat_input_steer_response">Steer the response</string>
<string name="chat_input_steer_response">Correct the response</string>
<string name="chat_input_queue_message">Queue message</string>
<!-- Bridge return labels -->
@@ -101,7 +101,7 @@
<!-- Chat screen placeholders -->
<string name="chat_placeholder_edit">Edit your message…</string>
<string name="chat_placeholder_steer">Steer the response…</string>
<string name="chat_placeholder_steer">Correct the response…</string>
<string name="chat_placeholder_queue">Queue a message…</string>
<string name="chat_placeholder_message">Message…</string>
<string name="chat_edit_busy_snackbar">Can\'t edit right now — wait for the current turn to finish</string>
@@ -370,7 +370,7 @@
<string name="cw_api_url_placeholder">192.168.1.10 or http://your-server:8642</string>
<string name="cw_api_url_supporting">Hermes API used by Chat and sessions — API port 8642 and http:// assumed for bare hosts (the dashboard\'s 9119 is derived separately)</string>
<string name="cw_scan_message">Scanning this LAN for Hermes dashboard/API…</string>
<string name="cw_dashboard_signin_hint">Sign in via the dashboard to unlock Manage and voice — the API key is optional for Chat.</string>
<string name="cw_dashboard_signin_hint">Sign in via the dashboard to unlock Manage and voice — the API key is only for the optional direct API fallback.</string>
<string name="cw_pair_relay_section">Pair Relay (optional)</string>
<string name="cw_pair_relay_for">Pair Relay with %1$s</string>
<string name="cw_pair_relay_scoped_desc">Add the optional Relay extension to this saved Hermes connection. This does not add or replace a server.</string>
@@ -888,7 +888,7 @@
<string name="active_section_optional_api_fallback">Optional direct API fallback</string>
<string name="active_section_api_not_required">Not required when this connection uses the Hermes Dashboard.</string>
<string name="active_section_where_api_key">Where do I get this?</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY is created on your Hermes server; it is not supplied by this app. Configure it only when you enable the optional API server, then enter the same value here.</string>
<string name="active_section_api_key_explainer">API_SERVER_KEY is created on your Hermes server; it is not supplied by this app. Configure it only when you enable the optional API server, which requires a usable key, then enter the same value here.</string>
<string name="active_section_core_hermes">Core Hermes</string>
<string name="active_section_optional_relay">Optional Relay</string>
<string name="active_section_extend_connection">Extend this connection</string>
@@ -1540,17 +1540,17 @@
<string name="voice_settings_back">Back</string>
<string name="voice_settings_scope_label">Voice scope</string>
<string name="voice_settings_global_voice">Global voice</string>
<string name="voice_settings_standard_scope_body">This connection speaks through your Hermes server\'s configured TTS and STT (config.yaml on the server, or the dashboard\'s Audio settings) — it\'s host-wide, not per profile. Pair Relay to pick providers, models, and voices from the phone and carry them per profile.</string>
<string name="voice_settings_standard_scope_body">Standard voice follows this Hermes host\'s existing TTS, STT, and voice configuration. These settings are shared across profiles; Relay can add profile-specific voice choices without replacing the host setup.</string>
<string name="voice_settings_scope_resolving">Resolving the active profile\'s voice scope…</string>
<string name="voice_settings_label_profile">Profile</string>
<string name="voice_settings_label_scope">Scope</string>
<string name="voice_settings_relay_scope_footer">Engine, route, and voice picks below are saved for this profile.</string>
<string name="voice_settings_relay_scope_footer">Relay voice choices below override the host defaults for this profile only. Clear an override to follow the Hermes host again.</string>
<string name="voice_settings_for_profile_title">Voice for this profile</string>
<string name="voice_settings_engine_label">Voice engine</string>
<string name="voice_settings_engine_hermes">Hermes Chat + Voice Output</string>
<string name="voice_settings_engine_hermes_desc">Hermes handles chat, tools, and memory; speech runs over the standard Hermes dashboard or Relay — whichever the STT/TTS route below picks.</string>
<string name="voice_settings_engine_hermes_desc">Hermes handles chat, tools, and memory. Speech uses the host\'s Standard configuration unless this profile selects a Relay voice override.</string>
<string name="voice_settings_engine_realtime">Realtime Agent</string>
<string name="voice_settings_engine_realtime_desc">Provider-native realtime speech with Hermes-brokered tools. Requires a paired Relay.</string>
<string name="voice_settings_engine_realtime_desc">A live provider session handles listening and speaking with its own voice model and voice. Hermes still handles tools, memory, and confirmations. Requires a paired Relay.</string>
<string name="voice_settings_experimental">Experimental</string>
<string name="voice_settings_realtime_no_relay">No Relay is configured for this connection, so the Realtime Agent can\'t start. Pair Relay in Settings → Connections, or switch back to Hermes Chat + Voice Output.</string>
<string name="voice_settings_route_label">STT/TTS route</string>
@@ -1564,19 +1564,19 @@
<string name="voice_settings_status_auto_hermes">Ready — using Hermes</string>
<string name="voice_settings_status_no_route">No route available yet</string>
<string name="voice_settings_route_auto">Auto</string>
<string name="voice_settings_route_auto_desc">Relay when paired; otherwise the Hermes dashboard. Recommended.</string>
<string name="voice_settings_route_auto_desc">Use this profile\'s Relay voice when available, then fall back to the Hermes host. Recommended.</string>
<string name="voice_settings_route_hermes">Hermes</string>
<string name="voice_settings_route_hermes_desc">The dashboard audio path Hermes Desktop uses — works on a Hermes install, no Relay plugin required.</string>
<string name="voice_settings_route_relay">Relay</string>
<string name="voice_settings_route_relay_desc">Relay plugin voice — profile-aware providers and streaming voice output.</string>
<string name="voice_settings_route_relay_desc">Use the provider, model, and voice saved for this profile through Relay.</string>
<string name="voice_settings_optional">Optional</string>
<string name="voice_settings_signin_route_hint">You\'re connected over the %1$s route, and dashboard sign-ins are per-host — a sign-in from your home network doesn\'t carry over. Sign in once in Manage while on this route to unlock voice here too.</string>
<string name="voice_settings_signin_route_hint">The saved dashboard session could not be reused on the %1$s route. Open Manage to sign in again and unlock voice.</string>
<string name="voice_settings_signin_default_hint">Your Hermes dashboard requires sign-in before standard voice can transcribe or speak. Signing in once in Manage unlocks it for this connection.</string>
<string name="voice_settings_sign_in_via_manage">Sign in via Manage</string>
<string name="voice_settings_unsupported_build_body">This Hermes server build doesn\'t expose the dashboard audio routes yet. Update hermes-agent on the server, or pair Relay to use Relay voice.</string>
<string name="voice_settings_tts_title">Text-to-Speech</string>
<string name="voice_settings_streaming_output_label">Streaming output (/voice/output)</string>
<string name="voice_settings_streaming_output_desc">The provider, model, and voice the agent speaks with on the Relay path.</string>
<string name="voice_settings_streaming_output_desc">Streams spoken replies with the provider, model, and voice saved for this profile. It does not change the Hermes chat model.</string>
<string name="voice_settings_basic_synthesize_label">Basic synthesize fallback (/voice/synthesize)</string>
<string name="voice_settings_basic_synthesize_desc">Always available as the stable speech safety net when provider-native voice is unavailable.</string>
<string name="voice_settings_provider_unavailable">unavailable</string>
@@ -1584,14 +1584,17 @@
<string name="voice_settings_yes">yes</string>
<string name="voice_settings_no">no</string>
<string name="voice_settings_label_provider">Provider</string>
<string name="voice_settings_provider_desc">Where speech is generated. Available choices come from this Hermes host and its installed providers.</string>
<string name="voice_settings_label_enabled">Enabled</string>
<string name="voice_settings_label_model">Model</string>
<string name="voice_settings_model_desc">Latest follows provider upgrades. Choose a versioned model to keep voice behavior pinned.</string>
<string name="voice_settings_label_voice">Voice</string>
<string name="voice_settings_voice_desc">Choose how replies sound. Previewing a voice does not save the selection.</string>
<string name="voice_settings_provider_gemini">Gemini</string>
<string name="voice_settings_provider_xai">xAI</string>
<string name="voice_settings_hide_enhanced">Hide enhanced voice (%1$s)</string>
<string name="voice_settings_advanced_enhanced">Advanced: enhanced voice (%1$s)</string>
<string name="voice_settings_enhanced_body">Pick a voice and tone for the %1$s TTS provider. Leave a field on \"Server default\" to use the relay\'s saved config.</string>
<string name="voice_settings_enhanced_body">Choose the options advertised by %1$s for this profile. Leave a field on \"Server default\" to follow the Relay host configuration.</string>
<string name="voice_settings_server_default">Server default</string>
<string name="voice_settings_voice_blank_label">Voice (blank = server default)</string>
<string name="voice_settings_supports_tone_tags">supports tone tags</string>
@@ -1599,7 +1602,9 @@
<string name="voice_settings_audio_tags_unsupported">Requires a Gemini 3.1 TTS model — pick one above to enable.</string>
<string name="voice_settings_voice_direction_label">Voice direction (optional)</string>
<string name="voice_settings_voice_direction_placeholder">e.g. Warm, calm narrator; unhurried pace.</string>
<string name="voice_settings_language_label">Language (optional, e.g. en)</string>
<string name="voice_settings_language_label">Language</string>
<string name="voice_settings_language_desc">Automatic lets the provider detect the spoken language. Choose a language only when detection is unreliable.</string>
<string name="voice_settings_language_auto">Automatic</string>
<string name="voice_settings_status_active">active</string>
<string name="voice_settings_status_unavailable">unavailable</string>
<string name="voice_settings_status_disabled">disabled</string>
@@ -1620,7 +1625,11 @@
<string name="voice_settings_model_id">Model ID</string>
<string name="voice_settings_voice_id">Voice ID</string>
<string name="voice_settings_streaming_latency">Streaming latency: %1$d</string>
<string name="voice_settings_fallback_desc">Use legacy Hermes TTS if streaming output fails before audio starts</string>
<string name="voice_settings_fallback_desc">If Relay streaming cannot start, speak through the Hermes host\'s Standard voice.</string>
<string name="voice_settings_provider_options_title">Voice options</string>
<string name="voice_settings_provider_options_desc">Only settings supported by the selected provider are shown.</string>
<string name="voice_settings_auto_speak">Read replies aloud</string>
<string name="voice_settings_auto_speak_desc">Automatically speak assistant replies on Hermes surfaces that honor this host setting.</string>
<string name="voice_settings_expressive_tags">Expressive speech tags</string>
<string name="voice_settings_expressive_tags_desc">Let xAI add tone cues (whisper, laugh, sigh, pitch shifts) to streamed speech.</string>
<string name="voice_settings_saving">Saving...</string>
@@ -1628,7 +1637,9 @@
<string name="voice_settings_save_and_test">Save &amp; test</string>
<string name="voice_settings_sample_rate_must_be_number">Sample rate must be a number</string>
<string name="voice_settings_realtime_agent_title">Realtime Agent</string>
<string name="voice_settings_realtime_agent_desc">Hermes still owns tools and confirmations. Realtime mode may fall back to stable voice if the provider disconnects.</string>
<string name="voice_settings_realtime_agent_desc">Realtime uses its own provider model and voice for a live speech session. It does not replace the Hermes chat model; Hermes still owns tools and confirmations.</string>
<string name="voice_settings_realtime_model_desc">Controls the live speech session, not the Hermes chat model. Latest follows provider upgrades; a versioned model stays pinned.</string>
<string name="voice_settings_realtime_voice_desc">The voice used inside the live session. Built-in and custom voices appear when the provider advertises them.</string>
<string name="voice_settings_detailed_trace">Detailed trace</string>
<string name="voice_settings_detailed_trace_desc">Show compact Hermes status and result provenance in the timeline</string>
<string name="voice_settings_persistent_session">Persistent session</string>
@@ -1643,7 +1654,7 @@
<string name="voice_settings_delivery_speak">Speak</string>
<string name="voice_settings_delivery_notify">Notify</string>
<string name="voice_settings_delivery_show_only">Show only</string>
<string name="voice_settings_realtime_defaults_desc">Server-side realtime voice agent defaults for this profile</string>
<string name="voice_settings_realtime_defaults_desc">Realtime provider, model, and voice saved for this profile. These choices are separate from Standard Hermes voice.</string>
<string name="voice_settings_save_realtime_agent">Save realtime agent</string>
<string name="voice_settings_global_controls_title">Global Voice Controls</string>
<string name="voice_settings_global_controls_desc">These settings apply to both voice engines, on every profile.</string>
@@ -1691,7 +1702,7 @@
<string name="voice_settings_signin_required">Sign in required.</string>
<string name="voice_settings_server_config_title">Server voice config</string>
<string name="voice_settings_standard_badge">Standard</string>
<string name="voice_settings_server_config_desc">Edit the host\'s text-to-speech and speech-to-text settings (config.yaml tts.* / stt.*) — the same values the dashboard\'s Audio settings write. Applies to new voice turns.</string>
<string name="voice_settings_server_config_desc">Edit this host\'s Standard Hermes voice settings (config.yaml tts.*, stt.*, and voice.*), matching the dashboard\'s Audio configuration. Changes are host-wide and apply to new voice turns.</string>
<string name="voice_settings_no_elevenlabs_key">No ElevenLabs API key on the server — set ELEVENLABS_API_KEY in Manage → Keys to pick a voice from a list.</string>
<string name="voice_settings_saving_ellipsis">Saving…</string>
<string name="voice_settings_discard">Discard</string>
@@ -1738,6 +1749,7 @@
<string name="dashboard_tab_cron">Cron</string>
<string name="dashboard_tab_mcp">MCP</string>
<string name="dashboard_tab_catalog">Catalog</string>
<string name="dashboard_tab_custom_endpoints">Endpoints</string>
<string name="dashboard_tab_profiles">Profiles</string>
<string name="dashboard_tab_models">Models</string>
<string name="dashboard_tab_keys">Keys</string>
@@ -1747,6 +1759,7 @@
<string name="dashboard_tab_cron_lower">cron</string>
<string name="dashboard_tab_mcp_lower">mcp</string>
<string name="dashboard_tab_catalog_lower">catalog</string>
<string name="dashboard_tab_custom_endpoints_lower">endpoints</string>
<string name="dashboard_tab_profiles_lower">profiles</string>
<string name="dashboard_tab_models_lower">models</string>
<string name="dashboard_tab_keys_lower">keys</string>
@@ -1763,6 +1776,8 @@
<string name="dashboard_tile_mcp_sub">Servers, status, tools</string>
<string name="dashboard_tile_catalog_title">Catalog</string>
<string name="dashboard_tile_catalog_sub">Discover upstream servers</string>
<string name="dashboard_tile_custom_endpoints_title">Custom Endpoints</string>
<string name="dashboard_tile_custom_endpoints_sub">OpenAI-compatible providers</string>
<string name="dashboard_tile_models_title">Models</string>
<string name="dashboard_tile_models_sub">Pick provider + default model</string>
<string name="dashboard_tile_keys_title">Keys</string>
@@ -1809,7 +1824,7 @@
<!-- Sign-in card -->
<string name="dashboard_signin_required_title">Dashboard sign-in required</string>
<string name="dashboard_signin_required_body">Manage uses the Hermes dashboard session at %1$s.</string>
<string name="dashboard_signin_route_hint">You\'re on the %1$s route. Dashboard sign-ins are per host, so your sign-in from the other route doesn\'t carry over — sign in once here and the app keeps both sessions.</string>
<string name="dashboard_signin_route_hint">The saved dashboard session could not be reused on the %1$s route. Sign in again to refresh it across this connection\'s trusted routes.</string>
<string name="dashboard_signin_with_provider">Sign in with %1$s</string>
<string name="dashboard_username_password">Username &amp; Password</string>
<string name="dashboard_username">Username</string>
@@ -1854,12 +1869,33 @@
<string name="dashboard_action_enable">Enable</string>
<string name="dashboard_action_disable">Disable</string>
<string name="dashboard_action_test">Test</string>
<string name="dashboard_action_authenticate">Authenticate</string>
<string name="dashboard_action_validate">Validate</string>
<string name="dashboard_action_edit">Edit</string>
<string name="dashboard_action_remove">Remove</string>
<string name="dashboard_action_soul">SOUL</string>
<string name="dashboard_action_edit_soul">Edit SOUL</string>
<string name="dashboard_action_use">Use</string>
<string name="dashboard_action_describe">Describe</string>
<string name="dashboard_action_model">Model</string>
<string name="dashboard_mcp_oauth_title">Authenticate %1$s</string>
<string name="dashboard_mcp_oauth_body">Hermes will open the provider in your browser. Return here after approving access; credentials stay on the Hermes server.</string>
<string name="dashboard_mcp_oauth_approved">MCP authentication approved</string>
<string name="dashboard_mcp_oauth_failed">MCP authentication failed</string>
<string name="dashboard_mcp_oauth_no_browser">No browser is available to complete MCP authentication.</string>
<string name="dashboard_custom_endpoint_add">Add endpoint</string>
<string name="dashboard_custom_endpoint_edit">Edit endpoint</string>
<string name="dashboard_custom_endpoint_name">Name</string>
<string name="dashboard_custom_endpoint_url">Base URL</string>
<string name="dashboard_custom_endpoint_model">Default model</string>
<string name="dashboard_custom_endpoint_key">API key (optional)</string>
<string name="dashboard_custom_endpoint_key_help">Leave blank to preserve an existing key when saving. Validation uses only a key entered here; Hermes does not expose or clear saved keys.</string>
<string name="dashboard_custom_endpoint_context">Context length (optional)</string>
<string name="dashboard_custom_endpoint_discover">Discover models from /models</string>
<string name="dashboard_custom_endpoint_valid">Endpoint reachable · %1$d model(s)</string>
<string name="dashboard_custom_endpoint_validate_failed">Endpoint validation failed</string>
<string name="dashboard_custom_endpoint_save_failed">Endpoint save failed</string>
<string name="dashboard_custom_endpoint_saved">Custom endpoint saved</string>
<string name="dashboard_action_completed">%1$s completed</string>
<string name="dashboard_action_failed">%1$s failed</string>
<string name="dashboard_more">More</string>
@@ -2174,7 +2210,7 @@
<string name="active_section_api_key_already_set">API key is already set</string>
<string name="active_section_api_key_needed_hint">Use the API_SERVER_KEY created on your Hermes server. The app does not issue this key.</string>
<string name="active_section_api_key_not_configured">API key not configured</string>
<string name="active_section_api_key_optional">API key (optional)</string>
<string name="active_section_api_key_optional">API key for direct API</string>
<string name="active_section_api_key_stored_hint">Key is stored securely.</string>
<string name="active_section_api_reachable_voice_review">API reachable — review voice config</string>
<string name="active_section_api_relay_voice_reachable">API reachable — Relay voice is configured</string>
@@ -2549,6 +2585,7 @@
<string name="tool_progress_status_completed">completed</string>
<string name="tool_progress_status_failed">failed</string>
<string name="tool_progress_status_running">running</string>
<string name="image_generation_rendering">Rendering image</string>
<string name="tool_progress_cd_collapse">Collapse</string>
<string name="tool_progress_cd_expand">Expand</string>
@@ -3288,4 +3325,10 @@
<string name="tool_output_risk_a11y">, %1$s output risk</string>
<string name="tool_output_risk_findings">Output risk findings</string>
<string name="tool_output_risk_redacted">Sensitive spans were redacted upstream.</string>
<string name="diag_gateway_heartbeat" translatable="false">Gateway loop: %1$s%2$s</string>
<string name="diag_toolsets_inventory" translatable="false">API toolsets: %1$d of %2$d enabled · Relay tools visible: %3$s</string>
<string name="diag_yes" translatable="false">yes</string>
<string name="diag_no" translatable="false">no</string>
<string name="dashboard_nous_terminal_warning" translatable="false">The Nous provider login needs attention on the Hermes host. This is separate from Manage sign-in; chat can continue through another configured provider.</string>
<string name="dashboard_gateway_topology" translatable="false">Gateway: %1$s · Profiles: %2$s · Ports: %3$s</string>
</resources>
@@ -9,12 +9,15 @@
- Visible warning badge when connected over http:// or ws://
- Users are encouraged to set up TLS for production deployments
The app makes NO connections to external services — only user-configured endpoints.
System CAs and CAs deliberately installed in Android's user credential store
are accepted. This expands the available trust anchors without bypassing
certificate-chain, hostname, or Relay certificate-pin verification.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="system" overridePins="false" />
<certificates src="user" overridePins="false" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -42,6 +42,22 @@ class ConnectionDashboardFieldsTest {
assertNull(Connection.deriveDefaultDashboardUrl("ws://localhost:8767"))
}
@Test
fun deriveDefaultApiUrl_usesSameHostAndApiPort() {
assertEquals(
"http://100.75.1.2:8642",
Connection.deriveDefaultApiUrl("http://100.75.1.2:9119"),
)
}
@Test
fun deriveDefaultApiUrl_preservesHttpsAndIpv6Host() {
assertEquals(
"https://[fd7a:115c:a1e0::1]:8642",
Connection.deriveDefaultApiUrl("https://[fd7a:115c:a1e0::1]:9119"),
)
}
@Test
fun resolvedDashboardUrl_usesExplicitOverride() {
val connection = sampleConnection(
@@ -0,0 +1,42 @@
package com.hermesandroid.relay.network
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NetworkSecurityConfigTest {
private val document by lazy {
val config = sequenceOf(
File("src/main/res/xml/network_security_config.xml"),
File("app/src/main/res/xml/network_security_config.xml"),
).firstOrNull(File::isFile)
?: error("network_security_config.xml not found")
DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(config)
}
@Test
fun releaseConfig_trustsOnlySystemAndUserCredentialStores() {
val certificates = document.getElementsByTagName("certificates")
val sources = (0 until certificates.length).map { index ->
certificates.item(index).attributes.getNamedItem("src").nodeValue
}
assertEquals(listOf("system", "user"), sources)
assertTrue(
(0 until certificates.length).all { index ->
certificates.item(index).attributes.getNamedItem("overridePins").nodeValue == "false"
},
)
}
@Test
fun releaseConfig_hasNoTrustOrHostnameBypass() {
assertEquals(0, document.getElementsByTagName("debug-overrides").length)
assertFalse(document.documentElement.textContent.contains("trust-all", ignoreCase = true))
assertFalse(document.documentElement.textContent.contains("ignore tls", ignoreCase = true))
}
}
@@ -0,0 +1,73 @@
package com.hermesandroid.relay.network.relay
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
class RelayHttpClientImageActivityTest {
private lateinit var server: MockWebServer
@Before
fun setUp() {
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun fetchImageActivityDecodesLifecycleAndScopesRequest() = runTest {
server.enqueue(
MockResponse().setResponseCode(200).setBody(
"""
{
"session_id": "session-1",
"profile": "victor",
"activities": [{
"call_id": "call-1",
"tool_name": "image_generate",
"state": "running",
"started_at": 100.0,
"completed_at": null
}]
}
""".trimIndent()
)
)
val client = RelayHttpClient(
okHttpClient = OkHttpClient(),
relayUrlProvider = { server.url("/").toString() },
sessionTokenProvider = { "paired-token" },
)
val snapshot = client.fetchImageActivity("victor", "session-1", 99.0).getOrThrow()
val request = server.takeRequest()
assertEquals("Bearer paired-token", request.getHeader("Authorization"))
assertEquals("victor", request.requestUrl?.queryParameter("profile"))
assertEquals("session-1", request.requestUrl?.queryParameter("session_id"))
assertEquals("call-1", snapshot?.activities?.single()?.callId)
assertEquals("running", snapshot?.activities?.single()?.state)
}
@Test
fun fetchImageActivityTreatsMissingOptionalRouteAsUnsupported() = runTest {
server.enqueue(MockResponse().setResponseCode(404))
val client = RelayHttpClient(
okHttpClient = OkHttpClient(),
relayUrlProvider = { server.url("/").toString() },
sessionTokenProvider = { "paired-token" },
)
assertNull(client.fetchImageActivity("default", "session-1", 99.0).getOrThrow())
}
}
@@ -0,0 +1,48 @@
package com.hermesandroid.relay.network.relay
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class VoiceOutputSessionPayloadTest {
@Test
fun draftOverridesAreIncludedWithoutPersistingConfig() {
val payload = Json.parseToJsonElement(
buildVoiceOutputSessionPayload(
profile = "default",
provider = "xai_tts",
model = "tts-1",
voice = "eve",
sampleRate = 24_000,
language = "en",
),
).jsonObject
assertEquals("default", payload.getValue("profile").jsonPrimitive.content)
assertEquals("xai_tts", payload.getValue("provider").jsonPrimitive.content)
assertEquals("tts-1", payload.getValue("model").jsonPrimitive.content)
assertEquals("eve", payload.getValue("voice").jsonPrimitive.content)
assertEquals(24_000, payload.getValue("sample_rate").jsonPrimitive.int)
assertEquals("en", payload.getValue("language").jsonPrimitive.content)
assertFalse(payload.containsKey("enabled"))
}
@Test
fun blankDraftValuesFallBackToSavedServerDefaults() {
val payload = Json.parseToJsonElement(
buildVoiceOutputSessionPayload(
profile = "default",
provider = " ",
model = "",
voice = null,
),
).jsonObject
assertEquals(setOf("profile"), payload.keys)
}
}
@@ -25,7 +25,7 @@ import java.util.concurrent.atomic.AtomicLong
* network-aware switching" (2026-04-19).
*
* Uses [MockWebServer] to stand up real loopback sockets so the
* OkHttp → HEAD /health path exercises the production code unchanged.
* OkHttp → GET /health path exercises the production code unchanged.
* A test-local mutable clock drives cache-TTL assertions deterministically
* without `Thread.sleep`.
*/
@@ -395,6 +395,30 @@ class EndpointResolverTest {
assertEquals("/api/status", reachableServer.takeRequest(1, TimeUnit.SECONDS)?.path)
}
@Test
fun apiHealthProbe_usesGetWhenServerRejectsHead() = runTest {
reachableServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse = when {
request.path != "/health" -> MockResponse().setResponseCode(404)
request.method == "HEAD" -> MockResponse().setResponseCode(405)
request.method == "GET" -> MockResponse().setResponseCode(200)
else -> MockResponse().setResponseCode(405)
}
}
val apiOnly = EndpointCandidate(
role = "tailscale",
api = ApiEndpoint(reachableServer.hostName, reachableServer.port),
)
val winner = EndpointResolver(fastClient, clock = { clockMillis.get() })
.resolve(listOf(apiOnly))
assertEquals(apiOnly, winner)
val request = reachableServer.takeRequest(1, TimeUnit.SECONDS)
assertEquals("GET", request?.method)
assertEquals("/health", request?.path)
}
// ---------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------
@@ -1,6 +1,8 @@
package com.hermesandroid.relay.network.shared
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Test
@@ -59,4 +61,120 @@ class ProfileApiUrlResolverTest {
),
)
}
@Test
fun resolveChatBase_buildsMultiplexProfilePath() {
assertEquals(
"https://hermes.example.test:8642/p/research_qa",
ProfileApiUrlResolver.resolveChatBase(
profileApiUrl = null,
baseApiUrl = "https://hermes.example.test:8642/",
selectedProfileName = "research_qa",
gatewayMode = "multiplex",
servedProfiles = listOf("default", "research_qa"),
),
)
}
@Test
fun resolveChatBase_keepsRootForDefaultProfile() {
assertEquals(
"https://hermes.example.test:8642",
ProfileApiUrlResolver.resolveChatBase(
profileApiUrl = null,
baseApiUrl = "https://hermes.example.test:8642/",
selectedProfileName = "default",
gatewayMode = "multiplex",
servedProfiles = listOf("default", "coder"),
),
)
}
@Test
fun resolveChatBase_keepsRootWithoutMultiplexTopology() {
assertEquals(
"https://hermes.example.test:8642",
ProfileApiUrlResolver.resolveChatBase(
profileApiUrl = null,
baseApiUrl = "https://hermes.example.test:8642",
selectedProfileName = "coder",
gatewayMode = null,
servedProfiles = emptyList(),
),
)
assertEquals(
"https://hermes.example.test:8642",
ProfileApiUrlResolver.resolveChatBase(
profileApiUrl = null,
baseApiUrl = "https://hermes.example.test:8642",
selectedProfileName = "coder",
gatewayMode = "single",
servedProfiles = listOf("default", "coder"),
),
)
}
@Test
fun resolveChatBase_prefersDedicatedProfileApi() {
assertEquals(
"https://hermes.example.test:8647",
ProfileApiUrlResolver.resolveChatBase(
profileApiUrl = "http://127.0.0.1:8647",
baseApiUrl = "https://hermes.example.test:8642",
selectedProfileName = "coder",
gatewayMode = "multiplex",
servedProfiles = listOf("default", "coder"),
),
)
}
@Test
fun resolveChatBase_switchesProfilesWithoutRetainingPriorPrefix() {
val base = "https://hermes.example.test:8642"
val coder = ProfileApiUrlResolver.resolveChatBase(
profileApiUrl = null,
baseApiUrl = base,
selectedProfileName = "coder",
gatewayMode = "multiplex",
servedProfiles = listOf("default", "coder", "research"),
)
val research = ProfileApiUrlResolver.resolveChatBase(
profileApiUrl = null,
baseApiUrl = base,
selectedProfileName = "research",
gatewayMode = "multiplex",
servedProfiles = listOf("default", "coder", "research"),
)
assertEquals("https://hermes.example.test:8642/p/coder", coder)
assertEquals("https://hermes.example.test:8642/p/research", research)
assertNotEquals(coder, research)
}
@Test
fun multiplexBase_keepsPrefixForEveryDirectApiRouteFamily() {
val base = ProfileApiUrlResolver.resolveChatBase(
profileApiUrl = null,
baseApiUrl = "https://hermes.example.test:8642",
selectedProfileName = "coder",
gatewayMode = "multiplex",
servedProfiles = listOf("default", "coder"),
)
val paths = listOf(
"/v1/models",
"/api/sessions",
"/api/sessions/session-1/messages",
"/api/sessions/session-1/chat/stream",
"/v1/chat/completions",
"/v1/runs",
"/v1/runs/run-1/events",
"/v1/runs/run-1/stop",
)
val encodedPaths = paths.map { path ->
"$base$path".toHttpUrlOrNull()?.encodedPath
}
assertEquals(paths.map { "/p/coder$it" }, encodedPaths)
}
}
@@ -125,6 +125,47 @@ class ChatHandlerTest {
assertEquals("Second", messages[1].content)
}
@Test
fun onStreamComplete_removesExactIntentionalSilenceMarker() {
handler.onTextDelta("assist-1", "NO")
handler.onTextDelta("assist-1", "_REPLY")
handler.onStreamComplete("assist-1")
assertTrue(handler.messages.value.isEmpty())
assertFalse(handler.isStreaming.value)
}
@Test
fun onStreamComplete_removesEmptyAssistantPlaceholder() {
handler.addPlaceholderMessage(
ChatMessage(
id = "assist-empty",
role = MessageRole.ASSISTANT,
content = "",
timestamp = 1L,
isStreaming = true,
),
)
handler.onStreamComplete("assist-empty")
assertTrue(handler.messages.value.isEmpty())
assertFalse(handler.isStreaming.value)
}
@Test
fun onStreamComplete_keepsProseThatMentionsSilenceMarker() {
handler.onTextDelta("assist-1", "I will not use NO_REPLY here.")
handler.onStreamComplete("assist-1")
val messages = handler.messages.value
assertEquals(1, messages.size)
assertEquals("I will not use NO_REPLY here.", messages.single().content)
assertFalse(messages.single().isStreaming)
}
// --- onStreamComplete ---
@Test
@@ -387,6 +428,20 @@ class ChatHandlerTest {
assertEquals("s2", sessions[0].sessionId)
}
@Test
fun updateSessions_deduplicatesDuplicateServerRows() {
handler.updateSessions(
listOf(
SessionItem(id = "s1", title = "First"),
SessionItem(id = "s1", title = "Duplicate"),
SessionItem(id = "s2", title = "Other"),
),
)
assertEquals(listOf("s1", "s2"), handler.sessions.value.map { it.sessionId })
assertEquals("First", handler.sessions.value.first().title)
}
@Test
fun updateSessions_preservesLocalTitle_whenServerReturnsNullTitle() {
// The server titles a session asynchronously after the first turn (and
@@ -573,6 +628,38 @@ class ChatHandlerTest {
assertEquals("s1", sessions[1].sessionId)
}
@Test
fun addSession_collapsesRepeatedOptimisticRows() {
val session = ChatSession(sessionId = "s1", title = "Fresh", model = null)
handler.addSession(session)
handler.addSession(session)
assertEquals(listOf("s1"), handler.sessions.value.map { it.sessionId })
}
@Test
fun addSession_afterRefreshKeepsSingleServerEnrichedRow() {
handler.updateSessions(
listOf(
SessionItem(
id = "s1",
title = "Server title",
model = "gpt-5.6",
messageCount = 2,
),
),
)
handler.addSession(ChatSession(sessionId = "s1", title = "Optimistic", model = null))
val session = handler.sessions.value.single()
assertEquals("s1", session.sessionId)
assertEquals("Server title", session.title)
assertEquals("gpt-5.6", session.model)
assertEquals(2, session.messageCount)
}
// --- setSessionId ---
@Test
@@ -657,6 +744,62 @@ class ChatHandlerTest {
assertEquals(MessageRole.SYSTEM, handler.messages.value[0].role)
}
@Test
fun loadMessageHistory_skipsTypedHiddenDisplayRows() {
handler.loadMessageHistory(
listOf(
MessageItem(
id = "hidden-1",
role = "user",
content = JsonPrimitive("internal compaction handoff"),
displayKind = "hidden",
),
MessageItem(id = "visible-1", role = "user", content = JsonPrimitive("visible")),
),
)
assertEquals(1, handler.messages.value.size)
assertEquals("visible", handler.messages.value.single().content)
}
@Test
fun loadMessageHistory_rendersTypedModelSwitchAsSystemTimelineRow() {
handler.loadMessageHistory(
listOf(
MessageItem(
id = "model-1",
role = "user",
content = JsonPrimitive("The model changed"),
displayKind = "model_switch",
displayMetadata = buildJsonObject { put("model", "gpt-5.6") },
),
),
)
val msg = handler.messages.value.single()
assertEquals(MessageRole.SYSTEM, msg.role)
assertEquals("Model changed to gpt-5.6", msg.content)
}
@Test
fun loadMessageHistory_rendersTypedDelegationCompletionAsSystemTimelineRow() {
handler.loadMessageHistory(
listOf(
MessageItem(
id = "delegation-1",
role = "user",
content = JsonPrimitive("background task complete"),
displayKind = "async_delegation_complete",
displayMetadata = buildJsonObject { put("task_count", 2) },
),
),
)
val msg = handler.messages.value.single()
assertEquals(MessageRole.SYSTEM, msg.role)
assertEquals("2 background tasks completed", msg.content)
}
@Test
fun loadMessageHistory_skipsToolMessages() {
val items = listOf(
@@ -59,6 +59,36 @@ class DashboardApiClientTest {
assertEquals("0.16.0", status.version)
}
@Test
fun getStatus_parsesNousAndOptionalGatewayTopology() = runTest {
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(
"""
{
"auth_required": false,
"nous_session_valid": "terminal",
"profiles": ["default", "worker"],
"gateway_mode": "multiplex",
"gateways": [{
"profile": "default",
"ports": {"api_server": 8642, "webhook": 8080},
"served_profiles": ["default", "worker"]
}]
}
""".trimIndent(),
),
)
val status = DashboardApiClient(baseUrl = server.url("/").toString())
.getStatus().getOrThrow()
assertEquals("terminal", status.nousSessionValid)
assertEquals(listOf("default", "worker"), status.profiles)
assertEquals("multiplex", status.gatewayMode)
assertEquals(8642, status.gateways.single().ports["api_server"])
assertEquals(listOf("default", "worker"), status.gateways.single().servedProfiles)
}
@Test
fun getModelOptions_alwaysRequestsUnconfiguredProviders() = runTest {
// HRUI-022: newer upstream hides unconfigured provider skeleton rows
@@ -273,6 +303,81 @@ class DashboardApiClientTest {
assertEquals(listOf("hermes_session"), cookies.map { it.name })
}
@Test
fun mirrorDashboardSessionCookies_reusesEncryptedSessionOnTrustedRoute() {
val store = InMemoryDashboardCookieStore()
store.save(
listOf(
storedCookie("hermes_session_at", "access", "192.168.1.20"),
storedCookie("hermes_session_rt", "refresh", "192.168.1.20"),
storedCookie("hermes_session_provider", "basic", "192.168.1.20"),
),
)
val mirrored = mirrorDashboardSessionCookies(
store = store,
targetUrl = "http://100.64.0.20:9119",
trustedHosts = setOf("192.168.1.20", "100.64.0.20"),
)
val cookies = DashboardCookieJar(store).loadForRequest(
"http://100.64.0.20:9119/api/auth/me".toHttpUrl(),
)
assertEquals(3, mirrored)
assertEquals(
listOf("hermes_session_at", "hermes_session_rt", "hermes_session_provider"),
cookies.map { it.name },
)
}
@Test
fun mirrorDashboardSessionCookies_doesNotCopyPkceOrToUnknownHost() {
val store = InMemoryDashboardCookieStore()
store.save(
listOf(
storedCookie("hermes_session_at", "access", "192.168.1.20"),
storedCookie("hermes_session_pkce", "verifier", "192.168.1.20"),
),
)
assertEquals(
0,
mirrorDashboardSessionCookies(
store = store,
targetUrl = "http://attacker.example:9119",
trustedHosts = setOf("192.168.1.20", "100.64.0.20"),
),
)
assertEquals(
1,
mirrorDashboardSessionCookies(
store = store,
targetUrl = "http://100.64.0.20:9119",
trustedHosts = setOf("192.168.1.20", "100.64.0.20"),
),
)
val mirroredNames = DashboardCookieJar(store).loadForRequest(
"http://100.64.0.20:9119/api/auth/me".toHttpUrl(),
).map { it.name }
assertEquals(listOf("hermes_session_at"), mirroredNames)
}
@Test
fun mirrorDashboardSessionCookies_explicitSignOutClearsEveryRoute() {
val store = InMemoryDashboardCookieStore()
store.save(listOf(storedCookie("hermes_session", "session", "192.168.1.20")))
mirrorDashboardSessionCookies(
store = store,
targetUrl = "http://100.64.0.20:9119",
trustedHosts = setOf("192.168.1.20", "100.64.0.20"),
)
store.clear()
assertTrue(store.load().isEmpty())
}
@Test
fun getStatus_defaultsMissingAuthFieldsForOlderDashboard() = runTest {
server.enqueue(
@@ -330,6 +435,22 @@ class DashboardApiClientTest {
assertEquals("basic", session.provider)
}
private fun storedCookie(
name: String,
value: String,
domain: String,
) = StoredDashboardCookie(
name = name,
value = value,
expiresAt = Long.MAX_VALUE,
domain = domain,
path = "/",
secure = false,
httpOnly = true,
hostOnly = true,
persistent = true,
)
@Test
fun currentSession_mapsUnauthorizedToUnauthenticated() = runTest {
server.enqueue(MockResponse().setResponseCode(401))
@@ -470,6 +591,122 @@ class DashboardApiClientTest {
assertTrue(body.contains(""""enable":false"""))
}
@Test
fun mcpOAuth_preservesProfileAndParsesOpaqueFlow() = runTest {
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(
"""{"flow_id":"opaque-flow","server_name":"hosted","status":"authorization_required","authorization_url":"https://auth.example/authorize?state=secret"}""",
),
)
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(
"""{"flow_id":"opaque-flow","server_name":"hosted","status":"approved","authorization_url":null}""",
),
)
val client = DashboardApiClient(server.url("/").toString())
val started = client.startMcpOAuth("hosted tools", profile = "work profile").getOrThrow()
val approved = client.getMcpOAuthFlow(started.flowId).getOrThrow()
assertEquals("opaque-flow", started.flowId)
assertEquals("approved", approved.status)
assertEquals("/api/mcp/servers/hosted%20tools/auth?profile=work%20profile", server.takeRequest().path)
assertEquals("/api/mcp/oauth/flows/opaque-flow", server.takeRequest().path)
}
@Test
fun mcpOAuthCapability_canonicalMissingFlowUsesReadOnlyGetAndIsSupported() = runTest {
server.enqueue(
MockResponse()
.setResponseCode(404)
.setHeader("Content-Type", "application/json")
.setBody("""{"detail":"OAuth flow not found or expired"}"""),
)
val client = DashboardApiClient(server.url("/").toString())
assertTrue(client.supportsHostedMcpOAuth().getOrThrow())
val request = server.takeRequest()
assertEquals("GET", request.method)
assertEquals("/api/mcp/oauth/flows/__relay_capability_probe_never_a_flow__", request.path)
assertEquals(1, server.requestCount)
}
@Test
fun mcpOAuthCapability_genericFastApi404UsesReadOnlyGetAndIsUnsupported() = runTest {
server.enqueue(
MockResponse()
.setResponseCode(404)
.setHeader("Content-Type", "application/json")
.setBody("""{"detail":"Not Found"}"""),
)
val client = DashboardApiClient(server.url("/").toString())
assertFalse(client.supportsHostedMcpOAuth().getOrThrow())
val request = server.takeRequest()
assertEquals("GET", request.method)
assertEquals("/api/mcp/oauth/flows/__relay_capability_probe_never_a_flow__", request.path)
assertEquals(1, server.requestCount)
}
@Test
fun mcpMutations_preserveSelectedProfile() = runTest {
repeat(5) {
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("{}"))
}
val client = DashboardApiClient(server.url("/").toString())
client.setMcpServerEnabled("hosted", true, "work profile").getOrThrow()
client.testMcpServer("hosted", "work profile").getOrThrow()
client.removeMcpServer("hosted", "work profile").getOrThrow()
client.installMcpCatalogEntry("hosted", profile = "work profile").getOrThrow()
assertEquals("/api/mcp/servers/hosted/enabled?profile=work%20profile", server.takeRequest().path)
assertEquals("/api/mcp/servers/hosted/test?profile=work%20profile", server.takeRequest().path)
assertEquals("/api/mcp/servers/hosted?profile=work%20profile", server.takeRequest().path)
assertEquals("/api/mcp/catalog/install?profile=work%20profile", server.takeRequest().path)
}
@Test
fun customEndpointCrud_usesPublicDashboardRoutesAndRedactedResponse() = runTest {
val listBody = """
{"endpoints":[{"id":"local","name":"Local","base_url":"https://llm.example/v1","model":"qwen","models":["qwen"],"has_api_key":true,"api_key_preview":"sk-…1234","is_current":true}],"current":{"provider":"local","model":"qwen"}}
""".trimIndent()
repeat(5) {
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(
if (it == 2) """{"ok":true,"reachable":true,"message":"","models":["qwen"]}"""
else if (it == 3) """{"ok":true,"provider":"local","model":"qwen"}"""
else listBody,
))
}
val client = DashboardApiClient(server.url("/").toString())
val draft = DashboardCustomEndpointDraft(
id = "local",
name = "Local",
baseUrl = "https://llm.example/v1",
model = "qwen",
apiKey = "never-persist-this",
)
val listed = client.getCustomEndpoints().getOrThrow()
client.saveCustomEndpoint(draft).getOrThrow()
val validation = client.validateCustomEndpoint(draft).getOrThrow()
client.activateCustomEndpoint("local").getOrThrow()
client.deleteCustomEndpoint("local").getOrThrow()
assertEquals("local", listed.currentProvider)
assertTrue(listed.endpoints.single().hasApiKey)
assertEquals(listOf("qwen"), validation.models)
assertEquals("/api/providers/custom-endpoints", server.takeRequest().path)
val save = server.takeRequest()
assertEquals("/api/providers/custom-endpoints", save.path)
assertTrue(save.body.readUtf8().contains("never-persist-this"))
assertEquals("/api/providers/custom-endpoints/validate", server.takeRequest().path)
assertEquals("/api/providers/custom-endpoints/local/activate", server.takeRequest().path)
assertEquals("/api/providers/custom-endpoints/local", server.takeRequest().path)
}
@Test
fun profileActions_useActiveAndDeleteRoutes() = runTest {
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok": true}"""))
@@ -749,6 +986,20 @@ class DashboardApiClientTest {
assertEquals("/api/config/schema", server.takeRequest().path)
}
@Test
fun getTtsToolsetConfig_hitsRuntimeProviderRegistry() = runTest {
server.enqueue(
MockResponse()
.setHeader("Content-Type", "application/json")
.setBody("""{"name":"tts","has_category":true,"providers":[]}"""),
)
val client = DashboardApiClient(baseUrl = server.url("/").toString())
client.getTtsToolsetConfig().getOrThrow()
assertEquals("/api/tools/toolsets/tts/config", server.takeRequest().path)
}
@Test
fun previewSessionPrune_postsDryRunAndParsesPreview() = runTest {
server.enqueue(
@@ -55,18 +55,46 @@ class DashboardConfigEditingTest {
}
@Test
fun voiceConfigFields_filtersToTtsAndSttPrefixesOnly() {
fun voiceConfigFields_includesStandardVoiceBehaviorFields() {
val voice = voiceConfigFields(parseConfigSchema(schema)).map { it.key }
assertEquals(
listOf("tts.provider", "tts.elevenlabs.voice_id", "stt.enabled", "stt.local.model"),
listOf(
"tts.provider",
"tts.elevenlabs.voice_id",
"stt.enabled",
"stt.local.model",
"voice.silence_threshold",
),
voice,
)
// The CLI-only `voice.*` category and unrelated `model` are excluded.
assertFalse(voice.any { it.startsWith("voice.") })
assertFalse(voice.contains("model"))
}
@Test
fun parseTtsToolsetProviders_usesStableIdsAndKeepsRuntimeStatus() {
val providers = parseTtsToolsetProviders(
obj(
"""
{
"providers": [
{"name":"Edge TTS","tts_provider":"edge","status":"ready","is_active":true},
{"name":"My Plugin","tts_provider":"my_plugin","status":"needs_keys","is_active":false},
{"name":"Old backend without a stable id","status":"ready"},
{"name":"Duplicate","tts_provider":"edge","status":"ready"}
]
}
""".trimIndent(),
),
)
assertEquals(listOf("edge", "my_plugin"), providers.map { it.id })
assertEquals("Edge TTS", providers.first().name)
assertEquals("ready", providers.first().status)
assertTrue(providers.first().isActive)
assertEquals("needs_keys", providers.last().status)
}
@Test
fun configValueAt_readsNestedDotPathsAndMissing() {
val tree = obj(
@@ -0,0 +1,73 @@
package com.hermesandroid.relay.network.upstream
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class DashboardStatusTopologyTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun parseStatus_readsMultiplexTopology() {
val root = json.decodeFromString<JsonObject>(
"""{
"auth_required": false,
"gateway_mode": "multiplex",
"profiles": ["default", "coder"]
}""",
)
val status = DashboardApiClient.parseStatus(root)
assertEquals("multiplex", status.gatewayMode)
assertEquals(listOf("default", "coder"), status.profiles)
}
@Test
fun parseStatus_keepsOlderPayloadCompatible() {
val status = DashboardApiClient.parseStatus(
json.decodeFromString<JsonObject>("""{"auth_required": true}"""),
)
assertNull(status.gatewayMode)
assertEquals(emptyList<String>(), status.profiles)
assertEquals(false, status.componentHealth.supported)
assertEquals(emptyList<String>(), status.componentHealth.components.map { it.name })
}
@Test
fun parseStatus_readsComponentHealthRollup() {
val root = json.decodeFromString<JsonObject>(
"""{
"auth_required": false,
"overall": "degraded",
"components": {
"gateway": {"status": "ok"},
"storage": {
"status": "degraded",
"message": "state DB unavailable",
"healthy": false,
"unhandled_5xx_count_5m": 2
},
"platforms": {"configured": 3, "connected": 1}
}
}""",
)
val status = DashboardApiClient.parseStatus(root)
assertEquals(true, status.componentHealth.supported)
assertEquals("degraded", status.componentHealth.overall)
assertEquals(listOf("gateway", "platforms", "storage"), status.componentHealth.components.map { it.name })
val storage = status.componentHealth.components.first { it.name == "storage" }
assertEquals("degraded", storage.status)
assertEquals("state DB unavailable", storage.message)
assertEquals(false, storage.healthy)
assertEquals(2, storage.unhandled5xxCount5m)
val platforms = status.componentHealth.components.first { it.name == "platforms" }
assertEquals(3, platforms.configured)
assertEquals(1, platforms.connected)
}
}
@@ -61,12 +61,32 @@ class GatewayClientHarness(
@Volatile
var recoveryAssistant = ""
@Volatile
var recoveryInflightStreaming: Boolean? = null
@Volatile
var recoveryQueuedUser: String? = null
@Volatile
var recoveryProject: JsonObject? = null
/** Optional durable-id -> live-id mapping for multi-session switch tests. */
val resumeLiveSessionIds = ConcurrentHashMap<String, String>()
@Volatile
var steerStatus = "queued"
@Volatile
var redirectStatus = "redirected"
@Volatile
var compressPayload: JsonObject = buildJsonObject {
put("status", "completed")
put("removed", 2)
put("before_messages", 8)
put("after_messages", 4)
}
@Volatile
var reasoningEffort = "medium"
@@ -172,10 +192,18 @@ class GatewayClientHarness(
)
}
"process.kill" -> buildJsonObject { put("status", "killed") }
"session.redirect" -> buildJsonObject {
put("status", redirectStatus)
put("text", (params["text"] as? JsonPrimitive)?.contentOrNull ?: "")
}
"session.steer" -> buildJsonObject {
put("status", steerStatus)
put("text", (params["text"] as? JsonPrimitive)?.contentOrNull ?: "")
}
"session.compress" -> compressPayload
"slash.exec" -> buildJsonObject {
put("output", "legacy compression started")
}
"image.attach_bytes", "image.attach.bytes" -> buildJsonObject {
put("attached", true)
put("count", 1)
@@ -225,6 +253,10 @@ class GatewayClientHarness(
else -> JsonObject(emptyMap())
}
"config.set" -> when ((params["key"] as? JsonPrimitive)?.contentOrNull) {
"model" -> buildJsonObject {
put("key", "model")
put("value", (params["value"] as? JsonPrimitive)?.contentOrNull ?: "")
}
"reasoning" -> {
reasoningEffort = (params["value"] as? JsonPrimitive)?.contentOrNull ?: reasoningEffort
buildJsonObject {
@@ -232,6 +264,10 @@ class GatewayClientHarness(
put("value", reasoningEffort)
}
}
"fast" -> buildJsonObject {
put("key", "fast")
put("value", (params["value"] as? JsonPrimitive)?.contentOrNull ?: "normal")
}
else -> JsonObject(emptyMap())
}
else -> JsonObject(emptyMap())
@@ -259,15 +295,24 @@ class GatewayClientHarness(
put("session_id", sessionId)
put("running", recoveryRunning)
put("status", if (recoveryRunning) "streaming" else "idle")
if (recoveryRunning) {
recoveryProject?.let { project ->
put("info", buildJsonObject { put("project", project) })
}
val inflightStreaming = recoveryInflightStreaming ?: recoveryRunning
if (recoveryRunning || recoveryInflightStreaming != null) {
put("inflight", buildJsonObject {
put("user", "research this")
put("assistant", recoveryAssistant)
put("streaming", true)
put("streaming", inflightStreaming)
})
}
recoveryQueuedUser?.let { user ->
put("queued", buildJsonObject { put("user", user) })
}
}
fun recoveryResult(sessionId: String): JsonObject = recoveryPayload(sessionId)
init {
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
@@ -475,6 +520,7 @@ class GatewayChatClientTest {
// Verify the create carried the title and the stored id was reported.
val create = harness.awaitRpc("session.create")
assertEquals("webui", (create["source"] as? JsonPrimitive)?.contentOrNull)
assertEquals("hello", (create["title"] as? JsonPrimitive)?.contentOrNull)
serverWs.send(harness.eventFrame("message.start", null, "live-1"))
@@ -879,6 +925,7 @@ class GatewayChatClientTest {
harness.awaitRpc("prompt.submit")
val resume = harness.awaitRpc("session.resume")
assertEquals("webui", (resume["source"] as? JsonPrimitive)?.contentOrNull)
assertEquals(
"20260101_010101_aaaaaa",
(resume["session_id"] as? JsonPrimitive)?.contentOrNull,
@@ -954,23 +1001,23 @@ class GatewayChatClientTest {
}
@Test
fun `socket loss mid-turn reconnects without resume and completes on the original session`() {
fun `socket loss mid-turn activates original session without resume and completes`() {
val r = Recorder()
client.sendTurn(null, "hello", null, r.callbacks) { r.preflightFailures += it }
val ws1 = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
// Server connection dies mid-turn (Wi-Fi roam analogue). The client
// must reconnect with a FRESH ticket but WITHOUT session.resume —
// upstream resume would mint a new id + fresh agent and orphan the
// running turn — then keep consuming the still-running turn's events,
// which arrive tagged with the ORIGINAL live session id ("live-1")
// on the shared gateway stream.
// must reconnect with a FRESH ticket and session.activate the original
// live runtime. session.resume would mint/rebind a different runtime and
// orphan the running turn.
harness.rpcLog.clear()
ws1.close(1011, "server crashed")
val ws2 = harness.awaitServerSocket()
assertTrue("reconnect must mint a fresh ticket", harness.ticketMints.get() >= 2)
val activate = harness.awaitRpc("session.activate")
assertEquals("live-1", (activate["session_id"] as? JsonPrimitive)?.contentOrNull)
ws2.send(
harness.eventFrame("message.delta", buildJsonObject { put("text", "after rejoin") }, "live-1"),
@@ -991,6 +1038,51 @@ class GatewayChatClientTest {
assertTrue(r.preflightFailures.isEmpty())
}
@Test
fun `mid-turn activate method-not-found keeps legacy socket recovery`() {
harness.methodNotFound += "session.activate"
val r = Recorder()
client.sendTurn(null, "hello", null, r.callbacks) { r.preflightFailures += it }
val ws1 = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
harness.rpcLog.clear()
ws1.close(1011, "radio roam")
val ws2 = harness.awaitServerSocket()
harness.awaitRpc("session.activate")
ws2.send(
harness.eventFrame("message.complete", buildJsonObject { put("text", "legacy") }, "live-1"),
)
assertTrue(r.completeLatch.await(10, TimeUnit.SECONDS))
assertTrue(r.errors.isEmpty())
assertTrue(r.preflightFailures.isEmpty())
assertTrue(harness.rpcLog.none { it.first == "session.resume" })
}
@Test
fun `lost submit ack before first event rejoins without duplicate fallback`() {
harness.suppressAckMethods += "prompt.submit"
val r = Recorder()
client.sendTurn(null, "only once", null, r.callbacks) { r.preflightFailures += it }
val ws1 = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
ws1.close(1011, "ack lost")
val ws2 = harness.awaitServerSocket()
val activate = harness.awaitRpc("session.activate")
assertEquals("live-1", (activate["session_id"] as? JsonPrimitive)?.contentOrNull)
ws2.send(
harness.eventFrame("message.complete", buildJsonObject { put("text", "done") }, "live-1"),
)
assertTrue(r.completeLatch.await(10, TimeUnit.SECONDS))
assertEquals(1, harness.rpcLog.count { it.first == "prompt.submit" })
assertTrue(r.preflightFailures.isEmpty())
assertTrue(r.errors.isEmpty())
assertTrue(harness.rpcLog.none { it.first == "session.resume" })
}
@Test
fun `failed rejoin surfaces stream error`() {
val r = Recorder()
@@ -1033,39 +1125,105 @@ class GatewayChatClientTest {
assertEquals("rm -rf /tmp/x — cleanup", ask.text)
}
// --- Steer ---
// --- Active-turn correction ---
@Test
fun `steer queued when the server accepts`() {
fun `redirect queued when the server accepts active-turn correction`() {
val r = Recorder()
client.sendTurn(null, "long job", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
harness.steerStatus = "queued"
harness.redirectStatus = "redirected"
assertEquals(SteerResult.Queued, runBlocking { client.steer("focus on tests") })
val steer = harness.awaitRpc("session.steer")
assertEquals("live-1", (steer["session_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals("focus on tests", (steer["text"] as? JsonPrimitive)?.contentOrNull)
val redirect = harness.awaitRpc("session.redirect")
assertEquals("live-1", (redirect["session_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals("focus on tests", (redirect["text"] as? JsonPrimitive)?.contentOrNull)
assertTrue(harness.rpcLog.none { it.first == "session.steer" })
}
@Test
fun `steer rejected propagates to the caller`() {
fun `redirect falls back to steer when unsupported`() {
val r = Recorder()
client.sendTurn(null, "long job", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
harness.steerStatus = "rejected"
harness.methodNotFound.add("session.redirect")
harness.steerStatus = "queued"
assertEquals(SteerResult.Queued, runBlocking { client.steer("legacy correction") })
harness.awaitRpc("session.redirect")
val steer = harness.awaitRpc("session.steer")
assertEquals("live-1", (steer["session_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals("legacy correction", (steer["text"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `redirect rejected propagates to the caller`() {
val r = Recorder()
client.sendTurn(null, "long job", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
harness.redirectStatus = "rejected"
assertEquals(SteerResult.Rejected, runBlocking { client.steer("too late") })
}
@Test
fun `steer with no live session fails without touching the wire`() {
fun `redirect with no live session fails without touching the wire`() {
assertEquals(SteerResult.Failed, runBlocking { client.steer("nothing running") })
assertTrue(harness.rpcLog.none { it.first == "session.redirect" })
assertTrue(harness.rpcLog.none { it.first == "session.steer" })
}
@Test
fun `compress session uses dedicated rpc and parses authoritative messages`() {
val r = Recorder()
client.sendTurn(null, "long job", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
harness.compressPayload = buildJsonObject {
put("status", "completed")
put("removed", 3)
put("before_messages", 9)
put("after_messages", 4)
put(
"messages",
harness.json.parseToJsonElement(
"""
[
{"id":"1","role":"user","content":"hello"},
{"id":"2","role":"assistant","content":"summary"}
]
""".trimIndent(),
),
)
}
val result = runBlocking { client.compressSession("tests").getOrThrow() }
val rpc = harness.awaitRpc("session.compress")
assertEquals("live-1", (rpc["session_id"] as? JsonPrimitive)?.contentOrNull)
assertEquals("tests", (rpc["focus_topic"] as? JsonPrimitive)?.contentOrNull)
assertEquals(3, result.removed)
assertEquals(2, result.messages.size)
assertTrue(harness.rpcLog.none { it.first == "slash.exec" })
}
@Test
fun `compress session falls back to slash exec when rpc is missing`() {
val r = Recorder()
client.sendTurn(null, "long job", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
harness.methodNotFound.add("session.compress")
val result = runBlocking { client.compressSession().getOrThrow() }
harness.awaitRpc("session.compress")
val slash = harness.awaitRpc("slash.exec")
assertEquals("/compress", (slash["command"] as? JsonPrimitive)?.contentOrNull)
assertEquals("legacy", result.status)
}
// --- Profile-bound sessions (upstream tui_gateway: session.create/resume
// take a `profile` arg; a session's agent is built from it) ---
@@ -1176,6 +1334,20 @@ class GatewayChatClientTest {
assertEquals(true, (create["fast"] as? JsonPrimitive)?.booleanOrNull)
}
@Test
fun `session create binds explicit normal fast tier`() {
val r = Recorder()
client.sessionModelProvider = {
GatewaySessionModel(model = null, provider = null, reasoningEffort = null, fast = false)
}
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
val create = harness.awaitRpc("session.create")
assertEquals(false, (create["fast"] as? JsonPrimitive)?.booleanOrNull)
}
@Test
fun `session create omits reasoning_effort and fast when unset`() {
val r = Recorder()
@@ -1485,6 +1657,43 @@ class GatewayChatClientTest {
assertEquals("live-resumed", (rpc["session_id"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `model update targets live session without global scope`() {
val r = Recorder()
client.sendTurn("stored-1", "hi", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("session.resume")
harness.awaitRpc("prompt.submit")
val result = runBlocking { client.setModel("grok-4.3 --provider xai") }
assertTrue(result.isSuccess)
val rpc = harness.awaitRpc("config.set")
assertEquals("model", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
assertEquals("grok-4.3 --provider xai", (rpc["value"] as? JsonPrimitive)?.contentOrNull)
assertEquals("live-resumed", (rpc["session_id"] as? JsonPrimitive)?.contentOrNull)
assertFalse(rpc.containsKey("scope"))
}
@Test
fun `fast update targets live session without global scope`() {
val r = Recorder()
client.sendTurn("stored-1", "hi", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
harness.awaitRpc("session.resume")
harness.awaitRpc("prompt.submit")
val result = runBlocking { client.setFast(false) }
assertTrue(result.isSuccess)
assertFalse(result.getOrThrow())
val rpc = harness.awaitRpc("config.set")
assertEquals("fast", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
assertEquals("normal", (rpc["value"] as? JsonPrimitive)?.contentOrNull)
assertEquals("live-resumed", (rpc["session_id"] as? JsonPrimitive)?.contentOrNull)
assertFalse(rpc.containsKey("scope"))
}
// --- Edit & regenerate ---
@Test
@@ -1670,6 +1879,241 @@ class GatewayChatClientTest {
assertEquals(listOf("recovered"), recorder.textDeltas.toList())
}
@Test
fun `recoverTurn keeps queued-only resume live`() {
harness.recoveryQueuedUser = "do this next"
val queuedRecorder = Recorder()
val recovery = runBlocking {
client.recoverTurn(
"stored-42",
null,
Recorder().callbacks,
queuedTurnProvider = {
GatewayInboundTurnRegistration(queuedRecorder.callbacks) { true }
},
).getOrThrow()
}
assertFalse(recovery.running)
assertTrue(recovery.hasPendingWork)
assertEquals("do this next", recovery.queued?.user)
assertNull(recovery.inflight)
assertNotNull(recovery.handle)
recovery.handle!!.detach()
}
@Test
fun `queued-only activation reroutes events received before acknowledgement`() {
runBlocking {
harness.recoveryQueuedUser = "do this next"
harness.suppressAckMethods += "session.activate"
val priorRecorder = Recorder()
val queuedRecorder = Recorder()
val pending = async(Dispatchers.IO) {
client.recoverTurn(
"stored-42",
"live-queued",
priorRecorder.callbacks,
queuedTurnProvider = {
GatewayInboundTurnRegistration(queuedRecorder.callbacks) { true }
},
).getOrThrow()
}
val ack = harness.awaitPendingAck()
ack.ws.send(harness.eventFrame("message.start", null, "live-queued"))
ack.ws.send(
harness.eventFrame(
"message.delta",
buildJsonObject { put("text", "queued answer") },
"live-queued",
),
)
harness.releaseAck(ack, harness.recoveryResult("live-queued"))
val recovery = pending.await()
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (queuedRecorder.textDeltas.isEmpty() && System.nanoTime() < deadline) delay(10)
assertEquals(emptyList<String>(), priorRecorder.textDeltas.toList())
assertEquals(listOf("queued answer"), queuedRecorder.textDeltas.toList())
recovery.handle?.detach()
}
}
@Test
fun `recoverTurn keeps inflight-only resume live`() {
harness.recoveryInflightStreaming = true
harness.recoveryAssistant = "partial"
val recovery = runBlocking {
client.recoverTurn("stored-42", null, Recorder().callbacks).getOrThrow()
}
assertTrue(recovery.running)
assertTrue(recovery.hasPendingWork)
assertEquals("partial", recovery.inflight?.assistant)
assertNull(recovery.queued)
assertNotNull(recovery.handle)
recovery.handle!!.detach()
}
@Test
fun `recoverTurn keeps inflight and queued resume live`() {
harness.recoveryInflightStreaming = true
harness.recoveryQueuedUser = "follow up"
val priorRecorder = Recorder()
val queuedRecorder = Recorder()
val recovery = runBlocking {
client.recoverTurn(
"stored-42",
null,
priorRecorder.callbacks,
queuedTurnProvider = {
GatewayInboundTurnRegistration(queuedRecorder.callbacks) { true }
},
).getOrThrow()
}
assertTrue(recovery.running)
assertTrue(recovery.hasPendingWork)
assertEquals("follow up", recovery.queued?.user)
assertNotNull(recovery.inflight)
assertNotNull(recovery.handle)
val serverWs = harness.awaitServerSocket()
serverWs.send(
harness.eventFrame(
"message.complete",
buildJsonObject { put("text", "current answer") },
"live-resumed",
),
)
assertTrue(priorRecorder.completeLatch.await(5, TimeUnit.SECONDS))
serverWs.send(harness.eventFrame("message.start", null, "live-resumed"))
serverWs.send(
harness.eventFrame(
"message.delta",
buildJsonObject { put("text", "queued answer") },
"live-resumed",
),
)
serverWs.send(
harness.eventFrame(
"message.complete",
buildJsonObject { put("text", "queued answer") },
"live-resumed",
),
)
assertTrue(queuedRecorder.completeLatch.await(5, TimeUnit.SECONDS))
assertEquals(listOf("current answer"), priorRecorder.textDeltas.toList())
assertEquals(listOf("queued answer"), queuedRecorder.textDeltas.toList())
}
@Test
fun `inflight and queued activation preserves turn boundary before acknowledgement`() {
runBlocking {
harness.recoveryInflightStreaming = true
harness.recoveryQueuedUser = "follow up"
harness.suppressAckMethods += "session.activate"
val priorRecorder = Recorder()
val queuedRecorder = Recorder()
val pending = async(Dispatchers.IO) {
client.recoverTurn(
"stored-42",
"live-running-queued",
priorRecorder.callbacks,
queuedTurnProvider = {
GatewayInboundTurnRegistration(queuedRecorder.callbacks) { true }
},
).getOrThrow()
}
val ack = harness.awaitPendingAck()
ack.ws.send(
harness.eventFrame(
"message.delta",
buildJsonObject { put("text", " current") },
"live-running-queued",
),
)
ack.ws.send(
harness.eventFrame(
"message.complete",
buildJsonObject { put("text", "current") },
"live-running-queued",
),
)
ack.ws.send(harness.eventFrame("message.start", null, "live-running-queued"))
ack.ws.send(
harness.eventFrame(
"message.delta",
buildJsonObject { put("text", "queued") },
"live-running-queued",
),
)
harness.releaseAck(ack, harness.recoveryResult("live-running-queued"))
val recovery = pending.await()
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (queuedRecorder.textDeltas.isEmpty() && System.nanoTime() < deadline) delay(10)
assertEquals(listOf(" current"), priorRecorder.textDeltas.toList())
assertEquals(listOf("queued"), queuedRecorder.textDeltas.toList())
recovery.handle?.detach()
}
}
@Test
fun `recoverTurn settles resume with neither inflight nor queued work`() {
val recovery = runBlocking {
client.recoverTurn("stored-42", null, Recorder().callbacks).getOrThrow()
}
assertFalse(recovery.running)
assertFalse(recovery.hasPendingWork)
assertNull(recovery.inflight)
assertNull(recovery.queued)
assertNull(recovery.handle)
}
@Test
fun `recoverTurn parses optional session project`() {
harness.recoveryProject = buildJsonObject {
put("id", "project-17")
put("slug", "hermes-relay")
put("name", "Hermes Relay")
put("primary_path", "/workspace/hermes-relay")
put("future_field", "ignored")
}
runBlocking {
client.recoverTurn("stored-42", null, Recorder().callbacks).getOrThrow()
}
assertEquals("project-17", client.serverProject.value?.id)
assertEquals("hermes-relay", client.serverProject.value?.slug)
assertEquals("Hermes Relay", client.serverProject.value?.name)
assertEquals("/workspace/hermes-relay", client.serverProject.value?.primaryPath)
}
@Test
fun `recoverTurn clears project for legacy session info`() {
harness.recoveryProject = buildJsonObject { put("name", "Previous Project") }
runBlocking {
client.recoverTurn("stored-42", null, Recorder().callbacks).getOrThrow()
}
assertEquals("Previous Project", client.serverProject.value?.name)
harness.recoveryProject = null
runBlocking {
client.recoverTurn("stored-42", null, Recorder().callbacks).getOrThrow()
}
assertNull(client.serverProject.value)
}
@Test
fun `recoverTurn returns no handle for an already-settled session`() {
harness.recoveryRunning = false
@@ -18,6 +18,7 @@ class GatewayEventMapperTest {
private class Recorder {
val textDeltas = mutableListOf<String>()
val interimMessages = mutableListOf<Pair<String, Boolean>>()
val thinkingDeltas = mutableListOf<String>()
val toolStarts = mutableListOf<Pair<String, String>>()
val toolDones = mutableListOf<Pair<String, String?>>()
@@ -42,6 +43,7 @@ class GatewayEventMapperTest {
onSessionId = { sessionIds += it },
onStart = { starts++ },
onTextDelta = { textDeltas += it },
onInterimMessage = { text, alreadyStreamed -> interimMessages += text to alreadyStreamed },
onThinkingDelta = { thinkingDeltas += it },
onToolCallStart = { id, name -> toolStarts += id to name },
onToolCallDone = { id, preview -> toolDones += id to preview },
@@ -169,6 +171,17 @@ class GatewayEventMapperTest {
assertEquals(2, r.textDeltas.size)
}
@Test
fun `message complete backfills text when response was not previewed`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("message.complete", obj("""{"text":"Final answer","response_previewed":false}"""))
assertEquals(listOf("Final answer"), r.textDeltas)
assertEquals(1, r.completes)
}
@Test
fun `message complete backfills text and reasoning when nothing streamed`() {
val r = Recorder()
@@ -182,6 +195,111 @@ class GatewayEventMapperTest {
assertEquals(1, r.completes)
}
@Test
fun `message interim seals preview and previewed complete does not duplicate`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent(
"message.interim",
obj("""{"text":"candidate answer","already_streamed":false}"""),
)
mapper.onEvent(
"message.complete",
obj("""{"text":"candidate answer","response_previewed":true}"""),
)
assertEquals(listOf("candidate answer" to false), r.interimMessages)
assertTrue(r.textDeltas.isEmpty())
assertEquals(1, r.starts)
assertEquals(1, r.completes)
}
@Test
fun `message complete after interim backfills distinct final when not previewed`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent(
"message.interim",
obj("""{"text":"candidate answer","already_streamed":false}"""),
)
mapper.onEvent("message.complete", obj("""{"text":"final answer"}"""))
assertEquals(listOf("candidate answer" to false), r.interimMessages)
assertEquals(listOf("final answer"), r.textDeltas)
assertEquals(1, r.completes)
}
@Test
fun `already streamed interim seals without replaying text`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("message.delta", obj("""{"text":"candidate answer"}"""))
mapper.onEvent(
"message.interim",
obj("""{"text":"candidate answer","already_streamed":true}"""),
)
assertEquals(listOf("candidate answer" to true), r.interimMessages)
assertEquals(listOf("candidate answer"), r.textDeltas)
}
@Test
fun `message complete suppresses exact intentional silence marker`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("message.complete", obj("""{"text":"[SILENT]"}"""))
assertTrue(r.textDeltas.isEmpty())
assertEquals(1, r.completes)
}
@Test
fun `message delta suppresses exact intentional silence marker`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("message.delta", obj("""{"text":"NO_REPLY"}"""))
mapper.onEvent("message.delta", obj("""{"text":"The profile said NO_REPLY for context."}"""))
assertEquals(listOf("The profile said NO_REPLY for context."), r.textDeltas)
}
@Test
fun `message interim already streamed seals without replaying text`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("message.delta", obj("""{"text":"candidate "}"""))
mapper.onEvent(
"message.interim",
obj("""{"text":"candidate answer","already_streamed":true}"""),
)
mapper.onEvent(
"message.complete",
obj("""{"text":"candidate answer","response_previewed":true}"""),
)
assertEquals(listOf("candidate "), r.textDeltas)
assertEquals(listOf("candidate answer" to true), r.interimMessages)
assertEquals(1, r.completes)
}
@Test
fun `message interim already streamed without text does not backfill previewed complete`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("message.delta", obj("""{"text":"candidate"}"""))
mapper.onEvent("message.interim", obj("""{"already_streamed":true}"""))
mapper.onEvent(
"message.complete",
obj("""{"text":"candidate","response_previewed":true}"""),
)
assertEquals(listOf("candidate"), r.textDeltas)
assertEquals(listOf("" to true), r.interimMessages)
assertEquals(1, r.completes)
}
@Test
fun `second message start signals turn boundary`() {
val r = Recorder()
@@ -564,18 +682,24 @@ class GatewayEventMapperTest {
}
@Test
fun `sudo secret and approval expiry events preserve correlation contract`() {
fun `sudo secret clarify and approval expiry events preserve correlation contract`() {
val r = Recorder()
val mapper = mapperWith(r)
mapper.onEvent("sudo.expire", obj("""{"request_id":"r2"}"""))
mapper.onEvent("secret.expire", obj("""{"request_id":"r3"}"""))
mapper.onEvent("clarify.expire", obj("""{"request_id":"r4"}"""))
mapper.onEvent("approval.expire", obj("""{"request_id":"ignored"}"""))
assertEquals(
listOf(GatewayAsk.Kind.SUDO, GatewayAsk.Kind.SECRET, GatewayAsk.Kind.APPROVAL),
listOf(
GatewayAsk.Kind.SUDO,
GatewayAsk.Kind.SECRET,
GatewayAsk.Kind.CLARIFY,
GatewayAsk.Kind.APPROVAL,
),
r.interactionExpiries.map { it.kind },
)
assertEquals(listOf("r2", "r3", null), r.interactionExpiries.map { it.requestId })
assertEquals(listOf("r2", "r3", "r4", null), r.interactionExpiries.map { it.requestId })
}
// --- Forward compat ---
@@ -601,10 +725,10 @@ class GatewayEventMapperTest {
val r = Recorder()
val mapper = mapperWith(r)
listOf(
"reasoning.delta", "thinking.delta", "message.delta", "message.start",
"reasoning.delta", "thinking.delta", "message.delta", "message.interim", "message.start",
"message.complete", "error", "clarify.request", "approval.request",
"sudo.request", "secret.request", "reasoning.available",
"sudo.expire", "secret.expire", "approval.expire",
"sudo.expire", "secret.expire", "clarify.expire", "approval.expire",
"tool.generating", "subagent.start", "subagent.thinking",
"subagent.tool", "subagent.progress", "subagent.complete",
"tool.output_risk", "moa.reference", "moa.aggregating",
@@ -18,6 +18,30 @@ import org.junit.Test
*/
class HermesApiClientTest {
@Test
fun toolsetInventory_parserPreservesEnabledAndResolvedTools() {
val parsed = parseToolsetListBody(
Json { ignoreUnknownKeys = true },
"""
{
"object": "list",
"platform": "api_server",
"data": [{
"name": "relay",
"label": "Relay",
"enabled": true,
"configured": true,
"tools": ["relay_status", "android_phone_status"]
}]
}
""".trimIndent(),
)
assertEquals("relay", parsed?.single()?.name)
assertTrue(parsed?.single()?.enabled == true)
assertEquals(listOf("relay_status", "android_phone_status"), parsed?.single()?.tools)
}
// --- buildApiRequestOrNull (#131 guard, streaming paths) ---
@Test
@@ -286,6 +310,73 @@ class HermesApiClientTest {
assertEquals("http://localhost:8642/v1/models", url)
}
@Test
fun modelOptionsPreserveAliasRequestIdAndRootMetadata() {
val parsed = parseModelOptionsBody(
Json { ignoreUnknownKeys = true },
"""{"object":"list","data":[{"id":"fast","root":"gpt-5-mini","parent":"hermes-agent"}]}""",
)
assertEquals("fast", parsed?.single()?.id)
assertEquals("gpt-5-mini", parsed?.single()?.root)
assertEquals("hermes-agent", parsed?.single()?.parent)
assertEquals("Routes to gpt-5-mini", parsed?.single()?.routeDetail)
}
@Test
fun modelOptionsIgnoreMalformedRowsButKeepCompatibilityShape() {
val parsed = parseModelOptionsBody(
Json { ignoreUnknownKeys = true },
"""{"data":[null,{"root":"missing-id"},{"id":"hermes-agent","extra":true}]}""",
)
assertEquals(listOf("hermes-agent"), parsed?.map { it.id })
assertNull(parseModelOptionsBody(Json, "not-json"))
}
@Test
fun drainFailurePreservesCodeMessageAndBoundedRetryHint() {
val message = streamHttpFailureMessage(
code = 503,
reason = "Service Unavailable",
retryAfter = "1",
body = """{"error":{"message":"Gateway is shutting down","code":"gateway_draining"}}""",
json = Json,
)
assertEquals(
"API error 503: gateway_draining: Gateway is shutting down (Retry-After: 1s)",
message,
)
}
@Test
fun ordinaryProvider503KeepsGenericClassificationAndUnsafeRetryHintIsIgnored() {
val message = streamHttpFailureMessage(
code = 503,
reason = "Service Unavailable",
retryAfter = "999999999",
body = """{"error":{"message":"provider offline","code":"provider_unavailable"}}""",
json = Json,
)
assertEquals("API error 503: provider_unavailable: provider offline", message)
}
@Test
fun drainRetryIsSinglePreEventAndBounded() {
val drain = "API error 503: gateway_draining: Gateway is shutting down"
assertEquals(1_000L, gatewayDrainRetryDelayMillis(503, "1", drain, false, false))
assertEquals(5_000L, gatewayDrainRetryDelayMillis(503, "999", drain, false, false))
assertNull(gatewayDrainRetryDelayMillis(503, "1", drain, true, false))
assertNull(gatewayDrainRetryDelayMillis(503, "1", drain, false, true))
assertNull(gatewayDrainRetryDelayMillis(503, "1", "provider unavailable", false, false))
assertNull(gatewayDrainRetryDelayMillis(
503,
"1",
"API error 503: provider_unavailable: message mentions gateway_draining",
false,
false,
))
assertNull(gatewayDrainRetryDelayMillis(500, "1", drain, false, false))
}
// --- Skills endpoint compatibility ---
@Test
@@ -30,6 +30,18 @@ import org.junit.Test
* [ChatPayloadResult.droppedAttachments] — never a silent drop.
*/
class HermesChatPayloadsTest {
@Test
fun `model route alias id is sent unchanged on every SSE payload`() {
val alias = "fast-route"
val session = buildSessionChatStreamPayload(message = "hi", modelOverride = alias)
val completion = buildChatCompletionsStreamPayload(message = "hi", modelOverride = alias)
val run = buildRunStreamPayload(message = "hi", modelOverride = alias)
assertEquals(alias, session.payload["model"]?.jsonPrimitive?.content)
assertEquals(alias, completion.payload["model"]?.jsonPrimitive?.content)
assertEquals(alias, run.payload["model"]?.jsonPrimitive?.content)
}
private val json = Json { ignoreUnknownKeys = true }
@@ -0,0 +1,139 @@
package com.hermesandroid.relay.network.upstream
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.CancellationException
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class McpOAuthFlowCoordinatorTest {
private lateinit var server: MockWebServer
@Before fun setUp() { server = MockWebServer().also { it.start() } }
@After fun tearDown() { server.shutdown() }
@Test
fun complete_opensHttpsAndPollsToApproved() = runTest {
enqueueFlow("authorization_required", "https://auth.example/start")
enqueueFlow("starting")
enqueueFlow("approved")
val opened = mutableListOf<String>()
val coordinator = McpOAuthFlowCoordinator(
DashboardApiClient(server.url("/").toString()),
pollDelayMillis = 0,
maxPolls = 3,
sleep = {},
)
val result = coordinator.complete("hosted", profile = "victor") {
opened += it
true
}.getOrThrow()
assertEquals("approved", result.status)
assertEquals(listOf("https://auth.example/start"), opened)
assertEquals("/api/mcp/servers/hosted/auth?profile=victor", server.takeRequest().path)
assertEquals("/api/mcp/oauth/flows/opaque", server.takeRequest().path)
assertEquals("/api/mcp/oauth/flows/opaque", server.takeRequest().path)
}
@Test
fun complete_rejectsNonHttpsWithoutOpeningBrowser() = runTest {
enqueueFlow("authorization_required", "http://auth.example/start")
var opened = false
val result = McpOAuthFlowCoordinator(
DashboardApiClient(server.url("/").toString()),
pollDelayMillis = 0,
sleep = {},
).complete("hosted") {
opened = true
true
}
assertTrue(result.isFailure)
assertFalse(opened)
assertTrue(result.exceptionOrNull()?.message.orEmpty().contains("HTTPS"))
}
@Test
fun complete_boundsTransientPollFailures() = runTest {
enqueueFlow("authorization_required", "https://auth.example/start")
repeat(3) { server.enqueue(MockResponse().setResponseCode(503).setBody("unavailable")) }
val result = McpOAuthFlowCoordinator(
DashboardApiClient(server.url("/").toString()),
pollDelayMillis = 0,
maxPolls = 20,
maxConsecutiveFailures = 3,
sleep = {},
).complete("hosted") { true }
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull()?.message.orEmpty().contains("HTTP 503"))
}
@Test
fun complete_surfacesOlderServerDuplicateAndCapacityResponsesWithoutOpening() = runTest {
for (code in listOf(404, 409, 429)) {
server.enqueue(MockResponse().setResponseCode(code).setBody("{}"))
var opened = false
val result = McpOAuthFlowCoordinator(
DashboardApiClient(server.url("/").toString()),
pollDelayMillis = 0,
sleep = {},
).complete("hosted") {
opened = true
true
}
assertTrue(result.exceptionOrNull()?.message.orEmpty().contains("HTTP $code"))
assertFalse(opened)
}
}
@Test
fun resume_afterRecreationPollsExistingOpaqueFlowWithoutAuthorizationUrl() = runTest {
enqueueFlow("approved")
val recreated = McpOAuthFlowCoordinator(
DashboardApiClient(server.url("/").toString()),
pollDelayMillis = 0,
sleep = {},
)
val result = recreated.resume("opaque").getOrThrow()
assertEquals("approved", result.status)
assertEquals("/api/mcp/oauth/flows/opaque", server.takeRequest().path)
}
@Test
fun resume_preservesCoroutineCancellation() = runTest {
enqueueFlow("starting")
val coordinator = McpOAuthFlowCoordinator(
DashboardApiClient(server.url("/").toString()),
pollDelayMillis = 1,
maxPolls = 2,
sleep = { throw CancellationException("screen left") },
)
var cancelled = false
try {
coordinator.resume("opaque")
} catch (_: CancellationException) {
cancelled = true
}
assertTrue(cancelled)
}
private fun enqueueFlow(status: String, authorizationUrl: String? = null) {
val url = authorizationUrl?.let { "\"$it\"" } ?: "null"
server.enqueue(
MockResponse().setHeader("Content-Type", "application/json").setBody(
"""{"flow_id":"opaque","server_name":"hosted","status":"$status","authorization_url":$url,"error":null}""",
),
)
}
}
@@ -0,0 +1,184 @@
package com.hermesandroid.relay.network.upstream
import android.content.Context
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamCallbacks
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamStatus
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.IOException
import java.util.concurrent.TimeUnit
class StandardHermesVoiceClientTest {
private lateinit var server: MockWebServer
@Before
fun setUp() {
server = MockWebServer()
server.start()
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun dashboardAudioTimeoutBoundsMatchUpstreamDesktopFloorAndRelayCap() {
assertEquals(180L, standardHermesDashboardAudioTimeoutSeconds(90L))
assertEquals(180L, standardHermesDashboardAudioTimeoutSeconds(180L))
assertEquals(420L, standardHermesDashboardAudioTimeoutSeconds(420L))
assertEquals(600L, standardHermesDashboardAudioTimeoutSeconds(900L))
}
@Test
fun dashboardAudioClientRaisesOnlyDashboardAudioTimeouts() {
val baseClient = DashboardApiClient.defaultClient()
val audioClient = standardHermesDashboardAudioClient(baseClient)
val audioTimeoutMillis = TimeUnit.SECONDS.toMillis(180L).toInt()
assertEquals(audioTimeoutMillis, audioClient.callTimeoutMillis)
assertEquals(audioTimeoutMillis, audioClient.readTimeoutMillis)
assertEquals(audioTimeoutMillis, audioClient.writeTimeoutMillis)
assertEquals(baseClient.connectTimeoutMillis, audioClient.connectTimeoutMillis)
assertEquals(TimeUnit.SECONDS.toMillis(45L).toInt(), baseClient.readTimeoutMillis)
assertEquals(TimeUnit.SECONDS.toMillis(30L).toInt(), baseClient.writeTimeoutMillis)
}
@Test
fun speechStreamUsesFreshTicketBuffersUntilOpenAndCarriesOddPcmByte() = runTest {
server.enqueue(ticketResponse())
lateinit var listener: WebSocketListener
lateinit var websocketRequest: Request
lateinit var socket: RecordingWebSocket
val pcm = mutableListOf<ByteArray>()
val formats = mutableListOf<Pair<Int, Int>>()
val client = testClient { request, capturedListener ->
websocketRequest = request
listener = capturedListener
RecordingWebSocket(request).also { socket = it }
}
val stream = client.openSpeechStream(
VoiceSpeechStreamCallbacks(
onStart = { rate, channels -> formats += rate to channels },
onPcm = { bytes, _ -> pcm += bytes },
),
).getOrThrow()!!
stream.append("Hello ")
stream.append("world.")
assertTrue(socket.sentText.isEmpty())
listener.onOpen(socket, switchingProtocols(websocketRequest))
assertEquals(listOf("{\"text\":\"Hello \"}", "{\"text\":\"world.\"}"), socket.sentText)
listener.onMessage(socket, "{\"type\":\"start\",\"sample_rate\":24000,\"channels\":1}")
listener.onMessage(socket, byteArrayOf(1, 2, 3).toByteString())
listener.onMessage(socket, byteArrayOf(4, 5, 6).toByteString())
stream.finish()
listener.onMessage(socket, "{\"type\":\"end\"}")
val outcome = stream.awaitOutcome()
assertEquals(VoiceSpeechStreamStatus.Completed, outcome.status)
assertTrue(outcome.audioStarted)
assertEquals(listOf(24_000 to 1), formats)
assertEquals(listOf(1, 2), pcm[0].map { it.toInt() })
assertEquals(listOf(3, 4, 5, 6), pcm[1].map { it.toInt() })
assertTrue(socket.sentText.last().contains("\"done\":true"))
val ticketRequest = server.takeRequest(1, TimeUnit.SECONDS)!!
assertEquals("/api/auth/ws-ticket", ticketRequest.path)
assertEquals("/api/audio/speak-stream", websocketRequest.url.encodedPath)
assertEquals("ticket-1", websocketRequest.url.queryParameter("ticket"))
}
@Test
fun speechStreamFallbackBeforeAudioRequestsLegacyPlayback() = runTest {
server.enqueue(ticketResponse())
lateinit var listener: WebSocketListener
lateinit var socket: RecordingWebSocket
val client = testClient { request, capturedListener ->
listener = capturedListener
RecordingWebSocket(request).also { socket = it }
}
val stream = client.openSpeechStream(
VoiceSpeechStreamCallbacks(onPcm = { _, _ -> }),
).getOrThrow()!!
listener.onOpen(socket, switchingProtocols(socket.request()))
listener.onMessage(socket, "{\"type\":\"fallback\"}")
val outcome = stream.awaitOutcome()
assertEquals(VoiceSpeechStreamStatus.Fallback, outcome.status)
assertFalse(outcome.audioStarted)
}
@Test
fun speechStreamFailureAfterAudioNeverRequestsReplay() = runTest {
server.enqueue(ticketResponse())
lateinit var listener: WebSocketListener
lateinit var socket: RecordingWebSocket
val client = testClient { request, capturedListener ->
listener = capturedListener
RecordingWebSocket(request).also { socket = it }
}
val stream = client.openSpeechStream(
VoiceSpeechStreamCallbacks(onPcm = { _, _ -> }),
).getOrThrow()!!
listener.onOpen(socket, switchingProtocols(socket.request()))
listener.onMessage(socket, byteArrayOf(1, 2).toByteString())
listener.onFailure(socket, IOException("route lost"), null)
val outcome = stream.awaitOutcome()
assertEquals(VoiceSpeechStreamStatus.Failed, outcome.status)
assertTrue(outcome.audioStarted)
}
private fun testClient(
socketFactory: (Request, WebSocketListener) -> WebSocket,
): StandardHermesVoiceClient = StandardHermesVoiceClient(
context = mockk<Context>(relaxed = true),
okHttpClient = DashboardApiClient.defaultClient(),
dashboardUrlProvider = { server.url("/").toString() },
webSocketFactory = socketFactory,
)
private fun ticketResponse(): MockResponse = MockResponse()
.setHeader("Content-Type", "application/json")
.setBody("{\"ticket\":\"ticket-1\",\"ttl_seconds\":30}")
private fun switchingProtocols(request: Request): Response = Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(101)
.message("Switching Protocols")
.build()
private class RecordingWebSocket(private val ownRequest: Request) : WebSocket {
val sentText = mutableListOf<String>()
var cancelled = false
override fun request(): Request = ownRequest
override fun queueSize(): Long = 0L
override fun send(text: String): Boolean = sentText.add(text)
override fun send(bytes: ByteString): Boolean = true
override fun close(code: Int, reason: String?): Boolean = true
override fun cancel() {
cancelled = true
}
}
}
@@ -0,0 +1,122 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.data.ToolCall
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ImageGenerationPlaceholderTest {
@Test
fun `active image generation uses diffusion placeholder`() {
val active = ToolCall(
id = "image-1",
name = " image_generate ",
args = null,
result = null,
success = null,
)
assertTrue(active.showsImageGenerationPlaceholder())
assertFalse(active.copy(isComplete = true, success = true).showsImageGenerationPlaceholder())
assertFalse(active.copy(name = "video_generate").showsImageGenerationPlaceholder())
}
@Test
fun `active image generation remains visible when generic tools are hidden`() {
val active = ToolCall(
id = "image-1",
name = "image_generate",
args = null,
result = null,
success = null,
)
assertTrue(active.isVisibleForToolDisplay("off"))
assertFalse(active.copy(name = "terminal").isVisibleForToolDisplay("off"))
assertFalse(active.copy(isComplete = true).isVisibleForToolDisplay("off"))
assertTrue(active.copy(name = "terminal").isVisibleForToolDisplay("compact"))
}
@Test
fun `image progress owns the bubble until a media result arrives`() {
val active = ToolCall(
id = "image-1",
name = "image_generate",
args = null,
result = null,
success = null,
)
val completed = active.copy(isComplete = true, success = true)
assertTrue(
shouldShowImageGenerationPlaceholder(
toolCalls = listOf(active),
isStreaming = true,
hasMediaResult = false,
)
)
assertTrue(
shouldShowImageGenerationPlaceholder(
toolCalls = listOf(completed),
isStreaming = true,
hasMediaResult = false,
)
)
assertFalse(
shouldShowImageGenerationPlaceholder(
toolCalls = listOf(completed),
isStreaming = true,
hasMediaResult = true,
)
)
}
@Test
fun `completed or failed turn cannot leave a stale image canvas`() {
val completed = ToolCall(
id = "image-1",
name = "image_generate",
args = null,
result = null,
success = true,
isComplete = true,
)
assertFalse(
shouldShowImageGenerationPlaceholder(
toolCalls = listOf(completed),
isStreaming = false,
hasMediaResult = false,
)
)
assertFalse(
shouldShowImageGenerationPlaceholder(
toolCalls = listOf(completed.copy(success = false)),
isStreaming = true,
hasMediaResult = false,
)
)
}
@Test
fun `diffusion field changes across the animation cycle`() {
val noisy = diffusionSignal(column = 12, row = 8, time = 0.25f, denoise = diffusionDenoise(0.08f))
val resolved = diffusionSignal(column = 12, row = 8, time = 3.5f, denoise = diffusionDenoise(0.62f))
assertTrue(noisy in 0f..1f)
assertTrue(resolved in 0f..1f)
assertNotEquals(noisy, resolved)
}
@Test
fun `denoise cycle resolves then resets`() {
val early = diffusionDenoise(0.05f)
val resolved = diffusionDenoise(0.82f)
val reset = diffusionDenoise(0.99f)
assertTrue(early < resolved)
assertTrue(reset < resolved)
}
}
@@ -0,0 +1,39 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h720dp-xhdpi")
class ImageGenerationPlaceholderUiTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `placeholder announces rendering image status`() {
compose.mainClock.autoAdvance = false
compose.setContent {
MaterialTheme {
ImageGenerationPlaceholder(Modifier.padding(16.dp))
}
}
compose.mainClock.advanceTimeBy(2_400)
compose.onNodeWithContentDescription("Rendering image").assertExists()
compose.onRoot().captureRoboImage("build/verification-shots/image-generation-placeholder.png")
}
}
@@ -0,0 +1,102 @@
package com.hermesandroid.relay.ui.components
import java.util.Base64
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicReference
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class InlineImageDataUrlTest {
private fun url(mime: String, bytes: ByteArray): String =
"data:$mime;base64,${Base64.getEncoder().encodeToString(bytes)}"
@Test
fun acceptsUpstreamImageMimesWhenMagicMatches() {
val fixtures = mapOf(
"image/png" to byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
"image/jpeg" to byteArrayOf(0xff.toByte(), 0xd8.toByte(), 0xff.toByte(), 0x00),
"image/webp" to "RIFF0000WEBP".encodeToByteArray(),
"image/gif" to "GIF89a".encodeToByteArray(),
"image/bmp" to "BM".encodeToByteArray(),
)
fixtures.forEach { (mime, bytes) ->
assertEquals(mime, decodeInlineImageDataUrl(url(mime, bytes))?.mime)
}
}
@Test
fun rejectsUnknownMimeInvalidBase64AndMimeSpoofing() {
assertNull(decodeInlineImageDataUrl(url("image/svg+xml", "<svg/>".encodeToByteArray())))
assertNull(decodeInlineImageDataUrl("data:image/png;base64,%%%"))
assertNull(decodeInlineImageDataUrl(url("image/png", "GIF89a".encodeToByteArray())))
assertNull(decodeInlineImageDataUrl("data:image/png,not-base64"))
}
@Test
fun rejectsOversizeBeforeAndAfterDecode() {
val png = byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
assertNull(decodeInlineImageDataUrl(url("image/png", png + ByteArray(9)), maxBytes = 16))
val tooLong = "data:image/png;base64," + "A".repeat(25)
assertNull(decodeInlineImageDataUrl(tooLong, maxBytes = 16))
}
@Test
fun samplesLargeImagesToBoundedThumbnailMemory() {
assertEquals(4, inlineImageSampleSize(4_000, 4_000))
assertEquals(1, inlineImageSampleSize(1_024, 1_024))
assertNull(inlineImageSampleSize(40_000, 100))
assertNull(inlineImageSampleSize(Int.MAX_VALUE, Int.MAX_VALUE))
assertNull(inlineImageSampleSize(0, 100))
}
@Test
fun markdownExtractionPreservesSensitivityForDataUrl() {
val png = byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
val (body, images) = extractChatInlineImages("before ||![sensitive](${url("image/png", png)})|| after")
assertEquals("before after", body)
assertEquals(1, images.size)
assertTrue(images.single().sensitive)
assertNotNull(decodeInlineImageDataUrl(images.single().src))
}
@Test
fun multipleInlineImagesAreCappedPerMessage() {
val png = byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
val marker = "![image](${url("image/png", png)})"
val (body, images) = extractChatInlineImages(List(7) { marker }.joinToString("\n"))
assertEquals(INLINE_IMAGE_DATA_MAX_PER_MESSAGE, images.size)
assertTrue(body.contains("Additional inline images omitted"))
assertEquals(1, Regex("Additional inline images omitted").findAll(body).count())
}
@Test
fun cumulativeRawImageBudgetOmitsLaterImages() {
val signature = byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
val fourMiBPng = signature + ByteArray(4 * 1024 * 1024 - signature.size)
val marker = "![image](${url("image/png", fourMiBPng)})"
val (body, images) = extractChatInlineImages(List(3) { marker }.joinToString("\n"))
assertEquals(2, images.size)
assertTrue(body.contains("Additional inline images omitted"))
}
@Test
fun saveShareDecodeRunsOnRequestedBackgroundDispatcher() = runBlocking {
val png = byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
val callerThread = Thread.currentThread()
val backgroundThread = AtomicReference(callerThread)
Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "inline-image-decode-test").also(backgroundThread::set)
}
.asCoroutineDispatcher().use { dispatcher ->
val decoded = decodeInlineImageDataUrlOffMain(url("image/png", png), dispatcher)
assertNotNull(decoded)
assertFalse(callerThread === backgroundThread.get())
}
}
}
@@ -0,0 +1,49 @@
package com.hermesandroid.relay.ui.components
import dev.snipme.highlights.Highlights
import dev.snipme.highlights.model.SyntaxThemes
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class SafeMarkdownHighlightedCodeTest {
@Test
fun malformedMultilineComment_ignoresReversedHighlightRange() {
val code = "*/path/*"
val unsafeHighlights = builder()
.code(code)
.build()
.getHighlights()
assertTrue(
"The dependency reproducer must contain a reversed range",
unsafeHighlights.any { it.location.start > it.location.end },
)
val annotated = buildSafeHighlightedAnnotatedString(
code = code,
language = null,
highlightsBuilder = builder(),
)
assertEquals(code, annotated.text)
assertTrue(annotated.spanStyles.all { it.start in 0..it.end && it.end <= code.length })
}
@Test
fun validCode_keepsSyntaxHighlighting() {
val code = "val answer = 42"
val annotated = buildSafeHighlightedAnnotatedString(
code = code,
language = "kotlin",
highlightsBuilder = builder(),
)
assertEquals(code, annotated.text)
assertTrue(annotated.spanStyles.isNotEmpty())
assertTrue(annotated.spanStyles.all { it.start in 0 until it.end && it.end <= code.length })
}
private fun builder(): Highlights.Builder =
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = true))
}
@@ -0,0 +1,18 @@
package com.hermesandroid.relay.ui.components
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SessionSourceBadgeTest {
@Test
fun `dashboard webui sessions are treated as own chats`() {
assertNull(sourceBadge("webui"))
assertNull(sourceBadge(" WebUI "))
}
@Test
fun `browser web sessions retain their source badge`() {
assertEquals("Web", sourceBadge("web")?.label)
}
}
@@ -0,0 +1,34 @@
package com.hermesandroid.relay.ui.screens
import org.junit.Assert.assertEquals
import org.junit.Test
class ChatHeaderSubtitleTest {
@Test
fun `active turn status replaces static model metadata`() {
assertEquals(
"Streaming",
resolveChatHeaderSubtitle(
isStreaming = true,
statusText = "Streaming",
projectName = "Hermes Relay",
personalityName = "Victor",
modelName = "GPT-5.6",
),
)
}
@Test
fun `idle subtitle retains project personality and model metadata`() {
assertEquals(
"Hermes Relay \u00B7 Victor \u00B7 GPT-5.6",
resolveChatHeaderSubtitle(
isStreaming = false,
statusText = "Connected",
projectName = "Hermes Relay",
personalityName = "Victor",
modelName = "GPT-5.6",
),
)
}
}
@@ -0,0 +1,94 @@
package com.hermesandroid.relay.ui.screens
import com.hermesandroid.relay.viewmodel.PendingMcpOAuth
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DashboardManageParityTest {
@Test
fun oauthMcpRow_exposesAuthenticateOnlyForAuthoritativeOauthField() {
val oauth = Json.parseToJsonElement(
"""{"name":"hosted","url":"https://mcp.example/mcp","transport":"http","auth":"oauth","enabled":true}""",
).jsonObject
val header = Json.parseToJsonElement(
"""{"name":"hosted","url":"https://mcp.example/mcp","transport":"http","auth":"header","enabled":true}""",
).jsonObject
assertTrue(dashboardActionsFor(oauth).any { it.kind == DashboardActionKind.AuthenticateMcp })
assertFalse(dashboardActionsFor(header).any { it.kind == DashboardActionKind.AuthenticateMcp })
}
@Test
fun customEndpointSummary_neverContainsSecretAndRequiresConfirmationForMutations() {
val root = Json.parseToJsonElement(
"""{"endpoints":[{"id":"local","name":"Local","base_url":"https://llm.example/v1","model":"qwen","has_api_key":true,"api_key_preview":"sk-…1234","is_current":false}]}""",
)
val row = summarizeCustomEndpoints(root).single()
assertEquals("https://llm.example/v1", row.subtitle)
assertFalse(row.meta.orEmpty().contains("sk-"))
assertTrue(row.actions.first { it.kind == DashboardActionKind.ActivateCustomEndpoint }.destructive)
assertTrue(row.actions.first { it.kind == DashboardActionKind.DeleteCustomEndpoint }.destructive)
}
@Test
fun mcpManagePlumbing_usesEffectiveProfileForListAndRowActions() {
assertEquals(
"/api/mcp/servers?profile=work%20profile",
dashboardSectionRequestPath("/api/mcp/servers", "work profile"),
)
assertEquals(
"/api/providers/custom-endpoints",
dashboardSectionRequestPath("/api/providers/custom-endpoints", "work profile"),
)
val scoped = scopeDashboardManageItems(
"/api/mcp/servers",
"work profile",
listOf(DashboardSummaryItem(id = "hosted", title = "Hosted")),
)
assertEquals("work profile", scoped.single().profile)
}
@Test
fun oauthCapabilityGate_requiresConfirmedSupportAndNoPendingFlow() {
assertFalse(canStartMcpOAuth(capabilitySupported = false, pending = null))
assertTrue(canStartMcpOAuth(capabilitySupported = true, pending = null))
assertFalse(
canStartMcpOAuth(
capabilitySupported = true,
pending = PendingMcpOAuth("flow-a", "server-a", "work", "connection-a|https://a.example"),
),
)
}
@Test
fun dismissedPendingA_thenRequestedB_keepsDialogBoundToAAndBlocksB() {
val pendingA = PendingMcpOAuth("flow-a", "server-a", "work", "connection-a|https://a.example")
val requestedB = DashboardSummaryItem(id = "server-b", title = "Server B", profile = "work")
val resolved = resolveMcpOAuthDialogItem(requestedB, pendingA)
assertEquals("server-a", resolved?.id)
assertEquals("server-a", resolved?.title)
assertFalse(canStartMcpOAuth(capabilitySupported = true, pending = pendingA))
}
@Test
fun connectionSwitch_holdsPendingFlowWithoutAllowingCrossHostResume() {
val routeA = mcpOAuthRouteIdentity(
"connection-a",
"HTTPS://user:password@Dashboard.Example:443/manage/?ticket=opaque#fragment",
)
val routeB = mcpOAuthRouteIdentity("connection-b", "https://other.example/")
val pendingA = PendingMcpOAuth("opaque-flow-a", "server-a", "work", routeA!!)
assertEquals("connection-a|https://dashboard.example/manage", routeA)
assertFalse(canResumeMcpOAuth(pendingA, routeB))
assertTrue(canResumeMcpOAuth(pendingA, routeA))
}
}
@@ -176,4 +176,24 @@ class ModelOptionsParserTest {
// Authenticated first; server (canonical) order kept within each group.
assertEquals(listOf("z-auth", "b-auth", "a-skel"), options.map { it.id })
}
@Test
fun disabledAndExcludedProvidersAreHiddenWhenUpstreamMarksThemExplicitly() {
val options = parse(
"""
{
"excluded_providers": ["anthropic"],
"providers": [
{"slug": "openai", "name": "OpenAI", "models": ["gpt-5.5"], "authenticated": true},
{"slug": "anthropic", "name": "Anthropic", "models": [], "authenticated": false},
{"slug": "xai", "name": "xAI", "models": ["grok-4"], "enabled": false},
{"slug": "local", "name": "Local", "models": ["llama"], "excluded": true},
{"slug": "ollama", "name": "Ollama", "models": ["qwen"]}
]
}
""".trimIndent(),
)
assertEquals(listOf("openai", "ollama"), options.map { it.id })
}
}
@@ -10,6 +10,17 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class RelayErrorClassifierTest {
@Test
fun gatewayDrainIsNotMisclassifiedAsProviderOutage() {
val err = classifyError(
IOException("API error 503: gateway_draining: Gateway is shutting down (Retry-After: 1s)"),
context = "send_message",
)
assertEquals("Hermes is restarting", err.title)
assertTrue(err.body.contains("draining"))
assertTrue(err.retryable)
}
@Test
fun connectivityErrorsAreClassifiedForSnackbarSuppression() {
// The "can't reach the server" family the themed banner owns.
@@ -139,6 +139,25 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals("victor", gatewayClient.sessionProfileProvider())
}
@Test
fun gatewaySessionCreateProviderPreservesExplicitFastFalse() {
serverWs.send(
gatewayHarness.eventFrame(
"session.info",
buildJsonObject { put("fast", false) },
"live-resumed",
),
)
val deadline = System.currentTimeMillis() + 5_000
while (viewModel.fastEnabled.value != false) {
shadowOf(Looper.getMainLooper()).idle()
if (System.currentTimeMillis() >= deadline) error("fast=false did not reconcile")
Thread.sleep(20)
}
assertEquals(false, gatewayClient.sessionModelProvider()?.fast)
}
@Test
fun dashboardOnlyConnectionCanSendWithoutApiClient() {
viewModel.updateGatewayClient(null)
@@ -528,6 +547,224 @@ class ChatViewModelGatewayInboundTurnTest {
})
}
@Test
fun queuedOnlyRecoverySettlesPriorCheckpointBeforeStreamingQueuedTurn() {
val now = System.currentTimeMillis()
val checkpointStore = MemoryCheckpointStore(
ChatTurnCheckpoint(
contextKey = PROFILE_CONTEXT,
sessionId = STORED_SESSION_ID,
liveSessionId = "live-resumed",
transport = "gateway",
user = ChatTurnUserCheckpoint("prior-user", "First prompt", now - 3_000L),
assistant = ChatTurnAssistantCheckpoint(
id = "prior-assistant",
content = "Old partial",
timestamp = now - 2_900L,
),
priorUserMessageCount = 0,
baselineAssistantCount = 0,
startedAt = now - 2_900L,
updatedAt = now,
),
)
persistedHistory = listOf(
MessageItem(
id = "server-prior-user",
sessionId = STORED_SESSION_ID,
role = "user",
content = JsonPrimitive("First prompt"),
),
MessageItem(
id = "server-prior-assistant",
sessionId = STORED_SESSION_ID,
role = "assistant",
content = JsonPrimitive("Completed first answer"),
),
)
gatewayHarness.recoveryRunning = false
gatewayHarness.recoveryQueuedUser = "Second queued prompt"
viewModel.setChatTurnCheckpointStore(checkpointStore)
handler.setSessionId(null)
viewModel.switchProfileContext(PROFILE_CONTEXT, STORED_SESSION_ID)
viewModel.prewarmGateway()
gatewayHarness.awaitRpc("session.activate")
awaitCondition {
handler.messages.value.any {
it.role == MessageRole.USER && it.content == "Second queued prompt"
} && handler.messages.value.any {
it.id.startsWith("gateway-inbound-") && it.isStreaming
}
}
val beforeEvents = handler.messages.value
assertTrue(beforeEvents.any {
it.role == MessageRole.ASSISTANT &&
it.content == "Completed first answer" &&
!it.isStreaming
})
assertFalse(beforeEvents.any { it.id == "prior-assistant" && it.isStreaming })
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
serverWs.send(
gatewayHarness.eventFrame(
"message.delta",
buildJsonObject { put("text", "Second answer") },
"live-resumed",
),
)
persistedHistory = persistedHistory + listOf(
MessageItem(
id = "server-queued-user",
sessionId = STORED_SESSION_ID,
role = "user",
content = JsonPrimitive("Second queued prompt"),
),
MessageItem(
id = "server-queued-assistant",
sessionId = STORED_SESSION_ID,
role = "assistant",
content = JsonPrimitive("Second answer"),
),
)
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", "Second answer") },
"live-resumed",
),
)
awaitCondition { !handler.isStreaming.value }
awaitCondition {
handler.messages.value.count {
it.role == MessageRole.ASSISTANT && it.content == "Second answer"
} == 1
}
assertTrue(handler.messages.value.any { it.content == "Completed first answer" })
assertFalse(handler.messages.value.any {
it.id == "prior-assistant" && it.content.contains("Second answer")
})
}
@Test
fun runningAndQueuedRecoveryCreatesANewPromptAndCheckpointAfterCurrentCompletion() {
val now = System.currentTimeMillis()
val checkpointStore = MemoryCheckpointStore(
ChatTurnCheckpoint(
contextKey = PROFILE_CONTEXT,
sessionId = STORED_SESSION_ID,
liveSessionId = "live-resumed",
transport = "gateway",
user = ChatTurnUserCheckpoint("prior-user", "First prompt", now - 3_000L),
assistant = ChatTurnAssistantCheckpoint(
id = "prior-assistant",
content = "Current partial",
timestamp = now - 2_900L,
),
priorUserMessageCount = 0,
baselineAssistantCount = 0,
startedAt = now - 2_900L,
updatedAt = now,
),
)
gatewayHarness.recoveryRunning = true
gatewayHarness.recoveryAssistant = "Current partial"
gatewayHarness.recoveryQueuedUser = "Second queued prompt"
viewModel.setChatTurnCheckpointStore(checkpointStore)
handler.setSessionId(null)
viewModel.switchProfileContext(PROFILE_CONTEXT, STORED_SESSION_ID)
viewModel.prewarmGateway()
gatewayHarness.awaitRpc("session.activate")
awaitCondition {
handler.messages.value.any { it.id == "prior-assistant" && it.isStreaming }
}
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", "Current partial completed") },
"live-resumed",
),
)
awaitCondition {
handler.messages.value.any {
it.role == MessageRole.USER && it.content == "Second queued prompt"
} && handler.messages.value.any {
it.id.startsWith("gateway-inbound-") && it.isStreaming
}
}
awaitCondition { checkpointStore.checkpoint?.user?.content == "Second queued prompt" }
assertTrue(
"prior turn was not settled before queued handoff: ${handler.messages.value}",
handler.messages.value.any {
it.id == "prior-assistant" &&
it.content.endsWith("Current partial completed") &&
!it.isStreaming
},
)
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
serverWs.send(
gatewayHarness.eventFrame(
"message.delta",
buildJsonObject { put("text", "Second answer") },
"live-resumed",
),
)
persistedHistory = listOf(
MessageItem(
id = "server-prior-user",
sessionId = STORED_SESSION_ID,
role = "user",
content = JsonPrimitive("First prompt"),
),
MessageItem(
id = "server-prior-assistant",
sessionId = STORED_SESSION_ID,
role = "assistant",
content = JsonPrimitive("Current partial completed"),
),
MessageItem(
id = "server-queued-user",
sessionId = STORED_SESSION_ID,
role = "user",
content = JsonPrimitive("Second queued prompt"),
),
MessageItem(
id = "server-queued-assistant",
sessionId = STORED_SESSION_ID,
role = "assistant",
content = JsonPrimitive("Second answer"),
),
)
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", "Second answer") },
"live-resumed",
),
)
awaitCondition { !handler.isStreaming.value }
awaitCondition { checkpointStore.checkpoint == null }
assertEquals(
1,
handler.messages.value.count {
it.role == MessageRole.USER && it.content == "Second queued prompt"
},
)
assertEquals(
1,
handler.messages.value.count {
it.role == MessageRole.ASSISTANT && it.content == "Second answer"
},
)
}
@Test
fun lateCanceledCompletionDrainsBeforeImmediateNextTurn() {
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
@@ -585,7 +822,7 @@ class ChatViewModelGatewayInboundTurnTest {
@Test
fun queuedMessageDrainsAfterUnsolicitedTurnCompletes() {
gatewayHarness.steerStatus = "rejected"
gatewayHarness.redirectStatus = "rejected"
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
serverWs.send(
gatewayHarness.eventFrame(
@@ -597,7 +834,7 @@ class ChatViewModelGatewayInboundTurnTest {
awaitCondition { handler.isStreaming.value }
viewModel.sendMessage("Run this next")
gatewayHarness.awaitRpc("session.steer")
gatewayHarness.awaitRpc("session.redirect")
awaitCondition { viewModel.queuedMessages.value == listOf("Run this next") }
persistedHistory = persistedAnswerHistory()
@@ -0,0 +1,72 @@
package com.hermesandroid.relay.viewmodel
import androidx.lifecycle.SavedStateHandle
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class DashboardManageOAuthViewModelTest {
@Test
fun pendingFlow_survivesRecreationWithOnlyOpaqueNonSecretFields() = runTest {
val handle = SavedStateHandle()
val first = DashboardManageOAuthViewModel(handle)
first.remember("opaque-flow", "hosted", "work", "connection-a|https://dashboard.example")
advanceUntilIdle()
val recreated = DashboardManageOAuthViewModel(handle)
advanceUntilIdle()
assertEquals(
PendingMcpOAuth("opaque-flow", "hosted", "work", "connection-a|https://dashboard.example"),
recreated.pending.value,
)
val persistedText = handle.keys().associateWith { key -> handle.get<Any?>(key) }.toString()
assertFalse(persistedText.contains("authorization_url"))
assertFalse(persistedText.contains("code="))
assertFalse(persistedText.contains("state="))
assertFalse(persistedText.contains("token"))
}
@Test
fun clearAndCapabilityRoutes_areStableAcrossRecreation() = runTest {
val handle = SavedStateHandle()
val first = DashboardManageOAuthViewModel(handle)
first.remember("opaque-flow", "hosted", null, "connection-a|https://dashboard.example")
first.markUnsupported("https://dashboard.example|work")
first.markSupported("https://dashboard.example|personal")
first.clear()
advanceUntilIdle()
val recreated = DashboardManageOAuthViewModel(handle)
advanceUntilIdle()
assertNull(recreated.pending.value)
assertTrue("https://dashboard.example|work" in recreated.unsupportedRoutes.value)
assertTrue("https://dashboard.example|personal" in recreated.supportedRoutes.value)
}
@Test
fun capabilityRoute_changesAreMutuallyExclusive() = runTest {
val handle = SavedStateHandle()
val viewModel = DashboardManageOAuthViewModel(handle)
val route = "https://dashboard.example|work"
viewModel.markUnsupported(route)
viewModel.markSupported(route)
advanceUntilIdle()
assertTrue(route in viewModel.supportedRoutes.value)
assertFalse(route in viewModel.unsupportedRoutes.value)
viewModel.markUnsupported(route)
advanceUntilIdle()
assertFalse(route in viewModel.supportedRoutes.value)
assertTrue(route in viewModel.unsupportedRoutes.value)
}
}
@@ -0,0 +1,77 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.ApiEndpoint
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.DashboardEndpoint
import com.hermesandroid.relay.data.EndpointCandidate
import org.junit.Assert.assertEquals
import org.junit.Test
class EffectiveDashboardRouteTest {
@Test
fun `selected route dashboard wins over explicit primary dashboard`() {
val connection = connection(
dashboardUrl = "http://192.168.1.20:9119",
apiServerUrl = "http://192.168.1.20:8642",
)
val tailscale = EndpointCandidate(
role = "tailscale",
priority = 1,
api = ApiEndpoint("100.71.8.56", 8642),
dashboard = DashboardEndpoint("http://100.71.8.56:9119"),
)
assertEquals(
"http://100.71.8.56:9119",
resolveEffectiveDashboardUrl(connection, tailscale),
)
}
@Test
fun `selected API-only route derives dashboard even when primary dashboard is explicit`() {
val connection = connection(
dashboardUrl = "http://192.168.1.20:9119",
apiServerUrl = "http://192.168.1.20:8642",
)
val tailscale = EndpointCandidate(
role = "tailscale",
priority = 1,
api = ApiEndpoint("100.71.8.56", 8642),
)
assertEquals(
"http://100.71.8.56:9119",
resolveEffectiveDashboardUrl(connection, tailscale),
)
}
@Test
fun `explicit saved dashboard remains fallback when selected route lacks standard surfaces`() {
val connection = connection(
dashboardUrl = "https://dashboard.example.com",
apiServerUrl = "https://api.example.com",
)
val incompleteRoute = EndpointCandidate(
role = "custom",
priority = 1,
)
assertEquals(
"https://dashboard.example.com",
resolveEffectiveDashboardUrl(connection, incompleteRoute),
)
}
private fun connection(
dashboardUrl: String?,
apiServerUrl: String,
) = Connection(
id = "connection",
label = "Hermes",
apiServerUrl = apiServerUrl,
relayUrl = "",
tokenStoreKey = "hermes_auth_connection",
dashboardUrl = dashboardUrl,
)
}
@@ -113,4 +113,127 @@ class RelayPairingPersistencePolicyTest {
result.routeCandidates.map { it.relay?.url },
)
}
@Test
fun `relay pairing appends QR routes missing from LAN-only standard connection`() {
val lan = EndpointCandidate(
role = "lan",
priority = 0,
dashboard = DashboardEndpoint("http://192.168.1.20:9119"),
api = ApiEndpoint("192.168.1.20", 8642),
)
val current = Connection(
id = "hermes-node",
label = "Hermes node",
apiServerUrl = "http://192.168.1.20:8642",
relayUrl = "",
tokenStoreKey = "hermes_auth_node",
dashboardUrl = "http://192.168.1.20:9119",
routeCandidates = listOf(lan),
)
val relayRoutes = listOf(
EndpointCandidate(
role = "lan",
priority = 0,
api = ApiEndpoint("192.168.1.20", 8642),
relay = RelayEndpoint("ws://192.168.1.20:8767", "ws"),
),
EndpointCandidate(
role = "tailscale",
priority = 1,
api = ApiEndpoint("100.71.8.56", 8642),
relay = RelayEndpoint("ws://100.71.8.56:8767", "ws"),
),
)
val result = preserveStandardConnectionWhileApplyingRelay(
current = current,
relayUrl = "ws://192.168.1.20:8767",
relayRoutes = relayRoutes,
)
assertEquals(listOf("lan", "tailscale"), result.routeCandidates.map { it.role })
assertEquals("100.71.8.56", result.routeCandidates[1].api?.host)
assertEquals(
"http://100.71.8.56:9119",
result.routeCandidates[1].dashboard?.url,
)
assertEquals("ws://100.71.8.56:8767", result.routeCandidates[1].relay?.url)
}
@Test
fun `relay pairing repairs an already persisted API-only tailscale route`() {
val tailscaleApi = ApiEndpoint("100.71.8.56", 8642)
val tailscaleRelay = RelayEndpoint("ws://100.71.8.56:8767", "ws")
val current = Connection(
id = "hermes-node",
label = "Hermes node",
apiServerUrl = "http://192.168.1.20:8642",
relayUrl = "ws://192.168.1.20:8767",
tokenStoreKey = "hermes_auth_node",
dashboardUrl = "http://192.168.1.20:9119",
routeCandidates = listOf(
EndpointCandidate(
role = "tailscale",
priority = 1,
api = tailscaleApi,
relay = tailscaleRelay,
),
),
)
val result = preserveStandardConnectionWhileApplyingRelay(
current = current,
relayUrl = current.relayUrl,
relayRoutes = current.routeCandidates,
)
assertEquals(
"http://100.71.8.56:9119",
result.routeCandidates.single().dashboard?.url,
)
}
@Test
fun `relay pairing fills API and Relay on manually-added dashboard route`() {
val tailscaleDashboard = DashboardEndpoint("http://100.71.8.56:9119")
val current = Connection(
id = "hermes-node",
label = "Hermes node",
apiServerUrl = "http://192.168.1.20:8642",
relayUrl = "ws://192.168.1.20:8767",
tokenStoreKey = "hermes_auth_node",
dashboardUrl = "http://192.168.1.20:9119",
routeCandidates = listOf(
EndpointCandidate(
role = "lan",
priority = 0,
dashboard = DashboardEndpoint("http://192.168.1.20:9119"),
api = ApiEndpoint("192.168.1.20", 8642),
),
EndpointCandidate(
role = "tailscale",
priority = 1,
dashboard = tailscaleDashboard,
),
),
)
val qrTailscale = EndpointCandidate(
role = "tailscale",
priority = 1,
api = ApiEndpoint("100.71.8.56", 8642),
relay = RelayEndpoint("ws://100.71.8.56:8767", "ws"),
)
val result = preserveStandardConnectionWhileApplyingRelay(
current = current,
relayUrl = current.relayUrl,
relayRoutes = listOf(qrTailscale),
)
val route = result.routeCandidates.single { it.role == "tailscale" }
assertEquals(tailscaleDashboard, route.dashboard)
assertEquals(qrTailscale.api, route.api)
assertEquals(qrTailscale.relay, route.relay)
}
}
@@ -0,0 +1,55 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamOutcome
import com.hermesandroid.relay.network.shared.VoiceSpeechStreamStatus
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.IOException
class StandardSpeechFallbackTest {
@Test
fun preAudioFallbackAndFailureUseLegacySpeech() {
assertTrue(
shouldFallbackStandardSpeech(
VoiceSpeechStreamOutcome(VoiceSpeechStreamStatus.Fallback, audioStarted = false),
),
)
assertTrue(
shouldFallbackStandardSpeech(
VoiceSpeechStreamOutcome(
VoiceSpeechStreamStatus.Failed,
audioStarted = false,
error = IOException("auth expired"),
),
),
)
}
@Test
fun postAudioDisconnectNeverReplaysWholeReply() {
assertFalse(
shouldFallbackStandardSpeech(
VoiceSpeechStreamOutcome(
VoiceSpeechStreamStatus.Failed,
audioStarted = true,
error = IOException("route changed"),
),
),
)
assertFalse(
shouldFallbackStandardSpeech(
VoiceSpeechStreamOutcome(VoiceSpeechStreamStatus.Completed, audioStarted = true),
),
)
}
@Test
fun completedStreamWithoutAudioUsesLegacySpeech() {
assertTrue(
shouldFallbackStandardSpeech(
VoiceSpeechStreamOutcome(VoiceSpeechStreamStatus.Completed, audioStarted = false),
),
)
}
}
+27
View File
@@ -0,0 +1,27 @@
# Voice Settings design QA
Status: **passed**
## Compared
- Source: `voice-audit/mockup-04-recommended-hybrid.png`
- Implementation: `voice-audit/implementation-03-provider-preview-refined.png`
- Viewport: Android `Medium_Phone`, 1080 x 2400 px (about 390 dp wide), font scale 1.0
- State: Output tab, xAI Grok TTS, Eve selected and actively previewing
## Resolved findings
- **P2 - Group boundaries were too weak.** Provider, model/voice, and language/quality surfaces now use the product's rounded Material cards with outline-token borders and a subtle surface-variant fill.
- **P2 - Preview state was too tall.** The existing voice waveform now accepts a caller-provided height; inline preview uses a compact 28 dp waveform while the full voice overlay keeps its 56 dp default.
- **P2 - Long catalogs displaced the core controls.** The selected/recommended voices remain inline, while the full catalog opens from a compact `View all voices` action.
- **P2 - Secondary controls competed with selection.** Latency, fallback, expressive tags, and manual IDs now stay inside the collapsed Language & quality section.
- **P3 - Active tab lacked the target emphasis.** The selected segment now uses the theme primary/on-primary colors.
## Final review
- Layout and hierarchy match the selected hybrid: summary, Output/Listening/Advanced tabs, grouped provider, grouped model/voice, inline play/stop, active waveform, and collapsed language/quality.
- Interactions are wired: tabs switch sections, provider/model/voice selections update the draft, only one preview can play, the active play button becomes Stop, and full catalogs remain accessible.
- Accessibility: native Material controls retain semantics and practical tap targets; the active preview has explicit play/stop descriptions; the screen remains vertically scrollable for large text and long provider catalogs.
- Accepted native adaptation: Android exposed dropdowns and dynamic account-backed catalogs use more vertical space than the static concept. The hierarchy and core actions remain intact without clipping or overlap.
No open P0, P1, or P2 findings.
+7
View File
@@ -178,6 +178,13 @@ Modes combine: `--purge --service` runs both.
- **For the `curl | sh` / `irm | iex` binary install:** no runtime deps — the binary is self-contained. `~/.hermes/bin/` on PATH.
- **For local clone + `npm link`:** Node.js ≥21 — needed for the built-in global `WebSocket`. Older Node needs `--experimental-websocket`; we don't support that.
On Windows, the prebuilt CLI trusts certificates from the Windows system store
in addition to its bundled roots. Local Node runs gain the same behavior on
Node 22.19 or newer; older supported Node releases keep their existing bundled
and `NODE_EXTRA_CA_CERTS` trust behavior because they do not expose the required
system-CA APIs. Certificate validation is never disabled, and the Relay's TOFU
SPKI pin is still checked after the certificate chain is accepted.
## First-time pairing
On the Hermes host, mint a one-time pairing invite:
+1 -1
View File
@@ -29,7 +29,7 @@
"build": "npm run gen:version && tsc -p tsconfig.build.json",
"build:watch": "tsc -p tsconfig.build.json --watch",
"build:bin": "npm run build:bin:win && npm run build:bin:linux && npm run build:bin:mac-x64 && npm run build:bin:mac-arm",
"build:bin:win": "npm run gen:version && bun build --compile --minify --sourcemap --target=bun-windows-x64 src/cli.ts --outfile dist/bin/hermes-relay-win-x64",
"build:bin:win": "npm run gen:version && bun build --compile --minify --sourcemap --target=bun-windows-x64 src/bunWindowsEntry.ts --outfile dist/bin/hermes-relay-win-x64",
"build:bin:linux": "npm run gen:version && bun build --compile --minify --sourcemap --target=bun-linux-x64 src/cli.ts --outfile dist/bin/hermes-relay-linux-x64",
"build:bin:mac-x64": "npm run gen:version && bun build --compile --minify --sourcemap --target=bun-darwin-x64 src/cli.ts --outfile dist/bin/hermes-relay-darwin-x64",
"build:bin:mac-arm": "npm run gen:version && bun build --compile --minify --sourcemap --target=bun-darwin-arm64 src/cli.ts --outfile dist/bin/hermes-relay-darwin-arm64",
+13
View File
@@ -0,0 +1,13 @@
export interface BunSystemCaBootstrapContext {
platform: NodeJS.Platform
bunVersion?: string
nodeUseSystemCa?: string
}
/** Bun reads NODE_USE_SYSTEM_CA only during process startup. */
export const shouldRelaunchBunWithWindowsSystemCa = ({
platform,
bunVersion,
nodeUseSystemCa
}: BunSystemCaBootstrapContext): boolean =>
platform === 'win32' && typeof bunVersion === 'string' && bunVersion.length > 0 && nodeUseSystemCa !== '1'
+42
View File
@@ -0,0 +1,42 @@
import { spawnSync } from 'node:child_process'
import { shouldRelaunchBunWithWindowsSystemCa } from './bunSystemCaBootstrap.js'
// Bun's Windows trust-store switch is startup-only. The released Windows
// executable therefore performs one early self-relaunch before importing any
// module that can create a TLS context. NODE_USE_SYSTEM_CA=1 keeps Bun's
// bundled roots and adds Windows roots for both node:tls and global WebSocket.
if (
shouldRelaunchBunWithWindowsSystemCa({
platform: process.platform,
bunVersion: process.versions.bun,
nodeUseSystemCa: process.env.NODE_USE_SYSTEM_CA
})
) {
const child = spawnSync(process.execPath, process.argv.slice(2), {
env: { ...process.env, NODE_USE_SYSTEM_CA: '1' },
stdio: 'inherit',
windowsHide: true
})
if (child.error) {
process.stderr.write(`hermes-relay: could not enable Windows system CA trust: ${child.error.message}\n`)
process.exit(1)
}
process.exit(child.status ?? 1)
}
const { main } = await import('./cli.js')
main()
.then((code) => process.exit(code ?? 0))
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
process.stderr.write(`hermes-relay: ${message}\n`)
if (process.env.HERMES_DEBUG) {
// eslint-disable-next-line no-console
console.error(error)
}
process.exit(1)
})
+78 -12
View File
@@ -24,6 +24,12 @@ import type {
SessionInterruptResponse,
SessionResumeResponse
} from '../gatewayTypes.js'
import {
sessionProjectName,
sessionQueuedPromptPreview,
sessionResumeActivity,
type SessionResumeActivity
} from '../gatewayTypes.js'
import { setupGracefulExit } from '../lib/gracefulExit.js'
import { renderLogo } from '../lib/logo.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
@@ -31,6 +37,7 @@ import { theme as makeTheme } from '../lib/theme.js'
import { deleteSession, saveSession } from '../remoteSessions.js'
import { runRelayChatTurn, type RelayChatTurnHandle } from '../relayChat.js'
import { CliRenderer } from '../renderer.js'
import { SessionResumeDrain } from '../resumeDrain.js'
import { fetchRecentSessions, pickSession } from '../sessionPicker.js'
import { ensureToolsConsent } from '../tools/consent.js'
import { configureComputerUseRuntime } from '../tools/computerGrants.js'
@@ -336,17 +343,40 @@ async function resolveHermesSessionId(
async function createOrResumeSession(
gw: GatewayClient,
resumeId: string | null
): Promise<{ sessionId: string; model: string | null }> {
resumeId: string | null,
renderer: CliRenderer
): Promise<{
sessionId: string
model: string | null
projectName: string | null
queuedPrompt: string | null
resumeActivity: SessionResumeActivity
resumeDrain: Promise<void> | null
}> {
const cols = process.stdout.columns ?? 80
if (resumeId) {
const raw = await gw.request<SessionResumeResponse>('session.resume', { session_id: resumeId, cols })
const r = asRpcResult<SessionResumeResponse>(raw)
if (!r?.session_id) {
throw new Error(`failed to resume session ${resumeId}`)
const drain = new SessionResumeDrain(gw, (event) => renderer.handle(event))
try {
const raw = await gw.request<SessionResumeResponse>('session.resume', { session_id: resumeId, cols })
const r = asRpcResult<SessionResumeResponse>(raw)
if (!r?.session_id) {
throw new Error(`failed to resume session ${resumeId}`)
}
const resumeActivity = sessionResumeActivity(r)
const drained = drain.activate(r.session_id, resumeActivity)
return {
sessionId: r.session_id,
model: r.info?.model ?? null,
projectName: sessionProjectName(r.info),
queuedPrompt: sessionQueuedPromptPreview(r),
resumeActivity,
resumeDrain: resumeActivity === 'idle' ? null : drained
}
} catch (error) {
drain.cancel()
throw error
}
return { sessionId: r.session_id, model: r.info?.model ?? null }
}
const raw = await gw.request<SessionCreateResponse>('session.create', { cols })
@@ -354,7 +384,14 @@ async function createOrResumeSession(
if (!r?.session_id) {
throw new Error('failed to create session')
}
return { sessionId: r.session_id, model: r.info?.model ?? null }
return {
sessionId: r.session_id,
model: r.info?.model ?? null,
projectName: sessionProjectName(r.info),
queuedPrompt: null,
resumeActivity: 'idle',
resumeDrain: null
}
}
/** Dispatch a REPL slash-command line to the right AttachPayload source.
@@ -650,17 +687,46 @@ export async function chatCommand(args: ParsedArgs): Promise<number> {
return 1
}
let session: { sessionId: string; model: string | null }
let session: {
sessionId: string
model: string | null
projectName: string | null
queuedPrompt: string | null
resumeActivity: SessionResumeActivity
resumeDrain: Promise<void> | null
}
try {
session = await createOrResumeSession(gw, resumeId)
session = await createOrResumeSession(gw, resumeId, renderer)
} catch (e) {
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
tearDown()
return 1
}
if (session.model) {
process.stderr.write(`Session ${session.sessionId.slice(0, 8)}… on ${session.model}\n`)
if (session.model || session.projectName) {
const modelDetail = session.model ? ` on ${session.model}` : ''
const projectDetail = session.projectName ? ` · ${session.projectName}` : ''
process.stderr.write(`Session ${session.sessionId.slice(0, 8)}…${modelDetail}${projectDetail}\n`)
}
if (session.resumeActivity === 'running-and-queued') {
process.stderr.write('Resumed live turn; the next accepted prompt is queued.\n')
} else if (session.resumeActivity === 'running') {
process.stderr.write('Resumed live turn still in progress.\n')
} else if (session.resumeActivity === 'queued') {
process.stderr.write('Resumed session with an accepted prompt waiting to run.\n')
}
if (session.queuedPrompt) {
process.stderr.write(`Queued prompt: ${session.queuedPrompt}\n`)
}
if (session.resumeDrain) {
try {
await session.resumeDrain
process.stderr.write('Resumed activity completed.\n')
} catch (e) {
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
tearDown()
return 1
}
}
renderer.handle({
type: 'session.info',
+74 -9
View File
@@ -31,6 +31,12 @@ import type {
SessionCreateResponse,
SessionResumeResponse
} from '../gatewayTypes.js'
import {
sessionProjectName,
sessionQueuedPromptPreview,
sessionResumeActivity,
type SessionResumeActivity
} from '../gatewayTypes.js'
import { getActiveDesktopRelayUrl } from '../desktopConfig.js'
import { formatError } from '../lib/hints.js'
import { setupGracefulExit } from '../lib/gracefulExit.js'
@@ -38,6 +44,7 @@ import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { theme as makeTheme, type Theme } from '../lib/theme.js'
import { printUsage, unknownSubcommand, type UsageSpec } from '../lib/usage.js'
import { resolveFirstRunUrl } from '../relayUrlPrompt.js'
import { SessionResumeDrain } from '../resumeDrain.js'
import { deleteSession, getSession, listSessions, saveSession } from '../remoteSessions.js'
import { RelayTransport } from '../transport/RelayTransport.js'
import { openInBrowser, startVoiceServer } from '../voiceServer.js'
@@ -437,18 +444,47 @@ function waitForReady(gw: GatewayClient, timeoutMs = READY_TIMEOUT_MS): Promise<
async function createOrResumeSession(
gw: GatewayClient,
resumeId: string | null
): Promise<{ sessionId: string; model: string | null }> {
): Promise<{
sessionId: string
model: string | null
projectName: string | null
queuedPrompt: string | null
resumeActivity: SessionResumeActivity
resumeDrain: Promise<void> | null
}> {
const cols = process.stdout.columns ?? 80
if (resumeId) {
const raw = await gw.request<SessionResumeResponse>('session.resume', { session_id: resumeId, cols })
const r = asRpcResult<SessionResumeResponse>(raw)
if (!r?.session_id) throw new Error(`failed to resume session ${resumeId}`)
return { sessionId: r.session_id, model: r.info?.model ?? null }
const drain = new SessionResumeDrain(gw)
try {
const raw = await gw.request<SessionResumeResponse>('session.resume', { session_id: resumeId, cols })
const r = asRpcResult<SessionResumeResponse>(raw)
if (!r?.session_id) throw new Error(`failed to resume session ${resumeId}`)
const resumeActivity = sessionResumeActivity(r)
const drained = drain.activate(r.session_id, resumeActivity)
return {
sessionId: r.session_id,
model: r.info?.model ?? null,
projectName: sessionProjectName(r.info),
queuedPrompt: sessionQueuedPromptPreview(r),
resumeActivity,
resumeDrain: resumeActivity === 'idle' ? null : drained
}
} catch (error) {
drain.cancel()
throw error
}
}
const raw = await gw.request<SessionCreateResponse>('session.create', { cols })
const r = asRpcResult<SessionCreateResponse>(raw)
if (!r?.session_id) throw new Error('failed to create session')
return { sessionId: r.session_id, model: r.info?.model ?? null }
return {
sessionId: r.session_id,
model: r.info?.model ?? null,
projectName: sessionProjectName(r.info),
queuedPrompt: null,
resumeActivity: 'idle',
resumeDrain: null
}
}
async function voiceMode(args: ParsedArgs): Promise<number> {
@@ -493,7 +529,14 @@ async function voiceMode(args: ParsedArgs): Promise<number> {
return 1
}
let session: { sessionId: string; model: string | null }
let session: {
sessionId: string
model: string | null
projectName: string | null
queuedPrompt: string | null
resumeActivity: SessionResumeActivity
resumeDrain: Promise<void> | null
}
try {
session = await createOrResumeSession(gw, conversation)
} catch (e) {
@@ -501,8 +544,30 @@ async function voiceMode(args: ParsedArgs): Promise<number> {
await tearDown()
return 1
}
if (session.model) {
process.stderr.write(`Session ${session.sessionId.slice(0, 8)}… on ${session.model}\n`)
if (session.model || session.projectName) {
const modelDetail = session.model ? ` on ${session.model}` : ''
const projectDetail = session.projectName ? ` · ${session.projectName}` : ''
process.stderr.write(`Session ${session.sessionId.slice(0, 8)}…${modelDetail}${projectDetail}\n`)
}
if (session.resumeActivity === 'running-and-queued') {
process.stderr.write('Resumed live turn; the next accepted prompt is queued.\n')
} else if (session.resumeActivity === 'running') {
process.stderr.write('Resumed live turn still in progress.\n')
} else if (session.resumeActivity === 'queued') {
process.stderr.write('Resumed session with an accepted prompt waiting to run.\n')
}
if (session.queuedPrompt) {
process.stderr.write(`Queued prompt: ${session.queuedPrompt}\n`)
}
if (session.resumeDrain) {
try {
await session.resumeDrain
process.stderr.write('Resumed activity completed.\n')
} catch (e) {
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
await tearDown()
return 1
}
}
try {
+56 -1
View File
@@ -62,11 +62,54 @@ export interface SessionCreateResponse {
}
export interface SessionResumeResponse {
inflight?: {
assistant?: string
streaming?: boolean
user?: string
}
info?: SessionInfo
message_count?: number
messages: GatewayTranscriptMessage[]
queued?: { user?: string }
resumed?: string
running?: boolean
session_id: string
status?: string
}
export type SessionResumeActivity = 'idle' | 'queued' | 'running' | 'running-and-queued'
/** Sanitize untrusted gateway text for a bounded, single-line terminal status. */
export function terminalStatusPreview(value: string | undefined, maxChars: number): string | null {
const compact = value
?.replaceAll(/\x1b\[[?\d;]*[a-zA-Z~]/g, '')
.replaceAll(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
.replaceAll(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
.replace(/\s+/g, ' ')
.trim()
if (!compact) return null
const limit = Math.max(2, maxChars)
return compact.length <= limit ? compact : `${compact.slice(0, limit - 1).trimEnd()}…`
}
/** Classify optional live-work fields added by newer vanilla Hermes gateways. */
export function sessionResumeActivity(response: SessionResumeResponse): SessionResumeActivity {
const running = response.running === true || response.inflight?.streaming === true
const queued = typeof response.queued?.user === 'string' && response.queued.user.trim().length > 0
if (running && queued) return 'running-and-queued'
if (running) return 'running'
if (queued) return 'queued'
return 'idle'
}
/** Return the accepted next-turn prompt as a safe one-line status preview. */
export function sessionQueuedPromptPreview(response: SessionResumeResponse, maxChars = 120): string | null {
return terminalStatusPreview(response.queued?.user, maxChars)
}
/** Return a displayable project name without requiring newer gateway metadata. */
export function sessionProjectName(info: SessionInfo | undefined): string | null {
return terminalStatusPreview(info?.project?.name, 80)
}
export interface SessionListItem {
@@ -139,6 +182,18 @@ export type GatewayEvent =
| { payload: SessionInfo; session_id?: string; type: 'session.info' }
| { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' }
| { payload?: undefined; session_id?: string; type: 'message.start' }
| {
payload?: {
already_streamed?: boolean
message?: string
preview?: string
rendered?: string
response_previewed?: boolean
text?: string
}
session_id?: string
type: 'message.interim'
}
| { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' }
| { payload: { line: string }; session_id?: string; type: 'gateway.stderr' }
| { payload?: { cwd?: string; python?: string }; session_id?: string; type: 'gateway.start_timeout' }
@@ -175,7 +230,7 @@ export type GatewayEvent =
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.complete' }
| { payload: { rendered?: string; text?: string }; session_id?: string; type: 'message.delta' }
| {
payload?: { reasoning?: string; rendered?: string; text?: string; usage?: Usage }
payload?: { reasoning?: string; rendered?: string; response_previewed?: boolean; text?: string; usage?: Usage }
session_id?: string
type: 'message.complete'
}
+22
View File
@@ -103,6 +103,28 @@ export class CliRenderer {
return
}
case 'message.interim': {
const text = ev.payload?.text ?? ev.payload?.message ?? ev.payload?.preview ?? ev.payload?.rendered
if (ev.payload?.already_streamed === true) {
if (this.inAssistantMessage && !this.lastDeltaEndedWithNewline) {
process.stdout.write('\n')
}
this.inAssistantMessage = false
this.lastDeltaEndedWithNewline = true
} else if (typeof text === 'string' && text.length > 0) {
if (!this.inAssistantMessage) {
process.stdout.write('\n')
}
process.stdout.write(text)
if (!text.endsWith('\n')) {
process.stdout.write('\n')
}
this.inAssistantMessage = false
this.lastDeltaEndedWithNewline = true
}
return
}
case 'message.complete': {
if (this.inAssistantMessage && !this.lastDeltaEndedWithNewline) {
process.stdout.write('\n')
+92
View File
@@ -0,0 +1,92 @@
import type { GatewayEvent, SessionResumeActivity } from './gatewayTypes.js'
interface GatewayEventSource {
on(event: 'event', listener: (event: GatewayEvent) => void): unknown
off(event: 'event', listener: (event: GatewayEvent) => void): unknown
}
const DEFAULT_IDLE_TIMEOUT_MS = 300_000
const MAX_BUFFERED_EVENTS = 512
/**
* Attach before `session.resume`, then drain every already-accepted turn to a
* terminal boundary before a CLI surface is allowed to submit another prompt.
*/
export class SessionResumeDrain {
private readonly buffered: GatewayEvent[] = []
private readonly listener: (event: GatewayEvent) => void
private readonly promise: Promise<void>
private resolvePromise!: () => void
private rejectPromise!: (error: Error) => void
private targetSessionId: string | null = null
private remainingTurns = 0
private timer: ReturnType<typeof setTimeout> | null = null
private settled = false
constructor(
private readonly source: GatewayEventSource,
private readonly onEvent?: (event: GatewayEvent) => void,
private readonly idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS
) {
this.promise = new Promise<void>((resolve, reject) => {
this.resolvePromise = resolve
this.rejectPromise = reject
})
this.listener = (event) => this.observe(event)
source.on('event', this.listener)
}
activate(sessionId: string, activity: SessionResumeActivity): Promise<void> {
if (this.targetSessionId !== null) {
throw new Error('resume activity drain already activated')
}
this.targetSessionId = sessionId
this.remainingTurns = activity === 'running-and-queued' ? 2 : activity === 'idle' ? 0 : 1
if (this.remainingTurns === 0) {
this.finish()
return this.promise
}
this.armTimer()
const pending = this.buffered.splice(0)
pending.forEach((event) => this.observe(event))
return this.promise
}
cancel(): void {
this.finish()
}
private observe(event: GatewayEvent): void {
if (this.settled) return
if (this.targetSessionId === null) {
if (this.buffered.length < MAX_BUFFERED_EVENTS) this.buffered.push(event)
return
}
if (event.session_id !== this.targetSessionId) return
this.onEvent?.(event)
this.armTimer()
if (event.type === 'message.complete' || event.type === 'error') {
this.remainingTurns -= 1
if (this.remainingTurns <= 0) this.finish()
}
}
private armTimer(): void {
if (this.timer) clearTimeout(this.timer)
this.timer = setTimeout(() => {
this.finish(new Error(`resumed activity idle timeout after ${this.idleTimeoutMs}ms`))
}, this.idleTimeoutMs)
this.timer.unref?.()
}
private finish(error?: Error): void {
if (this.settled) return
this.settled = true
if (this.timer) clearTimeout(this.timer)
this.timer = null
this.buffered.length = 0
this.source.off('event', this.listener)
if (error) this.rejectPromise(error)
else this.resolvePromise()
}
}

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