Compare commits

..
Author SHA1 Message Date
Bailey Dixon 3d11cfb5f2 Merge pull request #366 from Codename-11/dev
release(android): android-v1.9.1
2026-08-16 17:09:20 -04:00
Bailey Dixon 0f589d030f release(android): android-v1.9.1 2026-08-16 16:38:38 -04:00
Bailey Dixon af96579e06 merge: sync main into dev after desktop release 2026-08-16 16:20:04 -04:00
Bailey Dixon f0167ee00f fix(android): persist shared profile avatars 2026-08-16 15:29:43 -04:00
Bailey Dixon a5798e5e6c merge: integrate upstream animated profile pets 2026-08-16 15:04:18 -04:00
Bailey Dixon 479d788967 feat(android): sync upstream animated profile pets 2026-08-16 11:04:24 -04:00
Bailey Dixon 3f6723da6f merge: fix Nous callback authentication 2026-08-16 10:01:10 -04:00
Bailey Dixon c1cd376d8b fix(android): align Nous callback authentication 2026-08-16 10:01:02 -04:00
Bailey Dixon 87e2fb710d feat(android): add phone-only animated profile icons 2026-08-16 09:46:52 -04:00
Bailey Dixon 5a617f9482 merge: integrate Hermes profile contracts 2026-08-15 22:57:33 -04:00
Bailey Dixon 13152a4fab feat(android): adopt Hermes profile contracts 2026-08-15 22:56:30 -04:00
Bailey Dixon 7f31c88f46 merge: integrate high-value Android and Relay enhancements 2026-08-15 21:39:14 -04:00
Bailey Dixon 3e857b54a2 test(android): align hardened gateway contracts 2026-08-15 21:36:50 -04:00
Bailey Dixon 163e3341da fix(android): verify draft profile ownership 2026-08-15 21:36:49 -04:00
Bailey Dixon a5419df579 fix(android): guard draft model selection 2026-08-15 21:36:49 -04:00
Bailey Dixon e834c603d0 fix(android): clear rejected recovery events 2026-08-15 21:36:49 -04:00
Bailey Dixon a31d715569 feat(android): surface resource and model risk 2026-08-15 21:36:49 -04:00
Bailey Dixon c6b8d891ac fix(android): harden attachment delivery 2026-08-15 21:35:38 -04:00
Bailey Dixon 8996f34347 feat(android): add bounded cron and reset evidence 2026-08-15 21:35:38 -04:00
Bailey Dixon f5f1a4c7d4 fix(android): enforce profile ownership 2026-08-15 21:34:36 -04:00
Bailey Dixon 31eccc631e fix(android): fail closed on ambiguous rewinds 2026-08-15 21:34:35 -04:00
Bailey Dixon 64ba979816 Merge pull request #364 from Codename-11/feature/proactive-away-summary
feat(threads): surface messages received while away
2026-08-15 21:24:04 -04:00
Bailey Dixon 5be0979d74 chore(android): refresh localization catalog status 2026-08-15 21:13:05 -04:00
Bailey Dixon 80b1a3b6df Merge remote-tracking branch 'origin/dev' into feature/proactive-away-summary 2026-08-15 21:08:57 -04:00
Bailey Dixon 257a11ffa2 feat(threads): surface messages received while away 2026-08-15 21:08:50 -04:00
Bailey Dixon 62f3572e11 Merge pull request #363 from Codename-11/fix/android-media-download-state
fix(android): settle media download states
2026-08-15 21:03:40 -04:00
Bailey Dixon 621e526c7d fix(android): settle media download states 2026-08-15 20:52:40 -04:00
Bailey Dixon a5afec36d9 Merge pull request #234 from Codename-11/dependabot/github_actions/dev/actions/setup-python-7
chore(deps): bump actions/setup-python from 6 to 7
2026-08-15 20:11:58 -04:00
Bailey Dixon 29bf9a08ac Merge branch 'dev' into dependabot/github_actions/dev/actions/setup-python-7 2026-08-15 20:10:46 -04:00
Bailey Dixon 342e977594 Merge pull request #233 from Codename-11/dependabot/github_actions/dev/actions/setup-node-7
chore(deps): bump actions/setup-node from 4 to 7
2026-08-15 20:10:39 -04:00
Bailey Dixon 24e767e7ae Merge branch 'dev' into dependabot/github_actions/dev/actions/setup-node-7 2026-08-15 20:09:21 -04:00
Bailey Dixon fdf210ec2b Merge pull request #361 from Codename-11/dev
release(desktop): desktop-v0.4.0-beta.4
2026-08-15 13:49:30 -04:00
Bailey Dixon d05ab31296 release(desktop): desktop-v0.4.0-beta.4 2026-08-15 13:42:14 -04:00
Bailey Dixon 0e0cc16f47 Merge branch 'fix/desktop-daemon-stopped-loading' into dev 2026-08-15 13:32:25 -04:00
Bailey Dixon e90be03f2e fix(desktop): load UI while daemon is stopped 2026-08-15 13:32:11 -04:00
Bailey Dixon f1e4fdcc91 Merge pull request #360 from Codename-11/chore/backmerge-android-1.9.0
chore: back-merge Android 1.9.0 release
2026-08-14 21:52:34 -04:00
Bailey Dixon 5b8b3da350 chore: back-merge Android 1.9.0 release 2026-08-14 21:52:16 -04:00
Bailey Dixon 447ec356d7 Merge pull request #359 from Codename-11/dev
release(android): android-v1.9.0
2026-08-14 21:32:50 -04:00
Bailey Dixon ab359e5efb release(android): android-v1.9.0 2026-08-14 21:01:51 -04:00
Bailey Dixon 86b8161cb7 feat(android): refine sessions and messaging 2026-08-14 20:58:31 -04:00
Bailey Dixon e401fdb0c7 fix(android): persist landed message reactions 2026-08-14 17:04:54 -04:00
Bailey Dixon d4325d5aab fix(android): restore compact chat interactions 2026-08-14 16:57:19 -04:00
Bailey Dixon c734d75484 feat(android): add desktop-style session profiles 2026-08-14 16:57:18 -04:00
Bailey Dixon 0411804780 fix(android): accept compatible session flags 2026-08-14 16:57:18 -04:00
Bailey Dixon 8f89c2841d fix(android): keep dashboard teardown off main thread 2026-08-14 16:57:18 -04:00
Bailey Dixon 933c1842a0 Merge pull request #358 from Codename-11/chore/backmerge-desktop-beta3
chore: back-merge Desktop beta.3 release
2026-08-14 16:31:24 -04:00
Bailey Dixon dfe1b53327 chore: back-merge desktop beta.3 release 2026-08-14 16:30:37 -04:00
Bailey Dixon d36580a983 Merge pull request #356 from Codename-11/dev
release: Desktop beta.3 process-containment patch
2026-08-14 16:11:35 -04:00
Bailey Dixon 2d178ff884 Merge pull request #357 from Codename-11/release/desktop-0.4.0-beta.3
fix(desktop): close process containment race
2026-08-14 16:05:00 -04:00
Bailey Dixon d61955f82a fix(desktop): close process containment race 2026-08-14 15:58:57 -04:00
Bailey Dixon adec6ed2c0 Merge pull request #355 from Codename-11/release/desktop-0.4.0-beta.3
release(desktop): desktop-v0.4.0-beta.3
2026-08-14 15:44:24 -04:00
Bailey Dixon e9e44c8bb2 release(desktop): desktop-v0.4.0-beta.3 2026-08-14 15:36:53 -04:00
Bailey Dixon c6b0732a02 Merge pull request #354 from Codename-11/fix/desktop-tray-process-containment
fix(desktop): contain tray subprocess storms
2026-08-14 15:22:21 -04:00
Bailey Dixon d919115788 fix(desktop): contain tray subprocess storms 2026-08-14 15:15:12 -04:00
Bailey Dixon 49da085ae8 Merge pull request #353 from Codename-11/chore/backmerge-desktop-beta2-server-1.8.0
chore: backmerge Desktop beta.2 and Server 1.8.0 releases
2026-08-14 15:05:21 -04:00
Bailey Dixon 889b6f0858 chore: merge desktop beta.2 and server 1.8.0 release history into dev 2026-08-14 15:05:12 -04:00
Bailey Dixon 5100c524f6 Merge pull request #351 from Codename-11/dev
release: Desktop beta.2 and Server 1.8.0
2026-08-14 15:04:03 -04:00
Bailey Dixon c609ae867f Merge pull request #352 from Codename-11/chore/backmerge-desktop-beta1
chore: back-merge Desktop beta.1 release history
2026-08-14 14:49:23 -04:00
Bailey Dixon 107f8c7720 chore: merge desktop beta.1 release history into dev 2026-08-14 14:49:08 -04:00
Bailey Dixon 8963e4fafd Merge pull request #350 from Codename-11/release/server-1.8.0
release(server): server-v1.8.0
2026-08-14 14:46:57 -04:00
Bailey Dixon 0d6c3bd6b0 Merge pull request #346 from Codename-11/dev
release(desktop): desktop-v0.4.0-beta.1
2026-08-13 21:18:41 -04:00
Bailey Dixon 7440ef2948 Merge pull request #343 from Codename-11/dev
release: server 1.7.0 and desktop 0.4.0-alpha.8
2026-08-13 17:14:58 -04:00
dependabot[bot] de9211b7c5 chore(deps): bump actions/setup-node from 4 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-25 18:37:14 +00:00
dependabot[bot] 7ca5c61be5 chore(deps): bump actions/setup-python from 6 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-20 11:55:24 +00:00
132 changed files with 10325 additions and 971 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ jobs:
fetch-depth: 1
- name: Set up Python 3.11
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.11"
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
run: npm run build
- name: Setup Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.11"
+2 -2
View File
@@ -44,7 +44,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up Python 3.11
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.11"
@@ -84,7 +84,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up Python 3.11
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.11"
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
working-directory: website
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
- uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.12"
+37 -2
View File
@@ -80,7 +80,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.x'
bun-version-file: 'desktop/.bun-version'
- name: Install deps
run: npm ci
@@ -153,6 +153,40 @@ jobs:
desktop/dist/bin/hermes-relay-darwin-arm64
retention-days: 7
smoke-windows-cli-release-asset:
name: Smoke exact Windows CLI release asset
runs-on: windows-latest
needs:
- validate-release
- build-cli-binaries
steps:
- uses: actions/download-artifact@v8
with:
name: cli-binaries
path: release-assets
- name: Repeated launch and process cleanup gate
shell: pwsh
env:
EXPECTED_DESKTOP_VERSION: ${{ needs.validate-release.outputs.version }}
run: |
$ErrorActionPreference = 'Stop'
$exe = (Resolve-Path 'release-assets/hermes-relay-win-x64.exe').Path
1..20 | ForEach-Object {
$output = & $exe --version
if ($LASTEXITCODE -ne 0) { throw "Windows CLI smoke failed with exit $LASTEXITCODE" }
if ($output -ne "hermes-relay $env:EXPECTED_DESKTOP_VERSION") {
throw "Unexpected Windows CLI version output: $output"
}
}
Start-Sleep -Milliseconds 500
$leftovers = Get-CimInstance Win32_Process | Where-Object {
$_.ExecutablePath -eq $exe
}
if ($leftovers) {
throw "Windows CLI smoke left $(@($leftovers).Count) process(es) behind"
}
build-windows-tray-installer:
name: Build Windows tray installer
runs-on: windows-latest
@@ -175,7 +209,7 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.x'
bun-version-file: 'desktop/.bun-version'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
@@ -375,6 +409,7 @@ jobs:
runs-on: ubuntu-latest
needs:
- build-cli-binaries
- smoke-windows-cli-release-asset
- build-windows-tray-installer
steps:
# Needed so CLI_RELEASE_NOTES.md is available to render into the release body
+2 -2
View File
@@ -53,7 +53,7 @@ jobs:
- uses: actions/checkout@v7
- name: Set up Python 3.11
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.11"
@@ -88,7 +88,7 @@ jobs:
- uses: actions/checkout@v7
- name: Set up Python 3.11
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.11"
+45
View File
@@ -6,19 +6,64 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
## [1.9.1] - 2026-08-16
### Added
- **Android adopts Hermes-owned profile creation, shared avatars, and animated pets.** Current Gateways provide the profile roster, explicit shared/copied/isolated authentication choices, partial create outcomes, validated avatar upload/fetch/clear, and profile-scoped pet selection that follows the agent across supported Hermes clients. Older hosts retain authenticated Dashboard creation plus Relay/local presentation fallbacks, and profile deletion remains Dashboard-only.
- **Android identifies proactive messages delivered after reconnect.** Relay marks messages flushed from its bounded offline queue, Thread bubbles label them as received “While away,” and Android shows one accessible localized summary for the completed batch.
- **Android can create finite recurring schedules from Manage.** The native editor uses the authenticated Hermes Gateway `cron.manage` contract, optionally stops after 1–999 runs, and rejects invalid counts rather than silently creating unlimited work.
- **Chat resets retain content-free local evidence.** New-chat and Thread transitions save a bounded app-private checkpoint for user-reviewed Diagnostics without prompts, message text, IDs, profile names, paths, URLs, media, tool payloads, secrets, or telemetry.
- **Android surfaces host resource risk before chat state is lost.** Current Hermes Dashboard memory and disk pressure signals render as a persistent, capability-gated warning; older hosts remain unchanged and no telemetry is added.
- **Android honors Hermes model-selection safeguards.** Every Gateway model transition, including fresh-chat and Server-default choices, now avoids raw session overrides; picks requiring cost or data-training consent show Hermes' exact warning and apply only after a confirmed second request.
### Changed
- **Profile identity sources are explicit in Agent Passport.** Server-owned static avatars and upstream pets follow the Hermes profile, while phone picks, Relay-host imports, phone-only animated icons, and Sphere skins remain separate local presentation choices.
- **Interactive Gateway asks remain resolver-bound.** Android continues to use upstream clarify, approval, sudo, and secret response RPCs; connector-only prompt/reaction operations are not copied into Relay cards as a second approval protocol.
### Fixed
- **Shared avatar picks now persist from Android's filesystem picker.** The app accepts any image Android can decode, applies display orientation, and safely resizes or re-encodes it to the upstream PNG/JPEG/WebP and 2,000,000-byte contract. Successful writes update the local shared cache immediately, and upload failures remain visible beside the control.
- **Nous-hosted Android sign-in follows the official native broker contract.** The gateway now selects its native provider exactly as Hermes Desktop does, callback attempts retain the upstream five-minute window, and post-callback failures explain whether the one-time code, hosted gateway, network, response, or secure storage prevented session creation without exposing auth material.
- **Android edit-and-regenerate fails closed on incomplete durable history.** Mixed Gateway transcripts now require the selected message's durable row identity instead of attempting an ordinal-only rewind, while older Hermes histories with no row identities remain editable.
- **Android fails closed when a Gateway does not confirm the selected profile.** Named-profile session creation and recovery now require Hermes to echo the exact owning profile, preventing stale or older gateways from silently running the launch profile under another agent's identity. Profile inspection also keeps read-only Gateway data available when `profiles.configure` is unsupported while disabling further write attempts without discarding drafts.
- **Android attachment sends are bounded and fail closed.** Picked files are size-limited while streaming into the encoder, cold and queued Gateway sends upload only after the exact session is ready, and an unsupported or interrupted document upload no longer falls through to a text-only route while its file card implies delivery. Every attachment type retains the same compact collapse/expand affordance.
## [0.4.0-beta.4] - 2026-08-15
### Fixed
- **The Windows management UI remains available while the daemon is stopped.** Missing, stale, malformed, or temporarily unavailable daemon status now resolves to an explicit stopped state instead of trapping the tray on its loading screen, so configuration, diagnostics, host management, and daemon controls remain accessible.
## [1.9.0] - 2026-08-14
### Added
- **Android session browsing matches Hermes Desktop's recent organization model.** The primary session drawer can toggle between the active profile and all profiles, group by recency, project, status, or profile, order by supported session metrics, and narrow rows by status, project, profile, or pull-request state without collapsing duplicate IDs across profile stores. Named profiles receive stable identity-color badges with locally persisted color overrides.
- **Android can edit current Hermes profiles through the standard Gateway.** The Profile Inspector capability-gates `profiles.describe` and `profiles.configure`, keeps Relay-only memory editing and older-Hermes fallback intact, and reports partial section saves without discarding failed drafts.
- **Android sessions show their coding context when Hermes supplies it.** Session rows can display repository, Git branch, and the current state of the pull request created by that session while older hosts remain unchanged.
- Android Manage can now finish host-owned backup workflows, edit or remove learning nodes with explicit recovery guidance, configure and activate memory providers, and complete profile-scoped WhatsApp QR onboarding through the authenticated upstream Dashboard contracts.
### Fixed
- **Android network clients shut down safely during route changes.** Replacing an authenticated Dashboard client now moves OkHttp connection-pool eviction off the main thread, preventing a live TLS socket close from crashing the app with `NetworkOnMainThreadException`. (#334)
- **Android preserves authoritative Gateway outcomes.** Protected-file cards cannot offer forbidden persistent scopes, compression no-ops show the server result, bounded resume failures do not create context-free replacement sessions, and edit/regenerate retains durable row identities across consecutive rewinds.
- **Android routes and uploads against live upstream truth.** Multiplex API fallback trusts `served_profiles` instead of installed profiles, and generic documents carry the Gateway-issued `@file:` reference into ordinary and queued prompts.
- **Android clarify cards preserve upstream decision semantics.** Multi-select prompts keep independent selections and submit one exact list, while server expiry events—not an invented local deadline—retire unanswered cards.
- **Android keeps profile management and retained automation truthful.** Custom Endpoint list and mutation routes now follow the selected Hermes profile, while completed one-shot cron jobs show their retained outcome and expose only valid Runs/Delete actions.
- **Android and Relay recover more generated media reliably.** Android accepts upstream-valid wrapped, punctuated, adjacent, spaced, and Windows `MEDIA:` markers without consuming fenced examples, and Relay translates Docker-visible workspace, home, cache, and configured-mount paths before applying its existing credential, sandbox, and size checks.
- **Android keeps cross-profile sessions with their owning agent.** Opening a session from All Profiles hydrates, resumes, sends, and renders with that session's profile without changing the global profile selection; New Chat from that view starts with the default profile.
- **Android reactions and standard voice follow the active conversation.** Reactions resolve durable rows for both user and assistant messages, while Vanilla Hermes voice remains on the authenticated Gateway instead of requiring the optional API fallback.
- **Android session navigation behaves predictably.** The drawer closes on outside taps, uses an ungrouped recent-session list by default, retains project grouping as an explicit option, and exposes secondary actions in All Profiles mode.
## [0.4.0-beta.3] - 2026-08-14
### Fixed
- **Windows tray polling can no longer accumulate unbounded helper processes.** Grant discovery now uses lightweight local state, management refreshes are single-flight and visibility-aware, and child probes have hard timeouts, bounded output, tree cleanup, caching, and backoff. A dedicated bounded `tray.log` records sanitized operational failures without mixing them into daemon logs.
- **Concurrent Desktop lifecycle requests cannot start duplicate daemons.** Cross-process lifecycle and runtime ownership locks serialize startup and recovery while preserving stale-owner cleanup.
## [1.8.0] - 2026-08-14
+4 -14
View File
@@ -1,27 +1,17 @@
# Hermes-Relay CLI v__VERSION__
**Release Date:** 2026-08-14
**Release Date:** 2026-08-15
This beta makes connection recovery and Activity evidence inspectable in the compact management UI, and keeps the preferred CUA control engine usable when its upstream whole-desktop accessibility probe times out.
This patch keeps the Windows management UI usable when the Relay daemon is stopped or its status cannot be read.
**Beta phase.** Assets remain unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64, and macOS x64/arm64; the management UI is Windows-only.
## What's changed
### Added
- **Inspectable Activity evidence.** Commands, files, device work, connection lifecycle, and computer control share a consistent event stepper with dedicated failure details.
- **Optional screenshot retention.** Screenshot events can keep bounded local PNG evidence for Off, 1 day, 7 days, or 30 days and open it in a larger borderless viewer. Evidence stays outside the JSON activity log.
### Changed
- **Connection state is live and actionable.** The UI distinguishes connected, reconnecting, and stopped states, shows retry timing, and offers Retry now without freezing the popup.
- **Connection notices stay out of the way.** Compact connect, disconnect, and reconnect cards appear only while the main management UI is hidden.
### Fixed
- **CUA readiness no longer depends on the flaky global accessibility scan.** Hermes verifies the canonical runtime, required tools, daemon, and safe permission mode before structured control; explicit accessibility health remains available for diagnosis and individual actions still fail closed.
- **Connection errors retain useful context.** Activity records bounded retry and recovery evidence without flooding one event per backoff attempt.
- **Stopped daemons no longer block the management UI.** Missing, stale, malformed, or temporarily unavailable daemon status falls back to an explicit stopped state while hosts, settings, activity, CLI details, diagnostics, and daemon controls continue loading normally.
- **Starting the daemon restores live status without reopening the UI.** A valid running status continues through the same bounded, single-flight snapshot path introduced in beta.3.
## Install
+16
View File
@@ -1,5 +1,21 @@
# Hermes-Relay — Dev Log
## 2026-08-14 — Android 1.9.0 session identity and conversation controls
Hermes-Relay Android 1.9.0 is published from the immutable
`android-v1.9.0` tag. Multi-profile session browsing now keeps the aggregate
drawer scope selected while transcript hydration, resume, sending, and header
identity follow the session's owning profile. New Chat from All Profiles uses
the default profile, and the session list starts ungrouped while retaining
project grouping and the other desktop-style views as explicit options.
Message reactions now resolve durable rows for both user and assistant
messages. Vanilla Hermes voice remains on the authenticated Gateway instead of
requiring the optional API fallback. Session rows can expose profile, project,
branch, and pull-request context without crowding the chat header, secondary
drawer actions remain available in All Profiles, and outside taps dismiss the
drawer.
## 2026-08-09 — Gateway activity recovery and chat speech
Successful Android Gateway turns now reconcile against their profile-owned,
+3
View File
@@ -128,6 +128,7 @@ optional Windows installer.
| File | Purpose |
|---|---|
| `desktop/package.json` | canonical CLI version |
| `desktop/.bun-version` | exact Bun compiler/runtime for standalone binaries |
| `desktop/package-lock.json` | npm root/workspace package metadata |
| `desktop/src/version.ts` | compiled CLI runtime version |
| `desktop/tray/Cargo.toml` | native systray package version |
@@ -152,6 +153,8 @@ manually, run `npm run sync:version` before checking. `npm run verify` is the
single Windows release-parity gate: version sync, type-check, tests, TypeScript
build, compiled CLI smoke, and tray formatting, Clippy, check, and tests. CI runs
the portable portions on every desktop change and the Windows tray gates separately.
Release jobs read `desktop/.bun-version`; cross-built and Windows-built artifacts
must not silently embed different Bun runtime versions.
## Branching policy
+29 -17
View File
@@ -1,10 +1,10 @@
# Hermes-Relay-Android v1.8.1
# Hermes-Relay-Android v1.9.1
**Release Date:** August 9, 2026
**Release Date:** August 16, 2026
## Download
> Installing on your phone? Download `hermes-relay-1.8.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
> Installing on your phone? Download `hermes-relay-1.9.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
@@ -12,23 +12,35 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
## Summary
This patch keeps long Hermes conversations complete and aligns Android's
Gateway behavior with current upstream turn contracts.
This patch aligns Agent Passport with current Hermes profile identity, makes
shared avatar changes reliable from the phone, and hardens several Gateway
operations around profile ownership, attachments, recovery, and model consent.
## Added
- Create Hermes profiles with explicit shared, copied, or isolated
authentication choices and inspect partial setup outcomes.
- Select upstream animated pets that follow the active Hermes profile across
supported clients, while keeping phone-only animated icons separate.
- Create finite recurring schedules, review bounded reset evidence, and see
host resource or model-consent warnings before taking action.
## Fixed
- Complete transcript reads page explicitly across both API-server and
profile-scoped Dashboard routes, so sessions beyond Hermes' latest-500
default retain stable history, sharing, retry, edit, and recovery anchors.
- Gateway submit rejections preserve the authoritative server message without
silently falling through to SSE.
- Gateway event envelopes reconcile consistently, and edit-and-regenerate
requests send the required truncation confirmation.
- Shared avatar images selected from Android now persist. The app accepts any
decodable image and safely converts it to Hermes' supported static format and
size contract when needed.
- Named-profile sessions, draft writes, rewinds, and recovery fail closed when
Hermes cannot confirm the owning profile or durable history.
- Attachment sends remain bounded and no longer fall through to text-only
delivery after an unsupported or interrupted upload.
- Nous hosted sign-in follows the official native callback and provider
contract with clearer, non-sensitive failure guidance.
## Install / Verify
- App version: **1.8.1** (versionCode **42**).
- Standard Chat, sessions, Manage, and Vanilla Hermes voice continue to work
against unmodified upstream Hermes.
- The optional Relay plugin is not required for standard Android chat or hosted
Dashboard authentication.
- App version: **1.9.1** (versionCode **44**).
- Standard Chat, sessions, Manage, profile identity, and Vanilla Hermes voice
continue to work against unmodified upstream Hermes.
- The optional Relay plugin is not required for standard Android chat, shared
profile avatars, upstream pets, or hosted Dashboard authentication.
+1 -1
View File
@@ -954,7 +954,7 @@ Phase 1 (end-to-end spine) shipped on `Codename-11/phone-platform` — `send_mes
**Refinement (2026-06-29) — unified-session model: "Threads."** Going further on "unified surface": the agent conversation is **not a separate tab/segment** at all — it is a **source-tagged session inside the one Chat surface**, a **Thread** (`source=phone`). What makes a Thread special vs. a normal gateway chat are *session properties*, not a separate UI: (a) the agent can initiate, (b) relay `proactive` transport + relay-gated, (c) standing/named DM. **Scrollback = the gateway session store** (same read path Chat uses); **live receive = relay `proactive` push** (→ notification); **send = `proactive.reply`**. `ProactiveInboxStore` is demoted to a live-push cache + outbox (no parallel history). The Thread capability shows in the **best-path/capability UI** (relay tier, like terminal/bridge/voice) and as a clean **Threads** entry — thread-spool icon, NOT a phone glyph — pinned atop the session drawer when active; never a connection-wizard step. Degrades cleanly (no plugin → no `source=phone` sessions → Chat unchanged). **Supersedes the "separate Agent lane / 4th nav segment" sketch** and merges with the "source attribution in Chat" goal below. Keep the two "gateway" senses straight: *platform layer* (the Thread's `source`) ≠ *dashboard `/api/ws` transport* (how live bytes flow). Full re-cut: docs/decisions.md ADR 12.
- **Outbound buffering — ✅ relay-side DONE (2026-06-29).** `ProactiveChannel.push()` now queues agent→phone messages in a bounded deque (drop-oldest, 24 h TTL) when no phone is subscribed and returns `{queued: true}` (not 503); `_flush_outbound` delivers FIFO on the next subscribe (stale pruned). Inspect/cancel via `peek_outbound`/`cancel_outbound` + loopback `GET`/`DELETE /phone/outbound`. **UI surfacing of the queued state** (host-side, since the queue exists while the phone is OFFLINE): (a) ✅ **desktop CLI `relay queue` / `relay queue --clear` / `--cancel <id>` DONE (2026-06-29)** over the new endpoints (loopback-only — run on the relay host); a dashboard Relay-tab view is the optional GUI equivalent; (b) **remaining** — in the threaded agent surface, mark messages that arrived-while-away, and show the user's OWN pending replies (the Phase 3 reply queue) with a sending/Cancel affordance — that's where phone-side "queued + cancel" belongs.
- **Outbound buffering — ✅ relay-side + arrived-while-away receive UX DONE.** `ProactiveChannel.push()` queues agent→phone messages in a bounded deque (drop-oldest, 24 h TTL) when no phone is subscribed and returns `{queued: true}`. `_flush_outbound` delivers FIFO on the next subscribe (stale pruned), marks flushed messages, and sends one batch-complete count; Android labels those Thread bubbles “While away” and shows one accessible batch summary without changing unread behavior. Inspect/cancel remains available through `peek_outbound`/`cancel_outbound`, loopback `GET`/`DELETE /phone/outbound`, and desktop `relay queue`. **Remaining:** show the user's OWN pending replies (the Phase 3 reply queue) with an honest Queued/Cancel affordance; a dashboard queue view remains optional.
- **Threads surface (unified-session model — see ADR 12 + the Refinement above).** Build order, each shippable: **(1)** source tags in the session drawer (`source=phone` → clean **Threads** chip + thread-spool icon, NOT a phone glyph) — also delivers the "source attribution in Chat" goal; **(2)** open a Thread in Chat from its session-store history (reuse the existing message-history path); **(3)** route the live `proactive` push into the session view + notification + unread, demoting `ProactiveInboxStore` to cache/outbox; **(4)** reply from the Chat composer via `proactive.reply` + persist the user turn + local `Sending/Queued/Failed` status — **MVP**; **(5)** a **Threads capability row** in the best-path UI + a pinned **Threads** entry atop the drawer (thread-spool icon, shown only when relay-paired + opted-in) + retire `HermesInboxScreen`, re-point the notification deep-link + Settings "View messages"; **(6)** outbox/retry on reconnect; **(7)** relay `proactive.reply.ack` (honest Delivered) + `proactive.cancel`; **(8)** multi-thread `chat_id` (named/project Threads). **Verify gate before (1):** confirm the app's session-list/history path surfaces a `source=phone` session cleanly (upstream `session.list` returns all sources flat, so it should — but check whether the drawer currently filters it out). Honesty call: do NOT show "Delivered" until (7) lands (can't confirm it client-side before the ack).
- **Status (2026-06-29, implemented UNBUILT — verify in Studio):** **CODE-COMPLETE on `dev`:** slice **1** (drawer source tags + `ThreadSpoolGlyph` + Threads filter), **2** (open a Thread from history — free via the existing `loadSessionHistory` path), **3-parse** (carry `reply_to` on `ProactiveMessage`), **4** (composer reply in a `source=phone` session routes over `proactive.reply`; `MessageDeliveryStatus` SENDING→DELIVERED/FAILED on the bubble), **5** (Threads capability row in `SessionPathCard` + `threadsCapabilityActive` drawer wiring), **7** (relay `proactive.reply.ack` + `proactive.cancel` — 25/25 `unittest` green — and client ack handling). **DONE since (2026-06-29, built + on phone):** live **in-thread reply rendering**; **user-created named Threads** ("+ New Thread"); **retire `HermesInboxScreen`**; relay slice-7 ack/cancel **DEPLOYED** to the host so **"Delivered" is live**. **DONE (2026-08-14):** notification taps survive cold start and open the exact `chat_id`; agent-initiated outbound messages appear as connection-scoped provisional Threads backed by the bounded proactive store, then promote to the real `source=phone` session after the first reply. **DEFERRED:** per-session **unread badge**; **outbox/retry** (needs multiplexer connection-state); **agent-initiated** named Threads (upstream `send_message` thread param). On-device verifies for the create-flow: fresh-`chat_id` auto-create, the `…:dm:<chat_id>` id form, `renameSession` on a phone session.
- **User-created Threads (slice 8, Discord-style) — CODE-COMPLETE on `dev` (built + installed 2026-06-29; on-device behavior pending).** "+ New Thread" in the drawer's Threads view → name dialog → `ChatViewModel.startNewThread` mints a fresh `chat_id`; the first composer message opens it over `proactive.reply` (gateway auto-creates the `source=phone` session) → `switchToCreatedThread` polls + switches to the real session + applies the name. Existing-thread replies route by the `chat_id` parsed from the session id (`…:dm:<chat_id>`; opaque id → home fallback). **On-device verifies:** (1) a fresh-`chat_id` no-`reply_to` inbound creates a new `source=phone` session; (2) the phone session id carries the `…:dm:<chat_id>` form the client parses; (3) `renameSession` titles a phone session. **Remaining slice-8:** AGENT-initiated named Threads (the upstream `send_message` thread/chat_id param so the agent can open its own named Threads).
+1
View File
@@ -319,6 +319,7 @@ dependencies {
// Coil 3 — async image loading for generated images in chat
implementation(libs.coil.compose)
implementation(libs.coil.gif)
implementation(libs.coil.network.okhttp)
implementation(libs.exifinterface)
@@ -1 +1 @@
Long sessions now retain complete history beyond Hermes' latest-500 default, keeping edit, retry, sharing, and recovery anchors stable. Gateway submit rejections preserve the server's message without unintended SSE fallback, while event envelopes and edit-and-regenerate requests follow current upstream contracts.
Change shared avatars from your phone without oversized or native image formats disappearing. Select upstream animated pets that follow the Hermes profile across supported clients while keeping phone-only icons separate. Profile creation now exposes clear authentication choices, and attachment, recovery, rewind, model-consent, ownership, and hosted sign-in paths fail more safely.
+56
View File
@@ -1,5 +1,61 @@
{
"versions": [
{
"version": "1.9.1",
"title": "Profile identity that sticks",
"date": "2026-08-16",
"sections": [
{
"header": "Identity follows the right scope",
"bullets": [
"Change shared avatars from Android with automatic orientation, resizing, and safe conversion to the Hermes profile-asset contract.",
"Select upstream animated pets that follow the Hermes profile while phone-only animated icons, local avatar overrides, and Sphere skins stay local."
]
},
{
"header": "Profile setup stays explicit",
"bullets": [
"Create profiles with clear shared, copied, or isolated authentication choices and see partial setup outcomes.",
"Named-profile sessions and profile drafts fail closed when Hermes cannot confirm their owner."
]
},
{
"header": "Safer Gateway operations",
"bullets": [
"Attachments, rewinds, recovery, model-consent changes, and hosted sign-in now follow stricter upstream contracts.",
"Finite schedules, bounded reset evidence, and host resource warnings make consequential actions easier to review."
]
}
]
},
{
"version": "1.9.0",
"title": "Better sessions, reactions, and voice",
"date": "2026-08-14",
"sections": [
{
"header": "Sessions keep their identity",
"bullets": [
"Browse one profile or all profiles, customize sorting and filters, and optionally group sessions by project, recency, status, or profile.",
"Cross-profile sessions hydrate, resume, and send with their owning agent without changing the global profile selection; New Chat in All Profiles uses the default profile."
]
},
{
"header": "Conversation controls stay attached",
"bullets": [
"Reactions pin to durable rows on both user and assistant messages.",
"Vanilla Hermes voice stays on the authenticated Gateway instead of requiring the optional API fallback."
]
},
{
"header": "Context without clutter",
"bullets": [
"Session rows show profile, project, branch, and pull-request context when Hermes supplies it, while the default view remains ungrouped.",
"The session drawer restores secondary actions in All Profiles and closes when you tap outside it."
]
}
]
},
{
"version": "1.8.1",
"title": "Complete, reliable transcripts",
+6 -5
View File
@@ -1,6 +1,7 @@
v1.8.1 - Complete, reliable transcripts
v1.9.1 - Profile identity that sticks
* Keep complete history in long sessions beyond Hermes' latest-500 default.
* Preserve stable edit, retry, sharing, and recovery anchors while paging history.
* Show authoritative Gateway rejection messages without an unintended fallback.
* Reconcile Gateway events and edit-and-regenerate requests with current upstream contracts.
* Change shared avatars from your phone without oversized or native image formats disappearing.
* Select upstream animated pets that follow the Hermes profile across supported clients.
* Keep phone-only animated icons and local avatar overrides clearly separate.
* Create profiles with explicit authentication choices and safer ownership checks.
* Get stricter attachment, recovery, rewind, model-consent, and hosted sign-in behavior.
@@ -7,6 +7,8 @@ import android.os.Build
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.SingletonImageLoader
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import com.hermesandroid.relay.bridge.UnattendedAccessManager
@@ -38,7 +40,14 @@ class HermesRelayApp : Application(), SingletonImageLoader.Factory {
*/
override fun newImageLoader(context: PlatformContext): ImageLoader =
ImageLoader.Builder(context)
.components { add(OkHttpNetworkFetcherFactory()) }
.components {
add(OkHttpNetworkFetcherFactory())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
add(AnimatedImageDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.crossfade(true)
.build()
@@ -11,6 +11,7 @@ import com.hermesandroid.relay.data.replaceHermesReachCredential
import com.hermesandroid.relay.data.sameBrokerAuthority
import com.hermesandroid.relay.data.PairingPreferences
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.isSafeProfileUiMeta
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
import com.hermesandroid.relay.network.relay.models.Envelope
import kotlinx.coroutines.CoroutineScope
@@ -286,6 +287,7 @@ class AuthManager(
?: return@mapNotNull null
val model = obj["model"]?.jsonPrimitive?.contentOrNull
?: "unknown"
val provider = obj["provider"]?.jsonPrimitive?.contentOrNull.orEmpty()
val description = obj["description"]?.jsonPrimitive?.contentOrNull
?: ""
val systemMessage = obj["system_message"]?.jsonPrimitive?.contentOrNull
@@ -305,9 +307,15 @@ class AuthManager(
?.jsonPrimitive?.intOrNull
val apiServerKeyPresent = obj["api_server_key_present"]
?.jsonPrimitive?.booleanOrNull ?: false
val isDefault = obj["is_default"]?.jsonPrimitive?.booleanOrNull ?: false
val hasAvatar = obj["has_avatar"]?.jsonPrimitive?.booleanOrNull ?: false
val uiMeta = (obj["ui_meta"] as? JsonObject)
?.takeIf(::isSafeProfileUiMeta)
?: JsonObject(emptyMap())
Profile(
name = name,
model = model,
provider = provider,
description = description,
systemMessage = systemMessage,
gatewayRunning = gatewayRunning,
@@ -318,6 +326,9 @@ class AuthManager(
apiServerHost = apiServerHost,
apiServerPort = apiServerPort,
apiServerKeyPresent = apiServerKeyPresent,
isDefault = isDefault,
hasAvatar = hasAvatar,
uiMeta = uiMeta,
)
}
}
@@ -147,6 +147,12 @@ data class ChatMessage(
* never used as a Compose key or synthesized client-side.
*/
val rowId: Long? = null,
/**
* Durable iOS-style tapbacks attached to this server message. Hermes keeps
* one reaction per author in the message's display metadata; the UI also
* updates this list optimistically while a reaction write is in flight.
*/
val reactions: List<MessageReaction> = emptyList(),
/**
* Mixture-of-Agents advisor responses surfaced during the live turn.
* Unavailable advisors retain only neutral state, never their raw failure
@@ -156,6 +162,29 @@ data class ChatMessage(
val moaReferences: List<MoaReference> = emptyList(),
)
data class MessageReaction(
val emoji: String,
val author: String,
/** Epoch seconds, matching the Gateway/Desktop contract. */
val at: Double,
)
/** Apply Hermes' one-reaction-per-author, re-tap-to-retract semantics. */
internal fun applyMessageReaction(
reactions: List<MessageReaction>,
emoji: String?,
author: String = "user",
at: Double = System.currentTimeMillis() / 1000.0,
): List<MessageReaction> {
val previous = reactions.firstOrNull { it.author == author }
val withoutAuthor = reactions.filterNot { it.author == author }
return if (emoji.isNullOrBlank() || previous?.emoji == emoji) {
withoutAuthor
} else {
withoutAuthor + MessageReaction(emoji = emoji, author = author, at = at)
}
}
data class MoaReference(
val index: Int,
val count: Int?,
@@ -412,6 +441,11 @@ data class ChatSession(
val title: String?,
val model: String?,
val messageCount: Int = 0,
val inputTokens: Int = 0,
val outputTokens: Int = 0,
val actualCostUsd: Double? = null,
val estimatedCostUsd: Double? = null,
val isActive: Boolean = false,
val updatedAt: Long = 0L,
val startedAt: Long = 0L,
val lastActivityAt: Long = 0L,
@@ -436,6 +470,12 @@ data class ChatSession(
val pullRequestState: String? = null,
val pullRequestDraft: Boolean = false,
) {
val totalTokens: Int
get() = inputTokens + outputTokens
val costUsd: Double
get() = actualCostUsd ?: estimatedCostUsd ?: 0.0
val activityTimestamp: Long
get() = firstPositive(lastActivityAt, updatedAt, startedAt)
@@ -36,6 +36,8 @@ data class ProactiveInboxEntry(
val chatId: String? = null,
/** Owning saved connection. Null only for entries written by older builds. */
val connectionId: String? = null,
/** Relay proved this row came from its bounded offline queue. */
val arrivedWhileAway: Boolean = false,
)
private val Context.proactiveInboxStore: DataStore<Preferences> by
@@ -0,0 +1,125 @@
package com.hermesandroid.relay.data
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import java.io.ByteArrayOutputStream
import kotlin.math.roundToInt
internal fun profileAvatarMime(bytes: ByteArray): String? = when {
bytes.size >= 8 && bytes.copyOfRange(0, 8).contentEquals(
byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
) -> "image/png"
bytes.size >= 3 && bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() &&
bytes[2] == 0xff.toByte() -> "image/jpeg"
bytes.size >= 12 && bytes.copyOfRange(0, 4).contentEquals("RIFF".toByteArray()) &&
bytes.copyOfRange(8, 12).contentEquals("WEBP".toByteArray()) -> "image/webp"
else -> null
}
/**
* Convert any image Android can decode into the small static format accepted by
* upstream `profiles.set_asset`. ImageDecoder applies camera EXIF orientation
* and downsamples before allocating the bitmap on current Android releases.
*/
internal fun prepareProfileAvatar(
context: Context,
uri: Uri,
maxBytes: Int,
): ByteArray? {
val original = runCatching {
context.contentResolver.openInputStream(uri)?.use { input ->
val output = ByteArrayOutputStream(minOf(maxBytes + 1, 64 * 1024))
val buffer = ByteArray(16 * 1024)
while (output.size() <= maxBytes) {
val read = input.read(buffer)
if (read < 0) break
output.write(buffer, 0, read)
}
output.toByteArray()
}
}.getOrNull()
if (original != null && original.size <= maxBytes && profileAvatarMime(original) != null) {
return original
}
val bitmap = decodeProfileAvatar(context, uri) ?: return null
return try {
encodeProfileAvatar(bitmap, maxBytes)
} finally {
bitmap.recycle()
}
}
private fun decodeProfileAvatar(context: Context, uri: Uri): Bitmap? = runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(context.contentResolver, uri)) { decoder, info, _ ->
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
val width = info.size.width
val height = info.size.height
val longest = maxOf(width, height)
if (longest > PROFILE_AVATAR_MAX_DIMENSION) {
val scale = PROFILE_AVATAR_MAX_DIMENSION.toFloat() / longest
decoder.setTargetSize(
(width * scale).roundToInt().coerceAtLeast(1),
(height * scale).roundToInt().coerceAtLeast(1),
)
}
}
} else {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) }
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > PROFILE_AVATAR_MAX_DIMENSION) sample *= 2
context.contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample })
}
}
}.getOrNull()
internal fun encodeProfileAvatar(bitmap: Bitmap, maxBytes: Int): ByteArray? {
var working = bitmap.scaledToFit(PROFILE_AVATAR_MAX_DIMENSION)
var ownsWorking = working !== bitmap
try {
while (true) {
val format = if (working.hasAlpha()) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG
val qualities = if (format == Bitmap.CompressFormat.PNG) intArrayOf(100) else intArrayOf(92, 82, 72, 62)
for (quality in qualities) {
val encoded = ByteArrayOutputStream().use { output ->
if (!working.compress(format, quality, output)) null else output.toByteArray()
}
if (encoded != null && encoded.size <= maxBytes) return encoded
}
if (maxOf(working.width, working.height) <= PROFILE_AVATAR_MIN_DIMENSION) return null
val next = Bitmap.createScaledBitmap(
working,
(working.width * 0.75f).roundToInt().coerceAtLeast(1),
(working.height * 0.75f).roundToInt().coerceAtLeast(1),
true,
)
if (ownsWorking) working.recycle()
working = next
ownsWorking = true
}
} finally {
if (ownsWorking) working.recycle()
}
}
private fun Bitmap.scaledToFit(maxDimension: Int): Bitmap {
val longest = maxOf(width, height)
if (longest <= maxDimension) return this
val scale = maxDimension.toFloat() / longest
return Bitmap.createScaledBitmap(
this,
(width * scale).roundToInt().coerceAtLeast(1),
(height * scale).roundToInt().coerceAtLeast(1),
true,
)
}
private const val PROFILE_AVATAR_MAX_DIMENSION = 1024
private const val PROFILE_AVATAR_MIN_DIMENSION = 128
@@ -2,6 +2,35 @@ package com.hermesandroid.relay.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
internal fun isSafeProfileUiMeta(meta: JsonObject): Boolean {
if (meta.toString().toByteArray(Charsets.UTF_8).size > 65_536) return false
fun containsEmbeddedAsset(value: String): Boolean {
val compact = value.trim()
return compact.startsWith("data:image/", ignoreCase = true) ||
compact.startsWith("iVBORw0KGgo") || // PNG
compact.startsWith("/9j/") || // JPEG
compact.startsWith("UklGR") || // RIFF/WebP
compact.startsWith("R0lGOD") || // GIF
compact.startsWith("UEsDB") // ZIP/pet archive
}
fun safe(element: JsonElement): Boolean = when (element) {
is JsonObject -> element.size <= 128 && element.all { (key, value) ->
key.length <= 128 && safe(value)
}
is JsonArray -> element.size <= 128 && element.all(::safe)
is JsonPrimitive -> {
val value = element.contentOrNull
value == null || (value.length <= 4_096 && !containsEmbeddedAsset(value))
}
}
return safe(meta)
}
/**
* An agent profile advertised by a Hermes server in its `auth.ok` payload.
@@ -56,6 +85,7 @@ import kotlinx.serialization.Serializable
data class Profile(
val name: String,
val model: String,
val provider: String = "",
val description: String = "",
@SerialName("system_message")
val systemMessage: String? = null,
@@ -75,6 +105,12 @@ data class Profile(
val apiServerPort: Int? = null,
@SerialName("api_server_key_present")
val apiServerKeyPresent: Boolean = false,
@SerialName("is_default")
val isDefault: Boolean = false,
@SerialName("has_avatar")
val hasAvatar: Boolean = false,
@SerialName("ui_meta")
val uiMeta: JsonObject = JsonObject(emptyMap()),
) {
val hasIsolatedApi: Boolean
get() = !apiServerUrl.isNullOrBlank()
@@ -4,13 +4,15 @@ import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Local-only per-profile agent icons — the visual twin of [ProfileDisplayAliasStore].
* Per-profile icon cache. Local fallback paths and Hermes-owned avatar cache
* paths use separate keys so syncing either side never silently overwrites the other.
*
* Stores a **file path** to an image that was copied into app storage (not a SAF
* content URI, so it survives without a persistable-permission grant). Like the
@@ -25,6 +27,8 @@ class ProfileIconStore(
companion object {
private const val PREFIX = "profile_icon__"
private const val SERVER_PREFIX = "profile_avatar_server__"
private const val LOCAL_OVERRIDE_PREFIX = "profile_icon_override__"
private fun keyName(connectionId: String, profileName: String?): String =
"$PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}"
@@ -34,6 +38,20 @@ class ProfileIconStore(
private fun connectionPrefix(connectionId: String): String =
"$PREFIX${connectionId}__"
private fun serverKeyFor(connectionId: String, profileName: String) =
stringPreferencesKey("$SERVER_PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}")
private fun serverConnectionPrefix(connectionId: String): String =
"$SERVER_PREFIX${connectionId}__"
private fun localOverrideKeyFor(connectionId: String, profileName: String?) =
booleanPreferencesKey(
"$LOCAL_OVERRIDE_PREFIX${connectionId}__${AgentDisplay.profileSessionKey(profileName)}",
)
private fun localOverrideConnectionPrefix(connectionId: String): String =
"$LOCAL_OVERRIDE_PREFIX${connectionId}__"
}
suspend fun setIcon(connectionId: String, profileName: String?, path: String?) {
@@ -52,11 +70,39 @@ class ProfileIconStore(
return dataStore.data.map { prefs -> prefs[key] }
}
suspend fun setServerAvatar(connectionId: String, profileName: String, path: String?) {
dataStore.edit { prefs ->
val key = serverKeyFor(connectionId, profileName)
if (path.isNullOrBlank()) prefs.remove(key) else prefs[key] = path
}
}
fun serverAvatarFlow(connectionId: String, profileName: String): Flow<String?> {
val key = serverKeyFor(connectionId, profileName)
return dataStore.data.map { prefs -> prefs[key] }
}
suspend fun setLocalOverride(connectionId: String, profileName: String?, enabled: Boolean) {
dataStore.edit { prefs ->
val key = localOverrideKeyFor(connectionId, profileName)
if (enabled) prefs[key] = true else prefs.remove(key)
}
}
fun localOverrideFlow(connectionId: String, profileName: String?): Flow<Boolean> {
val key = localOverrideKeyFor(connectionId, profileName)
return dataStore.data.map { prefs -> prefs[key] ?: false }
}
suspend fun clearConnection(connectionId: String) {
val prefix = connectionPrefix(connectionId)
val prefixes = listOf(
connectionPrefix(connectionId),
serverConnectionPrefix(connectionId),
localOverrideConnectionPrefix(connectionId),
)
dataStore.edit { prefs ->
prefs.asMap().keys
.filter { it.name.startsWith(prefix) }
.filter { key -> prefixes.any(key.name::startsWith) }
.forEach { prefs.remove(it) }
}
}
@@ -66,5 +112,11 @@ class ProfileIconStore(
}
}
internal fun preferredProfileIcon(
server: String?,
local: String?,
useLocalOverride: Boolean,
): String? = if (useLocalOverride && !local.isNullOrBlank()) local else server ?: local
internal val Context.profileIconsDataStore: DataStore<Preferences>
by preferencesDataStore(name = "profile_icons")
@@ -164,6 +164,8 @@ enum class GatewayProfileSection(val wireName: String) {
Model("model"),
Skills("skills"),
Toolsets("toolsets"),
McpServers("mcp_servers"),
UiMeta("ui_meta"),
}
/** Null leaves a section unchanged; empty lists retain upstream replace semantics. */
@@ -174,6 +176,9 @@ data class GatewayProfilePatch(
val model: String? = null,
val disabledSkills: List<String>? = null,
val enabledToolsets: List<String>? = null,
val enabledMcpServers: List<String>? = null,
/** Small interoperable preferences only; binary assets belong in profiles.set_asset. */
val uiMeta: JsonObject? = null,
) {
val requestedSections: Set<GatewayProfileSection>
get() = buildSet {
@@ -182,9 +187,67 @@ data class GatewayProfilePatch(
if (provider != null && model != null) add(GatewayProfileSection.Model)
if (disabledSkills != null) add(GatewayProfileSection.Skills)
if (enabledToolsets != null) add(GatewayProfileSection.Toolsets)
if (enabledMcpServers != null) add(GatewayProfileSection.McpServers)
if (uiMeta != null) add(GatewayProfileSection.UiMeta)
}
}
enum class GatewayProfileAuthChoice {
/** Share the launch profile's refreshable OAuth/token store; copy static environment keys. */
Shared,
/** Copy the current credential snapshot into a separate profile-owned store. */
Copied,
/** Copy no credentials or provider defaults. */
Isolated,
}
data class GatewayProfileCreateRequest(
val name: String,
val description: String? = null,
val cloneFrom: String? = null,
val cloneAll: Boolean = false,
val noSkills: Boolean = false,
val soul: String? = null,
val model: String? = null,
val provider: String? = null,
val authChoice: GatewayProfileAuthChoice = GatewayProfileAuthChoice.Shared,
)
data class GatewayProfileCreateResult(
val name: String,
val soulWritten: Boolean,
val modelSet: Boolean,
val mirroredEnvironment: Boolean,
val mirroredAuth: String?,
val modelInherited: Boolean,
val voiceMirrored: Boolean,
) {
fun partialMessages(request: GatewayProfileCreateRequest): List<String> = buildList {
if (!request.soul.isNullOrBlank() && !soulWritten) add("SOUL was not saved")
if (!request.model.isNullOrBlank() && !modelSet) add("model was not saved")
if (request.authChoice == GatewayProfileAuthChoice.Shared && mirroredAuth != "shared") {
add("shared sign-in was not confirmed")
}
if (
request.authChoice == GatewayProfileAuthChoice.Copied &&
!mirroredEnvironment && mirroredAuth != "true"
) {
add("no credential source was copied")
}
}
}
data class GatewayProfileAsset(
val data: ByteArray,
val mime: String,
)
class GatewayProfileManagementUnsupportedException(
operation: String,
) : Exception("$operation is not supported by this gateway")
data class GatewayProfileConfigureResult(
val requested: Set<GatewayProfileSection>,
val applied: Set<GatewayProfileSection>,
@@ -9,6 +9,7 @@ import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
@@ -16,6 +17,8 @@ import kotlinx.serialization.json.Json
data class ProfilePresentation(
val order: List<String> = emptyList(),
val hidden: Set<String> = emptySet(),
/** Local-only named-profile accent overrides, stored as normalized RGB hex. */
val colors: Map<String, String> = emptyMap(),
)
/**
@@ -63,14 +66,17 @@ class ProfilePresentationStore(
private val json = Json { ignoreUnknownKeys = true }
private val listSerializer = ListSerializer(String.serializer())
private val mapSerializer = MapSerializer(String.serializer(), String.serializer())
private fun orderKey(connectionId: String) = stringPreferencesKey("order_$connectionId")
private fun hiddenKey(connectionId: String) = stringPreferencesKey("hidden_$connectionId")
private fun colorsKey(connectionId: String) = stringPreferencesKey("colors_$connectionId")
fun presentationFlow(connectionId: String): Flow<ProfilePresentation> = dataStore.data.map { prefs ->
ProfilePresentation(
order = decode(prefs[orderKey(connectionId)]),
hidden = decode(prefs[hiddenKey(connectionId)]).toSet(),
colors = decodeMap(prefs[colorsKey(connectionId)]),
)
}
@@ -82,10 +88,18 @@ class ProfilePresentationStore(
dataStore.edit { it[hiddenKey(connectionId)] = json.encodeToString(listSerializer, hidden.sorted()) }
}
suspend fun setColors(connectionId: String, colors: Map<String, String>) {
dataStore.edit {
if (colors.isEmpty()) it.remove(colorsKey(connectionId))
else it[colorsKey(connectionId)] = json.encodeToString(mapSerializer, colors.toSortedMap())
}
}
suspend fun clear(connectionId: String) {
dataStore.edit {
it.remove(orderKey(connectionId))
it.remove(hiddenKey(connectionId))
it.remove(colorsKey(connectionId))
}
}
@@ -98,6 +112,12 @@ class ProfilePresentationStore(
} else {
runCatching { json.decodeFromString(listSerializer, raw) }.getOrDefault(emptyList())
}
private fun decodeMap(raw: String?): Map<String, String> = if (raw == null) {
emptyMap()
} else {
runCatching { json.decodeFromString(mapSerializer, raw) }.getOrDefault(emptyMap())
}
}
internal val Context.profilePresentationDataStore: DataStore<Preferences>
@@ -148,6 +148,8 @@ class ConnectionManager(
private val reconnectJitterUnit: () -> Double = { kotlin.random.Random.nextDouble() },
/** Exact-authority pinned client for a plugin-proxy WSS URL. */
private val proxyClientProvider: ((String) -> OkHttpClient?)? = null,
/** Test seam for observing lifecycle teardown without opening a socket. */
private val okHttpClientFactory: (() -> OkHttpClient)? = null,
) {
private val supervisorJob = SupervisorJob()
private val scope = CoroutineScope(supervisorJob + Dispatchers.IO)
@@ -158,6 +160,7 @@ class ConnectionManager(
}
private fun buildClient(url: String? = null): OkHttpClient {
okHttpClientFactory?.let { return it() }
val builder = OkHttpClient.Builder()
// OkHttp's 10s default connectTimeout is LAN-tuned; a Tailscale
// DERP-relayed cold-start handshake can exceed it, and a failed
@@ -5,7 +5,9 @@ import android.util.Log
import com.hermesandroid.relay.network.relay.models.Envelope
import com.hermesandroid.relay.notifications.ProactiveMessageNotifier
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive
/**
@@ -50,7 +52,7 @@ class ProactiveMessageHandler(
/** Sink for the dedicated Hermes inbox (Phase 2a) — the always-present log. */
private val toInbox: ((ProactiveMessage) -> Unit)? = null,
/** Sink for injecting into the active chat session (Phase 2b). */
var toSession: ((ProactiveMessage) -> Unit)? = null,
var toSession: ((ProactiveMessage) -> Boolean)? = null,
/**
* Sink for the relay's per-reply ack (`proactive.reply.ack`) — lets the
* chat layer settle a Thread reply bubble from SENDING → DELIVERED. Wired
@@ -66,6 +68,8 @@ class ProactiveMessageHandler(
* [toSession]; wired after construction.
*/
var injectIntoThread: ((ProactiveMessage) -> Boolean)? = null,
/** One callback per completed queued-message flush, never per message. */
var onBacklogDelivered: ((Int) -> Unit)? = null,
) {
fun onMessage(envelope: Envelope) {
@@ -80,6 +84,10 @@ class ProactiveMessageHandler(
}
// Subscribe ack — informational; nothing to do client-side.
"proactive.subscribed" -> Log.d(TAG, "proactive subscribe acked")
"proactive.backlog.complete" -> {
val count = envelope.payload["count"]?.jsonPrimitive?.intOrNull ?: 0
if (count > 0) onBacklogDelivered?.invoke(count)
}
// Per-reply ack — settle the matching Thread reply bubble (the
// `client_msg_id` is the id the app stamped on its own reply).
"proactive.reply.ack" -> {
@@ -99,21 +107,23 @@ class ProactiveMessageHandler(
// session until the phone replies, so this cache is the provisional
// Thread transcript during that gap.
toInbox?.invoke(msg)
// Unified Threads: if this message belongs to the Thread currently open
// in Chat, render it inline there and stop before raising a notification.
if (injectIntoThread?.invoke(msg) == true) return
// The surfacing hint selects the additional surface.
// The surfacing hint selects the additional surface. Thread injection
// is best-effort presentation of the persisted row, not itself a reason
// to suppress an explicitly requested notification.
when (msg.surfacing?.lowercase()) {
"inbox" -> { /* inbox only — already recorded above */ }
"inbox" -> {
injectIntoThread?.invoke(msg)
}
"session" -> {
val sink = toSession
// Legacy explicit "inject into active session" path; if no sink
// (or no active chat) fall back to a notification so it isn't
// silently missed (the inbox copy already exists either way).
if (sink != null) sink.invoke(msg) else notify(msg)
val delivered = injectIntoThread?.invoke(msg) == true ||
toSession?.invoke(msg) == true
if (!delivered) notify(msg)
}
// null / "default" / "notification" / anything unrecognized.
else -> notify(msg)
else -> {
injectIntoThread?.invoke(msg)
notify(msg)
}
}
}
@@ -138,6 +148,7 @@ class ProactiveMessageHandler(
surfacing = payload["surfacing"]?.jsonPrimitive?.contentOrNull,
sentAt = payload["sent_at"]?.jsonPrimitive?.contentOrNull?.toLongOrNull(),
replyTo = payload["reply_to"]?.jsonPrimitive?.contentOrNull,
arrivedWhileAway = payload["queued_delivery"]?.jsonPrimitive?.booleanOrNull == true,
)
}
@@ -159,4 +170,6 @@ data class ProactiveMessage(
val sentAt: Long?,
/** Id of the message this one answers, if any (server threading hint). */
val replyTo: String? = null,
/** True only when Relay explicitly marked this as a reconnect queue flush. */
val arrivedWhileAway: Boolean = false,
)
@@ -471,7 +471,12 @@ class ChatHandler {
* across the history reconcile; idempotent on the proactive [messageId] so a
* re-delivered push (e.g. an outbound-buffer flush) never double-posts.
*/
fun addAgentThreadMessage(text: String, messageId: String?, agentName: String?) {
fun addAgentThreadMessage(
text: String,
messageId: String?,
agentName: String?,
arrivedWhileAway: Boolean = false,
) {
val id = messageId?.let { "proactive-$it" } ?: "proactive-${java.util.UUID.randomUUID()}"
_messages.update { list ->
if (messageId != null && list.any { it.id == id }) return@update list
@@ -481,6 +486,7 @@ class ChatHandler {
content = text,
timestamp = System.currentTimeMillis(),
agentName = agentName,
badges = if (arrivedWhileAway) listOf("While away") else emptyList(),
clientOnly = true,
)
(list + msg).let { if (it.size > MAX_MESSAGES) it.drop(it.size - MAX_MESSAGES) else it }
@@ -524,6 +530,19 @@ class ChatHandler {
}
}
/**
* Whether [messageId] can be addressed without downgrading a durable
* transcript to an ordinal-only rewind. A wholly legacy transcript has no
* row ids and remains compatible with older Gateways. Once any visible
* user turn has a durable row id, the selected turn must have one too;
* otherwise the caller must refresh instead of guessing by position.
*/
fun hasSafeGatewayRewindAddress(messageId: String): Boolean {
val userTurns = _messages.value.filter { it.isGatewayRewindUser() }
val target = userTurns.firstOrNull { it.matchesIdentity(messageId) } ?: return false
return target.rowId != null || userTurns.none { it.rowId != null }
}
/**
* Rebind visible user turns after Gateway rewrites a truncated durable
* prefix. The response is positional in the same user-ordinal space used
@@ -1520,7 +1539,8 @@ class ChatHandler {
// this as the same visible row across the post-turn reload.
prior.copy(
id = messageId,
rowId = item.rowId,
rowId = item.resolvedRowId,
reactions = item.reactions,
role = role,
content = cleanedContent,
attachments = carriedAttachments,
@@ -1551,7 +1571,8 @@ class ChatHandler {
// nothing local to carry).
ChatMessage(
id = messageId,
rowId = item.rowId,
rowId = item.resolvedRowId,
reactions = item.reactions,
role = role,
content = cleanedContent,
attachments = carriedAttachments,
@@ -2050,6 +2071,11 @@ class ChatHandler {
title = resolvedTitle,
model = item.model,
messageCount = item.messageCount ?: 0,
inputTokens = item.inputTokens ?: 0,
outputTokens = item.outputTokens ?: 0,
actualCostUsd = item.actualCostUsd,
estimatedCostUsd = item.estimatedCostUsd,
isActive = item.isActive,
updatedAt = activityAtMs,
startedAt = startedAtMs,
lastActivityAt = lastActivityAtMs,
@@ -0,0 +1,50 @@
package com.hermesandroid.relay.network.upstream
/**
* User-reviewed input for upstream `cron.manage` creation.
*
* Repeat is intentionally bounded on the client. Upstream treats zero and
* negative values as an unlimited schedule, which is unsafe when the user
* explicitly chose a finite run count.
*/
data class CronCreationDraft(
val name: String,
val schedule: String,
val prompt: String,
val repeat: Int? = null,
val profile: String? = null,
) {
fun validated(): Result<CronCreationDraft> = runCatching {
val cleanName = name.trim()
val cleanSchedule = schedule.trim()
val cleanPrompt = prompt.trim()
require(cleanName.isNotEmpty()) { "Schedule name is required" }
require(cleanSchedule.isNotEmpty()) { "Schedule is required" }
require(cleanPrompt.isNotEmpty()) { "Task instructions are required" }
require(repeat == null || repeat in MIN_REPEAT..MAX_REPEAT) {
"Run count must be between $MIN_REPEAT and $MAX_REPEAT"
}
copy(
name = cleanName,
schedule = cleanSchedule,
prompt = cleanPrompt,
profile = profile?.trim()?.takeIf(String::isNotEmpty),
)
}
companion object {
const val MIN_REPEAT = 1
const val MAX_REPEAT = 999
}
}
/** Parse an optional finite-count field without ever coercing invalid input to unlimited. */
internal fun parseFiniteRepeat(value: String): Result<Int?> = runCatching {
val clean = value.trim()
if (clean.isEmpty()) return@runCatching null
val parsed = clean.toIntOrNull() ?: throw IllegalArgumentException("Run count must be a whole number")
require(parsed in CronCreationDraft.MIN_REPEAT..CronCreationDraft.MAX_REPEAT) {
"Run count must be between ${CronCreationDraft.MIN_REPEAT} and ${CronCreationDraft.MAX_REPEAT}"
}
parsed
}
@@ -66,6 +66,29 @@ data class DashboardStatus(
@SerialName("gateway_mode") val gatewayMode: String? = null,
val gateways: List<DashboardGatewayTopology> = emptyList(),
val componentHealth: DashboardComponentHealthRollup = DashboardComponentHealthRollup(),
val memory: DashboardMemoryStatus? = null,
val disk: DashboardDiskStatus? = null,
)
@Serializable
data class DashboardMemoryStatus(
val pressure: String,
@SerialName("gateway_rss_mb") val gatewayRssMb: Int? = null,
@SerialName("system_total_mb") val systemTotalMb: Int? = null,
@SerialName("system_available_mb") val systemAvailableMb: Int? = null,
@SerialName("swap_used_mb") val swapUsedMb: Int? = null,
@SerialName("sampled_at") val sampledAt: String? = null,
@SerialName("last_boot_unclean") val lastBootUnclean: Boolean = false,
@SerialName("last_boot_suspected_oom") val lastBootSuspectedOom: Boolean = false,
@SerialName("boot_id") val bootId: String? = null,
)
@Serializable
data class DashboardDiskStatus(
val pressure: String,
@SerialName("total_mb") val totalMb: Int? = null,
@SerialName("free_mb") val freeMb: Int? = null,
@SerialName("used_percent") val usedPercent: Double? = null,
)
@Serializable
@@ -1711,6 +1734,35 @@ class DashboardApiClient(
gatewayMode = root.stringField("gateway_mode"),
gateways = gateways,
componentHealth = parseComponentHealth(root),
memory = parseMemoryStatus(root["memory"] as? JsonObject),
disk = parseDiskStatus(root["disk"] as? JsonObject),
)
}
private fun parseMemoryStatus(obj: JsonObject?): DashboardMemoryStatus? {
obj ?: return null
val pressure = obj.stringField("pressure")?.lowercase() ?: return null
return DashboardMemoryStatus(
pressure = pressure,
gatewayRssMb = obj.intField("gateway_rss_mb"),
systemTotalMb = obj.intField("system_total_mb"),
systemAvailableMb = obj.intField("system_available_mb"),
swapUsedMb = obj.intField("swap_used_mb"),
sampledAt = obj.stringField("sampled_at"),
lastBootUnclean = obj.booleanField("last_boot_unclean") ?: false,
lastBootSuspectedOom = obj.booleanField("last_boot_suspected_oom") ?: false,
bootId = obj.stringField("boot_id"),
)
}
private fun parseDiskStatus(obj: JsonObject?): DashboardDiskStatus? {
obj ?: return null
val pressure = obj.stringField("pressure")?.lowercase() ?: return null
return DashboardDiskStatus(
pressure = pressure,
totalMb = obj.intField("total_mb"),
freeMb = obj.intField("free_mb"),
usedPercent = (obj["used_percent"] as? JsonPrimitive)?.contentOrNull?.toDoubleOrNull(),
)
}
@@ -2,13 +2,20 @@ package com.hermesandroid.relay.network.upstream
import android.util.Log
import com.hermesandroid.relay.data.GatewayProfileConfigureResult
import com.hermesandroid.relay.data.GatewayProfileAsset
import com.hermesandroid.relay.data.GatewayProfileAuthChoice
import com.hermesandroid.relay.data.GatewayProfileCreateRequest
import com.hermesandroid.relay.data.GatewayProfileCreateResult
import com.hermesandroid.relay.data.GatewayProfileDescription
import com.hermesandroid.relay.data.GatewayProfileEditorClient
import com.hermesandroid.relay.data.GatewayProfileEditorUnsupportedException
import com.hermesandroid.relay.data.GatewayProfileManagementUnsupportedException
import com.hermesandroid.relay.data.GatewayProfilePatch
import com.hermesandroid.relay.data.GatewayProfileSection
import com.hermesandroid.relay.data.GatewayProfileSkill
import com.hermesandroid.relay.data.GatewayProfileToolset
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.isSafeProfileUiMeta
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.models.UsageInfo
import com.hermesandroid.relay.util.AppForegroundTracker
@@ -41,6 +48,7 @@ import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import java.util.Base64
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -102,7 +110,17 @@ class GatewayChatClient(
/** Existing upstream rich-chat vocabulary; do not invent a Relay-only source. */
private val sessionSource = "webui"
@Volatile
private var profileEditorSupported: Boolean? = null
private var profileDescribeSupported: Boolean? = null
@Volatile
private var profileConfigureSupported: Boolean? = null
@Volatile
private var profileListSupported: Boolean? = null
@Volatile
private var profileCreateSupported: Boolean? = null
@Volatile
private var profileGetAssetSupported: Boolean? = null
@Volatile
private var profileSetAssetSupported: Boolean? = null
companion object {
private const val TAG = "GatewayChatClient"
@@ -126,6 +144,7 @@ class GatewayChatClient(
private const val ASK_UNBOUNDED_TIMEOUT_MS = 600_000L
private const val RPC_TIMEOUT_MS = 15_000L
const val PROFILE_AVATAR_MAX_BYTES = 2_000_000
/**
* `prompt.submit` ack ceiling — mirrors upstream desktop's
@@ -373,14 +392,10 @@ class GatewayChatClient(
sessionProfileProvider().takeIf { !it.isNullOrBlank() }
/**
* Supplies the explicit in-chat overrides to bind onto each fresh
* `session.create` (upstream honors `model`/`provider`/`reasoning_effort`/
* `fast` → the new session's per-session overrides). Pulled live so it
* always reflects the current picker + safety/speed controls; null (or all
* fields null) = no explicit override, so the new session inherits the
* profile / server default. Wired by ChatViewModel. A live session keeps its
* agent config, so this only affects session creation — mid-session switches
* go through [setModel]/[setReasoning]/[setFast] (`config.set`).
* Supplies non-model overrides for each fresh `session.create`. Model and
* provider must never ride this raw create contract because that bypasses
* upstream model-selection confirmation; all model changes use [setModel]
* after [prepareModelSelectionSession].
*/
@Volatile
var sessionModelProvider: () -> GatewaySessionModel? = { null }
@@ -531,6 +546,9 @@ class GatewayChatClient(
* user explicitly queued behind an active turn. Newer gateways use the
* additive `queued:true` marker to preserve run-after semantics while
* the previous turn is still settling; older gateways ignore it.
* @param onAttachmentFailure invoked when attachment bytes could not be
* safely bound to this Gateway turn. Callers must surface that failure
* instead of falling back to a transport without equivalent media.
* @param onPreflightFailure invoked INSTEAD of starting the turn when the
* gateway could not be reached / authenticated / the prompt could not
* be submitted — i.e. nothing started server-side, so the caller can
@@ -547,6 +565,7 @@ class GatewayChatClient(
truncateBeforeRowId: Long? = null,
queuedFollowUp: Boolean = false,
onSurvivorUserRowIds: (List<Long?>) -> Unit = { },
onAttachmentFailure: ((String) -> Unit)? = null,
onPreflightFailure: (reason: String) -> Unit,
): ActiveTurnHandle {
val turn = GatewayTurn(dispatchOn(callbacks))
@@ -568,21 +587,33 @@ class GatewayChatClient(
turn.tracer.mark("session")
}
if (turn.cancelled) return@launch
val stagedImagePaths = mutableListOf<String>()
val attachmentRefs = attachments.mapNotNull { attachment ->
val upload = uploadAttachment(attachment).getOrElse { e ->
throw GatewayPreflightException("attachment upload failed: ${e.message}")
cleanupStagedAttachments(stagedImagePaths)
throw GatewayAttachmentPreflightException("attachment upload failed: ${e.message}")
}
stagedImagePaths += upload.attachedImagePaths()
if (attachment.requiresPromptReference()) {
upload.stringField("ref_text")
?: throw GatewayPreflightException(
"attachment upload failed: Hermes returned no readable file reference",
)
?: run {
cleanupStagedAttachments(stagedImagePaths)
throw GatewayAttachmentPreflightException(
"attachment upload failed: Hermes returned no readable file reference",
)
}
} else {
null
}
}
if (turn.cancelled) return@launch
if (!awaitCancelledTurnDrain(turn, storedSessionId)) return@launch
if (turn.cancelled) {
cleanupStagedAttachments(stagedImagePaths)
return@launch
}
if (!awaitCancelledTurnDrain(turn, storedSessionId)) {
cleanupStagedAttachments(stagedImagePaths)
return@launch
}
activeTurn = turn
turn.armWatchdog()
// Generic `file.attach` uploads are staged artifacts, not
@@ -598,9 +629,11 @@ class GatewayChatClient(
buildJsonObject {
put("session_id", liveSessionId ?: error("no live session"))
put("text", submittedText)
if (truncateBeforeUserOrdinal != null || truncateBeforeRowId != null) {
put("confirm_truncate", true)
}
truncateBeforeUserOrdinal?.let { ordinal ->
put("truncate_before_user_ordinal", ordinal)
put("confirm_truncate", true)
if (ordinal == 0) put("confirm_empty_truncate", true)
}
truncateBeforeRowId?.let { put("truncate_before_row_id", it) }
@@ -636,12 +669,19 @@ class GatewayChatClient(
// and duplicate the optimistic user turn on another
// transport. Surface the holder-aware upstream message
// through the normal failed-turn callback instead.
cleanupStagedAttachments(stagedImagePaths)
turn.tracer.done("submit-rejected")
turn.callbacks.onError(
submitError?.message ?: "Hermes rejected the new session",
)
return@launch
}
if (attachments.isNotEmpty()) {
cleanupStagedAttachments(stagedImagePaths)
throw GatewayAttachmentPreflightException(
submitError?.message ?: "attachment prompt submission could not be confirmed",
)
}
throw GatewayPreflightException(
submitError?.message ?: "prompt.submit failed",
)
@@ -665,6 +705,18 @@ class GatewayChatClient(
turn.tracer.done("resume-rejected")
turn.callbacks.onError(e.message ?: "Hermes could not resume this session")
}
} catch (e: GatewayAttachmentPreflightException) {
if (activeTurn === turn) activeTurn = null
if (!turn.cancelled) {
Log.w(TAG, "Gateway attachment preflight failed: ${e.message}")
turn.disarmWatchdog()
turn.tracer.done("attachment-preflight-fail")
callbackDispatcher {
(onAttachmentFailure ?: onPreflightFailure)(
e.message ?: "attachment could not be sent",
)
}
}
} catch (e: Exception) {
if (activeTurn === turn) activeTurn = null
if (!turn.cancelled) {
@@ -850,6 +902,66 @@ class GatewayChatClient(
return sessionReady
}
/**
* Obtain a session-scoped target before a model-selection `config.set`.
*
* Unlike [prewarmAwait], a draft is deliberately materialized here because
* sending a model with `session.create` bypasses upstream's cost/data-policy
* confirmation guard, while a sessionless `config.set` mutates global
* configuration. Draft creation therefore binds only the profile and
* non-model session options; the model always follows through [setModel].
*/
suspend fun prepareModelSelectionSession(requestedStoredId: String?): Result<String> {
if (requestedStoredId != null) {
return if (prewarmAwait(requestedStoredId)) {
Result.success(requestedStoredId)
} else {
Result.failure(GatewayPreflightException("gateway session is unavailable"))
}
}
return runCatching {
val requestedProfile = currentSessionProfile()
connectMutex.withLock {
ensureConnected()
val reusableStoredId = storedSessionId
if (
liveSessionId != null && reusableStoredId != null &&
liveSessionProfile == requestedProfile
) {
return@withLock reusableStoredId
}
if (liveSessionId != null || storedSessionId != null) {
throw GatewayPreflightException("draft session state changed before model selection")
}
val created = rpc(
"session.create",
buildJsonObject {
put("cols", DEFAULT_COLS)
put("source", sessionSource)
requestedProfile?.let { put("profile", it) }
currentSessionModel()?.let { options ->
options.reasoningEffort
?.takeIf(String::isNotBlank)
?.let { put("reasoning_effort", it) }
options.fast?.let { put("fast", it) }
}
},
).getOrElse { error ->
throw GatewayPreflightException("session.create failed: ${error.message}")
}
requireConfirmedSessionProfile(created, requestedProfile)
val live = created.stringField("session_id")
?: throw GatewayPreflightException("session.create returned no session_id")
val stored = created.stringField("stored_session_id") ?: live
liveSessionId = live
storedSessionId = stored
liveSessionProfile = requestedProfile
if (cancelledTurnDrain?.storedSessionId != stored) cancelledTurnDrain = null
stored
}
}
}
/**
* Reattach callbacks to a turn that survived the Android UI/process.
*
@@ -944,6 +1056,22 @@ class GatewayChatClient(
}
}
try {
requireConfirmedSessionProfile(response, requestedProfile)
} catch (error: GatewayPreflightException) {
synchronized(recoveryEventLock) { recoveryEvents = null }
if (activeTurn === boundTurn) activeTurn = null
boundTurn?.discardDeferredEvents()
boundTurn?.detach()
if (!preferredLiveId.isNullOrBlank()) {
claimedBackground?.let { backgroundTurns.putIfAbsent(preferredLiveId, it) }
}
liveSessionId = null
storedSessionId = null
liveSessionProfile = null
throw error
}
val recoveredLiveId = response.stringField("session_id")
?: run {
synchronized(recoveryEventLock) { recoveryEvents = null }
@@ -1280,6 +1408,245 @@ class GatewayChatClient(
.onSuccess { commandsCatalogCache = it }
}
/**
* Create a schedule through upstream's authenticated `cron.manage` RPC.
* No Relay scheduler or compatibility endpoint is involved.
*/
suspend fun createCronJob(draft: CronCreationDraft): Result<JsonObject> {
val validated = draft.validated().getOrElse { return Result.failure(it) }
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
return rpc(
"cron.manage",
buildJsonObject {
put("action", "add")
put("name", validated.name)
put("schedule", validated.schedule)
put("prompt", validated.prompt)
validated.repeat?.let { put("repeat", it) }
validated.profile?.let { put("profile", it) }
},
)
}
/** Current Hermes-owned profile roster; older gateways fail soft to the Dashboard/Relay list. */
suspend fun listProfiles(): Result<List<Profile>> {
if (profileListSupported == false) {
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.list"))
}
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
val response = rpc(
"profiles.list",
buildJsonObject { put("include_sessions", false) },
)
if (response.exceptionOrNull().isMethodNotFound()) {
profileListSupported = false
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.list"))
}
return response.mapCatching { payload ->
(payload["profiles"] as? JsonArray).orEmpty().mapNotNull { raw ->
val row = raw as? JsonObject ?: return@mapNotNull null
val name = row.stringField("name")?.trim()?.takeIf(String::isNotEmpty)
?: return@mapNotNull null
val uiMeta = (row["ui_meta"] as? JsonObject)
?.takeIf(::isSafeProfileUiMeta)
?: JsonObject(emptyMap())
Profile(
name = name,
model = row.stringField("model").orEmpty(),
provider = row.stringField("provider").orEmpty(),
description = row.stringField("description").orEmpty(),
skillCount = (row["skill_count"] as? JsonPrimitive)?.intOrNull ?: 0,
isDefault = (row["is_default"] as? JsonPrimitive)?.booleanOrNull ?: false,
hasAvatar = (row["has_avatar"] as? JsonPrimitive)?.booleanOrNull ?: false,
uiMeta = uiMeta,
)
}
}.onSuccess { profileListSupported = true }
}
/** Create through the Gateway so auth behavior is explicit and server-owned. */
suspend fun createProfile(request: GatewayProfileCreateRequest): Result<GatewayProfileCreateResult> {
if (profileCreateSupported == false) {
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.create"))
}
val name = request.name.trim()
if (name.isEmpty()) return Result.failure(IllegalArgumentException("profile name required"))
if ((request.model == null) != (request.provider == null)) {
return Result.failure(IllegalArgumentException("provider and model must be supplied together"))
}
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
val response = rpc(
"profiles.create",
buildJsonObject {
put("name", name)
request.description?.trim()?.takeIf(String::isNotEmpty)?.let { put("description", it) }
request.cloneFrom?.trim()?.takeIf(String::isNotEmpty)?.let { put("clone_from", it) }
put("clone_all", request.cloneAll)
put("no_skills", request.noSkills)
request.soul?.let { put("soul", it) }
request.model?.trim()?.takeIf(String::isNotEmpty)?.let { put("model", it) }
request.provider?.trim()?.takeIf(String::isNotEmpty)?.let { put("provider", it) }
put("mirror_credentials", request.authChoice != GatewayProfileAuthChoice.Isolated)
put("share_auth", request.authChoice == GatewayProfileAuthChoice.Shared)
},
)
if (response.exceptionOrNull().isMethodNotFound()) {
profileCreateSupported = false
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.create"))
}
return response.mapCatching { payload ->
val returnedName = payload.stringField("name")
?: throw GatewayRpcException("profiles.create returned no profile name")
if (returnedName != name) throw GatewayRpcException("profiles.create returned a different profile")
val mirrored = payload["mirrored"] as? JsonObject ?: JsonObject(emptyMap())
val auth = mirrored["auth"] as? JsonPrimitive
GatewayProfileCreateResult(
name = returnedName,
soulWritten = (payload["soul_written"] as? JsonPrimitive)?.booleanOrNull ?: false,
modelSet = (payload["model_set"] as? JsonPrimitive)?.booleanOrNull ?: false,
mirroredEnvironment = (mirrored["env"] as? JsonPrimitive)?.booleanOrNull ?: false,
mirroredAuth = auth?.contentOrNull,
modelInherited = (mirrored["model_inherited"] as? JsonPrimitive)?.booleanOrNull ?: false,
voiceMirrored = (mirrored["voice"] as? JsonPrimitive)?.booleanOrNull ?: false,
)
}.onSuccess { profileCreateSupported = true }
}
suspend fun getProfileAvatar(profileName: String): Result<GatewayProfileAsset?> {
if (profileGetAssetSupported == false) {
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.get_asset"))
}
val name = profileName.trim()
if (name.isEmpty()) return Result.failure(IllegalArgumentException("profile name required"))
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
val response = rpc(
"profiles.get_asset",
buildJsonObject { put("name", name); put("asset", "avatar") },
)
if (response.exceptionOrNull().isMethodNotFound()) {
profileGetAssetSupported = false
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.get_asset"))
}
return response.mapCatching { payload ->
if ((payload["found"] as? JsonPrimitive)?.booleanOrNull != true) return@mapCatching null
val declaredMime = payload.stringField("mime")
?: throw GatewayRpcException("profiles.get_asset returned no mime")
val encoded = payload.stringField("data")
?: throw GatewayRpcException("profiles.get_asset returned no data")
val (dataMime, base64) = splitImageData(encoded)
if (dataMime != null && dataMime != declaredMime) {
throw GatewayRpcException("profiles.get_asset mime mismatch")
}
val bytes = runCatching { Base64.getDecoder().decode(base64) }
.getOrElse { throw GatewayRpcException("profiles.get_asset returned malformed base64") }
if (bytes.size > PROFILE_AVATAR_MAX_BYTES) {
throw GatewayRpcException("profiles.get_asset returned an oversized avatar")
}
val actualMime = imageMime(bytes)
?: throw GatewayRpcException("profiles.get_asset returned an unsupported image")
if (actualMime != declaredMime) throw GatewayRpcException("profiles.get_asset magic mismatch")
val declaredSize = (payload["size"] as? JsonPrimitive)?.intOrNull
if (declaredSize != null && declaredSize != bytes.size) {
throw GatewayRpcException("profiles.get_asset size mismatch")
}
GatewayProfileAsset(bytes, actualMime)
}.onSuccess { profileGetAssetSupported = true }
}
suspend fun setProfileAvatar(profileName: String, bytes: ByteArray): Result<Int> {
val mime = imageMime(bytes)
?: return Result.failure(IllegalArgumentException("avatar must be PNG, JPEG, or WebP"))
if (bytes.size > PROFILE_AVATAR_MAX_BYTES) {
return Result.failure(IllegalArgumentException("avatar exceeds the 2,000,000 byte limit"))
}
return setProfileAvatarPayload(
profileName,
expectedSize = bytes.size,
params = buildJsonObject {
put("name", profileName.trim())
put("asset", "avatar")
put("data", "data:$mime;base64,${Base64.getEncoder().encodeToString(bytes)}")
},
)
}
suspend fun clearProfileAvatar(profileName: String): Result<Int> = setProfileAvatarPayload(
profileName,
expectedSize = 0,
params = buildJsonObject {
put("name", profileName.trim())
put("asset", "avatar")
put("clear", true)
},
)
private suspend fun setProfileAvatarPayload(
profileName: String,
expectedSize: Int,
params: JsonObject,
): Result<Int> {
if (profileSetAssetSupported == false) {
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.set_asset"))
}
if (profileName.trim().isEmpty()) {
return Result.failure(IllegalArgumentException("profile name required"))
}
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
val response = rpc("profiles.set_asset", params)
if (response.exceptionOrNull().isMethodNotFound()) {
profileSetAssetSupported = false
return Result.failure(GatewayProfileManagementUnsupportedException("profiles.set_asset"))
}
return response.mapCatching { payload ->
if ((payload["ok"] as? JsonPrimitive)?.booleanOrNull != true) {
throw GatewayRpcException("profiles.set_asset was not acknowledged")
}
val size = (payload["size"] as? JsonPrimitive)?.intOrNull
?: throw GatewayRpcException("profiles.set_asset returned no size")
if (size != expectedSize) throw GatewayRpcException("profiles.set_asset size mismatch")
size
}.onSuccess { profileSetAssetSupported = true }
}
private fun splitImageData(value: String): Pair<String?, String> {
if (!value.startsWith("data:")) return null to value
val marker = ";base64,"
val split = value.indexOf(marker)
if (split <= 5) throw GatewayRpcException("profiles.get_asset returned a malformed data URL")
return value.substring(5, split) to value.substring(split + marker.length)
}
private fun imageMime(bytes: ByteArray): String? = when {
bytes.size >= 8 && bytes.copyOfRange(0, 8).contentEquals(
byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
) -> "image/png"
bytes.size >= 3 && bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() &&
bytes[2] == 0xff.toByte() -> "image/jpeg"
bytes.size >= 12 && bytes.copyOfRange(0, 4).contentEquals("RIFF".toByteArray()) &&
bytes.copyOfRange(8, 12).contentEquals("WEBP".toByteArray()) -> "image/webp"
else -> null
}
/**
* Capability probe and authoritative editor snapshot. A method-not-found
* response is sticky for this client so older Hermes builds keep using the
@@ -1288,7 +1655,7 @@ class GatewayChatClient(
override suspend fun describeProfile(
profileName: String,
): Result<GatewayProfileDescription> {
if (profileEditorSupported == false) {
if (profileDescribeSupported == false) {
return Result.failure(GatewayProfileEditorUnsupportedException())
}
val name = profileName.trim()
@@ -1304,13 +1671,13 @@ class GatewayChatClient(
)
val error = response.exceptionOrNull()
if (error.isMethodNotFound()) {
profileEditorSupported = false
profileDescribeSupported = false
return Result.failure(GatewayProfileEditorUnsupportedException())
}
return response.mapCatching { payload ->
parseProfileDescription(payload, expectedName = name)
}.onSuccess {
profileEditorSupported = true
profileDescribeSupported = true
}
}
@@ -1319,7 +1686,7 @@ class GatewayChatClient(
profileName: String,
patch: GatewayProfilePatch,
): Result<GatewayProfileConfigureResult> {
if (profileEditorSupported != true) {
if (profileDescribeSupported != true || profileConfigureSupported == false) {
return Result.failure(GatewayProfileEditorUnsupportedException())
}
val name = profileName.trim()
@@ -1327,6 +1694,9 @@ class GatewayChatClient(
if ((patch.provider == null) != (patch.model == null)) {
return Result.failure(IllegalArgumentException("provider and model must be saved together"))
}
if (patch.uiMeta != null && !isSafeProfileUiMeta(patch.uiMeta)) {
return Result.failure(IllegalArgumentException("ui_meta must contain only small preference/reference metadata"))
}
val requested = patch.requestedSections
if (requested.isEmpty()) return Result.success(GatewayProfileConfigureResult(emptySet(), emptySet()))
val params = buildJsonObject {
@@ -1341,14 +1711,25 @@ class GatewayChatClient(
patch.enabledToolsets?.let { names ->
put("enabled_toolsets", JsonArray(names.map(::JsonPrimitive)))
}
patch.enabledMcpServers?.let { names ->
put("enabled_mcp_servers", JsonArray(names.map(::JsonPrimitive)))
}
patch.uiMeta?.let { put("ui_meta", it) }
}
return rpc("profiles.configure", params).mapCatching { payload ->
val response = rpc("profiles.configure", params)
if (response.exceptionOrNull().isMethodNotFound()) {
profileConfigureSupported = false
return Result.failure(GatewayProfileEditorUnsupportedException())
}
return response.mapCatching { payload ->
val appliedObject = payload["applied"] as? JsonObject
?: throw GatewayRpcException("profiles.configure returned no applied map")
val applied = requested.filterTo(linkedSetOf()) { section ->
(appliedObject[section.wireName] as? JsonPrimitive)?.booleanOrNull == true
}
GatewayProfileConfigureResult(requested = requested, applied = applied)
}.onSuccess {
profileConfigureSupported = true
}
}
@@ -1447,6 +1828,78 @@ class GatewayChatClient(
}
}
/** Fetch the active profile-scoped Hermes pet and its renderer contract. */
suspend fun petInfo(
profile: String? = currentSessionProfile(),
knownRevision: String? = null,
): Result<GatewayPetInfo> {
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
return rpc(
"pet.info",
buildJsonObject {
profile?.trim()?.takeIf { it.isNotEmpty() }?.let { put("profile", it) }
knownRevision?.trim()?.takeIf { it.isNotEmpty() }?.let { put("knownRevision", it) }
},
).mapCatching(::parseGatewayPetInfo)
}
/** List adoptable pets for the active Hermes profile. */
suspend fun petGallery(
profile: String? = currentSessionProfile(),
localOnly: Boolean = false,
): Result<GatewayPetGallery> {
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
return rpc(
"pet.gallery",
buildJsonObject {
put("localOnly", localOnly)
profile?.trim()?.takeIf { it.isNotEmpty() }?.let { put("profile", it) }
},
).mapCatching(::parseGatewayPetGallery)
}
/** Install if necessary and activate one pet in the selected Hermes profile. */
suspend fun selectPet(
slug: String,
profile: String? = currentSessionProfile(),
): Result<Unit> = mutatePet("pet.select", slug, profile)
/** Disable the active pet without deleting it from the selected Hermes profile. */
suspend fun disablePet(profile: String? = currentSessionProfile()): Result<Unit> =
mutatePet("pet.disable", slug = null, profile = profile)
private suspend fun mutatePet(method: String, slug: String?, profile: String?): Result<Unit> {
val normalizedSlug = slug?.trim()
if (normalizedSlug != null && !PETDEX_SLUG.matches(normalizedSlug)) {
return Result.failure(IllegalArgumentException("invalid Petdex slug"))
}
try {
connectMutex.withLock { ensureConnected() }
} catch (e: Exception) {
return Result.failure(e)
}
return rpc(
method,
buildJsonObject {
normalizedSlug?.let { put("slug", it) }
profile?.trim()?.takeIf { it.isNotEmpty() }?.let { put("profile", it) }
},
timeoutMs = PET_MUTATION_TIMEOUT_MS,
).mapCatching { response ->
if ((response["ok"] as? JsonPrimitive)?.booleanOrNull != true) {
throw GatewayRpcException("$method returned an invalid response")
}
}
}
/**
* Fetch the current chat session's running and recently-finished background
* processes. Callers never provide a session id: this wrapper resolves and
@@ -1646,12 +2099,16 @@ class GatewayChatClient(
* which falls through to `command.dispatch` and reports a spurious
* "not a quick/plugin/skill command" failure even when the switch applied.
*/
suspend fun setModel(value: String): Result<JsonObject> =
suspend fun setModel(
value: String,
confirmSelection: Boolean = false,
): Result<JsonObject> =
rpc(
"config.set",
buildJsonObject {
put("key", "model")
put("value", value)
if (confirmSelection) put("confirm_expensive_model", true)
liveSessionId?.let { put("session_id", it) }
},
)
@@ -1661,7 +2118,7 @@ class GatewayChatClient(
* id. This follows the upstream gateway contract, which resolves the row
* atomically inside the active session. A null emoji removes the reaction.
*/
suspend fun reactToNewest(role: String, emoji: String?): Result<JsonObject> {
suspend fun reactToMessage(rowId: Long?, role: String, emoji: String?): Result<JsonObject> {
require(role == "user" || role == "assistant") { "unsupported reaction role" }
val sid = liveSessionId
?: return Result.failure(GatewayRpcException("no live session"))
@@ -1669,7 +2126,7 @@ class GatewayChatClient(
"message.react",
buildJsonObject {
put("session_id", sid)
put("newest_role", role)
if (rowId != null) put("row_id", rowId) else put("newest_role", role)
if (emoji == null) put("emoji", JsonNull) else put("emoji", emoji)
put("author", "user")
},
@@ -1974,7 +2431,11 @@ class GatewayChatClient(
requestedProfile?.let { put("profile", it) }
},
)
val result = resumed.getOrNull()
val result = resumed.getOrNull()?.takeIf { response ->
runCatching { requireConfirmedSessionProfile(response, requestedProfile) }
.onFailure { Log.w(TAG, "Gateway prewarm rejected profile scope: ${it.message}") }
.isSuccess
}
val live = result?.stringField("session_id")
if (
live != null &&
@@ -2067,6 +2528,28 @@ class GatewayChatClient(
(result["info"] as? JsonObject)?.let { applySessionInfo(it) }
}
/**
* A named profile is safe to bind only when Hermes echoes that exact owner
* in the authoritative session result. Current multiplex gateways report
* `info.profile_name` on create/resume. Missing metadata means an older
* gateway may have ignored `params.profile`; a different name means the
* requested profile disappeared or resolved to the launch profile.
*/
private fun requireConfirmedSessionProfile(result: JsonObject, requestedProfile: String?) {
val expected = requestedProfile?.trim()?.takeIf { it.isNotEmpty() } ?: return
val actual = (result["info"] as? JsonObject)
?.stringField("profile_name")
?.trim()
?.takeIf { it.isNotEmpty() }
if (actual != expected) {
val detail = actual?.let { "Hermes returned profile '$it'" }
?: "Hermes did not confirm profile ownership"
throw GatewayPreflightException(
"$detail; refusing to use it as selected profile '$expected'",
)
}
}
/** Resolve a process RPC against the exact live id, resuming after reconnect when possible. */
private suspend fun ensureLiveProcessSession(): Result<String> {
val requestedProfile = currentSessionProfile()
@@ -2167,6 +2650,7 @@ class GatewayChatClient(
}
val live = result?.stringField("session_id")
if (live != null) {
requireConfirmedSessionProfile(result, requestedProfile)
liveSessionId = live
storedSessionId = requestedStoredId
liveSessionProfile = requestedProfile
@@ -2188,20 +2672,10 @@ class GatewayChatClient(
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
// per-session overrides. Upstream tui_gateway session.create
// reads `model`/`provider` (→ model_override), `reasoning_effort`
// (→ create_reasoning_override) and `fast` (→ priority service
// tier) — verified server.py:4175-4191. Without this a fresh
// chat ignores the picker/safety controls and builds the agent
// from the global default, and worse, setting effort/fast before
// the first message runs a SESSIONLESS config.set that upstream
// applies as a GLOBAL config write. A live session keeps its own
// config — this is create-only; mid-session switches use
// config.set (setModel/setReasoning/setFast).
// Only non-model draft settings may ride session.create. Model
// and provider always pass through confirmation-aware config.set
// before they become active.
currentSessionModel()?.let { sm ->
sm.model?.takeIf { it.isNotBlank() }?.let { put("model", it) }
sm.provider?.takeIf { it.isNotBlank() }?.let { put("provider", it) }
sm.reasoningEffort?.takeIf { it.isNotBlank() }?.let { put("reasoning_effort", it) }
sm.fast?.let { put("fast", it) }
}
@@ -2211,6 +2685,7 @@ class GatewayChatClient(
}
val live = created.stringField("session_id")
?: throw GatewayPreflightException("session.create returned no session_id")
requireConfirmedSessionProfile(created, requestedProfile)
val stored = created.stringField("stored_session_id") ?: live
liveSessionId = live
storedSessionId = stored
@@ -2830,6 +3305,25 @@ class GatewayChatClient(
val sid = liveSessionId
?: return Result.failure(GatewayRpcException("no live session"))
val mime = attachment.contentType.substringBefore(';').trim().lowercase()
val size = maxOf(
attachment.sizeBytes ?: 0L,
decodedBase64Size(attachment.base64),
)
val limit = when {
mime.startsWith("image/") -> ATTACH_IMAGE_MAX_BYTES
mime == "application/pdf" -> ATTACH_PDF_MAX_BYTES
else -> ATTACH_FILE_MAX_BYTES
}
if (size <= 0L) {
return Result.failure(GatewayRpcException("attachment is empty"))
}
if (size > limit) {
return Result.failure(
GatewayRpcException(
"attachment is too large (${size / (1024 * 1024)} MB; max ${limit / (1024 * 1024)} MB)",
),
)
}
return when {
mime.startsWith("image/") -> uploadImage(sid, attachment)
mime == "application/pdf" -> rpc(
@@ -2855,6 +3349,23 @@ class GatewayChatClient(
}
}
private suspend fun cleanupStagedAttachments(paths: List<String>) {
val sid = liveSessionId ?: return
paths.asReversed().distinct().forEach { path ->
val removed = rpc(
"image.detach",
buildJsonObject {
put("session_id", sid)
put("path", path)
},
timeoutMs = ATTACH_RPC_TIMEOUT_MS,
)
if (removed.isFailure) {
Log.w(TAG, "Could not clean up staged attachment: ${removed.exceptionOrNull()?.message}")
}
}
}
private fun GatewayAttachment.requiresPromptReference(): Boolean {
val mime = contentType.substringBefore(';').trim().lowercase()
return !mime.startsWith("image/") && mime != "application/pdf"
@@ -3330,11 +3841,15 @@ data class GatewayAttachment(
val base64: String,
val ext: String?,
val contentType: String,
val sizeBytes: Long? = null,
)
/** Connect/auth/submit failed before the turn started — safe to fall back to SSE. */
internal class GatewayPreflightException(message: String) : Exception(message)
/** Attachment bytes were not safely bound to a Gateway turn; never silently fall through to SSE. */
internal class GatewayAttachmentPreflightException(message: String) : Exception(message)
/** One connect attempt failed; [GatewayChatClient] may retry with a fresh ticket. */
internal class GatewayConnectAttemptException(message: String) : Exception(message)
@@ -3345,6 +3860,9 @@ internal class GatewayAuthoritativeResumeException(message: String) : Exception(
internal class GatewayRpcException(message: String, val code: Int? = null) : Exception(message)
private const val JSONRPC_METHOD_NOT_FOUND = -32601
private const val ATTACH_IMAGE_MAX_BYTES = 25L * 1024L * 1024L
private const val ATTACH_PDF_MAX_BYTES = 50L * 1024L * 1024L
private const val ATTACH_FILE_MAX_BYTES = 50L * 1024L * 1024L
private val AUTHORITATIVE_PROMPT_SUBMIT_REJECTIONS = setOf(
4004, // malformed truncation target
4018, // durable/ordinal target is no longer present
@@ -3359,6 +3877,21 @@ private val AUTHORITATIVE_PROMPT_SUBMIT_REJECTIONS = setOf(
private const val MAX_RECOVERED_CORRECTIONS = 32
private const val MAX_RECOVERED_CORRECTION_CHARS = 32_768
private const val PET_THUMB_DATA_PREFIX = "data:image/png;base64,"
internal fun decodedBase64Size(value: String): Long {
val compactLength = value.count { !it.isWhitespace() }
if (compactLength == 0) return 0L
val padding = value.trimEnd().takeLast(2).count { it == '=' }
return (compactLength.toLong() * 3L / 4L - padding).coerceAtLeast(0L)
}
private fun JsonObject.attachedImagePaths(): List<String> {
val direct = stringField("path")?.let(::listOf).orEmpty()
val pages = (this["pages"] as? JsonArray).orEmpty().mapNotNull { page ->
(page as? JsonObject)?.stringField("path")
}
return direct + pages
}
private const val MAX_PET_THUMB_BASE64_CHARS = 512 * 1024
private val PETDEX_SLUG = Regex("[a-z0-9][a-z0-9-]{0,127}")
private val STANDARD_BASE64 = Regex("[A-Za-z0-9+/]*={0,2}")
@@ -3381,6 +3914,135 @@ private fun isValidPetThumbnailDataUri(raw: String): Boolean {
STANDARD_BASE64.matches(payload)
}
private const val MAX_PET_SPRITESHEET_BYTES = 32L * 1024L * 1024L
private const val PET_MUTATION_TIMEOUT_MS = 120_000L
private const val MAX_PET_GALLERY_ITEMS = 10_000
private const val MAX_PET_STATE_ROWS = 64
data class GatewayPetInfo(
val enabled: Boolean,
val slug: String? = null,
val displayName: String? = null,
val mime: String? = null,
val spritesheet: ByteArray? = null,
val spritesheetRevision: String? = null,
val spritesheetUnchanged: Boolean = false,
val frameWidth: Int = 0,
val frameHeight: Int = 0,
val framesPerState: Int = 0,
val framesByState: Map<String, Int> = emptyMap(),
val framesByRow: Map<String, Int> = emptyMap(),
val loopMs: Int = 0,
val scale: Float = 1f,
val stateRows: List<String> = emptyList(),
)
data class GatewayPetGalleryItem(
val slug: String,
val displayName: String,
val installed: Boolean,
val spritesheetUrl: String?,
val curated: Boolean,
val generated: Boolean,
)
data class GatewayPetGallery(
val enabled: Boolean,
val active: String?,
val pets: List<GatewayPetGalleryItem>,
)
private fun parseGatewayPetInfo(response: JsonObject): GatewayPetInfo {
val enabled = (response["enabled"] as? JsonPrimitive)?.booleanOrNull
?: throw GatewayRpcException("pet.info returned an invalid response")
if (!enabled) return GatewayPetInfo(enabled = false)
val slug = response.stringField("slug")?.takeIf(PETDEX_SLUG::matches)
?: throw GatewayRpcException("pet.info returned an invalid slug")
val revision = response.stringField("spritesheetRevision")?.takeIf { it.length <= 256 }
?: throw GatewayRpcException("pet.info returned no spritesheet revision")
val unchanged = (response["spritesheetUnchanged"] as? JsonPrimitive)?.booleanOrNull == true
val mime = response.stringField("mime")?.takeIf { it == "image/png" || it == "image/webp" }
val encoded = response.stringField("spritesheetBase64")
val spritesheet = when {
encoded != null -> {
if (decodedBase64Size(encoded) > MAX_PET_SPRITESHEET_BYTES ||
encoded.length % 4 != 0 || !STANDARD_BASE64.matches(encoded)
) throw GatewayRpcException("pet.info returned an invalid spritesheet")
runCatching { Base64.getDecoder().decode(encoded) }
.getOrElse { throw GatewayRpcException("pet.info returned an invalid spritesheet") }
}
unchanged -> null
else -> throw GatewayRpcException("pet.info returned no spritesheet")
}
if (spritesheet != null && mime == null) {
throw GatewayRpcException("pet.info returned an invalid spritesheet mime")
}
fun requiredDimension(name: String, max: Int): Int =
(response[name] as? JsonPrimitive)?.intOrNull
?.takeIf { it in 1..max }
?: throw GatewayRpcException("pet.info returned an invalid $name")
val rows = (response["stateRows"] as? JsonArray).orEmpty().mapNotNull {
(it as? JsonPrimitive)?.contentOrNull?.trim()?.takeIf { name ->
name.isNotEmpty() && name.length <= 64
}
}
if (rows.isEmpty() || rows.size > MAX_PET_STATE_ROWS) {
throw GatewayRpcException("pet.info returned invalid state rows")
}
fun frameCounts(name: String): Map<String, Int> {
val values = response[name] as? JsonObject ?: return emptyMap()
if (values.size > MAX_PET_STATE_ROWS) throw GatewayRpcException("pet.info returned too many frame counts")
return values.mapNotNull { (key, value) ->
val count = (value as? JsonPrimitive)?.intOrNull
if (key.length <= 64 && count != null && count in 1..120) key to count else null
}.toMap()
}
return GatewayPetInfo(
enabled = true,
slug = slug,
displayName = response.stringField("displayName")?.take(256) ?: slug,
mime = mime,
spritesheet = spritesheet,
spritesheetRevision = revision,
spritesheetUnchanged = unchanged,
frameWidth = requiredDimension("frameW", 4096),
frameHeight = requiredDimension("frameH", 4096),
framesPerState = requiredDimension("framesPerState", 120),
framesByState = frameCounts("framesByState"),
framesByRow = frameCounts("framesByRow"),
loopMs = requiredDimension("loopMs", 60_000),
scale = response.stringField("scale")?.toFloatOrNull()?.coerceIn(0.1f, 3f) ?: 1f,
stateRows = rows,
)
}
private fun parseGatewayPetGallery(response: JsonObject): GatewayPetGallery {
val enabled = (response["enabled"] as? JsonPrimitive)?.booleanOrNull
?: throw GatewayRpcException("pet.gallery returned an invalid response")
val active = response.stringField("active")?.takeIf(PETDEX_SLUG::matches)
val rawPets = (response["pets"] as? JsonArray).orEmpty()
if (rawPets.size > MAX_PET_GALLERY_ITEMS) {
throw GatewayRpcException("pet.gallery returned too many pets")
}
val pets = rawPets.mapNotNull { element ->
val item = element as? JsonObject ?: return@mapNotNull null
val slug = item.stringField("slug")?.takeIf(PETDEX_SLUG::matches) ?: return@mapNotNull null
val rawUrl = item.stringField("spritesheetUrl")?.trim().orEmpty()
GatewayPetGalleryItem(
slug = slug,
displayName = item.stringField("displayName")?.take(256)?.ifBlank { slug } ?: slug,
installed = (item["installed"] as? JsonPrimitive)?.booleanOrNull == true,
spritesheetUrl = rawUrl.takeIf { it.isNotEmpty() && isTrustedPetdexAssetUrl(it) },
curated = (item["curated"] as? JsonPrimitive)?.booleanOrNull == true,
generated = (item["generated"] as? JsonPrimitive)?.booleanOrNull == true,
)
}
return GatewayPetGallery(enabled = enabled, active = active, pets = pets)
}
data class GatewayCompressResult(
val status: String,
val output: String? = null,
@@ -436,7 +436,8 @@ class HermesApiClient(
private val json: Json = Json {
ignoreUnknownKeys = true
isLenient = true
}
},
okHttpClient: OkHttpClient? = null,
) {
@Volatile
private var lastCapabilities: ServerCapabilities? = null
@@ -480,7 +481,7 @@ class HermesApiClient(
private val mainHandler = Handler(Looper.getMainLooper())
private val client: OkHttpClient = httpClient ?: OkHttpClient.Builder()
private val client: OkHttpClient = httpClient ?: okHttpClient ?: OkHttpClient.Builder()
.readTimeout(5, TimeUnit.MINUTES)
.connectTimeout(10, TimeUnit.SECONDS)
.build()
@@ -140,7 +140,16 @@ class NativeDashboardAuthClient(
.addQueryParameter("code_challenge_method", "S256")
.addQueryParameter("redirect_uri", redirectUri)
.addQueryParameter("state", state)
.apply { provider?.takeIf(String::isNotBlank)?.let { addQueryParameter("provider", it) } }
// Match the official Desktop client for Nous-hosted gateways: the
// gateway selects its single native-eligible provider. The provider
// name advertised to UI clients is presentation/configuration data,
// not a stable native-broker identifier. Other providers retain the
// explicit selector for direct client use and tests.
.apply {
provider
?.takeIf { it.isNotBlank() && !it.equals("nous", ignoreCase = true) }
?.let { addQueryParameter("provider", it) }
}
.build()
.toString()
val generation = NativeTokenRefreshCoordinator.beginAuthorization(
@@ -22,7 +22,7 @@ private const val CALLBACK_PATH = "/callback"
private const val MAX_REQUEST_LINE_BYTES = 8 * 1024
private const val MAX_HEADER_BYTES = 16 * 1024
private const val ACCEPT_POLL_MILLIS = 500
internal const val DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS = 2 * 60 * 1000L
internal const val DEFAULT_NATIVE_SIGN_IN_TIMEOUT_MILLIS = 5 * 60 * 1000L
internal val NATIVE_SIGN_IN_RETURN_URI = "${BuildConfig.APPLICATION_ID}://return"
private enum class CallbackPage(
@@ -46,6 +46,48 @@ private enum class CallbackPage(
message = "No session details were saved from this attempt.",
guidance = "Return to Hermes Relay and start sign-in again.",
),
AuthorizationRejected(
modifier = "failure",
eyebrow = "Provider sign-in",
title = "Sign-in was not completed",
message = "The provider returned without an approved authorization for Hermes.",
guidance = "Return to Hermes Relay and start again if you still want to sign in.",
),
CodeRejected(
modifier = "failure",
eyebrow = "Hosted Hermes callback",
title = "Hermes rejected the sign-in code",
message = "Google sign-in finished, but hosted Hermes could not exchange its one-time callback code for a session.",
guidance = "Return to Hermes Relay and start a fresh sign-in attempt.",
),
GatewayUnavailable(
modifier = "failure",
eyebrow = "Hosted Hermes callback",
title = "Hosted Hermes could not finish sign-in",
message = "The callback reached Hermes Relay, but the hosted Hermes sign-in service was unavailable.",
guidance = "Return to Hermes Relay, wait a moment, and try again.",
),
TransportFailure(
modifier = "failure",
eyebrow = "Secure sign-in connection",
title = "Could not reach hosted Hermes",
message = "Google sign-in finished, but the secure connection back to hosted Hermes was interrupted.",
guidance = "Return to Hermes Relay and retry on a stable connection.",
),
ResponseUnsupported(
modifier = "failure",
eyebrow = "Hosted Hermes callback",
title = "Hermes returned an unsupported session",
message = "The hosted gateway answered, but its sign-in response was not compatible with this app.",
guidance = "Return to Hermes Relay and check for app and hosted Hermes updates.",
),
SessionStorageFailure(
modifier = "failure",
eyebrow = "Secure session storage",
title = "The session could not be saved",
message = "Google sign-in finished, but Android could not securely save the Hermes session on this device.",
guidance = "Return to Hermes Relay and try again. If it repeats, check the app's diagnostics.",
),
Rejected(
modifier = "rejected",
eyebrow = "Protected callback",
@@ -194,14 +236,14 @@ class NativeDashboardSignInCoordinator(
writeResponse(
socket,
status = "400 Bad Request",
page = CallbackPage.Failure,
page = CallbackPage.AuthorizationRejected,
)
throw error
} catch (error: Exception) {
writeResponse(
socket,
status = "400 Bad Request",
page = CallbackPage.Failure,
page = callbackFailurePage(error),
)
throw error
}
@@ -296,6 +338,20 @@ class NativeDashboardSignInCoordinator(
}
}
private fun callbackFailurePage(error: Throwable): CallbackPage {
val stage = nativeDashboardSignInFailureStage(error)
return when {
stage == "token_http_400" -> CallbackPage.CodeRejected
stage == "callback_error" -> CallbackPage.AuthorizationRejected
stage == "token_http_429" || stage.startsWith("token_http_5") ->
CallbackPage.GatewayUnavailable
stage == "token_shape" -> CallbackPage.ResponseUnsupported
stage == "token_store" -> CallbackPage.SessionStorageFailure
stage.startsWith("token_transport") -> CallbackPage.TransportFailure
else -> CallbackPage.Failure
}
}
private fun callbackPageHtml(page: CallbackPage): String = """
<!doctype html>
<html lang="en">
@@ -1,19 +1,24 @@
package com.hermesandroid.relay.network.upstream.models
import com.hermesandroid.relay.data.MessageReaction
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
@@ -97,6 +102,36 @@ object FlexibleLongSerializer : KSerializer<Long?> {
}
}
/**
* Dashboard versions may expose SQLite JSON columns either as an object or as
* their raw JSON string. Normalize both shapes so persisted presentation data
* (notably message reactions) survives a history reload on every supported
* upstream version.
*/
object FlexibleJsonObjectSerializer : KSerializer<JsonObject?> {
override val descriptor = JsonObject.serializer().descriptor
override fun deserialize(decoder: Decoder): JsonObject? {
return try {
val jsonDecoder = decoder as? JsonDecoder ?: return null
when (val element = jsonDecoder.decodeJsonElement()) {
is JsonObject -> element
is JsonPrimitive -> element.content.takeIf { it.isNotBlank() }
?.let { Json.parseToJsonElement(it) as? JsonObject }
else -> null
}
} catch (_: Exception) {
null
}
}
override fun serialize(encoder: Encoder, value: JsonObject?) {
val jsonEncoder = encoder as? JsonEncoder
?: throw SerializationException("FlexibleJsonObjectSerializer requires JSON")
jsonEncoder.encodeJsonElement(value ?: JsonNull)
}
}
/** Timestamp serializer for Hermes session metadata.
*
* Upstream currently returns epoch seconds for `started_at` / `last_active`;
@@ -134,6 +169,32 @@ object FlexibleTimestampSerializer : KSerializer<Double?> {
}
}
/**
* Boolean serializer for session flags backed by SQLite integer columns.
*
* Older Dashboard responses can expose those columns as `0` / `1` instead of
* JSON booleans. Accept the equivalent primitive forms without making arbitrary
* numbers or strings truthy, and always serialize back to a real JSON boolean.
*/
object FlexibleBooleanSerializer : KSerializer<Boolean> {
override val descriptor = PrimitiveSerialDescriptor("FlexibleBoolean", PrimitiveKind.BOOLEAN)
override fun deserialize(decoder: Decoder): Boolean {
val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeBoolean()
val element = jsonDecoder.decodeJsonElement()
val value = (element as? JsonPrimitive)?.content?.trim()?.lowercase()
return when (value) {
"true", "1" -> true
"false", "0" -> false
else -> throw SerializationException("Expected a boolean-compatible value, got $element")
}
}
override fun serialize(encoder: Encoder, value: Boolean) {
encoder.encodeBoolean(value)
}
}
// --- Session CRUD responses ---
@Serializable
@@ -187,9 +248,16 @@ data class SessionItem(
@SerialName("tool_call_count") val toolCallCount: Int? = null,
@SerialName("input_tokens") val inputTokens: Int? = null,
@SerialName("output_tokens") val outputTokens: Int? = null,
@SerialName("has_model_config") val hasModelConfig: Boolean = false,
@SerialName("actual_cost_usd") val actualCostUsd: Double? = null,
@SerialName("estimated_cost_usd") val estimatedCostUsd: Double? = null,
@SerialName("is_active") val isActive: Boolean = false,
@SerialName("has_model_config")
@Serializable(with = FlexibleBooleanSerializer::class)
val hasModelConfig: Boolean = false,
/** Durable flags returned by current Dashboard and API-server session resources. */
@Serializable(with = FlexibleBooleanSerializer::class)
val pinned: Boolean = false,
@Serializable(with = FlexibleBooleanSerializer::class)
val archived: Boolean = false,
/** Optional workspace metadata added by newer Dashboard session lists. */
val cwd: String? = null,
@@ -327,7 +395,9 @@ data class MessageItem(
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,
@SerialName("display_metadata")
@Serializable(with = FlexibleJsonObjectSerializer::class)
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
@@ -335,11 +405,24 @@ data class MessageItem(
val reasoning: String? = null,
@SerialName("reasoning_content") val reasoningContent: String? = null,
) {
/**
* Dashboard history uses the SQLite row id as numeric `id`; Gateway
* history exposes the same value explicitly as `row_id`. Match Desktop by
* accepting either representation so persisted rows remain directly
* reactable after reload.
*/
val resolvedRowId: Long?
get() = rowId ?: id?.toLongOrNull()
/** Reasoning text under whichever field name the server used. */
val resolvedReasoning: String?
get() = reasoning?.takeIf { it.isNotBlank() }
?: reasoningContent?.takeIf { it.isNotBlank() }
/** Persisted tapbacks stored by Hermes in display_metadata.reactions. */
val reactions: List<MessageReaction>
get() = parseMessageReactions(displayMetadata?.get("reactions"))
/** Extract content as plain text string. Handles both string and array-of-parts formats. */
val contentText: String?
get() = when (content) {
@@ -367,6 +450,21 @@ data class MessageItem(
}
}
fun parseMessageReactions(element: JsonElement?): List<MessageReaction> =
(element as? JsonArray).orEmpty().mapNotNull { raw ->
val reaction = raw as? JsonObject ?: return@mapNotNull null
val emoji = (reaction["emoji"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
?: return@mapNotNull null
val author = (reaction["author"] as? JsonPrimitive)?.content
?.takeIf { it == "user" || it == "agent" }
?: return@mapNotNull null
MessageReaction(
emoji = emoji,
author = author,
at = (reaction["at"] as? JsonPrimitive)?.doubleOrNull ?: 0.0,
)
}
// --- SSE streaming events from /api/sessions/{id}/chat/stream ---
//
// Hermes WebAPI event types (from server source):
@@ -90,6 +90,29 @@ object ReliabilityCenter {
writer.execute { runCatching { target.append(report) } }
}
/** Persist a bounded, content-free checkpoint before a user-requested chat reset. */
fun recordSessionCheckpoint(context: Context, evidence: SessionResetEvidence) {
initialize(context)
val report = ReliabilityReport(
reportId = ReliabilityReport.newId("checkpoint"),
appSessionId = appSessionId,
timeIso = Instant.now().toString(),
kind = ReliabilityKind.SessionCheckpoint,
owner = ReliabilityOwner.Android,
severity = ReliabilitySeverity.Info,
summary = "Chat context reset",
recovery = "The prior context remains on Hermes when it had a stored session.",
reportRecommended = false,
technicalDetail = evidence.technicalDetail(),
routeRole = evidence.transport,
environment = environment(),
)
// A pre-reset checkpoint is useful only if it lands before state is
// replaced. The store is tiny and atomically rewritten, so persist it
// synchronously instead of queueing behind later failures.
runCatching { store?.append(report) }
}
fun reports(context: Context): List<ReliabilityReport> {
initialize(context)
return store?.readAll().orEmpty()
@@ -15,6 +15,7 @@ const val RELIABILITY_SCHEMA_VERSION = 1
enum class ReliabilityKind {
FatalCrash,
AnrSignal,
SessionCheckpoint,
RecoverableProductError,
Connectivity,
Authentication,
@@ -0,0 +1,33 @@
package com.hermesandroid.relay.reliability
/**
* Content-free evidence captured immediately before Android replaces a chat
* context. Deliberately excludes prompts, message text, IDs, profile names,
* URLs, paths, attachments, and tool arguments/results.
*/
data class SessionResetEvidence(
val reason: String,
val transport: String,
val messageCount: Int,
val toolCount: Int,
val queuedCount: Int,
val pendingAttachmentCount: Int,
val hadStoredSession: Boolean,
val turnActive: Boolean,
val askPending: Boolean,
) {
fun technicalDetail(): String = buildString {
appendLine("reason=${reason.safeToken()}")
appendLine("transport=${transport.safeToken()}")
appendLine("messages=${messageCount.coerceAtLeast(0)}")
appendLine("tools=${toolCount.coerceAtLeast(0)}")
appendLine("queued=${queuedCount.coerceAtLeast(0)}")
appendLine("pending_attachments=${pendingAttachmentCount.coerceAtLeast(0)}")
appendLine("had_stored_session=$hadStoredSession")
appendLine("turn_active=$turnActive")
append("ask_pending=$askPending")
}
private fun String.safeToken(): String =
lowercase().replace(Regex("[^a-z0-9_.-]"), "_").take(40).ifBlank { "unknown" }
}
@@ -176,7 +176,9 @@ internal class HermesRuntimeBinder(
chat.setDisplayProfileProvider { connection.effectiveDisplayProfile.value }
chat.setDisplayAliasProvider { connection.profileDisplayAlias.value }
chat.setProfileSessionLister { connection.listProfileScopedSessions() }
chat.setProfileMessageLoaderWithMode(connection::loadProfileScopedMessages)
chat.setProfileMessageLoaderWithMode { profileName, sessionId, mode ->
connection.loadProfileScopedMessages(profileName, sessionId, mode)
}
chat.setDashboardConfigLoader { connection.loadActiveDashboardConfig() }
chat.profileSessionDeleter = connection::deleteProfileScopedSession
chat.profileSessionRenamer = connection::renameProfileScopedSession
@@ -96,6 +96,7 @@ import com.hermesandroid.relay.ui.components.avatar.LocalFloatingPet
import com.hermesandroid.relay.ui.components.avatar.LocalPetPlaybackSpeed
import com.hermesandroid.relay.ui.components.avatar.LocalPetStabilize
import com.hermesandroid.relay.ui.components.avatar.PetLoader
import com.hermesandroid.relay.ui.components.avatar.toAvatar
import com.hermesandroid.relay.ui.components.avatar.SphereAvatar
import com.hermesandroid.relay.ui.components.avatar.resolveBackgroundAvatar
import com.hermesandroid.relay.ui.components.FloatingPetCompanion
@@ -116,6 +117,7 @@ import com.hermesandroid.relay.ui.components.PowerFeatureGateStatus
import com.hermesandroid.relay.ui.components.RelayStatusStrip
import com.hermesandroid.relay.ui.components.UnattendedGlobalBanner
import com.hermesandroid.relay.ui.components.UpdateAvailableBanner
import com.hermesandroid.relay.ui.components.HostResourcePressureBanner
import com.hermesandroid.relay.ui.components.rememberUpdateAvailability
import com.hermesandroid.relay.ui.components.resolveChatTransportStatus
import com.hermesandroid.relay.ui.components.WhatsNewDialog
@@ -741,8 +743,12 @@ fun RelayApp() {
) {
value = withContext(Dispatchers.IO) { PetLoader.loadPets(sphereContext) }
}
val activeFloatingPet = remember(floatingPetId, availablePets) {
availablePets.firstOrNull { it.id == floatingPetId }
val hermesPetState by connectionViewModel.hermesPetState.collectAsState()
val upstreamProfilePet = remember(hermesPetState.active) {
hermesPetState.active?.toAvatar()
}
val activeFloatingPet = remember(floatingPetId, availablePets, upstreamProfilePet) {
availablePets.firstOrNull { it.id == floatingPetId } ?: upstreamProfilePet
}
var floatingPetMenuExpanded by remember(activeFloatingPet?.id) { mutableStateOf(false) }
val activeBackgroundAvatar = remember(backgroundAvatarId, availablePets) {
@@ -887,6 +893,7 @@ fun RelayApp() {
// ChatViewModel isn't available where ConnectionViewModel builds the
// handler, so the session sink is set here at the app root where both
// ViewModels are in scope.
val proactiveSummaryResources = LocalContext.current.resources
LaunchedEffect(connectionViewModel, chatViewModel) {
connectionViewModel.proactiveMessageHandler.toSession = { msg ->
val text = buildString {
@@ -895,6 +902,15 @@ fun RelayApp() {
}
chatViewModel.injectProactiveMessage(text)
}
connectionViewModel.proactiveMessageHandler.onBacklogDelivered = { count ->
UiMessageBus.info(
proactiveSummaryResources.getQuantityString(
R.plurals.proactive_messages_arrived_while_away,
count,
count,
),
)
}
// Agent Thread reply path: a send from the chat composer while a
// source=phone Thread is open routes over the relay proactive
// channel (continues the gateway phone session) instead of a normal
@@ -906,7 +922,8 @@ fun RelayApp() {
chatViewModel.onProactiveReplyAck(clientMsgId, status)
}
// Unified Threads: render an inbound agent message inline in the open
// Thread (suppressing the notification/inbox) when it belongs there.
// Thread when it belongs there. Surfacing semantics still decide
// independently whether a system notification is also required.
connectionViewModel.proactiveMessageHandler.injectIntoThread = { msg ->
chatViewModel.injectThreadMessage(msg)
}
@@ -1383,6 +1400,22 @@ fun RelayApp() {
// user always knows the chat is sample data with no live server, and
// can exit into the real Connect flow with one tap.
val showDemoBanner = isDemoMode && !voiceUiState.voiceMode
val hostResourcePressure by connectionViewModel.hostResourcePressure.collectAsState()
val showHostResourcePressure = hostResourcePressure.needsAttention &&
!isDemoMode && !voiceUiState.voiceMode && !showStartupSphere
val hostResourcePressureText = buildList {
if (hostResourcePressure.lastBootSuspectedOom) {
add(stringResource(R.string.host_resource_recent_oom))
}
when (hostResourcePressure.memoryPressure) {
"critical" -> add(stringResource(R.string.host_resource_memory_critical))
"elevated" -> add(stringResource(R.string.host_resource_memory_elevated))
}
when (hostResourcePressure.diskPressure) {
"critical" -> add(stringResource(R.string.host_resource_disk_critical))
"elevated" -> add(stringResource(R.string.host_resource_disk_elevated))
}
}.distinct().joinToString(" ")
// Transient info/status banner (UiMessageBus) — thin, takes its own
// space, auto-dismisses. Folded into the inset accounting below so a
// child TopAppBar doesn't double-pad when this banner owns the top edge.
@@ -1489,6 +1522,18 @@ fun RelayApp() {
DemoModeBanner(onConnect = exitDemoToConnect)
}
AnimatedVisibility(
visible = showHostResourcePressure,
enter = fadeIn(tween(200)),
exit = fadeOut(tween(200)),
) {
HostResourcePressureBanner(
text = hostResourcePressureText,
critical = hostResourcePressure.critical,
includeStatusBarPadding = !showUnattendedBanner && !showDemoBanner,
)
}
// Connection status intentionally has NO top-of-screen surface (no
// banner, no strip, no float). Chat/agent status rides the chat header
// subtitle; the relay socket rides the bottom RelayStatusStrip cue. See
@@ -1499,7 +1544,7 @@ fun RelayApp() {
// that banner already padded the top — avoid double padding).
MessageBannerHost(
includeStatusBarPadding =
!showUnattendedBanner && !showDemoBanner,
!showUnattendedBanner && !showDemoBanner && !showHostResourcePressure,
)
// The update banner AND the connection-status indicator now render as
@@ -1539,7 +1584,7 @@ fun RelayApp() {
// The connection-status toast is now a floating overlay and
// doesn't occupy space above the Scaffold, so it no longer
// participates in the top-inset accounting.
if (showUnattendedBanner || showDemoBanner || connectionChipVisible ||
if (showUnattendedBanner || showDemoBanner || showHostResourcePressure || connectionChipVisible ||
showMessageBanner
) {
Modifier.consumeWindowInsets(WindowInsets.statusBars)
@@ -3,105 +3,422 @@ package com.hermesandroid.relay.ui.components
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.components.SphereState
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
import com.hermesandroid.relay.ui.components.avatar.PetAvatar
import com.hermesandroid.relay.ui.components.avatar.toAvatar
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
import java.io.File
/**
* Per-profile agent-icon picker — the visual twin of the local-name (alias) row.
* The chosen image is copied into app storage and shown beside the agent's name
* in chat. Client-side only: never sent to Hermes. Keyed per `(connection,
* profile)` by [ConnectionViewModel.setProfileIcon] / `ProfileIconStore`.
*/
/** Shared Hermes identity and a separately-scoped phone presentation override. */
@Composable
fun AgentIconRow(connectionViewModel: ConnectionViewModel) {
val iconPath by connectionViewModel.profileIcon.collectAsState()
val localIconPath by connectionViewModel.localProfileIcon.collectAsState()
val serverAvatarPath by connectionViewModel.serverProfileAvatar.collectAsState()
val useLocalOverride by connectionViewModel.useLocalProfileIconOverride.collectAsState()
val hostImportState by connectionViewModel.hostProfileIconImportState.collectAsState()
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri: Uri? -> uri?.let { connectionViewModel.setProfileIcon(it) } }
val sharedState by connectionViewModel.sharedProfileAvatarState.collectAsState()
val hermesPetState by connectionViewModel.hermesPetState.collectAsState()
val hermesPetAvatar = remember(hermesPetState.active) { hermesPetState.active?.toAvatar() }
var confirmSharedRemoval by remember { mutableStateOf(false) }
var showHermesPetPicker by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
LaunchedEffect(Unit) { connectionViewModel.refreshHermesPet() }
val sharedLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument(),
) { uri: Uri? -> uri?.let(connectionViewModel::setSharedProfileAvatar) }
val localLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument(),
) { uri: Uri? -> uri?.let(connectionViewModel::setProfileIcon) }
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = stringResource(R.string.agent_icon_title),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
AvatarSourceCard {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
val path = iconPath
if (!path.isNullOrBlank()) {
AsyncImage(
model = File(path),
contentDescription = stringResource(R.string.agent_icon_title),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
HermesPetPreview(hermesPetAvatar)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.agent_icon_hermes_pet_title),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = when {
hermesPetState.supported == false -> stringResource(R.string.agent_icon_hermes_pet_unsupported)
hermesPetState.active != null -> stringResource(
R.string.agent_icon_hermes_pet_active,
hermesPetState.active?.displayName.orEmpty(),
)
else -> stringResource(R.string.agent_icon_hermes_pet_empty)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (hermesPetState.loading) {
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
} else if (hermesPetState.supported != false) {
Switch(
checked = hermesPetState.active != null,
onCheckedChange = { enabled ->
if (enabled) {
showHermesPetPicker = true
connectionViewModel.loadHermesPetGallery()
} else {
connectionViewModel.disableHermesPet()
}
},
)
}
}
OutlinedButton(onClick = { launcher.launch(arrayOf("image/*")) }) {
Text(if (iconPath.isNullOrBlank()) stringResource(R.string.agent_icon_set) else stringResource(R.string.agent_icon_change))
}
if (!iconPath.isNullOrBlank()) {
TextButton(onClick = { connectionViewModel.clearProfileIcon() }) {
Text(stringResource(R.string.agent_icon_clear))
if (hermesPetState.supported != false) {
OutlinedButton(
onClick = {
showHermesPetPicker = true
connectionViewModel.loadHermesPetGallery()
},
enabled = !hermesPetState.loading,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.agent_icon_hermes_pet_browse))
}
}
}
OutlinedButton(
onClick = { connectionViewModel.importProfileIconFromHost() },
enabled = !hostImportState.loading,
) {
Text(
if (hostImportState.loading) {
stringResource(R.string.agent_icon_importing_host)
} else {
stringResource(R.string.agent_icon_import_host)
}
)
}
hostImportState.error?.let { error ->
Text(
text = error,
text = stringResource(R.string.agent_icon_hermes_pet_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = stringResource(R.string.agent_icon_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
AvatarSourceCard {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AvatarPreview(serverAvatarPath)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.agent_icon_shared_title),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = if (serverAvatarPath.isNullOrBlank()) {
stringResource(R.string.agent_icon_shared_empty)
} else {
stringResource(R.string.agent_icon_shared_active)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = {
sharedLauncher.launch(arrayOf("image/*"))
},
enabled = !sharedState.loading,
) {
Text(stringResource(R.string.agent_icon_change_shared))
}
if (!serverAvatarPath.isNullOrBlank()) {
TextButton(
onClick = { confirmSharedRemoval = true },
enabled = !sharedState.loading,
) {
Text(
text = stringResource(R.string.agent_icon_remove_shared),
color = MaterialTheme.colorScheme.error,
)
}
}
}
sharedState.error?.let { AvatarError(it) }
}
AvatarSourceCard {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
AvatarPreview(localIconPath)
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.agent_icon_phone_title),
style = MaterialTheme.typography.titleSmall,
)
Text(
text = stringResource(R.string.agent_icon_phone_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = useLocalOverride,
onCheckedChange = connectionViewModel::setUseLocalProfileIconOverride,
)
}
OutlinedButton(
onClick = { localLauncher.launch(arrayOf("image/*")) },
modifier = Modifier.fillMaxWidth(),
enabled = useLocalOverride,
) {
Text(
if (localIconPath.isNullOrBlank()) {
stringResource(R.string.agent_icon_choose_phone)
} else {
stringResource(R.string.agent_icon_change_phone)
},
)
}
OutlinedButton(
onClick = connectionViewModel::importProfileIconFromHost,
modifier = Modifier.fillMaxWidth(),
enabled = useLocalOverride && !hostImportState.loading,
) {
Text(
if (hostImportState.loading) {
stringResource(R.string.agent_icon_importing_host)
} else {
stringResource(R.string.agent_icon_import_host)
},
)
}
if (!localIconPath.isNullOrBlank()) {
TextButton(
onClick = connectionViewModel::clearProfileIcon,
enabled = useLocalOverride,
) {
Text(stringResource(R.string.agent_icon_remove_phone))
}
}
Text(
text = stringResource(R.string.agent_icon_animation_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
hostImportState.error?.let { AvatarError(it) }
hermesPetState.error?.let { AvatarError(it) }
}
if (confirmSharedRemoval) {
AlertDialog(
onDismissRequest = { confirmSharedRemoval = false },
title = { Text(stringResource(R.string.agent_icon_remove_shared_title)) },
text = { Text(stringResource(R.string.agent_icon_remove_shared_message)) },
confirmButton = {
TextButton(
onClick = {
confirmSharedRemoval = false
connectionViewModel.clearSharedProfileAvatar()
},
) {
Text(
text = stringResource(R.string.agent_icon_remove_shared),
color = MaterialTheme.colorScheme.error,
)
}
},
dismissButton = {
TextButton(onClick = { confirmSharedRemoval = false }) {
Text(stringResource(R.string.common_cancel))
}
},
)
}
if (showHermesPetPicker) {
AlertDialog(
onDismissRequest = { showHermesPetPicker = false },
title = { Text(stringResource(R.string.agent_icon_hermes_pet_picker_title)) },
text = {
if (hermesPetState.galleryLoading && hermesPetState.gallery.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth().padding(24.dp),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
} else {
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
if (hermesPetState.gallery.isEmpty()) {
item {
Text(
text = stringResource(R.string.agent_icon_hermes_pet_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(12.dp),
)
}
}
items(hermesPetState.gallery, key = { it.slug }) { pet ->
LaunchedEffect(pet.slug, pet.spritesheetUrl) {
connectionViewModel.loadHermesPetThumbnail(pet)
}
TextButton(
onClick = {
showHermesPetPicker = false
connectionViewModel.selectHermesPet(pet.slug)
},
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
PetGalleryPreview(hermesPetState.thumbnails[pet.slug])
Column(modifier = Modifier.weight(1f)) {
Text(pet.displayName, style = MaterialTheme.typography.titleSmall)
Text(
text = when {
pet.slug == hermesPetState.active?.slug -> stringResource(R.string.agent_icon_hermes_pet_selected)
pet.installed -> stringResource(R.string.agent_icon_hermes_pet_installed)
else -> stringResource(R.string.agent_icon_hermes_pet_adopt)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { showHermesPetPicker = false }) {
Text(stringResource(R.string.common_cancel))
}
},
)
}
}
@Composable
private fun AvatarSourceCard(content: @Composable ColumnScope.() -> Unit) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)),
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
content = content,
)
}
}
@Composable
private fun AvatarPreview(path: String?) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (!path.isNullOrBlank()) {
AsyncImage(
model = File(path),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
}
@Composable
private fun HermesPetPreview(pet: PetAvatar?) {
Box(
modifier = Modifier.size(56.dp),
contentAlignment = Alignment.Center,
) {
pet?.Render(
state = AvatarRenderState(state = SphereState.Idle),
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun PetGalleryPreview(dataUri: String?) {
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
if (dataUri != null) {
AsyncImage(
model = dataUri,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize().padding(3.dp),
)
}
}
}
@Composable
private fun AvatarError(message: String) {
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
@@ -94,7 +94,7 @@ import kotlinx.coroutines.delay
*/
enum class ChatInputTrailing { SEND, VOICE, STOP, STEER, QUEUE }
private val ChatComposerShape = RoundedCornerShape(18.dp)
private val ChatComposerShape = RoundedCornerShape(26.dp)
private val ChatInputChipShape = RoundedCornerShape(12.dp)
internal const val CHAT_INPUT_FIELD_TEST_TAG = "chat-input-field"
@@ -315,7 +315,7 @@ fun ChatInputBar(
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp)
.padding(horizontal = 8.dp, vertical = 3.dp)
.then(surfaceModifier),
) {
Column {
@@ -341,15 +341,15 @@ fun ChatInputBar(
}
Column(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
modifier = Modifier.padding(horizontal = 6.dp, vertical = 3.dp),
) {
BasicTextField(
value = value,
onValueChange = { if (it.length <= charLimit) onValueChange(it) },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 34.dp)
.padding(horizontal = 8.dp, vertical = 4.dp)
.heightIn(min = 30.dp)
.padding(horizontal = 10.dp, vertical = 2.dp)
// Keep directional keys inside the editor. Compose's
// BasicTextField owns normal caret/selection movement;
// cancelling focus traversal prevents a boundary arrow
@@ -408,7 +408,7 @@ fun ChatInputBar(
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp),
.heightIn(min = 44.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
@@ -0,0 +1,81 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.WarningAmber
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
/** Persistent, server-authored resource-pressure warning for the active Hermes host. */
@Composable
fun HostResourcePressureBanner(
text: String,
critical: Boolean,
includeStatusBarPadding: Boolean,
modifier: Modifier = Modifier,
) {
val background = if (critical) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.tertiaryContainer
}
val foreground = if (critical) {
MaterialTheme.colorScheme.onErrorContainer
} else {
MaterialTheme.colorScheme.onTertiaryContainer
}
Column(
modifier = modifier
.fillMaxWidth()
.background(background)
.then(
if (includeStatusBarPadding) {
Modifier.windowInsetsPadding(WindowInsets.statusBars)
} else {
Modifier
},
),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 32.dp)
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Icons.Outlined.WarningAmber,
contentDescription = null,
tint = foreground,
modifier = Modifier.size(17.dp),
)
Text(
text = text,
color = foreground,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
}
}
@@ -42,6 +42,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
@@ -252,6 +255,7 @@ private fun MessageRow(
Row(
modifier = Modifier
.fillMaxWidth()
.semantics { liveRegion = LiveRegionMode.Polite }
.heightIn(min = ROW_MIN_HEIGHT_DP.dp)
.padding(horizontal = 10.dp, vertical = 7.dp),
horizontalArrangement = Arrangement.spacedBy(9.dp),
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
@@ -43,6 +44,7 @@ import androidx.compose.material.icons.filled.FormatQuote
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -90,6 +92,7 @@ import java.text.SimpleDateFormat
import java.util.Date
internal const val CHAT_PET_IDENTITY_OBSTACLE_PREFIX = "chat-message-identity:"
private val MESSAGE_REACTIONS = listOf("❤️", "👍", "👎", "😂", "‼️", "❓")
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -455,11 +458,51 @@ fun MessageBubble(
val showEditAction = onEditMessage != null && isUser
val showSpeakAction = shouldShowSpeakResponseAction(message, onSpeakMessage != null)
val showStopSpeakingAction = shouldShowStopSpeakingAction(message, onStopSpeaking != null)
val selectedUserReaction = message.reactions.firstOrNull { it.author == "user" }?.emoji
if (onQuoteMessage != null || onReact != null || showEditAction || showSpeakAction || showStopSpeakingAction) {
DropdownMenu(
expanded = showMessageActions,
onDismissRequest = { showMessageActions = false },
shape = RoundedCornerShape(24.dp),
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 3.dp,
shadowElevation = 8.dp,
) {
if (onReact != null) {
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 6.dp),
) {
MESSAGE_REACTIONS.forEach { emoji ->
IconButton(
onClick = {
showMessageActions = false
onReact(emoji)
},
modifier = Modifier.background(
color = if (selectedUserReaction == emoji) {
MaterialTheme.colorScheme.secondaryContainer
} else {
Color.Transparent
},
shape = CircleShape,
),
) {
Text(
text = emoji,
fontSize = 24.sp,
modifier = Modifier.semantics {
contentDescription = "React with $emoji"
},
)
}
}
}
HorizontalDivider()
}
DropdownMenuItem(
text = { Text(stringResource(R.string.msg_bubble_copy)) },
onClick = {
@@ -515,8 +558,22 @@ fun MessageBubble(
},
)
}
if (onReact != null && selectedUserReaction != null) {
DropdownMenuItem(
text = { Text("Remove reaction") },
onClick = {
showMessageActions = false
onReact(null)
},
)
}
}
}
Box(
modifier = Modifier.padding(
bottom = if (message.reactions.isNotEmpty()) 8.dp else 0.dp,
),
) {
Surface(
shape = bubbleShape,
color = backgroundColor,
@@ -558,7 +615,7 @@ fun MessageBubble(
// same tactile confirm every chat app fires.
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
if (
onQuoteMessage != null || showEditAction || showSpeakAction ||
onQuoteMessage != null || onReact != null || showEditAction || showSpeakAction ||
showStopSpeakingAction
) {
showMessageActions = true
@@ -645,25 +702,6 @@ fun MessageBubble(
SelectionContainer { messageTextContent() }
}
}
if (onReact != null) {
listOf("👍", "❤️", "😂").forEach { emoji ->
DropdownMenuItem(
text = { Text("React $emoji") },
onClick = {
showMessageActions = false
onReact(emoji)
},
)
}
DropdownMenuItem(
text = { Text("Remove reaction") },
onClick = {
showMessageActions = false
onReact(null)
},
)
}
if (onSessionReference != null && sessionReferences.isNotEmpty()) {
sessionReferences.forEach { reference ->
TextButton(
@@ -838,6 +876,19 @@ fun MessageBubble(
}
}
}
if (message.reactions.isNotEmpty()) {
MessageReactionBadge(
reactions = message.reactions.map { it.emoji },
onOpen = onReact?.let { { showMessageActions = true } },
modifier = Modifier
.align(if (isUser) Alignment.BottomEnd else Alignment.BottomStart)
.offset(
x = if (isUser) (-10).dp else 10.dp,
y = 9.dp,
),
)
}
}
val inlineActions: @Composable () -> Unit = {
MessageInlineActions(
showQuote = onQuoteMessage != null,
@@ -890,6 +941,41 @@ fun MessageBubble(
} // end CompositionLocalProvider(LocalMediaBlurMode)
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun MessageReactionBadge(
reactions: List<String>,
onOpen: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
val description = "Reactions: ${reactions.joinToString(" ")}"
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest,
tonalElevation = 2.dp,
shadowElevation = 2.dp,
modifier = modifier
.then(
if (onOpen != null) {
Modifier.combinedClickable(onClick = onOpen, onLongClick = onOpen)
} else {
Modifier
},
)
.semantics { contentDescription = description },
) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp),
) {
reactions.forEach { emoji ->
Text(text = emoji, fontSize = 14.sp, lineHeight = 16.sp)
}
}
}
}
@Composable
private fun MessageInlineActions(
showQuote: Boolean,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,167 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import java.util.Locale
internal enum class SessionDrawerGrouping {
None,
Updated,
Project,
Status,
Profile,
}
internal enum class SessionDrawerOrdering {
Updated,
Created,
Title,
Status,
Tokens,
Cost,
}
internal enum class SessionDrawerStatus {
NeedsInput,
Working,
Idle,
}
internal enum class SessionDrawerPrState {
Open,
Draft,
Merged,
Closed,
None,
}
internal data class SessionDrawerViewOptions(
val grouping: SessionDrawerGrouping = SessionDrawerGrouping.None,
val ordering: SessionDrawerOrdering = SessionDrawerOrdering.Updated,
val statuses: Set<SessionDrawerStatus> = emptySet(),
val profiles: Set<String> = emptySet(),
val projects: Set<String> = emptySet(),
val pullRequests: Set<SessionDrawerPrState> = emptySet(),
val showProfile: Boolean = false,
val showUpdated: Boolean = true,
val showTokens: Boolean = false,
val showCost: Boolean = false,
)
internal data class SessionDrawerGroup(
val key: String,
val label: String?,
val rows: List<ProfileSessionRow>,
)
internal fun sessionRowKey(row: ProfileSessionRow): String =
"${row.profile.lowercase(Locale.ROOT)}:${row.session.sessionId}"
internal fun sessionProjectLabel(session: ChatSession): String {
val raw = (session.gitRepoRoot ?: session.workingDirectory)
?.trim()
?.trimEnd('/', '\\')
.orEmpty()
if (raw.isBlank()) return "No project"
return raw.substringAfterLast('/').substringAfterLast('\\').ifBlank { raw }
}
internal fun sessionDrawerStatus(
row: ProfileSessionRow,
activityStates: Map<String, SessionActivityState>,
): SessionDrawerStatus = when (
activityStates[sessionRowKey(row)] ?: activityStates[row.session.sessionId]
) {
SessionActivityState.NeedsInput -> SessionDrawerStatus.NeedsInput
SessionActivityState.Working -> SessionDrawerStatus.Working
null -> if (row.session.isActive) SessionDrawerStatus.Working else SessionDrawerStatus.Idle
}
internal fun sessionDrawerPrState(session: ChatSession): SessionDrawerPrState = when {
session.pullRequestNumber == null -> SessionDrawerPrState.None
session.pullRequestDraft -> SessionDrawerPrState.Draft
session.pullRequestState.equals("merged", ignoreCase = true) -> SessionDrawerPrState.Merged
session.pullRequestState.equals("closed", ignoreCase = true) -> SessionDrawerPrState.Closed
else -> SessionDrawerPrState.Open
}
internal fun filterAndSortSessionRows(
rows: List<ProfileSessionRow>,
options: SessionDrawerViewOptions,
activityStates: Map<String, SessionActivityState> = emptyMap(),
): List<ProfileSessionRow> {
val filtered = rows.asSequence()
.filter { options.statuses.isEmpty() || sessionDrawerStatus(it, activityStates) in options.statuses }
.filter { options.profiles.isEmpty() || it.profile in options.profiles }
.filter { options.projects.isEmpty() || sessionProjectLabel(it.session) in options.projects }
.filter { options.pullRequests.isEmpty() || sessionDrawerPrState(it.session) in options.pullRequests }
.toList()
val statusRank = mapOf(
SessionDrawerStatus.NeedsInput to 0,
SessionDrawerStatus.Working to 1,
SessionDrawerStatus.Idle to 2,
)
val comparator = when (options.ordering) {
SessionDrawerOrdering.Updated -> compareByDescending<ProfileSessionRow> { it.session.activityTimestamp }
SessionDrawerOrdering.Created -> compareByDescending { it.session.startTimestamp }
SessionDrawerOrdering.Title -> compareBy { it.session.title.orEmpty().lowercase(Locale.ROOT) }
SessionDrawerOrdering.Status -> compareBy { statusRank.getValue(sessionDrawerStatus(it, activityStates)) }
SessionDrawerOrdering.Tokens -> compareByDescending { it.session.totalTokens }
SessionDrawerOrdering.Cost -> compareByDescending { it.session.costUsd }
}
return filtered.sortedWith(
compareByDescending<ProfileSessionRow> { it.session.pinned }
.then(comparator)
.thenBy { it.session.title.orEmpty().lowercase(Locale.ROOT) },
)
}
internal fun groupSessionRows(
rows: List<ProfileSessionRow>,
grouping: SessionDrawerGrouping,
activityStates: Map<String, SessionActivityState> = emptyMap(),
nowMillis: Long = System.currentTimeMillis(),
): List<SessionDrawerGroup> {
if (rows.isEmpty()) return emptyList()
val grouped = rows.groupBy { row ->
when (grouping) {
SessionDrawerGrouping.None -> null
SessionDrawerGrouping.Updated -> updatedBucket(row.session.activityTimestamp, nowMillis)
SessionDrawerGrouping.Project -> sessionProjectLabel(row.session)
SessionDrawerGrouping.Status -> sessionDrawerStatus(row, activityStates).displayLabel
SessionDrawerGrouping.Profile -> row.profile
}
}
val groups = grouped.map { (label, groupRows) ->
SessionDrawerGroup(key = "${grouping.name}:$label", label = label, rows = groupRows)
}
return if (grouping == SessionDrawerGrouping.Project) {
groups.sortedWith(
compareBy<SessionDrawerGroup> { it.label != "No project" }
.thenByDescending { group -> group.rows.maxOfOrNull { it.session.activityTimestamp } ?: 0L }
.thenBy { it.label.orEmpty().lowercase(Locale.ROOT) },
)
} else {
groups
}
}
private val SessionDrawerStatus.displayLabel: String
get() = when (this) {
SessionDrawerStatus.NeedsInput -> "Needs input"
SessionDrawerStatus.Working -> "Working"
SessionDrawerStatus.Idle -> "Idle"
}
private fun updatedBucket(timestamp: Long, nowMillis: Long): String {
if (timestamp <= 0L) return "Older"
val age = (nowMillis - timestamp).coerceAtLeast(0L)
return when {
age < DAY_MILLIS -> "Today"
age < 2 * DAY_MILLIS -> "Yesterday"
age < 7 * DAY_MILLIS -> "Last 7 days"
else -> "Older"
}
}
private const val DAY_MILLIS = 24L * 60L * 60L * 1_000L
@@ -0,0 +1,34 @@
package com.hermesandroid.relay.ui.components.avatar
import com.hermesandroid.relay.viewmodel.connection.ProfileController
import java.io.File
/** Adapt upstream `pet.info` geometry to the existing bounded sprite renderer. */
fun ProfileController.HermesPetPresentation.toAvatar(): PetAvatar? {
val sheet = File(spritesheetPath)
if (!sheet.isFile || frameWidth <= 0 || frameHeight <= 0 || framesPerState <= 0) return null
val fps = (framesPerState * 1000f / loopMs.coerceAtLeast(1)).coerceIn(1f, 60f)
val clips = stateRows.mapIndexedNotNull { row, name ->
val normalized = name.trim().takeIf { it.isNotEmpty() } ?: return@mapIndexedNotNull null
val count = (framesByRow[normalized] ?: framesByState[normalized] ?: framesPerState)
.coerceIn(1, framesPerState)
normalized to PetClipSpec(
sheet = sheet.name,
frameWidth = frameWidth,
frameHeight = frameHeight,
frameCount = count,
startFrame = row * framesPerState,
fps = fps,
)
}.toMap()
if (clips["idle"] == null) return null
return runCatching {
PetSpec(
id = "hermes:$slug",
label = displayName,
description = "Profile-scoped Hermes animated pet",
reactive = PetReactiveSpec(voice = false, tools = true, intensity = true),
states = clips,
).toAvatar(sheet.parentFile ?: return null)
}.getOrNull()
}
@@ -56,6 +56,7 @@ import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button
@@ -152,7 +153,6 @@ import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import android.util.Base64
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
@@ -164,6 +164,8 @@ import com.hermesandroid.relay.data.Attachment
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.ChatComposerDraft
import com.hermesandroid.relay.data.ChatComposerDraftContext
import com.hermesandroid.relay.util.AttachmentTooLargeException
import com.hermesandroid.relay.util.readBase64Bounded
import com.hermesandroid.relay.data.ChatComposerDraftKey
import com.hermesandroid.relay.data.ChatQuoteReference
import com.hermesandroid.relay.data.buildChatQuotedPrompt
@@ -300,14 +302,12 @@ internal fun resolveSessionActivityStates(
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 }
@@ -823,18 +823,39 @@ fun ChatScreen(
// has been made (the /api/config fallback is more useful than the bare
// connection label).
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
var allProfileSessions by remember { mutableStateOf<List<ProfileSessionRow>>(emptyList()) }
var allProfileSessionsLoading by remember { mutableStateOf(false) }
val openedSessionProfileName by chatViewModel.openedSessionProfileName.collectAsState()
val conversationProfile = openedSessionProfileName?.let { owner ->
agentProfiles.firstOrNull { it.name.equals(owner, ignoreCase = true) }
?: allProfileSessions.firstOrNull {
it.profile.equals(owner, ignoreCase = true) &&
it.session.sessionId == currentSessionId
}?.session?.let { session ->
com.hermesandroid.relay.data.Profile(
name = owner,
model = session.model.orEmpty(),
description = owner,
)
}
?: com.hermesandroid.relay.data.Profile(
name = owner,
model = "",
description = owner,
)
} ?: effectiveProfile
val profileDisplayAlias by connectionViewModel.profileDisplayAlias.collectAsState()
val activeConnection by connectionViewModel.activeConnection.collectAsState()
val serverModelName by chatViewModel.serverModelName.collectAsState()
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
val modelProviders by chatViewModel.modelProviders.collectAsState()
val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState()
val modelSelectionConfirmation by chatViewModel.modelSelectionConfirmation.collectAsState()
val reasoningCapabilityRevision by chatViewModel.reasoningCapabilityRevision.collectAsState()
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
val selectedProviderOverride by chatViewModel.selectedProviderOverride.collectAsState()
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
val gatewayCurrentProvider by chatViewModel.gatewayCurrentProvider.collectAsState()
val gatewayProjectName by chatViewModel.gatewayProjectName.collectAsState()
val selectedReasoningEffort by chatViewModel.selectedReasoningEffort.collectAsState()
val currentSession = remember(sessions, currentSessionId) {
sessions.firstOrNull { it.sessionId == currentSessionId }
@@ -846,8 +867,8 @@ fun ChatScreen(
gatewayModel = gatewayCurrentModel,
gatewayProvider = gatewayCurrentProvider,
persistedSessionModel = currentSession?.model,
profileDefaultModel = effectiveProfile?.model,
serverDefaultModel = serverModelName,
profileDefaultModel = conversationProfile?.model,
serverDefaultModel = serverModelName.takeIf { openedSessionProfileName == null },
)
val sessionPickerProvider = sessionModelState.pickerProvider
?: sessionModelState.pickerModel?.let { model ->
@@ -1138,8 +1159,6 @@ fun ChatScreen(
}
val listState = rememberLazyListState()
val drawerState = rememberDrawerState(DrawerValue.Closed)
var allProfileSessions by remember { mutableStateOf<List<ProfileSessionRow>>(emptyList()) }
var allProfileSessionsLoading by remember { mutableStateOf(false) }
PetInteractionLayer(
owner = "chat-interaction-layer",
active = shouldHideChatPet(
@@ -1997,7 +2016,7 @@ fun ChatScreen(
// four keys, which missed updates in some cases (most notably a
// profile switch while the ConnectionInfoSheet was open, where the
// ambient sheet scope appeared to swallow the key comparison).
val agentDisplayName by remember(
val globalSelectedAgentDisplayName by remember(
effectiveProfile,
selectedPersonality,
defaultPersonality,
@@ -2005,13 +2024,31 @@ fun ChatScreen(
activeConnection?.label,
) {
derivedStateOf {
val profile = effectiveProfile
AgentDisplay.agentName(
profile = effectiveProfile,
selectedPersonality = selectedPersonality,
defaultPersonality = defaultPersonality,
connectionLabel = activeConnection?.label,
localDisplayAlias = profileDisplayAlias,
)
}
}
val agentDisplayName by remember(
conversationProfile,
selectedPersonality,
defaultPersonality,
profileDisplayAlias,
openedSessionProfileName,
activeConnection?.label,
) {
derivedStateOf {
val profile = conversationProfile
AgentDisplay.agentName(
profile = profile,
selectedPersonality = selectedPersonality,
defaultPersonality = defaultPersonality,
connectionLabel = activeConnection?.label,
localDisplayAlias = profileDisplayAlias,
localDisplayAlias = profileDisplayAlias.takeIf { openedSessionProfileName == null },
)
}
}
@@ -2040,13 +2077,13 @@ fun ChatScreen(
ModalNavigationDrawer(
drawerState = drawerState,
// Disable the drawer's edge-swipe while voice mode is up so the
// overlay reads as a true modal — the swipe gesture lives on the
// drawer itself, so the overlay's pointer scrim alone can't block it.
gesturesEnabled = !voiceUiState.voiceMode,
// Material routes scrim taps through the drawer's gesture handler.
// Keep it enabled so tapping outside always dismisses the drawer; the
// voice overlay already owns input while voice mode is visible.
gesturesEnabled = true,
drawerContent = {
val drawerTitle = if (effectiveProfile != null) {
stringResource(R.string.chat_profile_sessions, agentDisplayName)
stringResource(R.string.chat_profile_sessions, globalSelectedAgentDisplayName)
} else {
stringResource(R.string.chat_server_default_sessions)
}
@@ -2090,6 +2127,7 @@ fun ChatScreen(
currentSessionId = currentSessionId,
scopeTitle = drawerTitle,
scopeSubtitle = drawerSubtitle,
activeProfileName = effectiveProfile?.name ?: "default",
isLoading = isLoadingSessions,
isOpen = drawerState.isOpen,
activityStates = sessionActivityStates,
@@ -2101,6 +2139,24 @@ fun ChatScreen(
chatViewModel.createNewChat()
scope.launch { drawerState.close() }
},
onNewDefaultChat = {
val defaultProfile = agentProfiles.firstOrNull {
it.name.equals("default", ignoreCase = true)
} ?: com.hermesandroid.relay.data.Profile(
name = "default",
model = "",
description = "Default",
)
chatViewModel.createProfileChat(
profileName = "default",
profile = defaultProfile,
contextKey = AgentDisplay.profileContextKey(
connectionId = activeConnection?.id,
profileName = "default",
),
)
scope.launch { drawerState.close() }
},
onSelectSession = { sessionId ->
chatViewModel.switchSession(sessionId)
scope.launch { drawerState.close() }
@@ -2142,8 +2198,11 @@ fun ChatScreen(
onToggleSourceHidden = { source, hidden ->
connectionViewModel.setSourceHidden(source, hidden)
},
allProfilesSupported = !activeConnection?.resolvedDashboardUrl.isNullOrBlank(),
allProfileSessions = allProfileSessions,
allProfileSessionsLoading = allProfileSessionsLoading,
profileColors = profilePresentation.colors,
onProfileColorChange = connectionViewModel::setProfileColor,
onRefreshAllProfiles = {
if (!allProfileSessionsLoading) scope.launch {
allProfileSessionsLoading = true
@@ -2160,6 +2219,11 @@ fun ChatScreen(
title = item.title ?: item.preview,
model = item.model,
messageCount = item.messageCount ?: 0,
inputTokens = item.inputTokens ?: 0,
outputTokens = item.outputTokens ?: 0,
actualCostUsd = item.actualCostUsd,
estimatedCostUsd = item.estimatedCostUsd,
isActive = item.isActive,
startedAt = ((item.startedAt ?: 0.0) * 1000).toLong(),
lastActivityAt = ((item.resolvedLastActivity ?: 0.0) * 1000).toLong(),
source = item.source,
@@ -2190,9 +2254,29 @@ fun ChatScreen(
it.name.equals(profileName, ignoreCase = true)
}
if (target != null || profileName.equals("default", ignoreCase = true)) {
connectionViewModel.selectProfile(target)
chatViewModel.activateGatewayProfile(target)
chatViewModel.switchSession(sessionId)
val ownerProfile = target ?: allProfileSessions.firstOrNull {
it.profile.equals(profileName, ignoreCase = true) &&
it.session.sessionId == sessionId
}?.session?.let { session ->
com.hermesandroid.relay.data.Profile(
name = profileName,
model = session.model.orEmpty(),
description = profileName,
)
} ?: com.hermesandroid.relay.data.Profile(
name = profileName,
model = "",
description = profileName,
)
chatViewModel.openProfileSession(
profileName = profileName,
profile = ownerProfile,
contextKey = AgentDisplay.profileContextKey(
connectionId = activeConnection?.id,
profileName = profileName,
),
sessionId = sessionId,
)
scope.launch { drawerState.close() }
} else {
scope.launch {
@@ -2200,6 +2284,54 @@ fun ChatScreen(
}
}
},
onDeleteProfileSession = { profileName, sessionId ->
scope.launch {
if (connectionViewModel.deleteSession(profileName, sessionId)) {
allProfileSessions = allProfileSessions.filterNot {
it.profile == profileName && it.session.sessionId == sessionId
}
}
}
},
onRenameProfileSession = { profileName, sessionId, title ->
scope.launch {
if (connectionViewModel.renameSession(profileName, sessionId, title)) {
allProfileSessions = allProfileSessions.map { row ->
if (row.profile == profileName && row.session.sessionId == sessionId) {
row.copy(session = row.session.copy(title = title))
} else {
row
}
}
}
}
},
onSetProfileSessionPinned = { profileName, sessionId, pinned ->
scope.launch {
if (connectionViewModel.setSessionPinned(profileName, sessionId, pinned)) {
allProfileSessions = allProfileSessions.map { row ->
if (row.profile == profileName && row.session.sessionId == sessionId) {
row.copy(session = row.session.copy(pinned = pinned))
} else {
row
}
}
}
}
},
onSetProfileSessionArchived = { profileName, sessionId, archived ->
scope.launch {
if (connectionViewModel.setSessionArchived(profileName, sessionId, archived)) {
allProfileSessions = allProfileSessions.map { row ->
if (row.profile == profileName && row.session.sessionId == sessionId) {
row.copy(session = row.session.copy(archived = archived))
} else {
row
}
}
}
}
},
)
}
) {
@@ -2305,7 +2437,6 @@ fun ChatScreen(
resolveChatHeaderSubtitle(
isStreaming = isStreaming,
statusText = statusText,
projectName = gatewayProjectName,
personalityName = nonDefaultPersonality,
modelName = modelName,
)
@@ -2612,7 +2743,7 @@ fun ChatScreen(
selectedProfile = selectedProfile,
resolvedProfile = effectiveProfile,
presentation = profilePresentation,
activeDisplayName = agentDisplayName,
activeDisplayName = globalSelectedAgentDisplayName,
isProfileLocked = isProfileLocked,
lockedProfileName = lockedProfileName,
switchEnabled = profileSwitchEnabled,
@@ -3138,9 +3269,12 @@ fun ChatScreen(
isGatewayTransport &&
messageReactionsSupported &&
!message.isStreaming &&
message.uiKey in newestReactableMessageKeys
(
message.rowId != null ||
message.uiKey in newestReactableMessageKeys
)
) {
{ emoji -> chatViewModel.reactToNewest(message.role, emoji) }
{ emoji -> chatViewModel.reactToMessage(message, emoji) }
} else {
null
},
@@ -3997,6 +4131,23 @@ fun ChatScreen(
onDismiss = { showModelSheet = false },
)
}
modelSelectionConfirmation?.let { confirmation ->
AlertDialog(
onDismissRequest = chatViewModel::dismissModelSelectionConfirmation,
title = { Text(stringResource(R.string.chat_model_confirmation_title)) },
text = { Text(confirmation.message) },
confirmButton = {
TextButton(onClick = chatViewModel::confirmModelSelection) {
Text(stringResource(R.string.cw_continue))
}
},
dismissButton = {
TextButton(onClick = chatViewModel::dismissModelSelectionConfirmation) {
Text(stringResource(R.string.common_cancel))
}
},
)
}
if (showEffortSheet) {
OptionPickerSheet(
title = stringResource(R.string.chat_select_reasoning_effort),
@@ -4702,32 +4853,29 @@ private suspend fun ingestAttachmentFromUri(
) {
try {
val resolver = context.contentResolver
val maxSize = maxAttachmentMb.toLong() * 1024L * 1024L
val source = withContext(Dispatchers.IO) {
val mimeType = mimeOverride ?: resolver.getType(uri) ?: "application/octet-stream"
val fileName = resolveDisplayName(resolver, uri)
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } ?: return@withContext null
RawAttachmentSource(mimeType, fileName, bytes)
val payload = resolver.openInputStream(uri)?.use { input ->
readBase64Bounded(input, maxSize)
} ?: return@withContext null
RawAttachmentSource(mimeType, fileName, payload.base64, payload.sizeBytes)
} ?: return
val maxSize = maxAttachmentMb * 1024 * 1024
if (source.bytes.size > maxSize) {
Toast.makeText(
context,
context.getString(R.string.chat_file_too_large, maxAttachmentMb),
Toast.LENGTH_SHORT,
).show()
return
}
val base64 = withContext(Dispatchers.Default) {
Base64.encodeToString(source.bytes, Base64.NO_WRAP)
}
onAttachment(
Attachment(
contentType = source.mimeType,
content = base64,
content = source.base64,
fileName = source.fileName,
fileSize = source.bytes.size.toLong(),
fileSize = source.sizeBytes,
)
)
} catch (_: AttachmentTooLargeException) {
Toast.makeText(
context,
context.getString(R.string.chat_file_too_large, maxAttachmentMb),
Toast.LENGTH_SHORT,
).show()
} catch (e: Exception) {
Toast.makeText(context, context.getString(R.string.chat_failed_read_file), Toast.LENGTH_SHORT).show()
}
@@ -4736,7 +4884,8 @@ private suspend fun ingestAttachmentFromUri(
private data class RawAttachmentSource(
val mimeType: String,
val fileName: String,
val bytes: ByteArray,
val base64: String,
val sizeBytes: Long,
)
/**
@@ -76,6 +76,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
@@ -107,15 +108,20 @@ 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.data.GatewayProfileAuthChoice
import com.hermesandroid.relay.data.GatewayProfileCreateRequest
import com.hermesandroid.relay.data.GatewayProfileManagementUnsupportedException
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.CronCreationDraft
import com.hermesandroid.relay.network.upstream.DashboardAuthProvider
import com.hermesandroid.relay.network.upstream.DashboardAuthSession
import com.hermesandroid.relay.network.upstream.DashboardComponentHealthRollup
import com.hermesandroid.relay.network.upstream.DashboardStatus
import com.hermesandroid.relay.network.upstream.parseFiniteRepeat
import com.hermesandroid.relay.network.upstream.importDashboardCookieHeader
import com.hermesandroid.relay.ui.components.RelayChromeIconButton
import com.hermesandroid.relay.ui.components.RelayMetricCard
@@ -388,6 +394,7 @@ private fun dashboardPayloadKey(
/** Section-level (not per-item) affordances rendered at the top of a tab. */
private enum class DashboardSectionAction {
ChangeMainModel,
CreateCron,
CreateProfile,
BrowseSkillsHub,
UpdateSkillsHub,
@@ -443,6 +450,17 @@ private data class PendingDashboardAction(
val action: DashboardItemAction,
)
internal fun shouldUseLegacyDashboardProfileCreate(
request: GatewayProfileCreateRequest,
failure: Throwable,
explicitlyAllowed: Boolean,
): Boolean =
explicitlyAllowed && failure is GatewayProfileManagementUnsupportedException &&
request.authChoice == GatewayProfileAuthChoice.Shared &&
(request.cloneFrom == null || request.cloneFrom == "default") &&
!request.cloneAll && !request.noSkills && request.soul == null &&
request.model == null && request.provider == null
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardManagementScreen(
@@ -485,6 +503,7 @@ fun DashboardManagementScreen(
var inputAction by remember { mutableStateOf<PendingDashboardAction?>(null) }
var modelPickerTarget by remember { mutableStateOf<ModelPickerTarget?>(null) }
var showCreateProfile by remember { mutableStateOf(false) }
var showCreateCron by remember { mutableStateOf(false) }
var expensiveModelConfirm by remember { mutableStateOf<ExpensiveModelConfirm?>(null) }
var showSkillsHub by remember { mutableStateOf(false) }
var soulEditor by remember { mutableStateOf<SoulEditorState?>(null) }
@@ -882,35 +901,83 @@ fun DashboardManagementScreen(
}
fun submitCreateProfile(
name: String,
description: String,
cloneFromDefault: Boolean,
request: GatewayProfileCreateRequest,
mcpServers: List<String>,
allowLegacyDashboard: Boolean,
) {
if (dashboardUrl.isBlank() || actionInFlight) return
if (actionInFlight) return
val actionPayloadKey = payloadKey
actionInFlight = true
actionMessage = null
scope.launch {
val result = try {
withDashboardClient(clientFactory) { client ->
client.createProfile(
name = name,
cloneFromDefault = cloneFromDefault,
description = description.takeIf { it.isNotBlank() },
mcpServers = mcpServers,
val gatewayClient = connectionViewModel.activeGatewayChatClient()
val gatewayResult = gatewayClient
?.createProfile(request)
?: Result.failure(GatewayProfileManagementUnsupportedException("profiles.create"))
val result: Result<String> = gatewayResult.fold(
onSuccess = { created ->
val partial = created.partialMessages(request).toMutableList()
if (mcpServers.isNotEmpty()) {
val configured = gatewayClient
?.describeProfile(created.name)
?.mapCatching {
gatewayClient.configureProfile(
created.name,
com.hermesandroid.relay.data.GatewayProfilePatch(
enabledMcpServers = mcpServers,
),
).getOrThrow()
}
if (
configured == null || configured.isFailure ||
com.hermesandroid.relay.data.GatewayProfileSection.McpServers in
configured.getOrThrow().failed
) {
partial += context.getString(R.string.dashboard_profile_mcp_partial)
}
}
Result.success(
if (partial.isEmpty()) {
context.getString(R.string.dashboard_profile_created, created.name)
} else {
context.getString(
R.string.dashboard_profile_created_partial,
created.name,
partial.joinToString("; "),
)
},
)
},
onFailure = { gatewayFailure ->
// The authenticated Dashboard route remains the compatibility
// create surface for old hosts, but only for the legacy shared
// default. Never erase an explicit isolation choice.
if (shouldUseLegacyDashboardProfileCreate(request, gatewayFailure, allowLegacyDashboard)) {
try {
withDashboardClient(clientFactory) { client ->
client.createProfile(
name = request.name,
cloneFromDefault = request.cloneFrom != null,
description = request.description,
mcpServers = mcpServers,
)
}.map { context.getString(R.string.dashboard_profile_created_legacy, request.name) }
} catch (e: Exception) {
Result.failure(e)
}
} else {
Result.failure(gatewayFailure)
}
}
} catch (e: Exception) {
Result.failure(e)
}
)
actionMessage = result.fold(
onSuccess = {
onSuccess = { message ->
showCreateProfile = false
refreshingPayloads[actionPayloadKey] = true
forceReloadKey = actionPayloadKey
reloadNonce += 1
context.getString(R.string.dashboard_profile_created, name)
connectionViewModel.refreshGatewayProfiles()
message
},
onFailure = { err -> err.message ?: context.getString(R.string.dashboard_profile_create_failed) },
)
@@ -918,6 +985,29 @@ fun DashboardManagementScreen(
}
}
fun submitCreateCron(draft: CronCreationDraft) {
if (actionInFlight) return
val actionPayloadKey = payloadKey
actionInFlight = true
actionMessage = null
scope.launch {
val result = connectionViewModel.activeGatewayChatClient()
?.createCronJob(draft.copy(profile = effectiveProfileName))
?: Result.failure(IllegalStateException(context.getString(R.string.dashboard_cron_gateway_required)))
actionMessage = result.fold(
onSuccess = {
showCreateCron = false
refreshingPayloads[actionPayloadKey] = true
forceReloadKey = actionPayloadKey
reloadNonce += 1
context.getString(R.string.dashboard_cron_created, draft.name.trim())
},
onFailure = { err -> err.message ?: context.getString(R.string.dashboard_cron_create_failed) },
)
actionInFlight = false
}
}
fun runServerBackup() {
if (dashboardUrl.isBlank() || actionInFlight) return
actionInFlight = true
@@ -1146,11 +1236,17 @@ fun DashboardManagementScreen(
var newProfileDescription by remember { mutableStateOf("") }
var newProfileMcpServers by remember { mutableStateOf("") }
var cloneFromDefault by remember { mutableStateOf(true) }
var noSkills by remember { mutableStateOf(false) }
var authChoice by remember { mutableStateOf(GatewayProfileAuthChoice.Shared) }
var allowLegacyDashboard by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { showCreateProfile = false },
title = { Text(stringResource(R.string.dashboard_new_profile_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column(
modifier = Modifier.heightIn(max = 560.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = newProfileName,
onValueChange = { newProfileName = it },
@@ -1177,28 +1273,97 @@ fun DashboardManagementScreen(
) {
Checkbox(
checked = cloneFromDefault,
onCheckedChange = { cloneFromDefault = it },
onCheckedChange = {
cloneFromDefault = it
if (it) noSkills = false
},
)
Text(
text = stringResource(R.string.dashboard_clone_from_default),
style = MaterialTheme.typography.bodySmall,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = noSkills,
enabled = !cloneFromDefault,
onCheckedChange = { noSkills = it },
)
Text(
text = stringResource(R.string.dashboard_profile_no_skills),
style = MaterialTheme.typography.bodySmall,
)
}
Text(
text = stringResource(R.string.dashboard_profile_auth_title),
style = MaterialTheme.typography.labelLarge,
)
listOf(
GatewayProfileAuthChoice.Shared to R.string.dashboard_profile_auth_shared,
GatewayProfileAuthChoice.Copied to R.string.dashboard_profile_auth_copied,
GatewayProfileAuthChoice.Isolated to R.string.dashboard_profile_auth_isolated,
).forEach { (choice, label) ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
authChoice = choice
if (choice != GatewayProfileAuthChoice.Shared) allowLegacyDashboard = false
},
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(
selected = authChoice == choice,
onClick = {
authChoice = choice
if (choice != GatewayProfileAuthChoice.Shared) allowLegacyDashboard = false
},
)
Text(text = stringResource(label), style = MaterialTheme.typography.bodySmall)
}
}
Text(
text = stringResource(R.string.dashboard_profile_auth_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = allowLegacyDashboard,
enabled = authChoice == GatewayProfileAuthChoice.Shared,
onCheckedChange = { allowLegacyDashboard = it },
)
Text(
text = stringResource(R.string.dashboard_profile_allow_legacy),
style = MaterialTheme.typography.bodySmall,
)
}
}
},
confirmButton = {
TextButton(
onClick = {
submitCreateProfile(
name = newProfileName.trim(),
description = newProfileDescription.trim(),
cloneFromDefault = cloneFromDefault,
request = GatewayProfileCreateRequest(
name = newProfileName.trim(),
description = newProfileDescription.trim().takeIf(String::isNotEmpty),
cloneFrom = "default".takeIf { cloneFromDefault },
noSkills = noSkills,
authChoice = authChoice,
),
mcpServers = newProfileMcpServers
.split(',', '\n')
.map(String::trim)
.filter(String::isNotBlank)
.distinct()
.take(64),
allowLegacyDashboard = allowLegacyDashboard,
)
},
enabled = newProfileName.isNotBlank() && !actionInFlight,
@@ -1210,6 +1375,14 @@ fun DashboardManagementScreen(
)
}
if (showCreateCron) {
CronCreationDialog(
actionInFlight = actionInFlight,
onDismiss = { showCreateCron = false },
onCreate = ::submitCreateCron,
)
}
expensiveModelConfirm?.let { confirm ->
AlertDialog(
onDismissRequest = { expensiveModelConfirm = null },
@@ -1529,6 +1702,8 @@ fun DashboardManagementScreen(
when (sectionAction) {
DashboardSectionAction.ChangeMainModel ->
modelPickerTarget = ModelPickerTarget.Main
DashboardSectionAction.CreateCron ->
showCreateCron = true
DashboardSectionAction.CreateProfile ->
showCreateProfile = true
DashboardSectionAction.BrowseSkillsHub ->
@@ -2587,6 +2762,7 @@ private fun LoadedBody(
// 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 actionLabelNewSchedule = stringResource(R.string.dashboard_section_action_new_schedule)
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)
@@ -2614,6 +2790,9 @@ private fun LoadedBody(
DashboardManagementSection.Profiles -> listOf(
DashboardSectionAction.CreateProfile to actionLabelNewProfile,
)
DashboardManagementSection.Cron -> listOf(
DashboardSectionAction.CreateCron to actionLabelNewSchedule,
)
DashboardManagementSection.Skills -> listOf(
DashboardSectionAction.BrowseSkillsHub to actionLabelBrowseHub,
DashboardSectionAction.UpdateSkillsHub to actionLabelUpdateInstalled,
@@ -3176,6 +3355,82 @@ private fun ActionMessageCard(message: String) {
}
}
@Composable
private fun CronCreationDialog(
actionInFlight: Boolean,
onDismiss: () -> Unit,
onCreate: (CronCreationDraft) -> Unit,
) {
var name by remember { mutableStateOf("") }
var schedule by remember { mutableStateOf("") }
var prompt by remember { mutableStateOf("") }
var repeatText by remember { mutableStateOf("") }
val repeat = parseFiniteRepeat(repeatText)
val draft = repeat.getOrNull()?.let {
CronCreationDraft(name = name, schedule = schedule, prompt = prompt, repeat = it)
} ?: if (repeat.isSuccess) {
CronCreationDraft(name = name, schedule = schedule, prompt = prompt)
} else {
null
}
val canCreate = draft?.validated()?.isSuccess == true && !actionInFlight
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.dashboard_cron_create_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.dashboard_cron_name)) },
singleLine = true,
)
OutlinedTextField(
value = schedule,
onValueChange = { schedule = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.dashboard_cron_schedule)) },
supportingText = { Text(stringResource(R.string.dashboard_cron_schedule_hint)) },
singleLine = true,
)
OutlinedTextField(
value = prompt,
onValueChange = { prompt = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.dashboard_cron_prompt)) },
minLines = 3,
maxLines = 6,
)
OutlinedTextField(
value = repeatText,
onValueChange = { repeatText = it.take(4) },
modifier = Modifier.fillMaxWidth(),
label = { Text(stringResource(R.string.dashboard_cron_repeat)) },
supportingText = {
Text(
repeat.exceptionOrNull()?.message
?: stringResource(R.string.dashboard_cron_repeat_help),
)
},
isError = repeat.isFailure,
singleLine = true,
)
}
},
confirmButton = {
TextButton(
onClick = { draft?.let(onCreate) },
enabled = canCreate,
) { Text(stringResource(R.string.dashboard_create)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dashboard_cancel)) }
},
)
}
@Composable
private fun DashboardDetailDialog(
detail: DashboardDetailResult,
@@ -264,14 +264,37 @@ fun DashboardSignInScreen(
operation = "dashboard_native_pkce",
)
Log.w(NATIVE_DASHBOARD_AUTH_LOG_TAG, failureDetail)
actionMessage = nativeDashboardSignInActionMessage(
failureStage = failureStage,
errorMessage = error.message,
fallbackMessage = resources.getString(R.string.dashboard_signin_failed),
transportRetryMessage = resources.getString(
actionMessage = when (nativeDashboardSignInMessageKind(failureStage)) {
NativeDashboardSignInMessageKind.CallbackRejected -> resources.getString(
R.string.dashboard_native_signin_callback_rejected,
)
NativeDashboardSignInMessageKind.CodeRejected -> resources.getString(
R.string.dashboard_native_signin_code_rejected,
)
NativeDashboardSignInMessageKind.GatewayRejected -> resources.getString(
R.string.dashboard_native_signin_gateway_rejected,
)
NativeDashboardSignInMessageKind.RateLimited -> resources.getString(
R.string.dashboard_native_signin_rate_limited,
)
NativeDashboardSignInMessageKind.GatewayUnavailable -> resources.getString(
R.string.dashboard_native_signin_gateway_unavailable,
)
NativeDashboardSignInMessageKind.ResponseUnsupported -> resources.getString(
R.string.dashboard_native_signin_response_unsupported,
)
NativeDashboardSignInMessageKind.AttemptInactive -> resources.getString(
R.string.dashboard_native_signin_attempt_inactive,
)
NativeDashboardSignInMessageKind.SecureStorage -> resources.getString(
R.string.dashboard_native_signin_storage_failed,
)
NativeDashboardSignInMessageKind.Transport -> resources.getString(
R.string.dashboard_native_signin_transport_retry,
),
)
)
NativeDashboardSignInMessageKind.Generic -> error.message
?: resources.getString(R.string.dashboard_signin_failed)
}
actionIsError = true
} finally {
actionInFlight = false
@@ -362,17 +385,34 @@ fun DashboardSignInScreen(
}
}
internal fun nativeDashboardSignInActionMessage(
failureStage: String,
errorMessage: String?,
fallbackMessage: String,
transportRetryMessage: String,
): String = if (failureStage.startsWith("token_transport")) {
transportRetryMessage
} else {
errorMessage ?: fallbackMessage
internal enum class NativeDashboardSignInMessageKind {
CallbackRejected,
CodeRejected,
GatewayRejected,
RateLimited,
GatewayUnavailable,
ResponseUnsupported,
AttemptInactive,
SecureStorage,
Transport,
Generic,
}
internal fun nativeDashboardSignInMessageKind(failureStage: String): NativeDashboardSignInMessageKind =
when {
failureStage == "callback_error" -> NativeDashboardSignInMessageKind.CallbackRejected
failureStage == "token_http_400" -> NativeDashboardSignInMessageKind.CodeRejected
failureStage == "token_http_401" || failureStage == "token_http_403" ->
NativeDashboardSignInMessageKind.GatewayRejected
failureStage == "token_http_429" -> NativeDashboardSignInMessageKind.RateLimited
failureStage.startsWith("token_http_5") -> NativeDashboardSignInMessageKind.GatewayUnavailable
failureStage == "token_shape" -> NativeDashboardSignInMessageKind.ResponseUnsupported
failureStage == "inactive_generation" -> NativeDashboardSignInMessageKind.AttemptInactive
failureStage == "token_store" -> NativeDashboardSignInMessageKind.SecureStorage
failureStage.startsWith("token_transport") -> NativeDashboardSignInMessageKind.Transport
else -> NativeDashboardSignInMessageKind.Generic
}
@Composable
private fun DashboardAuthenticationComplete(onContinue: () -> Unit) {
Column(
@@ -127,6 +127,7 @@ fun ProfileInspectorScreen(
val skillsState by viewModel.skillsState.collectAsState()
val source by viewModel.source.collectAsState()
val gatewayDescription by viewModel.gatewayDescription.collectAsState()
val gatewayWritable by viewModel.gatewayWritable.collectAsState()
// Lazy first-load on screen entry. Keyed on the profile name so a
// re-entry for a different profile (unlikely but possible via deep
@@ -248,7 +249,7 @@ fun ProfileInspectorScreen(
InspectorSection.Config -> ConfigPane(
state = configState,
onRetry = { viewModel.refreshSection(InspectorSection.Config) },
gatewayEditable = source == ProfileInspectorSource.Gateway,
gatewayEditable = source == ProfileInspectorSource.Gateway && gatewayWritable,
editing = viewModel.configEditing.collectAsState().value,
descriptionDraft = viewModel.configDescriptionDraft.collectAsState().value,
providerDraft = viewModel.configProviderDraft.collectAsState().value,
@@ -273,6 +274,7 @@ fun ProfileInspectorScreen(
onDraftChange = { viewModel.updateSoulDraft(it) },
onSave = { viewModel.saveSoulEdit() },
onCancelEdit = { viewModel.cancelSoulEdit() },
editable = source != ProfileInspectorSource.Gateway || gatewayWritable,
)
InspectorSection.Memory -> MemoryPane(
state = memoryState,
@@ -298,7 +300,7 @@ fun ProfileInspectorScreen(
onToggleSkill = { name, enabled ->
viewModel.toggleSkill(name, enabled)
},
gatewayNative = source == ProfileInspectorSource.Gateway,
gatewayNative = source == ProfileInspectorSource.Gateway && gatewayWritable,
skillDrafts = viewModel.skillDrafts.collectAsState().value,
toolsets = gatewayDescription?.toolsets.orEmpty(),
toolsetDrafts = viewModel.toolsetDrafts.collectAsState().value,
@@ -835,6 +837,7 @@ private fun SoulPane(
onDraftChange: (String) -> Unit,
onSave: () -> Unit,
onCancelEdit: () -> Unit,
editable: Boolean,
) {
PaneShell(state = state, onRetry = onRetry) { response ->
Column(
@@ -886,11 +889,13 @@ private fun SoulPane(
},
)
}
IconButton(onClick = onBeginEdit) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = stringResource(R.string.profile_inspector_edit_soul),
)
if (editable) {
IconButton(onClick = onBeginEdit) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = stringResource(R.string.profile_inspector_edit_soul),
)
}
}
} else {
IconButton(
@@ -910,14 +915,14 @@ private fun SoulPane(
MonospaceEditor(
content = draft,
onContentChange = onDraftChange,
enabled = !saving,
enabled = editable && !saving,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
)
EditorBottomBar(
saving = saving,
canSave = true,
canSave = editable,
onSave = onSave,
onCancel = onCancelEdit,
)
@@ -0,0 +1,51 @@
package com.hermesandroid.relay.ui.theme
import androidx.compose.ui.graphics.Color
import java.util.Locale
import kotlin.math.abs
import kotlin.math.roundToInt
private const val PROFILE_SATURATION = 0.68f
private const val PROFILE_LIGHTNESS = 0.58f
/** The same evenly-spaced 12-hue picker model used by Hermes Desktop. */
val ProfileAccentSwatches: List<String> = (0 until 12).map { index ->
hslColor(index * 30f, PROFILE_SATURATION, PROFILE_LIGHTNESS).toRgbHex()
}
/** Desktop-compatible deterministic profile identity color; default remains neutral. */
fun resolveProfileAccent(name: String?, overrides: Map<String, String>): Color? {
val key = name?.trim().orEmpty()
if (key.isBlank() || key.equals("default", ignoreCase = true)) return null
return accentColor(overrides[key]) ?: deterministicProfileAccent(key)
}
internal fun deterministicProfileAccent(name: String): Color {
var hash = 0u
name.forEach { character -> hash = hash * 31u + character.code.toUInt() }
return hslColor((hash % 360u).toFloat(), PROFILE_SATURATION, PROFILE_LIGHTNESS)
}
private fun hslColor(hue: Float, saturation: Float, lightness: Float): Color {
val chroma = (1f - abs(2f * lightness - 1f)) * saturation
val section = (hue / 60f) % 6f
val x = chroma * (1f - abs(section % 2f - 1f))
val (red, green, blue) = when {
section < 1f -> Triple(chroma, x, 0f)
section < 2f -> Triple(x, chroma, 0f)
section < 3f -> Triple(0f, chroma, x)
section < 4f -> Triple(0f, x, chroma)
section < 5f -> Triple(x, 0f, chroma)
else -> Triple(chroma, 0f, x)
}
val match = lightness - chroma / 2f
return Color(red + match, green + match, blue + match)
}
private fun Color.toRgbHex(): String = String.format(
Locale.ROOT,
"#%02X%02X%02X",
(red * 255f).roundToInt(),
(green * 255f).roundToInt(),
(blue * 255f).roundToInt(),
)
@@ -0,0 +1,46 @@
package com.hermesandroid.relay.util
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.util.Base64
internal data class BoundedBase64Payload(
val base64: String,
val sizeBytes: Long,
)
internal class AttachmentTooLargeException(
val limitBytes: Long,
) : Exception("attachment exceeds the $limitBytes byte limit")
/**
* Base64-encode [input] while enforcing [limitBytes] before an oversized body
* is buffered. The picker previously called `readBytes()` and checked the cap
* afterwards, allowing a content provider to exhaust the app heap first.
*/
internal fun readBase64Bounded(
input: InputStream,
limitBytes: Long,
): BoundedBase64Payload {
require(limitBytes >= 0L) { "limitBytes must be non-negative" }
val initialCapacity = minOf(limitBytes, 64L * 1024L).toInt()
val encoded = ByteArrayOutputStream(initialCapacity)
var count = 0L
Base64.getEncoder().wrap(encoded).use { base64Out ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val remaining = limitBytes - count
val requested = minOf(buffer.size.toLong(), remaining + 1L).toInt()
val read = input.read(buffer, 0, requested)
if (read < 0) break
if (read == 0) continue
if (read.toLong() > remaining) throw AttachmentTooLargeException(limitBytes)
base64Out.write(buffer, 0, read)
count += read
}
}
return BoundedBase64Payload(
base64 = encoded.toString(Charsets.US_ASCII.name()),
sizeBytes = count,
)
}
File diff suppressed because it is too large Load Diff
@@ -95,6 +95,7 @@ import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
import com.hermesandroid.relay.data.KEY_GATEWAY_KEEP_ALIVE
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.GatewayPetGalleryItem
import com.hermesandroid.relay.network.upstream.GatewayKeepAliveService
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.network.shared.RouteProbeOutcome
@@ -156,6 +157,31 @@ internal data class RelayUiInputs(
val configured: Boolean,
)
data class HostResourcePressureStatus(
val memoryPressure: String? = null,
val memoryAvailableMb: Int? = null,
val diskPressure: String? = null,
val diskFreeMb: Int? = null,
val lastBootSuspectedOom: Boolean = false,
) {
val needsAttention: Boolean
get() = memoryPressure in setOf("elevated", "critical") ||
diskPressure in setOf("elevated", "critical") ||
lastBootSuspectedOom
val critical: Boolean
get() = memoryPressure == "critical" || diskPressure == "critical" || lastBootSuspectedOom
}
internal fun DashboardStatus.hostResourcePressure(): HostResourcePressureStatus =
HostResourcePressureStatus(
memoryPressure = memory?.pressure,
memoryAvailableMb = memory?.systemAvailableMb,
diskPressure = disk?.pressure,
diskFreeMb = disk?.freeMb,
lastBootSuspectedOom = memory?.lastBootSuspectedOom == true,
)
internal fun RelayUiInputs.requiresReconnectGrace(): Boolean =
configured &&
url.isNotBlank() &&
@@ -765,6 +791,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
},
rebuildChatApiClient = { rebuildChatApiClient() },
relayHttpClient = relayHttpClient,
gatewayClientProvider = { upstreamTransport.activeGatewayChatClient() },
)
// --- Relay connection state ---
@@ -1071,6 +1098,10 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val standardVoiceAvailability: StateFlow<StandardVoiceAvailability> =
_standardVoiceAvailability.asStateFlow()
private val _hostResourcePressure = MutableStateFlow(HostResourcePressureStatus())
val hostResourcePressure: StateFlow<HostResourcePressureStatus> =
_hostResourcePressure.asStateFlow()
private val _standardAudioApiReachable = MutableStateFlow(false)
val standardAudioApiReachable: StateFlow<Boolean> = _standardAudioApiReachable.asStateFlow()
@@ -1571,11 +1602,30 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
suspend fun listAllProfileSessions(limit: Int = 200): Result<List<SessionItem>>? =
profileController.listAllProfileSessions(limit)
suspend fun deleteSession(profileName: String, sessionId: String): Boolean =
profileController.deleteSession(profileName, sessionId)
suspend fun renameSession(profileName: String, sessionId: String, title: String): Boolean =
profileController.renameSession(profileName, sessionId, title)
suspend fun setSessionPinned(profileName: String, sessionId: String, pinned: Boolean): Boolean =
profileController.setSessionPinned(profileName, sessionId, pinned)
suspend fun setSessionArchived(profileName: String, sessionId: String, archived: Boolean): Boolean =
profileController.setSessionArchived(profileName, sessionId, archived)
suspend fun loadProfileScopedMessages(
sessionId: String,
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
): Result<List<MessageItem>>? = profileController.loadProfileScopedMessages(sessionId, mode)
suspend fun loadProfileScopedMessages(
profileName: String?,
sessionId: String,
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
): Result<List<MessageItem>>? =
profileController.loadProfileScopedMessages(profileName, sessionId, mode)
/**
* Delete a session scoped to the ACTIVE PROFILE via the dashboard
* `DELETE /api/sessions/{id}?profile=` surface — the write twin of
@@ -1624,6 +1674,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
fun setProfileHidden(profileName: String?, hidden: Boolean) =
profileController.setProfileHidden(profileName, hidden)
fun setProfileColor(profileName: String, colorHex: String?) =
profileController.setProfileColor(profileName, colorHex)
fun resetProfilePresentation() = profileController.resetProfilePresentation()
/**
@@ -1640,6 +1693,14 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
/** The active profile's local agent-icon path (client-side, never sent to Hermes). */
val profileIcon: StateFlow<String?> get() = profileController.profileIcon
val localProfileIcon: StateFlow<String?> get() = profileController.localProfileIcon
val serverProfileAvatar: StateFlow<String?> get() = profileController.serverProfileAvatar
val useLocalProfileIconOverride: StateFlow<Boolean>
get() = profileController.useLocalProfileIconOverride
val sharedProfileAvatarState: StateFlow<ProfileController.SharedAvatarState>
get() = profileController.sharedAvatarState
val hermesPetState: StateFlow<ProfileController.HermesPetState>
get() = profileController.hermesPetState
/** A local icon path for a specific profile identity on the active connection. */
fun profileIconFlow(profileName: String?) = profileController.profileIconFlow(profileName)
@@ -1649,10 +1710,32 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
fun setProfileIcon(uri: Uri) = profileController.setProfileIcon(uri)
fun setSharedProfileAvatar(uri: Uri) = profileController.setSharedProfileAvatar(uri)
fun setUseLocalProfileIconOverride(enabled: Boolean) =
profileController.setUseLocalProfileIconOverride(enabled)
fun importProfileIconFromHost() = profileController.importProfileIconFromHost()
fun clearProfileIcon() = profileController.clearProfileIcon()
fun uploadLocalProfileIconToHermes() = profileController.uploadLocalProfileIconToHermes()
fun clearSharedProfileAvatar() = profileController.clearSharedProfileAvatar()
fun refreshHermesPet() = profileController.refreshHermesPet()
fun loadHermesPetGallery() = profileController.loadHermesPetGallery()
fun selectHermesPet(slug: String) = profileController.selectHermesPet(slug)
fun loadHermesPetThumbnail(pet: GatewayPetGalleryItem) =
profileController.loadHermesPetThumbnail(pet)
fun disableHermesPet() = profileController.disableHermesPet()
fun refreshGatewayProfiles() = profileController.refreshGatewayProfiles()
fun selectProfile(profile: Profile?) = profileController.selectProfile(profile)
// --- Profile lock (per-connection pin to one profile) ------------------
@@ -2363,6 +2446,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
receivedAt = msg.sentAt ?: System.currentTimeMillis(),
chatId = msg.chatId,
connectionId = connectionStore.activeConnectionId.value,
arrivedWhileAway = msg.arrivedWhileAway,
),
)
}
@@ -4236,6 +4320,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
.collectLatest {
_standardVoiceAvailability.value = StandardVoiceAvailability.Unknown
_standardAudioApiReachable.value = false
_hostResourcePressure.value = HostResourcePressureStatus()
_serverChatDisplaySettings.value = null
updateGatewayAvailability(GatewayAvailability.Unknown)
probeStandardVoice()
@@ -4535,6 +4620,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
if (connectionId == null || dashboardUrl.isNullOrBlank()) {
_standardVoiceAvailability.value = StandardVoiceAvailability.Unknown
_standardAudioApiReachable.value = false
_hostResourcePressure.value = HostResourcePressureStatus()
_serverChatDisplaySettings.value = null
updateGatewayAvailability(GatewayAvailability.Unknown)
return
@@ -4553,11 +4639,13 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
updateDashboardTopology(connectionId, null)
_standardVoiceAvailability.value = StandardVoiceAvailability.Unreachable
_standardAudioApiReachable.value = false
_hostResourcePressure.value = HostResourcePressureStatus()
_serverChatDisplaySettings.value = null
updateGatewayAvailability(GatewayAvailability.Unreachable)
recordDashboardStatusIfChanged(connectionId, status = null, session = null)
return
}
_hostResourcePressure.value = status.hostResourcePressure()
updateDashboardTopology(connectionId, status)
val session = if (status.authRequired) client.currentSession().getOrNull() else null
val authed = !status.authRequired || session?.authenticated == true
@@ -4595,6 +4683,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
android.util.Log.w("ConnectionVM", "probeStandardVoice failed: ${e.message}")
_standardVoiceAvailability.value = StandardVoiceAvailability.Unreachable
_standardAudioApiReachable.value = false
_hostResourcePressure.value = HostResourcePressureStatus()
_serverChatDisplaySettings.value = null
updateGatewayAvailability(GatewayAvailability.Unreachable)
} finally {
@@ -58,6 +58,9 @@ class ProfileInspectorViewModel(
private val _gatewayDescription = MutableStateFlow<GatewayProfileDescription?>(null)
val gatewayDescription: StateFlow<GatewayProfileDescription?> = _gatewayDescription.asStateFlow()
private val _gatewayWritable = MutableStateFlow(false)
val gatewayWritable: StateFlow<Boolean> = _gatewayWritable.asStateFlow()
private var gatewayConfigureUnsupported = false
private val _configState = MutableStateFlow<LoadState<ProfileConfigResponse>>(LoadState.Idle)
val configState: StateFlow<LoadState<ProfileConfigResponse>> = _configState.asStateFlow()
@@ -351,6 +354,7 @@ class ProfileInspectorViewModel(
if (configResult.isSuccess || soulResult.isSuccess || skillsResult.isSuccess) {
_source.value = ProfileInspectorSource.Relay
_gatewayDescription.value = null
_gatewayWritable.value = false
}
val fallbackMessage = gatewayError
?.takeUnless { it is GatewayProfileEditorUnsupportedException }
@@ -375,7 +379,8 @@ class ProfileInspectorViewModel(
private fun applyGatewayDescription(description: GatewayProfileDescription) {
if (description.name != profileName) return
_gatewayDescription.value = description
_skillToggleSupported.value = true
_gatewayWritable.value = !gatewayConfigureUnsupported
_skillToggleSupported.value = !gatewayConfigureUnsupported
_configState.value = LoadState.Loaded(description.toConfigResponse())
_soulState.value = LoadState.Loaded(description.toSoulResponse())
_skillsState.value = LoadState.Loaded(description.toSkillsResponse())
@@ -393,6 +398,11 @@ class ProfileInspectorViewModel(
viewModelScope.launch {
val configured = client.configureProfile(profileName, patch)
if (configured.isFailure) {
if (configured.exceptionOrNull() is GatewayProfileEditorUnsupportedException) {
gatewayConfigureUnsupported = true
_gatewayWritable.value = false
_skillToggleSupported.value = false
}
clearSavingFlags()
_editEvents.tryEmit(EditEvent.Error(configured.exceptionOrNull()?.message ?: "Save failed"))
return@launch
@@ -4,9 +4,13 @@ import android.content.Context
import android.net.Uri
import com.hermesandroid.relay.auth.AuthManager
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.GatewayProfileManagementUnsupportedException
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.ProfileDisplayAliasStore
import com.hermesandroid.relay.data.ProfileIconStore
import com.hermesandroid.relay.data.prepareProfileAvatar
import com.hermesandroid.relay.data.profileAvatarMime
import com.hermesandroid.relay.data.preferredProfileIcon
import com.hermesandroid.relay.data.ProfileLockStore
import com.hermesandroid.relay.data.ProfilePresentation
import com.hermesandroid.relay.data.ProfilePresentationPolicy
@@ -17,6 +21,10 @@ import com.hermesandroid.relay.data.SessionTransport
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.DashboardProfileScope
import com.hermesandroid.relay.network.upstream.GatewayAvailability
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.GatewayPetGalleryItem
import com.hermesandroid.relay.network.upstream.GatewayPetInfo
import com.hermesandroid.relay.network.upstream.GatewayRpcException
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.network.upstream.SessionMessageLoadMode
import com.hermesandroid.relay.network.upstream.models.SessionItem
@@ -39,7 +47,46 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.io.File
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
internal fun isCurrentProfileAvatarRefresh(
requestConnectionId: String,
activeConnectionId: String?,
requestGeneration: Long,
currentGeneration: Long,
): Boolean = requestConnectionId == activeConnectionId && requestGeneration == currentGeneration
internal fun mergeGatewayProfileRoster(
gateway: List<Profile>,
fallback: List<Profile>,
): List<Profile> = gateway.map { authoritative ->
val extension = fallback.firstOrNull { it.name == authoritative.name }
?: return@map authoritative
authoritative.copy(
systemMessage = extension.systemMessage,
gatewayRunning = extension.gatewayRunning,
hasSoul = extension.hasSoul,
apiServerEnabled = extension.apiServerEnabled,
apiServerUrl = extension.apiServerUrl,
apiServerHost = extension.apiServerHost,
apiServerPort = extension.apiServerPort,
apiServerKeyPresent = extension.apiServerKeyPresent,
)
}
internal fun selectProfileRoster(
gatewayAuthoritative: Boolean,
gateway: List<Profile>,
fallback: List<Profile>,
): List<Profile> = if (gatewayAuthoritative) {
mergeGatewayProfileRoster(gateway, fallback)
} else {
fallback
}
/**
* Owns the **agent-profiles cluster** of
@@ -93,6 +140,8 @@ class ProfileController(
private val rebuildChatApiClient: suspend () -> Unit,
/** Optional Relay client used only for importing an icon from the host. */
private val relayHttpClient: RelayHttpClient? = null,
/** Current per-connection Gateway client; profile management stays upstream-owned. */
private val gatewayClientProvider: () -> GatewayChatClient? = { null },
) {
// Server-advertised named agent configs, flattened to a StateFlow the
@@ -101,15 +150,24 @@ class ProfileController(
// underlying AuthManager instance is replaced and the public flow needs to
// repoint at the new manager's backing state.
private val _dashboardProfiles = MutableStateFlow<List<Profile>>(emptyList())
private val _gatewayProfiles = MutableStateFlow<List<Profile>>(emptyList())
private val _gatewayRosterAuthoritative = MutableStateFlow(false)
private val avatarRefreshGeneration = AtomicLong(0L)
private val petRefreshGeneration = AtomicLong(0L)
private val petGalleryGeneration = AtomicLong(0L)
private val petThumbnailRequests = ConcurrentHashMap.newKeySet<String>()
val agentProfiles: StateFlow<List<Profile>> = combine(
authManagerFlow.flatMapLatest { it.agentProfiles },
_dashboardProfiles,
) { relay, dashboard ->
// Prefer the relay's list when it has entries (richer runtime metadata);
// fall back to the dashboard list so a dashboard-only connection still
// sees its server profiles in the chat picker.
relay.ifEmpty { dashboard }
_gatewayProfiles,
_gatewayRosterAuthoritative,
) { relay, dashboard, gateway, gatewayAuthoritative ->
// Current Gateway rows are authoritative for shared profile metadata and
// avatar presence. Relay-only runtime/API routing fields are joined by
// exact name so adopting the roster never drops an isolated profile route.
val fallback = relay.ifEmpty { dashboard }
selectProfileRoster(gatewayAuthoritative, gateway, fallback)
}.stateIn(scope, SharingStarted.Eagerly, authManagerFlow.value.agentProfiles.value)
private val _selectedProfile = MutableStateFlow<Profile?>(null)
@@ -257,8 +315,8 @@ class ProfileController(
val profileIconStore: ProfileIconStore = ProfileIconStore(context)
/** The active profile's local agent-icon path (twin of [profileDisplayAlias]). */
val profileIcon: StateFlow<String?> = combine(
/** The active profile's device-local fallback icon. */
val localProfileIcon: StateFlow<String?> = combine(
activeConnectionId,
selectedProfile,
) { connectionId, profile ->
@@ -271,11 +329,63 @@ class ProfileController(
}
}.stateIn(scope, SharingStarted.Eagerly, null)
private val activeServerAvatarIdentity: Flow<Pair<String?, String?>> = combine(
activeConnectionId,
selectedProfile,
serverDefaultProfileScope,
_gatewayProfiles,
) { connectionId, selected, serverDefault, gatewayProfiles ->
connectionId to (
selected?.name ?: serverDefault?.active
?: gatewayProfiles.firstOrNull(Profile::isDefault)?.name
)
}
/** Cached bytes fetched from Hermes; always preferred over the local fallback. */
val serverProfileAvatar: StateFlow<String?> = activeServerAvatarIdentity
.flatMapLatest { (connectionId, profileName) ->
if (connectionId == null || profileName.isNullOrBlank()) flowOf(null)
else profileIconStore.serverAvatarFlow(connectionId, profileName)
}.stateIn(scope, SharingStarted.Eagerly, null)
/** Whether this phone should prefer its local image over Hermes' shared avatar. */
val useLocalProfileIconOverride: StateFlow<Boolean> = combine(
activeConnectionId,
selectedProfile,
) { connectionId, profile ->
connectionId to AgentDisplay.profileRequestName(profile?.name)
}.flatMapLatest { (connectionId, profileName) ->
if (connectionId == null) flowOf(false)
else profileIconStore.localOverrideFlow(connectionId, profileName)
}.stateIn(scope, SharingStarted.Eagerly, false)
val profileIcon: StateFlow<String?> = combine(
serverProfileAvatar,
localProfileIcon,
useLocalProfileIconOverride,
) { server, local, localOverride -> preferredProfileIcon(server, local, localOverride) }
.stateIn(scope, SharingStarted.Eagerly, null)
/** Local icon for any profile identity on the active connection. */
fun profileIconFlow(profileName: String?): Flow<String?> = activeConnectionId
.flatMapLatest { connectionId ->
if (connectionId == null) flowOf(null)
else profileIconStore.iconFlow(connectionId, profileName)
fun profileIconFlow(profileName: String?): Flow<String?> = combine(
activeConnectionId,
serverDefaultProfileScope,
_gatewayProfiles,
) { connectionId, serverDefault, gatewayProfiles ->
connectionId to (
profileName ?: serverDefault?.active
?: gatewayProfiles.firstOrNull(Profile::isDefault)?.name
)
}
.flatMapLatest { (connectionId, serverProfileName) ->
if (connectionId == null) return@flatMapLatest flowOf(null)
val local = profileIconStore.iconFlow(connectionId, profileName)
val localOverride = profileIconStore.localOverrideFlow(connectionId, profileName)
if (serverProfileName.isNullOrBlank()) local else combine(
profileIconStore.serverAvatarFlow(connectionId, serverProfileName),
local,
localOverride,
) { server, fallback, override -> preferredProfileIcon(server, fallback, override) }
}
data class HostIconImportState(
@@ -287,6 +397,44 @@ class ProfileController(
val hostIconImportState: StateFlow<HostIconImportState> =
_hostIconImportState.asStateFlow()
data class SharedAvatarState(
val loading: Boolean = false,
val error: String? = null,
)
private val _sharedAvatarState = MutableStateFlow(SharedAvatarState())
val sharedAvatarState: StateFlow<SharedAvatarState> = _sharedAvatarState.asStateFlow()
data class HermesPetPresentation(
val connectionId: String,
val profileName: String?,
val slug: String,
val displayName: String,
val spritesheetPath: String,
val spritesheetRevision: String,
val frameWidth: Int,
val frameHeight: Int,
val framesPerState: Int,
val framesByState: Map<String, Int>,
val framesByRow: Map<String, Int>,
val loopMs: Int,
val scale: Float,
val stateRows: List<String>,
)
data class HermesPetState(
val supported: Boolean? = null,
val loading: Boolean = false,
val galleryLoading: Boolean = false,
val active: HermesPetPresentation? = null,
val gallery: List<GatewayPetGalleryItem> = emptyList(),
val thumbnails: Map<String, String> = emptyMap(),
val error: String? = null,
)
private val _hermesPetState = MutableStateFlow(HermesPetState())
val hermesPetState: StateFlow<HermesPetState> = _hermesPetState.asStateFlow()
/**
* Load the host's agent profiles from the dashboard `/api/profiles` into
* [agentProfiles] (merged in the combine above). Lets the chat agent sheet
@@ -302,6 +450,8 @@ class ProfileController(
return
}
scope.launch {
refreshGatewayProfiles(connectionId)
refreshHermesPet(connectionId)
val client = dashboardClientFactory(connectionId, dashboardUrl)
val defaultScope = client.getActiveProfileScope().getOrNull()
if (activeConnectionId.value != connectionId) return@launch
@@ -324,6 +474,258 @@ class ProfileController(
}
}
fun refreshGatewayProfiles() {
val connectionId = activeConnectionId.value ?: return
scope.launch {
refreshGatewayProfiles(connectionId)
refreshHermesPet(connectionId)
}
}
private suspend fun refreshGatewayProfiles(connectionId: String) {
val gateway = gatewayClientProvider() ?: return
val generation = avatarRefreshGeneration.incrementAndGet()
gateway.listProfiles().onSuccess { profiles ->
if (!isCurrentProfileAvatarRefresh(connectionId, activeConnectionId.value, generation, avatarRefreshGeneration.get())) return@onSuccess
_gatewayProfiles.value = profiles
_gatewayRosterAuthoritative.value = true
profiles.forEach { profile ->
if (!profile.hasAvatar) {
clearServerAvatarCache(connectionId, profile.name, generation)
} else {
gateway.getProfileAvatar(profile.name).onSuccess { asset ->
if (!isCurrentProfileAvatarRefresh(connectionId, activeConnectionId.value, generation, avatarRefreshGeneration.get())) {
return@onSuccess
}
if (asset == null) {
clearServerAvatarCache(connectionId, profile.name, generation)
} else {
val path = copyServerAvatarBytes(connectionId, profile.name, asset.data, asset.mime)
if (path != null && avatarRefreshGeneration.get() == generation) {
profileIconStore.setServerAvatar(connectionId, profile.name, path)
}
}
}
}
}
}.onFailure { failure ->
if (
failure is GatewayProfileManagementUnsupportedException &&
isCurrentProfileAvatarRefresh(
connectionId,
activeConnectionId.value,
generation,
avatarRefreshGeneration.get(),
)
) {
_gatewayProfiles.value = emptyList()
_gatewayRosterAuthoritative.value = false
}
}
}
private suspend fun clearServerAvatarCache(connectionId: String, profileName: String, generation: Long) {
if (avatarRefreshGeneration.get() != generation) return
profileIconStore.serverAvatarFlow(connectionId, profileName).first()?.let { runCatching { File(it).delete() } }
profileIconStore.setServerAvatar(connectionId, profileName, null)
}
/** Refresh the active profile's upstream sprite contract without re-sending an unchanged sheet. */
fun refreshHermesPet() {
val connectionId = activeConnectionId.value ?: return
scope.launch { refreshHermesPet(connectionId) }
}
private suspend fun refreshHermesPet(connectionId: String) {
val gateway = gatewayClientProvider() ?: run {
_hermesPetState.value = HermesPetState(supported = null)
return
}
val profileName = resolveSessionProfileName()
val generation = petRefreshGeneration.incrementAndGet()
val previous = _hermesPetState.value.active?.takeIf {
it.connectionId == connectionId && it.profileName == profileName
}
_hermesPetState.value = _hermesPetState.value.copy(loading = true, error = null)
gateway.petInfo(profile = profileName, knownRevision = previous?.spritesheetRevision).fold(
onSuccess = { info ->
if (!isCurrentPetRefresh(connectionId, generation)) return@fold
if (!info.enabled) {
_hermesPetState.value = HermesPetState(supported = true)
return@fold
}
val presentation = cacheHermesPet(connectionId, profileName, info, previous)
_hermesPetState.value = if (presentation == null) {
HermesPetState(
supported = true,
error = "Hermes returned an animated pet that this phone could not cache",
)
} else {
_hermesPetState.value.copy(
supported = true,
loading = false,
active = presentation,
error = null,
)
}
},
onFailure = { failure ->
if (!isCurrentPetRefresh(connectionId, generation)) return@fold
_hermesPetState.value = if ((failure as? GatewayRpcException)?.code == -32601) {
HermesPetState(supported = false)
} else {
_hermesPetState.value.copy(
loading = false,
error = failure.message ?: "Could not load the Hermes animated pet",
)
}
},
)
}
/** Load the profile-scoped upstream gallery only when the user opens the picker. */
fun loadHermesPetGallery() {
val connectionId = activeConnectionId.value ?: return
val gateway = gatewayClientProvider() ?: return
val profileName = resolveSessionProfileName()
val generation = petGalleryGeneration.incrementAndGet()
scope.launch {
_hermesPetState.value = _hermesPetState.value.copy(galleryLoading = true, error = null)
gateway.petGallery(profile = profileName).fold(
onSuccess = { gallery ->
if (!isCurrentPetGallery(connectionId, profileName, generation)) return@fold
_hermesPetState.value = _hermesPetState.value.copy(
supported = true,
galleryLoading = false,
gallery = gallery.pets,
error = null,
)
},
onFailure = { failure ->
if (!isCurrentPetGallery(connectionId, profileName, generation)) return@fold
_hermesPetState.value = _hermesPetState.value.copy(
galleryLoading = false,
error = failure.message ?: "Could not load the Hermes pet gallery",
)
},
)
}
}
fun selectHermesPet(slug: String) = mutateHermesPet { gateway, profile ->
gateway.selectPet(slug, profile)
}
/** Lazily fetch one upstream-cropped idle frame for a visible gallery row. */
fun loadHermesPetThumbnail(pet: GatewayPetGalleryItem) {
if (_hermesPetState.value.thumbnails.containsKey(pet.slug)) return
val connectionId = activeConnectionId.value ?: return
val gateway = gatewayClientProvider() ?: return
val profileName = resolveSessionProfileName()
val requestKey = "$connectionId\u0000${AgentDisplay.profileSessionKey(profileName)}\u0000${pet.slug}"
if (!petThumbnailRequests.add(requestKey)) return
scope.launch {
try {
gateway.petThumbnail(
slug = pet.slug,
spritesheetUrl = pet.spritesheetUrl,
profile = profileName,
).onSuccess { dataUri ->
if (
dataUri != null && activeConnectionId.value == connectionId &&
resolveSessionProfileName() == profileName
) {
_hermesPetState.value = _hermesPetState.value.copy(
thumbnails = _hermesPetState.value.thumbnails + (pet.slug to dataUri),
)
}
}
} finally {
petThumbnailRequests.remove(requestKey)
}
}
}
fun disableHermesPet() = mutateHermesPet { gateway, profile ->
gateway.disablePet(profile)
}
private fun mutateHermesPet(
mutation: suspend (GatewayChatClient, String?) -> Result<Unit>,
) {
val connectionId = activeConnectionId.value ?: return
val gateway = gatewayClientProvider() ?: return
val profileName = resolveSessionProfileName()
scope.launch {
_hermesPetState.value = _hermesPetState.value.copy(loading = true, error = null)
mutation(gateway, profileName).fold(
onSuccess = { refreshHermesPet(connectionId) },
onFailure = { failure ->
_hermesPetState.value = _hermesPetState.value.copy(
loading = false,
error = failure.message ?: "Could not update the Hermes animated pet",
)
},
)
}
}
private fun isCurrentPetRefresh(connectionId: String, generation: Long): Boolean =
activeConnectionId.value == connectionId && petRefreshGeneration.get() == generation
private fun isCurrentPetGallery(connectionId: String, profileName: String?, generation: Long): Boolean =
activeConnectionId.value == connectionId &&
resolveSessionProfileName() == profileName &&
petGalleryGeneration.get() == generation
private suspend fun cacheHermesPet(
connectionId: String,
profileName: String?,
info: GatewayPetInfo,
previous: HermesPetPresentation?,
): HermesPetPresentation? = withContext(Dispatchers.IO) {
val slug = info.slug ?: return@withContext null
val revision = info.spritesheetRevision ?: return@withContext null
val path = if (info.spritesheetUnchanged && previous?.spritesheetRevision == revision) {
previous.spritesheetPath.takeIf { File(it).isFile }
} else {
val bytes = info.spritesheet ?: return@withContext null
val dir = File(context.filesDir, "hermes-profile-pets").apply { mkdirs() }
val key = MessageDigest.getInstance("SHA-256")
.digest("$connectionId\u0000${AgentDisplay.profileSessionKey(profileName)}".toByteArray())
.take(12)
.joinToString("") { "%02x".format(it) }
val extension = if (info.mime == "image/webp") "webp" else "png"
val target = File(dir, "$key.$extension")
val pending = File(dir, "$key.$extension.tmp")
runCatching {
pending.writeBytes(bytes)
if (!pending.renameTo(target)) {
target.writeBytes(bytes)
pending.delete()
}
target.absolutePath
}.getOrNull()
} ?: return@withContext null
HermesPetPresentation(
connectionId = connectionId,
profileName = profileName,
slug = slug,
displayName = info.displayName ?: slug,
spritesheetPath = path,
spritesheetRevision = revision,
frameWidth = info.frameWidth,
frameHeight = info.frameHeight,
framesPerState = info.framesPerState,
framesByState = info.framesByState,
framesByRow = info.framesByRow,
loopMs = info.loopMs,
scale = info.scale,
stateRows = info.stateRows,
)
}
/** Effective profile namespace for Gateway and profile-scoped session I/O. */
fun resolveSessionProfileName(selectedProfileName: String? = _selectedProfile.value?.name): String? =
AgentDisplay.effectiveSessionProfileName(
@@ -350,6 +752,38 @@ class ProfileController(
return dashboardClientFactory(connectionId, dashboardUrl).listAllProfileSessions(limit)
}
suspend fun deleteSession(profileName: String, sessionId: String): Boolean {
val connectionId = activeConnectionId.value ?: return false
val dashboardUrl = activeDashboardUrlProvider() ?: return false
return dashboardClientFactory(connectionId, dashboardUrl)
.deleteSession(sessionId, profileName)
.isSuccess
}
suspend fun renameSession(profileName: String, sessionId: String, title: String): Boolean {
val connectionId = activeConnectionId.value ?: return false
val dashboardUrl = activeDashboardUrlProvider() ?: return false
return dashboardClientFactory(connectionId, dashboardUrl)
.renameSession(sessionId, title, profileName)
.isSuccess
}
suspend fun setSessionPinned(profileName: String, sessionId: String, pinned: Boolean): Boolean {
val connectionId = activeConnectionId.value ?: return false
val dashboardUrl = activeDashboardUrlProvider() ?: return false
return dashboardClientFactory(connectionId, dashboardUrl)
.setSessionPinned(sessionId, pinned, profileName)
.isSuccess
}
suspend fun setSessionArchived(profileName: String, sessionId: String, archived: Boolean): Boolean {
val connectionId = activeConnectionId.value ?: return false
val dashboardUrl = activeDashboardUrlProvider() ?: return false
return dashboardClientFactory(connectionId, dashboardUrl)
.setSessionArchived(sessionId, archived, profileName)
.isSuccess
}
/**
* A session's transcript, scoped to the active profile via the dashboard
* `/api/sessions/{id}/messages?profile=`. Returns `null` off the dashboard
@@ -358,10 +792,19 @@ class ProfileController(
suspend fun loadProfileScopedMessages(
sessionId: String,
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
): Result<List<MessageItem>>? = loadProfileScopedMessages(
profileName = resolveSessionProfileName(),
sessionId = sessionId,
mode = mode,
)
suspend fun loadProfileScopedMessages(
profileName: String?,
sessionId: String,
mode: SessionMessageLoadMode = SessionMessageLoadMode.COMPLETE,
): Result<List<MessageItem>>? {
val connectionId = activeConnectionId.value ?: return null
val dashboardUrl = activeDashboardUrlProvider() ?: return null
val profileName = resolveSessionProfileName()
return dashboardClientFactory(connectionId, dashboardUrl)
.getSessionMessages(sessionId, profileName, mode)
}
@@ -431,6 +874,7 @@ class ProfileController(
scope.launch {
val path = copyIcon(connectionId, profileName, uri) ?: return@launch
profileIconStore.setIcon(connectionId, profileName, path)
profileIconStore.setLocalOverride(connectionId, profileName, true)
}
}
@@ -456,6 +900,7 @@ class ProfileController(
)
} else {
profileIconStore.setIcon(connectionId, profileName, path)
profileIconStore.setLocalOverride(connectionId, profileName, true)
_hostIconImportState.value = HostIconImportState()
}
},
@@ -479,23 +924,120 @@ class ProfileController(
}
}
fun setUseLocalProfileIconOverride(enabled: Boolean) {
val connectionId = activeConnectionId.value ?: return
val profileName = AgentDisplay.profileRequestName(_selectedProfile.value?.name)
scope.launch { profileIconStore.setLocalOverride(connectionId, profileName, enabled) }
}
/** Upload a selected static image directly to Hermes without changing this phone's override. */
fun setSharedProfileAvatar(uri: Uri) {
val connectionId = activeConnectionId.value ?: return
val profileName = resolveSharedAssetProfileName()
val gateway = gatewayClientProvider()
if (profileName.isNullOrBlank() || gateway == null) {
_sharedAvatarState.value = SharedAvatarState(error = "Shared avatars require a current Hermes Gateway")
return
}
scope.launch {
_sharedAvatarState.value = SharedAvatarState(loading = true)
val bytes = withContext(Dispatchers.IO) {
prepareProfileAvatar(context, uri, GatewayChatClient.PROFILE_AVATAR_MAX_BYTES)
}
if (bytes == null) {
_sharedAvatarState.value = SharedAvatarState(error = "That image could not be prepared for Hermes")
return@launch
}
gateway.setProfileAvatar(profileName, bytes).fold(
onSuccess = {
cacheAcknowledgedSharedAvatar(connectionId, profileName, bytes)
_sharedAvatarState.value = SharedAvatarState()
},
onFailure = { failure ->
_sharedAvatarState.value = SharedAvatarState(
error = failure.message ?: "Shared avatar upload failed",
)
},
)
}
}
/** Explicit migration: upload the current device-local fallback to Hermes. */
fun uploadLocalProfileIconToHermes() {
val connectionId = activeConnectionId.value ?: return
val profileName = resolveSharedAssetProfileName()
if (profileName.isNullOrBlank()) {
_sharedAvatarState.value = SharedAvatarState(error = "Hermes profile identity is not available")
return
}
val gateway = gatewayClientProvider()
if (gateway == null) {
_sharedAvatarState.value = SharedAvatarState(error = "Shared avatars require a current Hermes Gateway")
return
}
scope.launch {
_sharedAvatarState.value = SharedAvatarState(loading = true)
val path = profileIconStore.iconFlow(
connectionId,
AgentDisplay.profileRequestName(_selectedProfile.value?.name),
).first()
val file = path?.let(::File)
val bytes = when {
file == null || !file.isFile -> null
file.length() > GatewayChatClient.PROFILE_AVATAR_MAX_BYTES -> null
else -> withContext(Dispatchers.IO) { runCatching { file.readBytes() }.getOrNull() }
}
if (bytes == null) {
_sharedAvatarState.value = SharedAvatarState(error = "Choose a local PNG, JPEG, or WebP under 2 MB first")
return@launch
}
gateway.setProfileAvatar(profileName, bytes).fold(
onSuccess = {
cacheAcknowledgedSharedAvatar(connectionId, profileName, bytes)
_sharedAvatarState.value = SharedAvatarState()
},
onFailure = { failure ->
_sharedAvatarState.value = SharedAvatarState(
error = failure.message ?: "Shared avatar upload failed",
)
},
)
}
}
/** Clear only Hermes' shared asset; the device-local fallback remains untouched. */
fun clearSharedProfileAvatar() {
val connectionId = activeConnectionId.value ?: return
val profileName = resolveSharedAssetProfileName()
val gateway = gatewayClientProvider()
if (profileName.isNullOrBlank() || gateway == null) {
_sharedAvatarState.value = SharedAvatarState(error = "Shared avatars require a current Hermes Gateway")
return
}
scope.launch {
_sharedAvatarState.value = SharedAvatarState(loading = true)
gateway.clearProfileAvatar(profileName).fold(
onSuccess = {
val generation = avatarRefreshGeneration.incrementAndGet()
clearServerAvatarCache(connectionId, profileName, generation)
_gatewayProfiles.value = _gatewayProfiles.value.map { profile ->
if (profile.name == profileName) profile.copy(hasAvatar = false) else profile
}
_sharedAvatarState.value = SharedAvatarState()
},
onFailure = { failure ->
_sharedAvatarState.value = SharedAvatarState(
error = failure.message ?: "Shared avatar clear failed",
)
},
)
}
}
/** Copy [uri]'s image bytes into app-internal storage; returns the path or null. */
private suspend fun copyIcon(connectionId: String, profileName: String?, uri: Uri): String? =
withContext(Dispatchers.IO) {
try {
val dir = File(context.filesDir, "profile-icons").apply { mkdirs() }
val key = AgentDisplay.profileSessionKey(profileName)
val safe = "${connectionId}_$key"
.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else '_' }
.joinToString("")
val out = File(dir, "$safe.png")
context.contentResolver.openInputStream(uri)?.use { input ->
out.outputStream().use { input.copyTo(it) }
} ?: return@withContext null
out.absolutePath
} catch (t: Throwable) {
null
}
readBoundedUri(uri, LOCAL_PROFILE_ICON_MAX_BYTES)?.let { bytes ->
copyIconBytes(connectionId, profileName, bytes)
}
private suspend fun copyIconBytes(
@@ -504,17 +1046,131 @@ class ProfileController(
bytes: ByteArray,
): String? = withContext(Dispatchers.IO) {
try {
val extension = localImageExtension(bytes) ?: return@withContext null
val dir = File(context.filesDir, "profile-icons").apply { mkdirs() }
val key = AgentDisplay.profileSessionKey(profileName)
val safe = "${connectionId}_$key"
.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else '_' }
.joinToString("")
File(dir, "$safe.png").also { it.writeBytes(bytes) }.absolutePath
val target = File(dir, "$safe.$extension")
target.writeBytes(bytes)
LOCAL_PROFILE_ICON_EXTENSIONS
.filterNot(extension::equals)
.forEach { stale -> runCatching { File(dir, "$safe.$stale").delete() } }
target.absolutePath
} catch (_: Throwable) {
null
}
}
private suspend fun readBoundedUri(uri: Uri, maxBytes: Int): ByteArray? =
withContext(Dispatchers.IO) {
runCatching {
context.contentResolver.openInputStream(uri)?.use { input ->
val output = ByteArrayOutputStream(minOf(maxBytes, 64 * 1024))
val buffer = ByteArray(16 * 1024)
var total = 0
while (true) {
val read = input.read(buffer)
if (read < 0) break
total += read
if (total > maxBytes) return@use null
output.write(buffer, 0, read)
}
output.toByteArray()
}
}.getOrNull()
}
private fun localImageExtension(bytes: ByteArray): String? = when {
bytes.size >= 8 && bytes.copyOfRange(0, 8).contentEquals(
byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
) -> "png"
bytes.size >= 3 && bytes[0] == 0xff.toByte() && bytes[1] == 0xd8.toByte() &&
bytes[2] == 0xff.toByte() -> "jpg"
bytes.size >= 6 && (
bytes.copyOfRange(0, 6).contentEquals("GIF87a".toByteArray()) ||
bytes.copyOfRange(0, 6).contentEquals("GIF89a".toByteArray())
) -> "gif"
bytes.size >= 12 && bytes.copyOfRange(0, 4).contentEquals("RIFF".toByteArray()) &&
bytes.copyOfRange(8, 12).contentEquals("WEBP".toByteArray()) -> "webp"
else -> null
}
private suspend fun copyServerAvatarBytes(
connectionId: String,
profileName: String,
bytes: ByteArray,
mime: String,
): String? = withContext(Dispatchers.IO) {
try {
val extension = when (mime) {
"image/png" -> "png"
"image/jpeg" -> "jpg"
"image/webp" -> "webp"
else -> return@withContext null
}
val dir = File(context.filesDir, "profile-avatars-server").apply { mkdirs() }
val safe = MessageDigest.getInstance("SHA-256")
.digest("$connectionId\u0000${AgentDisplay.profileSessionKey(profileName)}".toByteArray())
.joinToString("") { (it.toInt() and 0xff).toString(16).padStart(2, '0') }
val target = File(dir, "$safe.$extension")
val temp = File(dir, "$safe.$extension.tmp")
temp.writeBytes(bytes)
try {
java.nio.file.Files.move(
temp.toPath(),
target.toPath(),
java.nio.file.StandardCopyOption.ATOMIC_MOVE,
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
)
} catch (_: java.nio.file.AtomicMoveNotSupportedException) {
java.nio.file.Files.move(
temp.toPath(),
target.toPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
)
}
listOf("png", "jpg", "webp")
.filterNot(extension::equals)
.forEach { stale -> runCatching { File(dir, "$safe.$stale").delete() } }
target.absolutePath
} catch (_: Throwable) {
null
}
}
private suspend fun cacheAcknowledgedSharedAvatar(
connectionId: String,
profileName: String,
bytes: ByteArray,
) {
if (activeConnectionId.value != connectionId) return
val mime = profileAvatarMime(bytes) ?: return
val generation = avatarRefreshGeneration.incrementAndGet()
val path = copyServerAvatarBytes(connectionId, profileName, bytes, mime) ?: return
if (!isCurrentProfileAvatarRefresh(
connectionId,
activeConnectionId.value,
generation,
avatarRefreshGeneration.get(),
)
) return
profileIconStore.setServerAvatar(connectionId, profileName, path)
_gatewayProfiles.value = _gatewayProfiles.value.map { profile ->
if (profile.name == profileName) profile.copy(hasAvatar = true) else profile
}
}
private fun resolveSharedAssetProfileName(): String? =
resolveSessionProfileName()
?: _gatewayProfiles.value.firstOrNull(Profile::isDefault)?.name
private companion object {
const val LOCAL_PROFILE_ICON_MAX_BYTES = 8_000_000
val LOCAL_PROFILE_ICON_EXTENSIONS = listOf("png", "jpg", "gif", "webp")
}
/**
* The stored lock-token for a (possibly null) profile. Server default —
* A null selection maps to [AgentDisplay.SERVER_DEFAULT_PROFILE_KEY]; a
@@ -583,6 +1239,24 @@ class ProfileController(
}
}
/** Persist or clear a local cosmetic accent for a named profile. */
fun setProfileColor(profileName: String, colorHex: String?) {
val connectionId = activeConnectionId.value ?: return
val key = profileName.trim()
if (key.isBlank() || key.equals("default", ignoreCase = true)) return
scope.launch {
profilePresentationWriteMutex.withLock {
val updated = profilePresentationStore
.presentationFlow(connectionId)
.first()
.colors
.toMutableMap()
.apply { if (colorHex == null) remove(key) else put(key, colorHex) }
profilePresentationStore.setColors(connectionId, updated)
}
}
}
fun resetProfilePresentation() {
val connectionId = activeConnectionId.value ?: return
scope.launch {
@@ -600,6 +1274,11 @@ class ProfileController(
* [selectProfile] is the gated public entry point.
*/
private fun applyProfileSelection(normalizedProfile: Profile?) {
_sharedAvatarState.value = SharedAvatarState()
_hostIconImportState.value = HostIconImportState()
petRefreshGeneration.incrementAndGet()
petGalleryGeneration.incrementAndGet()
_hermesPetState.value = HermesPetState()
_selectedProfile.value = normalizedProfile
setLastSessionId(null)
val connectionId = activeConnectionId.value ?: return
@@ -608,6 +1287,7 @@ class ProfileController(
scope.launch {
profileSelectionStore.setSelectedProfile(connectionId, normalizedProfile?.name)
rebuildChatApiClient()
refreshHermesPet(connectionId)
}
refreshLastSessionForProfile(connectionId, normalizedProfile?.name)
}
@@ -781,6 +1461,12 @@ class ProfileController(
// pending persisted name can't resolve against the previous connection's
// profiles before the new connection's list arrives.
_dashboardProfiles.value = emptyList()
_gatewayProfiles.value = emptyList()
_gatewayRosterAuthoritative.value = false
avatarRefreshGeneration.incrementAndGet()
petRefreshGeneration.incrementAndGet()
petGalleryGeneration.incrementAndGet()
_hermesPetState.value = HermesPetState()
}
/** Clear just the in-memory selection + pending state (resetAppData). */
@@ -790,6 +1476,9 @@ class ProfileController(
_pendingSelectedProfileName.value = null
_serverDefaultProfileScope.value = null
_serverDefaultProfileSettled.value = false
petRefreshGeneration.incrementAndGet()
petGalleryGeneration.incrementAndGet()
_hermesPetState.value = HermesPetState()
}
fun clearSelectedProfile() {
@@ -16,6 +16,7 @@ import com.hermesandroid.relay.network.upstream.isNativeDashboardTransportEligib
import com.hermesandroid.relay.network.upstream.ServerCapabilities
import com.hermesandroid.relay.network.upstream.resolveStreamingEndpointPreference
import com.hermesandroid.relay.network.upstream.trustedDashboardBearerAuthOrNull
import com.hermesandroid.relay.network.shutdownOffMainThread
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -80,6 +81,10 @@ class UpstreamTransportController(
/** Applies pairing-bound TLS to a standard authenticated client when needed. */
private val pinnedClientProvider: (String, okhttp3.OkHttpClient) -> okhttp3.OkHttpClient? =
{ _, _ -> null },
private val dashboardHttpClientFactory:
(DashboardCookieStore, DashboardBearerAuth?) -> okhttp3.OkHttpClient = { cookieStore, bearerAuth ->
DashboardApiClient.defaultClient(cookieStore, bearerAuth)
},
) {
// --- Per-connection dashboard cookie stores ----------------------------
@@ -151,9 +156,9 @@ class UpstreamTransportController(
* scoping, the gateway client, standard-API setup probe) route through.
*/
fun dashboardClientFor(connectionId: String, dashboardUrl: String): DashboardApiClient {
val base = DashboardApiClient.defaultClient(
cookieStore = dashboardCookieStoreFor(connectionId),
bearerAuth = bearerAuthForTrustedDashboard(connectionId, dashboardUrl),
val base = dashboardHttpClientFactory(
dashboardCookieStoreFor(connectionId),
bearerAuthForTrustedDashboard(connectionId, dashboardUrl),
)
return DashboardApiClient(
baseUrl = dashboardUrl,
@@ -167,9 +172,9 @@ class UpstreamTransportController(
* no active connection (the standard-voice probe path).
*/
fun dashboardClientForActive(dashboardUrl: String): DashboardApiClient {
val base = DashboardApiClient.defaultClient(
cookieStore = activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
bearerAuth = activeConnectionIdProvider()?.let {
val base = dashboardHttpClientFactory(
activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
activeConnectionIdProvider()?.let {
bearerAuthForTrustedDashboard(it, dashboardUrl)
},
)
@@ -219,9 +224,9 @@ class UpstreamTransportController(
disposeDashboardHttpClient(client)
dashboardHttpClientCache = null
}
val base = DashboardApiClient.defaultClient(
cookieStore = activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
bearerAuth = activeConnectionIdProvider()?.let { activeId ->
val base = dashboardHttpClientFactory(
activeDashboardCookieStore() ?: InMemoryDashboardCookieStore(),
activeConnectionIdProvider()?.let { activeId ->
bearerAuthForTrustedDashboard(activeId, dashboardUrl)
},
)
@@ -250,10 +255,12 @@ class UpstreamTransportController(
}
private fun disposeDashboardHttpClient(client: okhttp3.OkHttpClient) {
client.dispatcher.cancelAll()
client.connectionPool.evictAll()
runCatching { client.cache?.close() }
client.dispatcher.executorService.shutdown()
shutdownOffMainThread("DashboardHttpClient-shutdown") {
client.dispatcher.cancelAll()
client.connectionPool.evictAll()
runCatching { client.cache?.close() }
client.dispatcher.executorService.shutdown()
}
}
// --- Gateway availability ----------------------------------------------
+97 -1
View File
@@ -1722,6 +1722,17 @@
<!-- Section action buttons -->
<string name="dashboard_section_action_change_main_model">Alterar modelo principal</string>
<string name="dashboard_section_action_new_profile">Novo perfil</string>
<string name="dashboard_section_action_new_schedule">Novo agendamento</string>
<string name="dashboard_cron_create_title">Criar agendamento</string>
<string name="dashboard_cron_name">Nome</string>
<string name="dashboard_cron_schedule">Agendamento</string>
<string name="dashboard_cron_schedule_hint">Exemplos: every 2h, 0 9 * * 1-5 ou 2026-08-20 09:00</string>
<string name="dashboard_cron_prompt">Instruções da tarefa</string>
<string name="dashboard_cron_repeat">Parar após esta quantidade de execuções (opcional)</string>
<string name="dashboard_cron_repeat_help">Deixe em branco para manter o comportamento normal. Agendamentos finitos aceitam de 1 a 999 execuções.</string>
<string name="dashboard_cron_gateway_required">A criação de agendamentos requer uma conexão atual com o Hermes Gateway.</string>
<string name="dashboard_cron_created">Agendamento “%1$s” criado</string>
<string name="dashboard_cron_create_failed">Não foi possível criar o agendamento</string>
<string name="dashboard_section_action_browse_hub">Explorar a central</string>
<string name="dashboard_section_action_update_installed">Atualizar instaladas</string>
<!-- Action labels (rendered via kind → string) -->
@@ -1863,6 +1874,16 @@
<string name="dashboard_skills_update_failed">Falha ao iniciar a atualização de habilidades</string>
<!-- Profile create -->
<string name="dashboard_profile_created">Perfil %1$s criado</string>
<string name="dashboard_profile_created_partial">O perfil %1$s foi criado, mas %2$s. Revise antes de usar.</string>
<string name="dashboard_profile_created_legacy">O perfil %1$s foi criado pela rota antiga do Painel. Revise as credenciais antes de usar.</string>
<string name="dashboard_profile_no_skills">Não adicionar as habilidades incluídas</string>
<string name="dashboard_profile_auth_title">Autenticação</string>
<string name="dashboard_profile_auth_shared">Compartilhar login (recomendado) — um conjunto de tokens renováveis</string>
<string name="dashboard_profile_auth_copied">Copiar credenciais atuais — instantâneo separado que pode ficar desatualizado</string>
<string name="dashboard_profile_auth_isolated">Isolado — não copiar credenciais nem padrões do provedor</string>
<string name="dashboard_profile_auth_help">O Hermes aplica esta escolha no servidor. Senhas e tokens nunca são retornados ao Android.</string>
<string name="dashboard_profile_allow_legacy">Se a criação pelo Gateway não estiver disponível, permitir a rota antiga do Painel. O comportamento das credenciais depende do host e deve ser revisado.</string>
<string name="dashboard_profile_mcp_partial">A seleção de servidores MCP não foi aplicada</string>
<string name="dashboard_profile_create_failed">Falha ao criar o perfil</string>
<!-- Stats for nerds -->
<string name="stats_overview">Visão geral</string>
@@ -2466,7 +2487,35 @@
<plurals name="tool_run_using_tools"><item quantity="one">Usando %d ferramenta</item><item quantity="many">Usando %d ferramentas</item><item quantity="other">Usando %d ferramentas</item></plurals>
<plurals name="tool_run_used_tools"><item quantity="one">Usou %d ferramenta</item><item quantity="many">Usou %d ferramentas</item><item quantity="other">Usou %d ferramentas</item></plurals>
<!-- AgentIconRow -->
<string name="agent_icon_title">Ícone do agente</string>
<string name="agent_icon_title">Visual do agente</string>
<string name="agent_icon_hermes_pet_title">Mascote animado do Hermes</string>
<string name="agent_icon_hermes_pet_description">Vinculado ao perfil e compartilhado com as interfaces upstream do Hermes. Reage à atividade do agente e aparece como companheiro no celular.</string>
<string name="agent_icon_hermes_pet_active">Ativo: %1$s</string>
<string name="agent_icon_hermes_pet_empty">Nenhum mascote upstream selecionado</string>
<string name="agent_icon_hermes_pet_unsupported">Atualize o Hermes para gerenciar mascotes animados aqui</string>
<string name="agent_icon_hermes_pet_browse">Explorar mascotes do Hermes</string>
<string name="agent_icon_hermes_pet_picker_title">Escolha um mascote do Hermes</string>
<string name="agent_icon_hermes_pet_selected">Selecionado</string>
<string name="agent_icon_hermes_pet_installed">Instalado</string>
<string name="agent_icon_hermes_pet_adopt">Adotar do Petdex</string>
<string name="agent_icon_shared_title">Compartilhado no Hermes</string>
<string name="agent_icon_shared_active">Avatar compartilhado</string>
<string name="agent_icon_shared_empty">Nenhum avatar compartilhado</string>
<string name="agent_icon_change_shared">Alterar avatar compartilhado</string>
<string name="agent_icon_remove_shared">Remover</string>
<string name="agent_icon_remove_shared_title">Remover avatar compartilhado?</string>
<string name="agent_icon_remove_shared_message">Isso remove o avatar do Hermes e de outros clientes. A imagem deste celular não será afetada.</string>
<string name="agent_icon_phone_title">Somente neste celular</string>
<string name="agent_icon_phone_description">Substitua o avatar compartilhado somente neste celular sem alterar o Hermes.</string>
<string name="agent_icon_choose_phone">Escolher imagem do celular</string>
<string name="agent_icon_change_phone">Alterar imagem do celular</string>
<string name="agent_icon_remove_phone">Remover imagem do celular</string>
<string name="agent_icon_animation_note">Substituições animadas em GIF e WebP são reproduzidas somente neste celular. Use o mascote animado do Hermes acima para uma animação upstream vinculada ao perfil.</string>
<string name="agent_icon_set_local">Escolher imagem reserva do dispositivo</string>
<string name="agent_icon_change_local">Alterar imagem reserva do dispositivo</string>
<string name="agent_icon_clear_local">Limpar imagem reserva do dispositivo</string>
<string name="agent_icon_upload_shared">Enviar ícone do dispositivo ao Hermes</string>
<string name="agent_icon_clear_shared">Limpar avatar compartilhado do Hermes</string>
<string name="agent_icon_set">Escolher arquivo</string>
<string name="agent_icon_change">Escolher arquivo</string>
<string name="agent_icon_clear">Limpar</string>
@@ -3304,6 +3353,14 @@
<string name="dashboard_native_signin_requires_https">O login seguro pelo navegador exige um endereço HTTPS do painel.</string>
<string name="dashboard_native_signin_unavailable">O login seguro pelo navegador não está disponível para esta conexão. Atualize a conexão e tente novamente.</string>
<string name="dashboard_native_signin_transport_retry">A conexão segura foi interrompida. Toque no botão de login para tentar novamente.</string>
<string name="dashboard_native_signin_callback_rejected">O provedor não aprovou este login. Se você cancelou, nenhuma ação é necessária; caso contrário, inicie uma nova tentativa.</string>
<string name="dashboard_native_signin_code_rejected">O login com o Google terminou, mas o Hermes hospedado rejeitou o código de retorno de uso único antes de criar uma sessão. Inicie uma nova tentativa.</string>
<string name="dashboard_native_signin_gateway_rejected">O Hermes hospedado recusou a troca da sessão. Verifique se o agente hospedado está atualizado e inicie o login novamente.</string>
<string name="dashboard_native_signin_rate_limited">O Hermes hospedado recebeu tentativas de login demais. Aguarde um momento e tente novamente.</string>
<string name="dashboard_native_signin_gateway_unavailable">O login com o Google terminou, mas o serviço de login do Hermes hospedado está indisponível. Aguarde um momento e tente novamente.</string>
<string name="dashboard_native_signin_response_unsupported">O Hermes hospedado retornou uma resposta de login que este app não pode usar. Verifique se há atualizações do Hermes Relay e do agente hospedado.</string>
<string name="dashboard_native_signin_attempt_inactive">Esta tentativa de login foi cancelada ou substituída. Feche a aba antiga e comece novamente.</string>
<string name="dashboard_native_signin_storage_failed">O login com o Google terminou, mas o Android não conseguiu salvar a sessão do Hermes com segurança. Tente novamente; se o problema se repetir, abra Diagnósticos para ver a etapa da falha.</string>
<string name="conn_info_yolo_mode_desc_ephemeral">Ignore as solicitações de aprovação somente neste chat. A opção é redefinida quando a sessão muda.</string>
<string name="conn_info_yolo_mode_profile_off">As aprovações do perfil estão desativadas, então este chat já ignora as solicitações. Escolha Manual ou Inteligente antes de usar a exceção por chat.</string>
<string name="conn_info_approval_mode_title">Modo de aprovação do perfil</string>
@@ -3884,6 +3941,35 @@
<string name="dashboard_whatsapp_saved">WhatsApp ativado; o Hermes iniciou a reinicialização do gateway.</string>
<string name="drawer_all_profiles">Todos os perfis</string>
<string name="drawer_no_profile_sessions">Nenhuma sessão de perfil correspondente.</string>
<string name="drawer_customize_sessions">Personalizar sessões</string>
<string name="drawer_group_by">Agrupar por</string>
<string name="drawer_order_by">Ordenar por</string>
<string name="drawer_show_metadata">Mostrar nas linhas</string>
<string name="drawer_show_details">Mostrar detalhes</string>
<string name="drawer_filters">Filtros</string>
<string name="drawer_project_home">Início</string>
<string name="drawer_expand_project">Expandir %1$s</string>
<string name="drawer_collapse_project">Recolher %1$s</string>
<plurals name="drawer_project_session_count">
<item quantity="one">%1$d sessão</item>
<item quantity="other">%1$d sessões</item>
</plurals>
<plurals name="drawer_profile_count">
<item quantity="one">%1$d perfil</item>
<item quantity="other">%1$d perfis</item>
</plurals>
<string name="drawer_filter_status">Status</string>
<string name="drawer_filter_profile">Perfil</string>
<string name="drawer_filter_project">Projeto</string>
<string name="drawer_filter_pull_request">Pull request</string>
<string name="drawer_option_updated">Atualizado</string>
<string name="drawer_option_profile">Perfil</string>
<string name="drawer_option_tokens">Tokens</string>
<string name="drawer_option_cost">Custo</string>
<string name="drawer_reset_filters">Redefinir</string>
<string name="drawer_profile_colors">Cores dos perfis</string>
<string name="drawer_profile_color_auto">Automática</string>
<string name="drawer_profile_color_set">Definir a cor do perfil %1$s como %2$s</string>
<string name="drawer_close">Fechar</string>
<string name="profile_inspector_gateway_settings">Configurações do perfil do Gateway</string>
<string name="profile_inspector_gateway_settings_hint">Salvo diretamente pelo Hermes para este perfil selecionado.</string>
@@ -3910,4 +3996,14 @@
<string name="secure_link_fallback_ready">Se o Secure Link não estiver disponível, o Hermes poderá tentar as rotas alternativas aprovadas abaixo.</string>
<string name="secure_link_no_fallback">Nenhuma rota alternativa está incluída. Os serviços protegidos permanecerão offline se o Secure Link não estiver disponível.</string>
<string name="secure_link_auth_note">A proteção do transporte não ignora a autenticação. O pareamento do Relay, as credenciais da API e o login no Painel continuam sendo exigidos por serviço.</string>
<plurals name="proactive_messages_arrived_while_away">
<item quantity="one">%1$d mensagem chegou enquanto você estava ausente</item>
<item quantity="other">%1$d mensagens chegaram enquanto você estava ausente</item>
</plurals>
<string name="chat_model_confirmation_title">Confirmar seleção do modelo</string>
<string name="host_resource_recent_oom">O Hermes reiniciou recentemente, provavelmente por falta de memória.</string>
<string name="host_resource_memory_critical">A memória do servidor está criticamente baixa; chats ativos podem parar.</string>
<string name="host_resource_memory_elevated">A memória do servidor está baixa.</string>
<string name="host_resource_disk_critical">O armazenamento do servidor está criticamente baixo; mensagens e configurações podem não ser salvas.</string>
<string name="host_resource_disk_elevated">O armazenamento do servidor está baixo.</string>
</resources>
+94 -1
View File
@@ -1796,6 +1796,17 @@
<!-- Section action buttons -->
<string name="dashboard_section_action_change_main_model">更改主模型</string>
<string name="dashboard_section_action_new_profile">新建配置文件</string>
<string name="dashboard_section_action_new_schedule">新建计划</string>
<string name="dashboard_cron_create_title">创建计划</string>
<string name="dashboard_cron_name">名称</string>
<string name="dashboard_cron_schedule">计划</string>
<string name="dashboard_cron_schedule_hint">示例:every 2h、0 9 * * 1-5 或 2026-08-20 09:00</string>
<string name="dashboard_cron_prompt">任务说明</string>
<string name="dashboard_cron_repeat">运行指定次数后停止(可选)</string>
<string name="dashboard_cron_repeat_help">留空以保留计划的正常行为。有限计划可设置 1–999 次运行。</string>
<string name="dashboard_cron_gateway_required">创建计划需要当前 Hermes Gateway 连接。</string>
<string name="dashboard_cron_created">已创建计划“%1$s”</string>
<string name="dashboard_cron_create_failed">无法创建计划</string>
<string name="dashboard_section_action_browse_hub">浏览中心</string>
<string name="dashboard_section_action_update_installed">更新已安装</string>
@@ -1950,6 +1961,16 @@
<!-- Profile create -->
<string name="dashboard_profile_created">已创建配置文件 %1$s</string>
<string name="dashboard_profile_created_partial">配置文件 %1$s 已创建,但%2$s。使用前请检查。</string>
<string name="dashboard_profile_created_legacy">配置文件 %1$s 已通过旧版控制面板路由创建。使用前请检查其凭据。</string>
<string name="dashboard_profile_no_skills">不添加内置技能</string>
<string name="dashboard_profile_auth_title">身份验证</string>
<string name="dashboard_profile_auth_shared">共享登录(推荐)— 共用一个可刷新的令牌池</string>
<string name="dashboard_profile_auth_copied">复制当前凭据 — 独立快照可能会过期</string>
<string name="dashboard_profile_auth_isolated">隔离 — 不复制凭据或提供商默认值</string>
<string name="dashboard_profile_auth_help">Hermes 在服务器上应用此选择。密码和令牌绝不会返回到 Android。</string>
<string name="dashboard_profile_allow_legacy">如果无法通过 Gateway 创建,则允许使用旧版控制面板路由。凭据行为取决于该主机,必须进行检查。</string>
<string name="dashboard_profile_mcp_partial">未应用 MCP 服务器选择</string>
<string name="dashboard_profile_create_failed">创建配置文件失败</string>
<!-- Stats for nerds -->
@@ -2576,7 +2597,35 @@
<plurals name="tool_run_used_tools"><item quantity="other">已使用 %d 个工具</item></plurals>
<!-- AgentIconRow -->
<string name="agent_icon_title">代理图标</string>
<string name="agent_icon_title">代理外观</string>
<string name="agent_icon_hermes_pet_title">Hermes 动画宠物</string>
<string name="agent_icon_hermes_pet_description">按配置文件保存,并与上游 Hermes 界面共享。它会响应代理活动,并作为手机伴侣显示。</string>
<string name="agent_icon_hermes_pet_active">已启用:%1$s</string>
<string name="agent_icon_hermes_pet_empty">未选择上游宠物</string>
<string name="agent_icon_hermes_pet_unsupported">请更新 Hermes 以在此管理动画宠物</string>
<string name="agent_icon_hermes_pet_browse">浏览 Hermes 宠物</string>
<string name="agent_icon_hermes_pet_picker_title">选择 Hermes 宠物</string>
<string name="agent_icon_hermes_pet_selected">已选择</string>
<string name="agent_icon_hermes_pet_installed">已安装</string>
<string name="agent_icon_hermes_pet_adopt">从 Petdex 领养</string>
<string name="agent_icon_shared_title">在 Hermes 中共享</string>
<string name="agent_icon_shared_active">共享头像</string>
<string name="agent_icon_shared_empty">无共享头像</string>
<string name="agent_icon_change_shared">更改共享头像</string>
<string name="agent_icon_remove_shared">移除</string>
<string name="agent_icon_remove_shared_title">移除共享头像?</string>
<string name="agent_icon_remove_shared_message">这会从 Hermes 和其他客户端移除头像,但不会影响本手机上的图像。</string>
<string name="agent_icon_phone_title">仅限本手机</string>
<string name="agent_icon_phone_description">仅在本手机上覆盖共享头像,不更改 Hermes。</string>
<string name="agent_icon_choose_phone">选择手机图像</string>
<string name="agent_icon_change_phone">更改手机图像</string>
<string name="agent_icon_remove_phone">移除手机图像</string>
<string name="agent_icon_animation_note">GIF 和 WebP 动画替代仅在本手机上播放。如需按配置文件管理的上游动画,请使用上方的 Hermes 动画宠物。</string>
<string name="agent_icon_set_local">选择设备备用图像</string>
<string name="agent_icon_change_local">更改设备备用图像</string>
<string name="agent_icon_clear_local">清除设备备用图像</string>
<string name="agent_icon_upload_shared">将设备图标上传到 Hermes</string>
<string name="agent_icon_clear_shared">清除共享的 Hermes 头像</string>
<string name="agent_icon_set">选择文件</string>
<string name="agent_icon_change">选择文件</string>
<string name="agent_icon_clear">清除</string>
@@ -3397,6 +3446,14 @@
<string name="dashboard_native_signin_requires_https">安全浏览器登录需要 HTTPS 控制面板地址。</string>
<string name="dashboard_native_signin_unavailable">此连接无法使用安全浏览器登录。请刷新连接后重试。</string>
<string name="dashboard_native_signin_transport_retry">安全连接已中断。请点按登录按钮重试。</string>
<string name="dashboard_native_signin_callback_rejected">提供商未批准本次登录。如果是你取消的,则无需操作;否则请重新开始登录。</string>
<string name="dashboard_native_signin_code_rejected">Google 登录已完成,但托管的 Hermes 在创建会话前拒绝了一次性回调代码。请重新开始登录。</string>
<string name="dashboard_native_signin_gateway_rejected">托管的 Hermes 拒绝了会话交换。请确认托管代理已更新,然后重新开始登录。</string>
<string name="dashboard_native_signin_rate_limited">托管的 Hermes 收到了过多登录尝试。请稍等片刻后重试。</string>
<string name="dashboard_native_signin_gateway_unavailable">Google 登录已完成,但托管的 Hermes 登录服务不可用。请稍等片刻后重试。</string>
<string name="dashboard_native_signin_response_unsupported">托管的 Hermes 返回了此应用无法使用的登录响应。请检查 Hermes Relay 和托管代理的更新。</string>
<string name="dashboard_native_signin_attempt_inactive">本次登录尝试已取消或被替换。请关闭旧浏览器标签页并重新开始。</string>
<string name="dashboard_native_signin_storage_failed">Google 登录已完成,但 Android 无法安全保存 Hermes 会话。请重试;如果问题重复出现,请打开“诊断”查看失败阶段。</string>
<string name="conn_info_yolo_mode_desc_ephemeral">仅在此聊天中跳过批准提示。会在会话更改时重置。</string>
<string name="conn_info_yolo_mode_profile_off">配置文件批准已关闭,因此此聊天已经会跳过提示。使用单聊天例外前,请选择手动或智能。</string>
<string name="conn_info_approval_mode_title">配置文件批准模式</string>
@@ -3972,6 +4029,33 @@
<string name="dashboard_whatsapp_saved">WhatsApp 已启用;Hermes 已开始重启网关。</string>
<string name="drawer_all_profiles">所有配置文件</string>
<string name="drawer_no_profile_sessions">没有匹配的配置文件会话。</string>
<string name="drawer_customize_sessions">自定义会话</string>
<string name="drawer_group_by">分组方式</string>
<string name="drawer_order_by">排序方式</string>
<string name="drawer_show_metadata">在行中显示</string>
<string name="drawer_show_details">显示详细信息</string>
<string name="drawer_filters">筛选器</string>
<string name="drawer_project_home">主页</string>
<string name="drawer_expand_project">展开%1$s</string>
<string name="drawer_collapse_project">折叠%1$s</string>
<plurals name="drawer_project_session_count">
<item quantity="other">%1$d 个会话</item>
</plurals>
<plurals name="drawer_profile_count">
<item quantity="other">%1$d 个配置文件</item>
</plurals>
<string name="drawer_filter_status">状态</string>
<string name="drawer_filter_profile">配置文件</string>
<string name="drawer_filter_project">项目</string>
<string name="drawer_filter_pull_request">拉取请求</string>
<string name="drawer_option_updated">更新时间</string>
<string name="drawer_option_profile">配置文件</string>
<string name="drawer_option_tokens">令牌</string>
<string name="drawer_option_cost">费用</string>
<string name="drawer_reset_filters">重置</string>
<string name="drawer_profile_colors">配置文件颜色</string>
<string name="drawer_profile_color_auto">自动</string>
<string name="drawer_profile_color_set">将 %1$s 配置文件颜色设为 %2$s</string>
<string name="drawer_close">关闭</string>
<string name="profile_inspector_gateway_settings">Gateway 配置文件设置</string>
<string name="profile_inspector_gateway_settings_hint">通过 Hermes 直接保存到当前所选配置文件。</string>
@@ -3998,4 +4082,13 @@
<string name="secure_link_fallback_ready">如果 Secure Link 不可用,Hermes 可以尝试下面已批准的备用路由。</string>
<string name="secure_link_no_fallback">未包含备用路由。如果 Secure Link 不可用,受保护的服务将保持离线。</string>
<string name="secure_link_auth_note">传输保护不会绕过身份验证。Relay 配对、API 凭据和控制面板登录仍会按服务分别强制执行。</string>
<plurals name="proactive_messages_arrived_while_away">
<item quantity="other">离开期间收到了 %1$d 条消息</item>
</plurals>
<string name="chat_model_confirmation_title">确认模型选择</string>
<string name="host_resource_recent_oom">Hermes 最近可能因内存不足而重启。</string>
<string name="host_resource_memory_critical">主机内存严重不足,进行中的聊天可能会停止。</string>
<string name="host_resource_memory_elevated">主机内存不足。</string>
<string name="host_resource_disk_critical">主机存储空间严重不足,消息和设置可能无法保存。</string>
<string name="host_resource_disk_elevated">主机存储空间不足。</string>
</resources>
+97 -1
View File
@@ -1799,6 +1799,17 @@
<!-- Section action buttons -->
<string name="dashboard_section_action_change_main_model">Hauptmodell ändern</string>
<string name="dashboard_section_action_new_profile">Neues Profil</string>
<string name="dashboard_section_action_new_schedule">Neuer Zeitplan</string>
<string name="dashboard_cron_create_title">Zeitplan erstellen</string>
<string name="dashboard_cron_name">Name</string>
<string name="dashboard_cron_schedule">Zeitplan</string>
<string name="dashboard_cron_schedule_hint">Beispiele: every 2h, 0 9 * * 1-5 oder 2026-08-20 09:00</string>
<string name="dashboard_cron_prompt">Aufgabenanweisungen</string>
<string name="dashboard_cron_repeat">Nach dieser Anzahl von Ausführungen stoppen (optional)</string>
<string name="dashboard_cron_repeat_help">Leer lassen, um das normale Verhalten des Zeitplans beizubehalten. Endliche Zeitpläne akzeptieren 1–999 Ausführungen.</string>
<string name="dashboard_cron_gateway_required">Zum Erstellen eines Zeitplans ist eine aktuelle Hermes-Gateway-Verbindung erforderlich.</string>
<string name="dashboard_cron_created">Zeitplan „%1$s“ erstellt</string>
<string name="dashboard_cron_create_failed">Zeitplan konnte nicht erstellt werden</string>
<string name="dashboard_section_action_browse_hub">Sammlung durchsuchen</string>
<string name="dashboard_section_action_update_installed">Installierte aktualisieren</string>
@@ -1953,6 +1964,16 @@
<!-- Profile create -->
<string name="dashboard_profile_created">Profil %1$s erstellt</string>
<string name="dashboard_profile_created_partial">Profil %1$s wurde erstellt, aber %2$s. Vor der Verwendung prüfen.</string>
<string name="dashboard_profile_created_legacy">Profil %1$s wurde über die ältere Dashboard-Route erstellt. Zugangsdaten vor der Verwendung prüfen.</string>
<string name="dashboard_profile_no_skills">Gebündelte Skills nicht hinzufügen</string>
<string name="dashboard_profile_auth_title">Authentifizierung</string>
<string name="dashboard_profile_auth_shared">Anmeldung teilen (empfohlen) — ein aktualisierbarer Token-Pool</string>
<string name="dashboard_profile_auth_copied">Aktuelle Zugangsdaten kopieren — separater Snapshot, der veralten kann</string>
<string name="dashboard_profile_auth_isolated">Isoliert — keine Zugangsdaten oder Anbieterstandards kopieren</string>
<string name="dashboard_profile_auth_help">Hermes wendet diese Auswahl auf dem Server an. Passwörter und Token werden nie an Android zurückgegeben.</string>
<string name="dashboard_profile_allow_legacy">Wenn die Gateway-Erstellung nicht verfügbar ist, die ältere Dashboard-Route zulassen. Das Verhalten der Zugangsdaten hängt vom Host ab und muss geprüft werden.</string>
<string name="dashboard_profile_mcp_partial">Die MCP-Serverauswahl wurde nicht angewendet</string>
<string name="dashboard_profile_create_failed">Profil konnte nicht erstellt werden</string>
<!-- Stats for nerds -->
@@ -2579,7 +2600,35 @@
<plurals name="tool_run_used_tools"><item quantity="one">%d Werkzeug verwendet</item><item quantity="other">%d Werkzeuge verwendet</item></plurals>
<!-- AgentIconRow -->
<string name="agent_icon_title">Agentensymbol</string>
<string name="agent_icon_title">Agentendarstellung</string>
<string name="agent_icon_hermes_pet_title">Animiertes Hermes-Haustier</string>
<string name="agent_icon_hermes_pet_description">Profilbezogen und mit Upstream-Hermes-Oberflächen geteilt. Es reagiert auf Agentenaktivität und erscheint als Begleiter auf dem Smartphone.</string>
<string name="agent_icon_hermes_pet_active">Aktiv: %1$s</string>
<string name="agent_icon_hermes_pet_empty">Kein Upstream-Haustier ausgewählt</string>
<string name="agent_icon_hermes_pet_unsupported">Hermes aktualisieren, um animierte Haustiere hier zu verwalten</string>
<string name="agent_icon_hermes_pet_browse">Hermes-Haustiere durchsuchen</string>
<string name="agent_icon_hermes_pet_picker_title">Hermes-Haustier auswählen</string>
<string name="agent_icon_hermes_pet_selected">Ausgewählt</string>
<string name="agent_icon_hermes_pet_installed">Installiert</string>
<string name="agent_icon_hermes_pet_adopt">Aus Petdex übernehmen</string>
<string name="agent_icon_shared_title">In Hermes geteilt</string>
<string name="agent_icon_shared_active">Geteilter Avatar</string>
<string name="agent_icon_shared_empty">Kein geteilter Avatar</string>
<string name="agent_icon_change_shared">Geteilten Avatar ändern</string>
<string name="agent_icon_remove_shared">Entfernen</string>
<string name="agent_icon_remove_shared_title">Geteilten Avatar entfernen?</string>
<string name="agent_icon_remove_shared_message">Dadurch wird der Avatar aus Hermes und anderen Clients entfernt. Das Bild auf diesem Smartphone bleibt erhalten.</string>
<string name="agent_icon_phone_title">Nur dieses Smartphone</string>
<string name="agent_icon_phone_description">Den geteilten Avatar nur auf diesem Smartphone überschreiben, ohne Hermes zu ändern.</string>
<string name="agent_icon_choose_phone">Smartphone-Bild auswählen</string>
<string name="agent_icon_change_phone">Smartphone-Bild ändern</string>
<string name="agent_icon_remove_phone">Smartphone-Bild entfernen</string>
<string name="agent_icon_animation_note">Animierte GIF- und WebP-Überschreibungen werden nur auf diesem Smartphone abgespielt. Für eine profilbezogene Upstream-Animation oben ein animiertes Hermes-Haustier verwenden.</string>
<string name="agent_icon_set_local">Geräte-Ersatzbild auswählen</string>
<string name="agent_icon_change_local">Geräte-Ersatzbild ändern</string>
<string name="agent_icon_clear_local">Geräte-Ersatzbild löschen</string>
<string name="agent_icon_upload_shared">Gerätesymbol zu Hermes hochladen</string>
<string name="agent_icon_clear_shared">Geteilten Hermes-Avatar löschen</string>
<string name="agent_icon_set">Datei auswählen</string>
<string name="agent_icon_change">Datei auswählen</string>
<string name="agent_icon_clear">Löschen</string>
@@ -3464,6 +3513,14 @@
<string name="dashboard_native_signin_requires_https">Die sichere Browser-Anmeldung erfordert eine HTTPS-Dashboard-Adresse.</string>
<string name="dashboard_native_signin_unavailable">Die sichere Browser-Anmeldung ist für diese Verbindung nicht verfügbar. Aktualisiere die Verbindung und versuche es erneut.</string>
<string name="dashboard_native_signin_transport_retry">Die sichere Verbindung wurde unterbrochen. Tippe auf die Anmeldeschaltfläche, um es erneut zu versuchen.</string>
<string name="dashboard_native_signin_callback_rejected">Der Anbieter hat diese Anmeldung nicht genehmigt. Wenn du abgebrochen hast, ist keine Aktion nötig; andernfalls starte einen neuen Anmeldeversuch.</string>
<string name="dashboard_native_signin_code_rejected">Die Google-Anmeldung wurde abgeschlossen, aber das gehostete Hermes hat den einmaligen Rückrufcode abgelehnt, bevor eine Sitzung erstellt werden konnte. Starte einen neuen Anmeldeversuch.</string>
<string name="dashboard_native_signin_gateway_rejected">Das gehostete Hermes hat den Sitzungsaustausch abgelehnt. Prüfe, ob der gehostete Agent aktuell ist, und starte die Anmeldung erneut.</string>
<string name="dashboard_native_signin_rate_limited">Das gehostete Hermes hat zu viele Anmeldeversuche erhalten. Warte einen Moment und versuche es erneut.</string>
<string name="dashboard_native_signin_gateway_unavailable">Die Google-Anmeldung wurde abgeschlossen, aber der Anmeldedienst des gehosteten Hermes ist nicht verfügbar. Warte einen Moment und versuche es erneut.</string>
<string name="dashboard_native_signin_response_unsupported">Das gehostete Hermes hat eine Anmeldeantwort zurückgegeben, die diese App nicht verwenden kann. Suche nach Updates für Hermes Relay und den gehosteten Agent.</string>
<string name="dashboard_native_signin_attempt_inactive">Dieser Anmeldeversuch wurde abgebrochen oder ersetzt. Schließe den alten Browser-Tab und starte erneut.</string>
<string name="dashboard_native_signin_storage_failed">Die Google-Anmeldung wurde abgeschlossen, aber Android konnte die Hermes-Sitzung nicht sicher speichern. Versuche es erneut. Falls der Fehler wiederholt auftritt, öffne die Diagnose für die Fehlerstufe.</string>
<string name="conn_info_yolo_mode_desc_ephemeral">Umgehe Bestätigungsabfragen nur für diesen Chat. Wird beim Sitzungswechsel zurückgesetzt.</string>
<string name="conn_info_yolo_mode_profile_off">Profilbestätigungen sind deaktiviert, daher umgeht dieser Chat bereits Abfragen. Wähle Manuell oder Smart, bevor du die chatbezogene Ausnahme verwendest.</string>
<string name="conn_info_approval_mode_title">Profil-Bestätigungsmodus</string>
@@ -4044,6 +4101,35 @@
<string name="dashboard_whatsapp_saved">WhatsApp aktiviert; Hermes hat einen Gateway-Neustart gestartet.</string>
<string name="drawer_all_profiles">Alle Profile</string>
<string name="drawer_no_profile_sessions">Keine passenden Profilsitzungen.</string>
<string name="drawer_customize_sessions">Sitzungen anpassen</string>
<string name="drawer_group_by">Gruppieren nach</string>
<string name="drawer_order_by">Sortieren nach</string>
<string name="drawer_show_metadata">In Zeilen anzeigen</string>
<string name="drawer_show_details">Details anzeigen</string>
<string name="drawer_filters">Filter</string>
<string name="drawer_project_home">Startseite</string>
<string name="drawer_expand_project">%1$s erweitern</string>
<string name="drawer_collapse_project">%1$s reduzieren</string>
<plurals name="drawer_project_session_count">
<item quantity="one">%1$d Sitzung</item>
<item quantity="other">%1$d Sitzungen</item>
</plurals>
<plurals name="drawer_profile_count">
<item quantity="one">%1$d Profil</item>
<item quantity="other">%1$d Profile</item>
</plurals>
<string name="drawer_filter_status">Status</string>
<string name="drawer_filter_profile">Profil</string>
<string name="drawer_filter_project">Projekt</string>
<string name="drawer_filter_pull_request">Pull Request</string>
<string name="drawer_option_updated">Aktualisiert</string>
<string name="drawer_option_profile">Profil</string>
<string name="drawer_option_tokens">Tokens</string>
<string name="drawer_option_cost">Kosten</string>
<string name="drawer_reset_filters">Zurücksetzen</string>
<string name="drawer_profile_colors">Profilfarben</string>
<string name="drawer_profile_color_auto">Automatisch</string>
<string name="drawer_profile_color_set">Profilfarbe von %1$s auf %2$s setzen</string>
<string name="drawer_close">Schließen</string>
<string name="profile_inspector_gateway_settings">Gateway-Profileinstellungen</string>
<string name="profile_inspector_gateway_settings_hint">Wird für dieses ausgewählte Profil direkt über Hermes gespeichert.</string>
@@ -4070,4 +4156,14 @@
<string name="secure_link_fallback_ready">Wenn Secure Link nicht verfügbar ist, kann Hermes die genehmigten Ausweichrouten unten versuchen.</string>
<string name="secure_link_no_fallback">Keine Ausweichroute enthalten. Geschützte Dienste bleiben offline, wenn Secure Link nicht verfügbar ist.</string>
<string name="secure_link_auth_note">Transportschutz umgeht keine Authentifizierung. Relay-Kopplung, API-Zugangsdaten und Dashboard-Anmeldung werden weiterhin pro Dienst erzwungen.</string>
<plurals name="proactive_messages_arrived_while_away">
<item quantity="one">%1$d Nachricht ist eingegangen, während du weg warst</item>
<item quantity="other">%1$d Nachrichten sind eingegangen, während du weg warst</item>
</plurals>
<string name="chat_model_confirmation_title">Modellauswahl bestätigen</string>
<string name="host_resource_recent_oom">Hermes wurde kürzlich wahrscheinlich wegen Speichermangels neu gestartet.</string>
<string name="host_resource_memory_critical">Der Arbeitsspeicher des Hosts ist äußerst knapp; aktive Chats können beendet werden.</string>
<string name="host_resource_memory_elevated">Der Arbeitsspeicher des Hosts wird knapp.</string>
<string name="host_resource_disk_critical">Der Speicherplatz des Hosts ist äußerst knapp; Nachrichten und Einstellungen können möglicherweise nicht gespeichert werden.</string>
<string name="host_resource_disk_elevated">Der Speicherplatz des Hosts wird knapp.</string>
</resources>
+97 -1
View File
@@ -1646,6 +1646,17 @@
<string name="dashboard_empty_section">No se devolvió ningún %1$s</string>
<string name="dashboard_section_action_change_main_model">Cambiar modelo principal</string>
<string name="dashboard_section_action_new_profile">Nuevo perfil</string>
<string name="dashboard_section_action_new_schedule">Nueva programación</string>
<string name="dashboard_cron_create_title">Crear programación</string>
<string name="dashboard_cron_name">Nombre</string>
<string name="dashboard_cron_schedule">Programación</string>
<string name="dashboard_cron_schedule_hint">Ejemplos: every 2h, 0 9 * * 1-5 o 2026-08-20 09:00</string>
<string name="dashboard_cron_prompt">Instrucciones de la tarea</string>
<string name="dashboard_cron_repeat">Detener después de esta cantidad de ejecuciones (opcional)</string>
<string name="dashboard_cron_repeat_help">Déjalo en blanco para conservar el comportamiento normal. Las programaciones finitas aceptan entre 1 y 999 ejecuciones.</string>
<string name="dashboard_cron_gateway_required">La creación de programaciones requiere una conexión actual con Hermes Gateway.</string>
<string name="dashboard_cron_created">Programación «%1$s» creada</string>
<string name="dashboard_cron_create_failed">No se pudo crear la programación</string>
<string name="dashboard_section_action_browse_hub">Centro de exploración</string>
<string name="dashboard_section_action_update_installed">Actualización instalada</string>
<string name="dashboard_action_set">Colocar</string>
@@ -1774,6 +1785,16 @@
<string name="dashboard_skills_update_started">La actualización de habilidades comenzó en el servidor: actualice las habilidades en un minuto</string>
<string name="dashboard_skills_update_failed">La actualización de habilidades no pudo iniciarse</string>
<string name="dashboard_profile_created">Perfil %1$s creado</string>
<string name="dashboard_profile_created_partial">Se creó el perfil %1$s, pero %2$s. Revísalo antes de usarlo.</string>
<string name="dashboard_profile_created_legacy">El perfil %1$s se creó mediante la ruta antigua del panel. Revisa sus credenciales antes de usarlo.</string>
<string name="dashboard_profile_no_skills">No añadir las habilidades incluidas</string>
<string name="dashboard_profile_auth_title">Autenticación</string>
<string name="dashboard_profile_auth_shared">Compartir inicio de sesión (recomendado) — un grupo de tokens renovables</string>
<string name="dashboard_profile_auth_copied">Copiar credenciales actuales — instantánea separada que puede quedar desactualizada</string>
<string name="dashboard_profile_auth_isolated">Aislado — no copiar credenciales ni valores predeterminados del proveedor</string>
<string name="dashboard_profile_auth_help">Hermes aplica esta opción en el servidor. Las contraseñas y los tokens nunca se devuelven a Android.</string>
<string name="dashboard_profile_allow_legacy">Si la creación mediante Gateway no está disponible, permitir la ruta antigua del panel. El comportamiento de las credenciales depende del host y debe revisarse.</string>
<string name="dashboard_profile_mcp_partial">No se aplicó la selección de servidores MCP</string>
<string name="dashboard_profile_create_failed">Error al crear el perfil</string>
<string name="stats_overview">Descripción general</string>
<string name="stats_reset">Reiniciar</string>
@@ -2350,7 +2371,35 @@
<plurals name="tool_run_used_device"><item quantity="one">Se ejecutó %d acción del dispositivo</item><item quantity="other">Se ejecutaron %d acciones del dispositivo</item></plurals>
<plurals name="tool_run_using_tools"><item quantity="one">Usando %d herramienta</item><item quantity="other">Usando %d herramientas</item></plurals>
<plurals name="tool_run_used_tools"><item quantity="one">Se usó %d herramienta</item><item quantity="other">Se usaron %d herramientas</item></plurals>
<string name="agent_icon_title">Icono de agente</string>
<string name="agent_icon_title">Aspecto del agente</string>
<string name="agent_icon_hermes_pet_title">Mascota animada de Hermes</string>
<string name="agent_icon_hermes_pet_description">Se guarda por perfil y se comparte con las superficies upstream de Hermes. Reacciona a la actividad del agente y aparece como acompañante en el teléfono.</string>
<string name="agent_icon_hermes_pet_active">Activa: %1$s</string>
<string name="agent_icon_hermes_pet_empty">No hay mascota upstream seleccionada</string>
<string name="agent_icon_hermes_pet_unsupported">Actualiza Hermes para gestionar aquí mascotas animadas</string>
<string name="agent_icon_hermes_pet_browse">Explorar mascotas de Hermes</string>
<string name="agent_icon_hermes_pet_picker_title">Elige una mascota de Hermes</string>
<string name="agent_icon_hermes_pet_selected">Seleccionada</string>
<string name="agent_icon_hermes_pet_installed">Instalada</string>
<string name="agent_icon_hermes_pet_adopt">Adoptar desde Petdex</string>
<string name="agent_icon_shared_title">Compartido en Hermes</string>
<string name="agent_icon_shared_active">Avatar compartido</string>
<string name="agent_icon_shared_empty">Sin avatar compartido</string>
<string name="agent_icon_change_shared">Cambiar avatar compartido</string>
<string name="agent_icon_remove_shared">Quitar</string>
<string name="agent_icon_remove_shared_title">¿Quitar el avatar compartido?</string>
<string name="agent_icon_remove_shared_message">Esto quita el avatar de Hermes y de otros clientes. La imagen del teléfono no se modifica.</string>
<string name="agent_icon_phone_title">Solo este teléfono</string>
<string name="agent_icon_phone_description">Reemplaza el avatar compartido solo en este teléfono sin cambiar Hermes.</string>
<string name="agent_icon_choose_phone">Elegir imagen del teléfono</string>
<string name="agent_icon_change_phone">Cambiar imagen del teléfono</string>
<string name="agent_icon_remove_phone">Quitar imagen del teléfono</string>
<string name="agent_icon_animation_note">Las sustituciones GIF y WebP animadas se reproducen solo en este teléfono. Usa arriba una mascota animada de Hermes para una animación upstream por perfil.</string>
<string name="agent_icon_set_local">Elegir imagen alternativa del dispositivo</string>
<string name="agent_icon_change_local">Cambiar imagen alternativa del dispositivo</string>
<string name="agent_icon_clear_local">Borrar imagen alternativa del dispositivo</string>
<string name="agent_icon_upload_shared">Subir icono del dispositivo a Hermes</string>
<string name="agent_icon_clear_shared">Borrar avatar compartido de Hermes</string>
<string name="agent_icon_set">Elegir archivo</string>
<string name="agent_icon_change">Elegir archivo</string>
<string name="agent_icon_clear">Claro</string>
@@ -3149,6 +3198,14 @@
<string name="dashboard_native_signin_requires_https">El inicio de sesión seguro en el navegador requiere una dirección HTTPS del panel.</string>
<string name="dashboard_native_signin_unavailable">El inicio de sesión seguro en el navegador no está disponible para esta conexión. Actualiza la conexión e inténtalo de nuevo.</string>
<string name="dashboard_native_signin_transport_retry">Se interrumpió la conexión segura. Toca el botón de inicio de sesión para volver a intentarlo.</string>
<string name="dashboard_native_signin_callback_rejected">El proveedor no aprobó este inicio de sesión. Si lo cancelaste, no tienes que hacer nada; de lo contrario, inicia un intento nuevo.</string>
<string name="dashboard_native_signin_code_rejected">El inicio de sesión con Google terminó, pero Hermes alojado rechazó el código de devolución de un solo uso antes de crear una sesión. Inicia un intento nuevo.</string>
<string name="dashboard_native_signin_gateway_rejected">Hermes alojado rechazó el intercambio de sesión. Comprueba que el agente alojado esté actualizado y vuelve a iniciar sesión.</string>
<string name="dashboard_native_signin_rate_limited">Hermes alojado recibió demasiados intentos de inicio de sesión. Espera un momento y vuelve a intentarlo.</string>
<string name="dashboard_native_signin_gateway_unavailable">El inicio de sesión con Google terminó, pero el servicio de acceso de Hermes alojado no está disponible. Espera un momento y vuelve a intentarlo.</string>
<string name="dashboard_native_signin_response_unsupported">Hermes alojado devolvió una respuesta de inicio de sesión que esta aplicación no puede usar. Busca actualizaciones de Hermes Relay y del agente alojado.</string>
<string name="dashboard_native_signin_attempt_inactive">Este intento de inicio de sesión se canceló o fue reemplazado. Cierra la pestaña anterior y empieza de nuevo.</string>
<string name="dashboard_native_signin_storage_failed">El inicio de sesión con Google terminó, pero Android no pudo guardar de forma segura la sesión de Hermes. Vuelve a intentarlo; si se repite, abre Diagnósticos para ver la fase del error.</string>
<string name="conn_info_yolo_mode_desc_ephemeral">Omite las solicitudes de aprobación solo para este chat. Se restablece al cambiar de sesión.</string>
<string name="conn_info_yolo_mode_profile_off">Las aprobaciones del perfil están desactivadas, por lo que este chat ya omite las solicitudes. Elige Manual o Inteligente antes de usar la excepción por chat.</string>
<string name="conn_info_approval_mode_title">Modo de aprobación del perfil</string>
@@ -3729,6 +3786,35 @@
<string name="dashboard_whatsapp_saved">WhatsApp activado; Hermes inició un reinicio del gateway.</string>
<string name="drawer_all_profiles">Todos los perfiles</string>
<string name="drawer_no_profile_sessions">No hay sesiones de perfil coincidentes.</string>
<string name="drawer_customize_sessions">Personalizar sesiones</string>
<string name="drawer_group_by">Agrupar por</string>
<string name="drawer_order_by">Ordenar por</string>
<string name="drawer_show_metadata">Mostrar en las filas</string>
<string name="drawer_show_details">Mostrar detalles</string>
<string name="drawer_filters">Filtros</string>
<string name="drawer_project_home">Inicio</string>
<string name="drawer_expand_project">Expandir %1$s</string>
<string name="drawer_collapse_project">Contraer %1$s</string>
<plurals name="drawer_project_session_count">
<item quantity="one">%1$d sesión</item>
<item quantity="other">%1$d sesiones</item>
</plurals>
<plurals name="drawer_profile_count">
<item quantity="one">%1$d perfil</item>
<item quantity="other">%1$d perfiles</item>
</plurals>
<string name="drawer_filter_status">Estado</string>
<string name="drawer_filter_profile">Perfil</string>
<string name="drawer_filter_project">Proyecto</string>
<string name="drawer_filter_pull_request">Solicitud de incorporación</string>
<string name="drawer_option_updated">Actualizado</string>
<string name="drawer_option_profile">Perfil</string>
<string name="drawer_option_tokens">Tokens</string>
<string name="drawer_option_cost">Coste</string>
<string name="drawer_reset_filters">Restablecer</string>
<string name="drawer_profile_colors">Colores de perfil</string>
<string name="drawer_profile_color_auto">Automático</string>
<string name="drawer_profile_color_set">Establecer el color del perfil %1$s en %2$s</string>
<string name="drawer_close">Cerrar</string>
<string name="profile_inspector_gateway_settings">Ajustes del perfil de Gateway</string>
<string name="profile_inspector_gateway_settings_hint">Se guarda directamente mediante Hermes para este perfil seleccionado.</string>
@@ -3755,4 +3841,14 @@
<string name="secure_link_fallback_ready">Si Secure Link no está disponible, Hermes puede probar las rutas alternativas aprobadas que aparecen abajo.</string>
<string name="secure_link_no_fallback">No se incluye ninguna ruta alternativa. Los servicios protegidos permanecerán sin conexión si Secure Link no está disponible.</string>
<string name="secure_link_auth_note">La protección del transporte no omite la autenticación. El emparejamiento de Relay, las credenciales de API y el inicio de sesión del Panel se siguen aplicando por servicio.</string>
<plurals name="proactive_messages_arrived_while_away">
<item quantity="one">Ha llegado %1$d mensaje mientras no estabas</item>
<item quantity="other">Han llegado %1$d mensajes mientras no estabas</item>
</plurals>
<string name="chat_model_confirmation_title">Confirmar selección de modelo</string>
<string name="host_resource_recent_oom">Hermes se reinició recientemente, probablemente por falta de memoria.</string>
<string name="host_resource_memory_critical">La memoria del servidor está casi agotada; los chats activos podrían detenerse.</string>
<string name="host_resource_memory_elevated">La memoria del servidor se está agotando.</string>
<string name="host_resource_disk_critical">El almacenamiento del servidor está casi agotado; puede que los mensajes y ajustes no se guarden.</string>
<string name="host_resource_disk_elevated">El almacenamiento del servidor se está agotando.</string>
</resources>
+96 -1
View File
@@ -1812,6 +1812,17 @@
<!-- Section action buttons -->
<string name="dashboard_section_action_change_main_model">メイン機種変更</string>
<string name="dashboard_section_action_new_profile">新しいプロフィール</string>
<string name="dashboard_section_action_new_schedule">新しいスケジュール</string>
<string name="dashboard_cron_create_title">スケジュールを作成</string>
<string name="dashboard_cron_name">名前</string>
<string name="dashboard_cron_schedule">スケジュール</string>
<string name="dashboard_cron_schedule_hint">例: every 2h、0 9 * * 1-5、2026-08-20 09:00</string>
<string name="dashboard_cron_prompt">タスクの指示</string>
<string name="dashboard_cron_repeat">この実行回数の後に停止(任意)</string>
<string name="dashboard_cron_repeat_help">通常の動作を維持する場合は空欄にします。有限スケジュールには1~999回を指定できます。</string>
<string name="dashboard_cron_gateway_required">スケジュールの作成には、現在のHermes Gateway接続が必要です。</string>
<string name="dashboard_cron_created">スケジュール「%1$s」を作成しました</string>
<string name="dashboard_cron_create_failed">スケジュールを作成できませんでした</string>
<string name="dashboard_section_action_browse_hub">ハブを参照する</string>
<string name="dashboard_section_action_update_installed">アップデートがインストールされました</string>
@@ -1966,6 +1977,16 @@
<!-- Profile create -->
<string name="dashboard_profile_created">プロファイル %1$s が作成されました</string>
<string name="dashboard_profile_created_partial">プロファイル %1$s は作成されましたが、%2$s。使用前に確認してください。</string>
<string name="dashboard_profile_created_legacy">プロファイル %1$s は旧 Dashboard ルートで作成されました。使用前に認証情報を確認してください。</string>
<string name="dashboard_profile_no_skills">同梱スキルを追加しない</string>
<string name="dashboard_profile_auth_title">認証</string>
<string name="dashboard_profile_auth_shared">サインインを共有(推奨)— 更新可能なトークンプールを共有</string>
<string name="dashboard_profile_auth_copied">現在の認証情報をコピー — 別スナップショットのため古くなる場合があります</string>
<string name="dashboard_profile_auth_isolated">分離 — 認証情報やプロバイダー既定値をコピーしない</string>
<string name="dashboard_profile_auth_help">Hermes がサーバー上でこの選択を適用します。パスワードやトークンが Android に返されることはありません。</string>
<string name="dashboard_profile_allow_legacy">Gateway で作成できない場合、旧 Dashboard ルートを許可します。認証情報の動作はホストによって異なるため確認が必要です。</string>
<string name="dashboard_profile_mcp_partial">MCP サーバーの選択は適用されませんでした</string>
<string name="dashboard_profile_create_failed">プロファイルの作成に失敗しました</string>
<!-- Stats for nerds -->
@@ -2590,7 +2611,35 @@
<plurals name="tool_run_used_tools"><item quantity="other">%d 個のツールを使用</item></plurals>
<!-- AgentIconRow -->
<string name="agent_icon_title">エージェントアイコン</string>
<string name="agent_icon_title">エージェントの外観</string>
<string name="agent_icon_hermes_pet_title">Hermes アニメーションペット</string>
<string name="agent_icon_hermes_pet_description">プロファイル単位で upstream Hermes の各画面と共有されます。エージェントの活動に反応し、スマートフォンのコンパニオンとして表示されます。</string>
<string name="agent_icon_hermes_pet_active">有効: %1$s</string>
<string name="agent_icon_hermes_pet_empty">upstream ペットが選択されていません</string>
<string name="agent_icon_hermes_pet_unsupported">ここでアニメーションペットを管理するには Hermes を更新してください</string>
<string name="agent_icon_hermes_pet_browse">Hermes ペットを探す</string>
<string name="agent_icon_hermes_pet_picker_title">Hermes ペットを選択</string>
<string name="agent_icon_hermes_pet_selected">選択中</string>
<string name="agent_icon_hermes_pet_installed">インストール済み</string>
<string name="agent_icon_hermes_pet_adopt">Petdex から迎える</string>
<string name="agent_icon_shared_title">Hermes 全体で共有</string>
<string name="agent_icon_shared_active">共有アバター</string>
<string name="agent_icon_shared_empty">共有アバターなし</string>
<string name="agent_icon_change_shared">共有アバターを変更</string>
<string name="agent_icon_remove_shared">削除</string>
<string name="agent_icon_remove_shared_title">共有アバターを削除しますか?</string>
<string name="agent_icon_remove_shared_message">Hermes と他のクライアントからアバターを削除します。この端末の画像には影響しません。</string>
<string name="agent_icon_phone_title">この端末のみ</string>
<string name="agent_icon_phone_description">Hermes を変更せず、この端末だけ共有アバターを上書きします。</string>
<string name="agent_icon_choose_phone">端末の画像を選択</string>
<string name="agent_icon_change_phone">端末の画像を変更</string>
<string name="agent_icon_remove_phone">端末の画像を削除</string>
<string name="agent_icon_animation_note">アニメーション GIF と WebP の上書きはこの端末でのみ再生されます。プロファイル単位の upstream アニメーションには、上の Hermes アニメーションペットを使用してください。</string>
<string name="agent_icon_set_local">端末の予備画像を選択</string>
<string name="agent_icon_change_local">端末の予備画像を変更</string>
<string name="agent_icon_clear_local">端末の予備画像を消去</string>
<string name="agent_icon_upload_shared">端末アイコンを Hermes にアップロード</string>
<string name="agent_icon_clear_shared">共有 Hermes アバターを消去</string>
<string name="agent_icon_set">ファイルを選択</string>
<string name="agent_icon_change">ファイルを選択</string>
<string name="agent_icon_clear">クリア</string>
@@ -3463,6 +3512,14 @@
<string name="dashboard_native_signin_requires_https">安全なブラウザサインインには、HTTPSのダッシュボードアドレスが必要です。</string>
<string name="dashboard_native_signin_unavailable">この接続では安全なブラウザサインインを利用できません。接続を更新して、もう一度お試しください。</string>
<string name="dashboard_native_signin_transport_retry">安全な接続が中断されました。サインインボタンをタップして、もう一度お試しください。</string>
<string name="dashboard_native_signin_callback_rejected">プロバイダーがこのサインインを承認しませんでした。キャンセルした場合は操作不要です。それ以外の場合は、新しいサインインを開始してください。</string>
<string name="dashboard_native_signin_code_rejected">Google サインインは完了しましたが、ホストされた Hermes がセッション作成前に一回限りのコールバックコードを拒否しました。新しいサインインを開始してください。</string>
<string name="dashboard_native_signin_gateway_rejected">ホストされた Hermes がセッション交換を拒否しました。ホスト側エージェントが最新であることを確認し、サインインをやり直してください。</string>
<string name="dashboard_native_signin_rate_limited">ホストされた Hermes が短時間に多すぎるサインイン試行を受け取りました。しばらく待ってから再試行してください。</string>
<string name="dashboard_native_signin_gateway_unavailable">Google サインインは完了しましたが、ホストされた Hermes のサインインサービスを利用できません。しばらく待ってから再試行してください。</string>
<string name="dashboard_native_signin_response_unsupported">ホストされた Hermes から、このアプリでは使用できないサインイン応答が返されました。Hermes Relay とホスト側エージェントの更新を確認してください。</string>
<string name="dashboard_native_signin_attempt_inactive">このサインイン試行はキャンセルされたか置き換えられました。古いブラウザタブを閉じて、やり直してください。</string>
<string name="dashboard_native_signin_storage_failed">Google サインインは完了しましたが、Android が Hermes セッションを安全に保存できませんでした。再試行し、繰り返す場合は診断で失敗段階を確認してください。</string>
<string name="conn_info_yolo_mode_desc_ephemeral">このチャットでのみ承認確認を省略します。セッションが変わるとリセットされます。</string>
<string name="conn_info_yolo_mode_profile_off">プロフィールの承認がオフのため、このチャットではすでに確認を省略しています。チャット単位の例外を使う前に、手動またはスマートを選択してください。</string>
<string name="conn_info_approval_mode_title">プロフィール承認モード</string>
@@ -4043,6 +4100,35 @@
<string name="dashboard_whatsapp_saved">WhatsApp を有効にしました。Hermes がゲートウェイの再起動を開始しました。</string>
<string name="drawer_all_profiles">すべてのプロファイル</string>
<string name="drawer_no_profile_sessions">一致するプロファイルセッションはありません。</string>
<string name="drawer_customize_sessions">セッションをカスタマイズ</string>
<string name="drawer_group_by">グループ化</string>
<string name="drawer_order_by">並び順</string>
<string name="drawer_show_metadata">行に表示</string>
<string name="drawer_show_details">詳細を表示</string>
<string name="drawer_filters">フィルター</string>
<string name="drawer_project_home">ホーム</string>
<string name="drawer_expand_project">%1$sを展開</string>
<string name="drawer_collapse_project">%1$sを折りたたむ</string>
<plurals name="drawer_project_session_count">
<item quantity="one">%1$d件のセッション</item>
<item quantity="other">%1$d件のセッション</item>
</plurals>
<plurals name="drawer_profile_count">
<item quantity="one">%1$d件のプロファイル</item>
<item quantity="other">%1$d件のプロファイル</item>
</plurals>
<string name="drawer_filter_status">ステータス</string>
<string name="drawer_filter_profile">プロフィール</string>
<string name="drawer_filter_project">プロジェクト</string>
<string name="drawer_filter_pull_request">プルリクエスト</string>
<string name="drawer_option_updated">更新日時</string>
<string name="drawer_option_profile">プロフィール</string>
<string name="drawer_option_tokens">トークン</string>
<string name="drawer_option_cost">コスト</string>
<string name="drawer_reset_filters">リセット</string>
<string name="drawer_profile_colors">プロフィールの色</string>
<string name="drawer_profile_color_auto">自動</string>
<string name="drawer_profile_color_set">%1$s のプロフィール色を %2$s に設定</string>
<string name="drawer_close">閉じる</string>
<string name="profile_inspector_gateway_settings">Gateway プロファイル設定</string>
<string name="profile_inspector_gateway_settings_hint">選択中のプロファイルに Hermes 経由で直接保存します。</string>
@@ -4069,4 +4155,13 @@
<string name="secure_link_fallback_ready">Secure Link を利用できない場合、Hermes は以下の承認済み代替ルートを試行できます。</string>
<string name="secure_link_no_fallback">代替ルートは含まれていません。Secure Link を利用できない場合、保護対象サービスはオフラインのままです。</string>
<string name="secure_link_auth_note">トランスポート保護によって認証が省略されることはありません。Relay のペアリング、API 資格情報、ダッシュボードへのログインはサービスごとに引き続き適用されます。</string>
<plurals name="proactive_messages_arrived_while_away">
<item quantity="other">離れている間に%1$d件のメッセージが届きました</item>
</plurals>
<string name="chat_model_confirmation_title">モデル選択を確認</string>
<string name="host_resource_recent_oom">Hermes は最近、メモリ不足が原因と思われる再起動を行いました。</string>
<string name="host_resource_memory_critical">ホストのメモリが極端に不足しています。進行中のチャットが停止する可能性があります。</string>
<string name="host_resource_memory_elevated">ホストのメモリが不足しています。</string>
<string name="host_resource_disk_critical">ホストのストレージが極端に不足しています。メッセージや設定を保存できない可能性があります。</string>
<string name="host_resource_disk_elevated">ホストのストレージが不足しています。</string>
</resources>
+103 -1
View File
@@ -1822,6 +1822,14 @@
<string name="dashboard_native_signin_requires_https">Безопасный вход в браузере требует HTTPS-адреса панели управления.</string>
<string name="dashboard_native_signin_unavailable">Безопасный вход в браузере недоступен для этого подключения. Обновите подключение и попробуйте снова.</string>
<string name="dashboard_native_signin_transport_retry">Защищённое соединение было прервано. Нажмите кнопку входа, чтобы повторить попытку.</string>
<string name="dashboard_native_signin_callback_rejected">Провайдер не подтвердил этот вход. Если вы отменили его, ничего делать не нужно; иначе начните новую попытку входа.</string>
<string name="dashboard_native_signin_code_rejected">Вход через Google завершён, но размещённый Hermes отклонил одноразовый код обратного вызова до создания сеанса. Начните новую попытку входа.</string>
<string name="dashboard_native_signin_gateway_rejected">Размещённый Hermes отклонил обмен сеанса. Убедитесь, что размещённый агент обновлён, и начните вход заново.</string>
<string name="dashboard_native_signin_rate_limited">Размещённый Hermes получил слишком много попыток входа. Подождите немного и повторите попытку.</string>
<string name="dashboard_native_signin_gateway_unavailable">Вход через Google завершён, но служба входа размещённого Hermes недоступна. Подождите немного и повторите попытку.</string>
<string name="dashboard_native_signin_response_unsupported">Размещённый Hermes вернул ответ входа, который это приложение не может использовать. Проверьте обновления Hermes Relay и размещённого агента.</string>
<string name="dashboard_native_signin_attempt_inactive">Эта попытка входа была отменена или заменена. Закройте старую вкладку и начните заново.</string>
<string name="dashboard_native_signin_storage_failed">Вход через Google завершён, но Android не смог безопасно сохранить сеанс Hermes. Повторите попытку; если ошибка повторится, откройте диагностику, чтобы увидеть этап сбоя.</string>
<string name="dashboard_close_signin">Закрыть вход</string>
<string name="dashboard_error_title">Панель управления недоступна</string>
<string name="dashboard_error_target">Не удалось загрузить из %1$s</string>
@@ -1831,6 +1839,17 @@
<string name="dashboard_empty_section">Не возвращено %1$s</string>
<string name="dashboard_section_action_change_main_model">Изменить основную модель</string>
<string name="dashboard_section_action_new_profile">Новый профиль</string>
<string name="dashboard_section_action_new_schedule">Новое расписание</string>
<string name="dashboard_cron_create_title">Создать расписание</string>
<string name="dashboard_cron_name">Название</string>
<string name="dashboard_cron_schedule">Расписание</string>
<string name="dashboard_cron_schedule_hint">Примеры: every 2h, 0 9 * * 1-5 или 2026-08-20 09:00</string>
<string name="dashboard_cron_prompt">Инструкции задачи</string>
<string name="dashboard_cron_repeat">Остановить после указанного числа запусков (необязательно)</string>
<string name="dashboard_cron_repeat_help">Оставьте пустым для обычного поведения расписания. Для конечных расписаний можно указать от 1 до 999 запусков.</string>
<string name="dashboard_cron_gateway_required">Для создания расписания требуется текущее подключение к Hermes Gateway.</string>
<string name="dashboard_cron_created">Расписание «%1$s» создано</string>
<string name="dashboard_cron_create_failed">Не удалось создать расписание</string>
<string name="dashboard_section_action_browse_hub">Просмотр центра</string>
<string name="dashboard_section_action_update_installed">Обновить установленное</string>
<string name="dashboard_action_set">Установить</string>
@@ -1955,6 +1974,16 @@
<string name="dashboard_skills_update_started">Обновление навыка начато на сервере — обновите Навыки через минуту</string>
<string name="dashboard_skills_update_failed">Обновление навыка не удалось</string>
<string name="dashboard_profile_created">Профиль %1$s создан</string>
<string name="dashboard_profile_created_partial">Профиль %1$s создан, но %2$s. Проверьте его перед использованием.</string>
<string name="dashboard_profile_created_legacy">Профиль %1$s создан через старый маршрут панели. Проверьте его учётные данные перед использованием.</string>
<string name="dashboard_profile_no_skills">Не добавлять встроенные навыки</string>
<string name="dashboard_profile_auth_title">Аутентификация</string>
<string name="dashboard_profile_auth_shared">Общий вход (рекомендуется) — единый пул обновляемых токенов</string>
<string name="dashboard_profile_auth_copied">Копировать текущие учётные данные — отдельный снимок может устареть</string>
<string name="dashboard_profile_auth_isolated">Изолированный — не копировать учётные данные и настройки провайдера</string>
<string name="dashboard_profile_auth_help">Hermes применяет этот выбор на сервере. Пароли и токены никогда не возвращаются в Android.</string>
<string name="dashboard_profile_allow_legacy">Если создание через Gateway недоступно, разрешить старый маршрут панели. Поведение учётных данных зависит от хоста и требует проверки.</string>
<string name="dashboard_profile_mcp_partial">Выбор серверов MCP не был применён</string>
<string name="dashboard_profile_create_failed">Создание профиля не удалось</string>
<string name="stats_overview">Обзор</string>
<string name="stats_reset">Сброс</string>
@@ -2553,7 +2582,35 @@
<plurals name="tool_run_used_device"><item quantity="one">Выполнено %d действие устройства</item><item quantity="few">Выполнено %d действия устройства</item><item quantity="many">Выполнено %d действий устройства</item><item quantity="other">Выполнено %d действий устройства</item></plurals>
<plurals name="tool_run_using_tools"><item quantity="one">Используется %d инструмент</item><item quantity="few">Используется %d инструмента</item><item quantity="many">Используется %d инструментов</item><item quantity="other">Используется %d инструментов</item></plurals>
<plurals name="tool_run_used_tools"><item quantity="one">Использован %d инструмент</item><item quantity="few">Использовано %d инструмента</item><item quantity="many">Использовано %d инструментов</item><item quantity="other">Использовано %d инструментов</item></plurals>
<string name="agent_icon_title">Иконка агента</string>
<string name="agent_icon_title">Внешний вид агента</string>
<string name="agent_icon_hermes_pet_title">Анимированный питомец Hermes</string>
<string name="agent_icon_hermes_pet_description">Привязан к профилю и доступен в upstream-интерфейсах Hermes. Реагирует на действия агента и отображается как спутник на телефоне.</string>
<string name="agent_icon_hermes_pet_active">Активен: %1$s</string>
<string name="agent_icon_hermes_pet_empty">Upstream-питомец не выбран</string>
<string name="agent_icon_hermes_pet_unsupported">Обновите Hermes, чтобы управлять анимированными питомцами здесь</string>
<string name="agent_icon_hermes_pet_browse">Выбрать питомца Hermes</string>
<string name="agent_icon_hermes_pet_picker_title">Выберите питомца Hermes</string>
<string name="agent_icon_hermes_pet_selected">Выбран</string>
<string name="agent_icon_hermes_pet_installed">Установлен</string>
<string name="agent_icon_hermes_pet_adopt">Добавить из Petdex</string>
<string name="agent_icon_shared_title">Общий в Hermes</string>
<string name="agent_icon_shared_active">Общий аватар</string>
<string name="agent_icon_shared_empty">Нет общего аватара</string>
<string name="agent_icon_change_shared">Изменить общий аватар</string>
<string name="agent_icon_remove_shared">Удалить</string>
<string name="agent_icon_remove_shared_title">Удалить общий аватар?</string>
<string name="agent_icon_remove_shared_message">Аватар будет удалён из Hermes и других клиентов. Изображение на телефоне не изменится.</string>
<string name="agent_icon_phone_title">Только на этом телефоне</string>
<string name="agent_icon_phone_description">Заменить общий аватар только на этом телефоне, не изменяя Hermes.</string>
<string name="agent_icon_choose_phone">Выбрать изображение на телефоне</string>
<string name="agent_icon_change_phone">Изменить изображение на телефоне</string>
<string name="agent_icon_remove_phone">Удалить изображение с телефона</string>
<string name="agent_icon_animation_note">Анимированные GIF- и WebP-переопределения работают только на этом телефоне. Для профильной upstream-анимации используйте анимированного питомца Hermes выше.</string>
<string name="agent_icon_set_local">Выбрать резервное изображение на устройстве</string>
<string name="agent_icon_change_local">Изменить резервное изображение на устройстве</string>
<string name="agent_icon_clear_local">Удалить резервное изображение с устройства</string>
<string name="agent_icon_upload_shared">Загрузить значок устройства в Hermes</string>
<string name="agent_icon_clear_shared">Удалить общий аватар Hermes</string>
<string name="agent_icon_set">Выбрать файл</string>
<string name="agent_icon_change">Выбрать файл</string>
<string name="agent_icon_clear">Очистить</string>
@@ -3765,6 +3822,39 @@
<string name="dashboard_whatsapp_saved">WhatsApp включён; Hermes начал перезапуск шлюза.</string>
<string name="drawer_all_profiles">Все профили</string>
<string name="drawer_no_profile_sessions">Нет подходящих сеансов профиля.</string>
<string name="drawer_customize_sessions">Настроить сеансы</string>
<string name="drawer_group_by">Группировать по</string>
<string name="drawer_order_by">Сортировать по</string>
<string name="drawer_show_metadata">Показывать в строках</string>
<string name="drawer_show_details">Показывать подробности</string>
<string name="drawer_filters">Фильтры</string>
<string name="drawer_project_home">Главная</string>
<string name="drawer_expand_project">Развернуть %1$s</string>
<string name="drawer_collapse_project">Свернуть %1$s</string>
<plurals name="drawer_project_session_count">
<item quantity="one">%1$d сеанс</item>
<item quantity="few">%1$d сеанса</item>
<item quantity="many">%1$d сеансов</item>
<item quantity="other">%1$d сеанса</item>
</plurals>
<plurals name="drawer_profile_count">
<item quantity="one">%1$d профиль</item>
<item quantity="few">%1$d профиля</item>
<item quantity="many">%1$d профилей</item>
<item quantity="other">%1$d профиля</item>
</plurals>
<string name="drawer_filter_status">Статус</string>
<string name="drawer_filter_profile">Профиль</string>
<string name="drawer_filter_project">Проект</string>
<string name="drawer_filter_pull_request">Запрос на слияние</string>
<string name="drawer_option_updated">Обновлено</string>
<string name="drawer_option_profile">Профиль</string>
<string name="drawer_option_tokens">Токены</string>
<string name="drawer_option_cost">Стоимость</string>
<string name="drawer_reset_filters">Сбросить</string>
<string name="drawer_profile_colors">Цвета профилей</string>
<string name="drawer_profile_color_auto">Автоматически</string>
<string name="drawer_profile_color_set">Установить цвет профиля %1$s: %2$s</string>
<string name="drawer_close">Закрыть</string>
<string name="profile_inspector_gateway_settings">Настройки профиля Gateway</string>
<string name="profile_inspector_gateway_settings_hint">Сохраняются напрямую через Hermes для выбранного профиля.</string>
@@ -3791,4 +3881,16 @@
<string name="secure_link_fallback_ready">Если Secure Link недоступен, Hermes может попробовать одобренные резервные маршруты ниже.</string>
<string name="secure_link_no_fallback">Резервный маршрут не указан. Защищённые сервисы останутся офлайн, если Secure Link недоступен.</string>
<string name="secure_link_auth_note">Защита транспорта не отменяет аутентификацию. Сопряжение Relay, учётные данные API и вход в Панель по-прежнему проверяются отдельно для каждого сервиса.</string>
<plurals name="proactive_messages_arrived_while_away">
<item quantity="one">Пока вас не было, пришло %1$d сообщение</item>
<item quantity="few">Пока вас не было, пришло %1$d сообщения</item>
<item quantity="many">Пока вас не было, пришло %1$d сообщений</item>
<item quantity="other">Пока вас не было, пришло %1$d сообщения</item>
</plurals>
<string name="chat_model_confirmation_title">Подтвердите выбор модели</string>
<string name="host_resource_recent_oom">Hermes недавно перезапустился, вероятно, из-за нехватки памяти.</string>
<string name="host_resource_memory_critical">Память хоста почти исчерпана; активные чаты могут остановиться.</string>
<string name="host_resource_memory_elevated">Память хоста заканчивается.</string>
<string name="host_resource_disk_critical">Хранилище хоста почти заполнено; сообщения и настройки могут не сохраниться.</string>
<string name="host_resource_disk_elevated">Хранилище хоста заканчивается.</string>
</resources>
+98 -2
View File
@@ -1494,6 +1494,10 @@
<string name="proactive_about">About</string>
<string name="proactive_about_desc">When on, your phone tells the relay it\'s open to agent-initiated messages. The agent delivers them over the same paired connection the relay already uses — no new permissions beyond notifications.</string>
<string name="proactive_about_server_note">Your server must also enable the phone platform (set PHONE_ENABLED on the server). Until both sides are on and the phone is paired, nothing is pushed.</string>
<plurals name="proactive_messages_arrived_while_away">
<item quantity="one">%1$d message arrived while you were away</item>
<item quantity="other">%1$d messages arrived while you were away</item>
</plurals>
<!-- ProfileInspectorScreen -->
<string name="profile_inspector_back">Back</string>
@@ -1974,6 +1978,14 @@
<string name="dashboard_native_signin_requires_https">Secure browser sign-in requires an HTTPS dashboard address.</string>
<string name="dashboard_native_signin_unavailable">Secure browser sign-in is unavailable for this connection. Refresh the connection and try again.</string>
<string name="dashboard_native_signin_transport_retry">The secure connection was interrupted. Tap the sign-in button to try again.</string>
<string name="dashboard_native_signin_callback_rejected">The provider did not approve this sign-in. If you cancelled, no action is needed; otherwise start a fresh sign-in attempt.</string>
<string name="dashboard_native_signin_code_rejected">Google sign-in finished, but hosted Hermes rejected the one-time callback code before it could create a session. Start a fresh sign-in attempt.</string>
<string name="dashboard_native_signin_gateway_rejected">Hosted Hermes refused the session exchange. Check that the hosted agent is current, then start sign-in again.</string>
<string name="dashboard_native_signin_rate_limited">Hosted Hermes received too many sign-in attempts. Wait a moment, then try again.</string>
<string name="dashboard_native_signin_gateway_unavailable">Google sign-in finished, but the hosted Hermes sign-in service is unavailable. Wait a moment, then try again.</string>
<string name="dashboard_native_signin_response_unsupported">Hosted Hermes returned a sign-in response this app could not use. Check for Hermes Relay and hosted agent updates.</string>
<string name="dashboard_native_signin_attempt_inactive">This sign-in attempt was cancelled or replaced. Close the old browser tab and start again.</string>
<string name="dashboard_native_signin_storage_failed">Google sign-in finished, but Android could not securely save the Hermes session. Try again; if it repeats, open Diagnostics for the failure stage.</string>
<string name="dashboard_close_signin">Close sign-in</string>
<!-- Error body -->
@@ -1989,6 +2001,17 @@
<!-- Section action buttons -->
<string name="dashboard_section_action_change_main_model">Change main model</string>
<string name="dashboard_section_action_new_profile">New profile</string>
<string name="dashboard_section_action_new_schedule">New schedule</string>
<string name="dashboard_cron_create_title">Create schedule</string>
<string name="dashboard_cron_name">Name</string>
<string name="dashboard_cron_schedule">Schedule</string>
<string name="dashboard_cron_schedule_hint">Examples: every 2h, 0 9 * * 1-5, or 2026-08-20 09:00</string>
<string name="dashboard_cron_prompt">Task instructions</string>
<string name="dashboard_cron_repeat">Stop after this many runs (optional)</string>
<string name="dashboard_cron_repeat_help">Leave blank to keep the schedule’s normal behavior. Finite schedules accept 1–999 runs.</string>
<string name="dashboard_cron_gateway_required">Schedule creation requires a current Hermes Gateway connection.</string>
<string name="dashboard_cron_created">Created schedule “%1$s”</string>
<string name="dashboard_cron_create_failed">Couldn’t create schedule</string>
<string name="dashboard_section_action_browse_hub">Browse hub</string>
<string name="dashboard_section_action_update_installed">Update installed</string>
<string name="dashboard_section_action_server_backup">Create server backup</string>
@@ -2188,6 +2211,16 @@
<!-- Profile create -->
<string name="dashboard_profile_created">Profile %1$s created</string>
<string name="dashboard_profile_created_partial">Profile %1$s created, but %2$s. Review it before use.</string>
<string name="dashboard_profile_created_legacy">Profile %1$s created through the older Dashboard route. Review its credentials before use.</string>
<string name="dashboard_profile_no_skills">Do not seed bundled skills</string>
<string name="dashboard_profile_auth_title">Authentication</string>
<string name="dashboard_profile_auth_shared">Share sign-in (recommended) — one refreshable token pool</string>
<string name="dashboard_profile_auth_copied">Copy current credentials — separate snapshot that can drift</string>
<string name="dashboard_profile_auth_isolated">Isolated — copy no credentials or provider defaults</string>
<string name="dashboard_profile_auth_help">Hermes applies this choice on the server. Passwords and tokens are never returned to Android.</string>
<string name="dashboard_profile_allow_legacy">If Gateway create is unavailable, allow the older Dashboard route. Credential behavior depends on that host and must be reviewed.</string>
<string name="dashboard_profile_mcp_partial">MCP server selection was not applied</string>
<string name="dashboard_profile_create_failed">Profile create failed</string>
<!-- Stats for nerds -->
@@ -2835,7 +2868,35 @@
<plurals name="tool_run_used_tools"><item quantity="one">Used %d tool</item><item quantity="other">Used %d tools</item></plurals>
<!-- AgentIconRow -->
<string name="agent_icon_title">Agent icon</string>
<string name="agent_icon_title">Agent visuals</string>
<string name="agent_icon_hermes_pet_title">Hermes animated pet</string>
<string name="agent_icon_hermes_pet_description">Profile-scoped and shared with upstream Hermes surfaces. It reacts to agent activity and appears as the phone companion.</string>
<string name="agent_icon_hermes_pet_active">Active: %1$s</string>
<string name="agent_icon_hermes_pet_empty">No upstream pet selected</string>
<string name="agent_icon_hermes_pet_unsupported">Update Hermes to manage animated pets here</string>
<string name="agent_icon_hermes_pet_browse">Browse Hermes pets</string>
<string name="agent_icon_hermes_pet_picker_title">Choose a Hermes pet</string>
<string name="agent_icon_hermes_pet_selected">Selected</string>
<string name="agent_icon_hermes_pet_installed">Installed</string>
<string name="agent_icon_hermes_pet_adopt">Adopt from Petdex</string>
<string name="agent_icon_shared_title">Shared across Hermes</string>
<string name="agent_icon_shared_active">Shared avatar</string>
<string name="agent_icon_shared_empty">No shared avatar</string>
<string name="agent_icon_change_shared">Change shared avatar</string>
<string name="agent_icon_remove_shared">Remove</string>
<string name="agent_icon_remove_shared_title">Remove shared avatar?</string>
<string name="agent_icon_remove_shared_message">This removes the avatar from Hermes and other clients. Your phone image is not affected.</string>
<string name="agent_icon_phone_title">This phone only</string>
<string name="agent_icon_phone_description">Override the shared avatar on this phone without changing Hermes.</string>
<string name="agent_icon_choose_phone">Choose phone image</string>
<string name="agent_icon_change_phone">Change phone image</string>
<string name="agent_icon_remove_phone">Remove phone image</string>
<string name="agent_icon_animation_note">Animated GIF and WebP overrides play only on this phone. Use Hermes animated pets above for an upstream-managed, profile-scoped animation.</string>
<string name="agent_icon_set_local">Choose device fallback</string>
<string name="agent_icon_change_local">Change device fallback</string>
<string name="agent_icon_clear_local">Clear device fallback</string>
<string name="agent_icon_upload_shared">Upload device icon to Hermes</string>
<string name="agent_icon_clear_shared">Clear shared Hermes avatar</string>
<string name="agent_icon_set">Choose file</string>
<string name="agent_icon_change">Choose file</string>
<string name="agent_icon_clear">Clear</string>
@@ -4063,8 +4124,37 @@
<string name="support_bundle_copied">Support information copied</string>
<string name="support_bundle_no_share">Support information copied — no app found to share to</string>
<string name="support_bundle_share_title">Share Hermes-Relay support information</string>
<string name="drawer_all_profiles">All profiles</string>
<string name="drawer_all_profiles">All Profiles</string>
<string name="drawer_no_profile_sessions">No matching profile sessions.</string>
<string name="drawer_customize_sessions">Customize sessions</string>
<string name="drawer_group_by">Group by</string>
<string name="drawer_order_by">Order by</string>
<string name="drawer_show_metadata">Show on rows</string>
<string name="drawer_show_details">Show details</string>
<string name="drawer_filters">Filters</string>
<string name="drawer_project_home">Home</string>
<string name="drawer_expand_project">Expand %1$s</string>
<string name="drawer_collapse_project">Collapse %1$s</string>
<plurals name="drawer_project_session_count">
<item quantity="one">%1$d session</item>
<item quantity="other">%1$d sessions</item>
</plurals>
<plurals name="drawer_profile_count">
<item quantity="one">%1$d profile</item>
<item quantity="other">%1$d profiles</item>
</plurals>
<string name="drawer_filter_status">Status</string>
<string name="drawer_filter_profile">Profile</string>
<string name="drawer_filter_project">Project</string>
<string name="drawer_filter_pull_request">Pull request</string>
<string name="drawer_option_updated">Updated</string>
<string name="drawer_option_profile">Profile</string>
<string name="drawer_option_tokens">Tokens</string>
<string name="drawer_option_cost">Cost</string>
<string name="drawer_reset_filters">Reset</string>
<string name="drawer_profile_colors">Profile colors</string>
<string name="drawer_profile_color_auto">Auto</string>
<string name="drawer_profile_color_set">Set %1$s profile color to %2$s</string>
<string name="drawer_close">Close</string>
<string name="profile_inspector_gateway_settings">Gateway profile settings</string>
<string name="profile_inspector_gateway_settings_hint">Saved directly through upstream Hermes for this selected profile.</string>
@@ -4076,4 +4166,10 @@
<string name="profile_inspector_toolsets">Toolsets</string>
<string name="profile_inspector_toolsets_hint">Choose which upstream tool groups this profile can use.</string>
<string name="profile_inspector_tool_count">%1$d tools</string>
<string name="chat_model_confirmation_title">Confirm model selection</string>
<string name="host_resource_recent_oom">Hermes recently restarted after likely running out of memory.</string>
<string name="host_resource_memory_critical">Host memory is critically low; active chats may stop.</string>
<string name="host_resource_memory_elevated">Host memory is running low.</string>
<string name="host_resource_disk_critical">Host storage is critically low; messages and settings may fail to save.</string>
<string name="host_resource_disk_elevated">Host storage is running low.</string>
</resources>
@@ -336,4 +336,23 @@ class AuthManagerProfilesParseTest {
assertFalse(parsed[0].apiServerKeyPresent)
assertFalse(parsed[0].hasIsolatedApi)
}
@Test
fun parsesBoundedSharedProfileMetadata() {
val parsed = AuthManager.parseAgentProfiles(buildJsonArray {
add(buildJsonObject {
put("name", "operator")
put("model", "gpt")
put("provider", "openai")
put("is_default", true)
put("has_avatar", true)
put("ui_meta", buildJsonObject { put("accent", "#ff5500") })
})
}).single()
assertEquals("openai", parsed.provider)
assertTrue(parsed.isDefault)
assertTrue(parsed.hasAvatar)
assertEquals("#ff5500", (parsed.uiMeta["accent"] as JsonPrimitive).content)
}
}
@@ -12,6 +12,18 @@ import org.junit.Test
*/
class ChatMessageTest {
@Test
fun messageReaction_replacesAndRetractsTheUsersTapback() {
val agent = MessageReaction("🔥", "agent", 1.0)
val heart = applyMessageReaction(listOf(agent), "❤️", at = 2.0)
assertEquals(listOf(agent, MessageReaction("❤️", "user", 2.0)), heart)
val replaced = applyMessageReaction(heart, "😂", at = 3.0)
assertEquals(listOf(agent, MessageReaction("😂", "user", 3.0)), replaced)
assertEquals(listOf(agent), applyMessageReaction(replaced, "😂", at = 4.0))
}
// --- ChatMessage creation with defaults ---
@Test
@@ -0,0 +1,39 @@
package com.hermesandroid.relay.data
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ProfileAvatarImageTest {
@Test
fun `oversized bitmap is encoded within upstream limit`() {
val bitmap = Bitmap.createBitmap(1600, 1200, Bitmap.Config.ARGB_8888).apply {
eraseColor(0xff336699.toInt())
}
val encoded = encodeProfileAvatar(bitmap, 80_000)
assertNotNull(encoded)
assertTrue(encoded!!.size <= 80_000)
assertTrue(profileAvatarMime(encoded) in setOf("image/png", "image/jpeg"))
val decoded = BitmapFactory.decodeByteArray(encoded, 0, encoded.size)
assertTrue(maxOf(decoded.width, decoded.height) <= 1024)
decoded.recycle()
bitmap.recycle()
}
@Test
fun `profile avatar mime recognizes upstream formats`() {
assertEquals("image/jpeg", profileAvatarMime(byteArrayOf(0xff.toByte(), 0xd8.toByte(), 0xff.toByte())))
assertEquals(
"image/webp",
profileAvatarMime("RIFF1234WEBP".toByteArray()),
)
}
}
@@ -9,6 +9,8 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -53,6 +55,42 @@ class ProfileIconStoreTest {
assertEquals("/files/c.png", store.iconFlow("conn-2", null).first())
}
@Test
fun serverAvatarsKeepDuplicateProfileNamesConnectionScoped() = runBlocking {
store.setServerAvatar("conn-1", "operator", "/server/a.png")
store.setServerAvatar("conn-2", "operator", "/server/b.png")
assertEquals("/server/a.png", store.serverAvatarFlow("conn-1", "operator").first())
assertEquals("/server/b.png", store.serverAvatarFlow("conn-2", "operator").first())
}
@Test
fun clearingServerAvatarDoesNotOverwriteLocalFallback() = runBlocking {
store.setIcon("conn-1", "operator", "/local/icon.png")
store.setServerAvatar("conn-1", "operator", "/server/avatar.png")
store.setServerAvatar("conn-1", "operator", null)
assertNull(store.serverAvatarFlow("conn-1", "operator").first())
assertEquals("/local/icon.png", store.iconFlow("conn-1", "operator").first())
}
@Test
fun localOverrideIsScopedAndDefaultsOff() = runBlocking {
assertFalse(store.localOverrideFlow("conn-1", "operator").first())
store.setLocalOverride("conn-1", "operator", true)
assertTrue(store.localOverrideFlow("conn-1", "operator").first())
assertFalse(store.localOverrideFlow("conn-2", "operator").first())
assertFalse(store.localOverrideFlow("conn-1", "other").first())
}
@Test
fun preferredIconUsesLocalOnlyWhenOverrideHasAnImage() {
assertEquals("/server.png", preferredProfileIcon("/server.png", "/local.gif", false))
assertEquals("/local.gif", preferredProfileIcon("/server.png", "/local.gif", true))
assertEquals("/server.png", preferredProfileIcon("/server.png", null, true))
}
private class InMemoryPreferencesDataStore : DataStore<Preferences> {
private val state = MutableStateFlow<Preferences>(emptyPreferences())
@@ -25,9 +25,14 @@ class ProfilePresentationStoreTest {
fun orderAndHiddenProfilesRoundTripPerConnection() = runBlocking {
store.setOrder("one", listOf("beta", "alpha"))
store.setHidden("one", setOf("gamma"))
store.setColors("one", mapOf("alpha" to "#356CFF"))
assertEquals(
ProfilePresentation(order = listOf("beta", "alpha"), hidden = setOf("gamma")),
ProfilePresentation(
order = listOf("beta", "alpha"),
hidden = setOf("gamma"),
colors = mapOf("alpha" to "#356CFF"),
),
store.presentationFlow("one").first(),
)
assertEquals(ProfilePresentation(), store.presentationFlow("two").first())
@@ -104,6 +109,7 @@ class ProfilePresentationStoreTest {
fun clearRemovesOnlyOneConnectionsPreferences() = runBlocking {
store.setOrder("one", listOf("beta"))
store.setHidden("one", setOf("alpha"))
store.setColors("one", mapOf("beta" to "#ED4E8B"))
store.setOrder("two", listOf("gamma"))
store.clear("one")
@@ -0,0 +1,130 @@
package com.hermesandroid.relay.network
import android.content.Context
import android.os.Looper
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
import com.hermesandroid.relay.network.relay.ConnectionManager
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.viewmodel.connection.UpstreamTransportController
import io.mockk.mockk
import java.util.concurrent.AbstractExecutorService
import java.util.concurrent.CountDownLatch
import java.util.concurrent.ExecutorService
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import okhttp3.Dispatcher
import okhttp3.OkHttpClient
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Owner-level coverage for every production ConnectionPool.evictAll() teardown. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class NetworkShutdownOwnerTest {
@Test
fun hermesApiClient_shutdownLeavesMainThread() {
val teardown = TrackingExecutorService()
val client = HermesApiClient(
baseUrl = "https://hermes.example.test",
apiKey = "test-key",
okHttpClient = clientWith(teardown),
)
client.shutdown()
teardown.assertShutdownOffMainThread()
}
@Test
fun dashboardApiClient_shutdownLeavesMainThread() {
val teardown = TrackingExecutorService()
val client = DashboardApiClient(
baseUrl = "https://hermes.example.test",
okHttpClient = clientWith(teardown),
)
client.shutdown()
teardown.assertShutdownOffMainThread()
}
@Test
fun connectionManager_shutdownLeavesMainThread() {
val teardown = TrackingExecutorService()
val manager = ConnectionManager(
multiplexer = ChannelMultiplexer(),
okHttpClientFactory = { clientWith(teardown) },
)
manager.shutdown()
teardown.assertShutdownOffMainThread()
}
@Test
fun upstreamTransportController_connectionSwitchResetLeavesMainThread() {
val dashboardUrl = "https://hermes.example.test"
val teardown = TrackingExecutorService()
val controller = UpstreamTransportController(
context = mockk<Context>(relaxed = true),
activeConnectionIdProvider = { null },
dashboardUrlProvider = { dashboardUrl },
gatewayKeepAliveProvider = { false },
dashboardHttpClientFactory = { _, _ -> clientWith(teardown) },
)
controller.dashboardHttpClientForActive(dashboardUrl)
controller.resetGatewayForConnectionSwitch()
teardown.assertShutdownOffMainThread()
}
private fun clientWith(executor: ExecutorService): OkHttpClient =
OkHttpClient.Builder()
.dispatcher(Dispatcher(executor))
.build()
private class TrackingExecutorService : AbstractExecutorService() {
private val shutdown = AtomicBoolean(false)
private val shutdownLatch = CountDownLatch(1)
private val shutdownThread = AtomicReference<Thread>()
override fun shutdown() {
shutdownThread.compareAndSet(null, Thread.currentThread())
shutdown.set(true)
shutdownLatch.countDown()
}
override fun shutdownNow(): MutableList<Runnable> {
shutdown()
return mutableListOf()
}
override fun isShutdown(): Boolean = shutdown.get()
override fun isTerminated(): Boolean = shutdown.get()
override fun awaitTermination(timeout: Long, unit: TimeUnit): Boolean =
shutdownLatch.await(timeout, unit)
override fun execute(command: Runnable) = command.run()
fun assertShutdownOffMainThread() {
assertSame(Looper.myLooper(), Looper.getMainLooper())
assertTrue("owner did not shut its OkHttp dispatcher down", shutdownLatch.await(5, TimeUnit.SECONDS))
assertNotSame(
"owner performed OkHttp teardown on the main thread",
Looper.getMainLooper().thread,
shutdownThread.get(),
)
}
}
}
@@ -0,0 +1,122 @@
package com.hermesandroid.relay.network.relay
import android.content.Context
import com.hermesandroid.relay.network.relay.models.Envelope
import com.hermesandroid.relay.notifications.ProactiveMessageNotifier
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import io.mockk.verify
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class ProactiveMessageHandlerTest {
private val context = mockk<Context>(relaxed = true)
@Before
fun setUp() {
mockkObject(ProactiveMessageNotifier)
every {
ProactiveMessageNotifier.notify(any(), any(), any(), any(), any())
} just Runs
}
@After
fun tearDown() {
unmockkObject(ProactiveMessageNotifier)
}
@Test
fun `notification surfacing notifies even when matching Thread is open`() {
val persisted = mutableListOf<ProactiveMessage>()
val handler = ProactiveMessageHandler(context, toInbox = persisted::add).apply {
injectIntoThread = { true }
}
handler.onMessage(messageEnvelope(surfacing = "notification"))
assertEquals(1, persisted.size)
verify(exactly = 1) {
ProactiveMessageNotifier.notify(context, "Hermes", "ready", "m-1", "phone")
}
}
@Test
fun `inbox surfacing persists silently`() {
val handler = ProactiveMessageHandler(context, toInbox = {}).apply {
injectIntoThread = { true }
}
handler.onMessage(messageEnvelope(surfacing = "inbox"))
verify(exactly = 0) {
ProactiveMessageNotifier.notify(any(), any(), any(), any(), any())
}
}
@Test
fun `session surfacing suppresses notification only when a destination accepts`() {
val accepted = ProactiveMessageHandler(context, toInbox = {}).apply {
injectIntoThread = { false }
toSession = { true }
}
accepted.onMessage(messageEnvelope(surfacing = "session"))
verify(exactly = 0) {
ProactiveMessageNotifier.notify(any(), any(), any(), any(), any())
}
val unavailable = ProactiveMessageHandler(context, toInbox = {}).apply {
injectIntoThread = { false }
toSession = { false }
}
unavailable.onMessage(messageEnvelope(surfacing = "session"))
verify(exactly = 1) {
ProactiveMessageNotifier.notify(context, "Hermes", "ready", "m-1", "phone")
}
}
@Test
fun `queued delivery is persisted with provenance and summarized once`() {
val persisted = mutableListOf<ProactiveMessage>()
val summaries = mutableListOf<Int>()
val handler = ProactiveMessageHandler(context, toInbox = persisted::add).apply {
onBacklogDelivered = summaries::add
}
handler.onMessage(messageEnvelope(surfacing = "inbox", queuedDelivery = true))
handler.onMessage(
Envelope(
channel = "proactive",
type = "proactive.backlog.complete",
payload = buildJsonObject { put("count", JsonPrimitive(1)) },
),
)
assertTrue(persisted.single().arrivedWhileAway)
assertEquals(listOf(1), summaries)
}
private fun messageEnvelope(
surfacing: String,
queuedDelivery: Boolean = false,
) = Envelope(
channel = "proactive",
type = "phone.message",
payload = buildJsonObject {
put("message_id", JsonPrimitive("m-1"))
put("chat_id", JsonPrimitive("phone"))
put("text", JsonPrimitive("ready"))
put("title", JsonPrimitive("Hermes"))
put("surfacing", JsonPrimitive(surfacing))
if (queuedDelivery) put("queued_delivery", JsonPrimitive(true))
},
)
}
@@ -975,6 +975,49 @@ class ChatHandlerTest {
assertEquals(42L, handler.messages.value.last().rowId)
}
@Test
fun loadMessageHistory_hydratesPersistedReactionsAndLetsServerWin() {
fun metadata(emoji: String) = buildJsonObject {
put("reactions", buildJsonArray {
add(buildJsonObject {
put("emoji", emoji)
put("author", "user")
put("at", 1_700_000_000.0)
})
})
}
handler.loadMessageHistory(
listOf(
MessageItem(
id = "assistant-1",
rowId = 42L,
role = "assistant",
content = JsonPrimitive("Reply"),
displayMetadata = metadata("❤️"),
),
),
)
assertEquals("❤️", handler.messages.value.single().reactions.single().emoji)
handler.mutateMessage("assistant-1") { message ->
message.copy(reactions = listOf(com.hermesandroid.relay.data.MessageReaction("👍", "user", 2.0)))
}
handler.loadMessageHistory(
listOf(
MessageItem(
id = "assistant-1",
rowId = 42L,
role = "assistant",
content = JsonPrimitive("Reply"),
displayMetadata = metadata("😂"),
),
),
)
assertEquals("😂", handler.messages.value.single().reactions.single().emoji)
}
@Test
fun rebindSurvivorUserRowIds_replacesPrefixAndClearsUnboundTurns() {
handler.loadMessageHistory(
@@ -994,6 +1037,37 @@ class ChatHandlerTest {
assertEquals(listOf("user-1", "assistant-1", "user-2"), messages.map { it.uiKey })
}
@Test
fun hasSafeGatewayRewindAddress_allowsWhollyLegacyHistory() {
handler.loadMessageHistory(
listOf(
MessageItem(id = "legacy-1", role = "user", content = JsonPrimitive("First")),
MessageItem(id = "legacy-2", role = "user", content = JsonPrimitive("Second")),
),
)
assertTrue(handler.hasSafeGatewayRewindAddress("legacy-1"))
}
@Test
fun hasSafeGatewayRewindAddress_requiresSelectedIdInDurableHistory() {
handler.loadMessageHistory(
listOf(
MessageItem(id = "legacy-1", role = "user", content = JsonPrimitive("First")),
MessageItem(
id = "durable-2",
rowId = 73L,
role = "user",
content = JsonPrimitive("Second"),
),
),
)
assertFalse(handler.hasSafeGatewayRewindAddress("legacy-1"))
assertTrue(handler.hasSafeGatewayRewindAddress("durable-2"))
assertFalse(handler.hasSafeGatewayRewindAddress("missing"))
}
@Test
fun reconcileInterimMessage_collapsesProvisionalFinalBubble() {
handler.addPlaceholderMessage(
@@ -0,0 +1,39 @@
package com.hermesandroid.relay.network.upstream
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class CronManagementTest {
@Test
fun `finite repeat accepts bounded whole numbers and optional blank`() {
assertNull(parseFiniteRepeat(" ").getOrThrow())
assertEquals(2, parseFiniteRepeat("2").getOrThrow())
assertEquals(999, parseFiniteRepeat("999").getOrThrow())
}
@Test
fun `finite repeat never coerces unsafe values to unlimited`() {
assertTrue(parseFiniteRepeat("0").isFailure)
assertTrue(parseFiniteRepeat("-2").isFailure)
assertTrue(parseFiniteRepeat("1000").isFailure)
assertTrue(parseFiniteRepeat("two").isFailure)
}
@Test
fun `draft validation trims fields and preserves explicit profile`() {
val result = CronCreationDraft(
name = " Morning brief ",
schedule = " every 1d ",
prompt = " Summarize updates ",
repeat = 3,
profile = " work ",
).validated().getOrThrow()
assertEquals("Morning brief", result.name)
assertEquals("every 1d", result.schedule)
assertEquals("Summarize updates", result.prompt)
assertEquals("work", result.profile)
}
}
@@ -35,6 +35,8 @@ class DashboardStatusTopologyTest {
assertEquals(emptyList<String>(), status.profiles)
assertEquals(false, status.componentHealth.supported)
assertEquals(emptyList<String>(), status.componentHealth.components.map { it.name })
assertNull(status.memory)
assertNull(status.disk)
}
@Test
@@ -70,4 +72,33 @@ class DashboardStatusTopologyTest {
assertEquals(3, platforms.configured)
assertEquals(1, platforms.connected)
}
@Test
fun parseStatus_readsAuthoritativeResourcePressureBlocks() {
val status = DashboardApiClient.parseStatus(
json.decodeFromString<JsonObject>(
"""{
"auth_required": false,
"memory": {
"pressure": "critical",
"system_available_mb": 48,
"last_boot_suspected_oom": true,
"boot_id": "2026-08-15T10:00:00Z"
},
"disk": {
"pressure": "elevated",
"free_mb": 384,
"used_percent": 92.5
}
}""",
),
)
assertEquals("critical", status.memory?.pressure)
assertEquals(48, status.memory?.systemAvailableMb)
assertEquals(true, status.memory?.lastBootSuspectedOom)
assertEquals("elevated", status.disk?.pressure)
assertEquals(384, status.disk?.freeMb)
assertEquals(92.5, status.disk?.usedPercent)
}
}
@@ -28,6 +28,7 @@ import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
@@ -60,6 +61,13 @@ class GatewayClientHarness(
var failTicketMint = false
var resumeFails = false
/** Authoritative profile owner echoed by session results; null follows request. */
@Volatile
var sessionProfileOverride: String? = null
@Volatile
var omitSessionProfileMetadata = false
@Volatile
var recoveryRunning = false
@@ -108,6 +116,13 @@ class GatewayClientHarness(
@Volatile
var approvalMode = "smart"
@Volatile
var modelConfirmationMessage: String? = null
val modelsRequiringConfirmation: MutableSet<String> = ConcurrentHashMap.newKeySet()
@Volatile
var createdSessionProfileName: String? = null
/** Config keys rejected with the older-gateway unknown-key response. */
val unsupportedConfigKeys: MutableSet<String> = ConcurrentHashMap.newKeySet()
@@ -124,12 +139,74 @@ class GatewayClientHarness(
put("dataUri", "data:image/png;base64,iVBORw0KGgo=")
}
@Volatile
var petInfoPayload: JsonObject = buildJsonObject {
put("enabled", true)
put("slug", "boba")
put("displayName", "Boba")
put("mime", "image/png")
put("spritesheetBase64", "iVBORw0KGgo=")
put("spritesheetRevision", "123:8")
put("frameW", 192)
put("frameH", 208)
put("framesPerState", 8)
put("framesByState", buildJsonObject { put("idle", 6); put("run", 8) })
put("framesByRow", buildJsonObject { put("idle", 6); put("running", 8) })
put("loopMs", 1100)
put("scale", 0.75)
put("stateRows", JsonArray(listOf(JsonPrimitive("idle"), JsonPrimitive("running"))))
}
@Volatile
var petGalleryPayload: JsonObject = buildJsonObject {
put("enabled", true)
put("active", "boba")
put("pets", JsonArray(listOf(buildJsonObject {
put("slug", "boba")
put("displayName", "Boba")
put("installed", true)
put("spritesheetUrl", "https://assets.petdex.dev/pets/boba/sprites.png")
put("curated", true)
put("generated", false)
})))
}
@Volatile
var fileAttachPayload: JsonObject = buildJsonObject {
put("attached", true)
put("ref_text", "@file:notes.txt")
}
@Volatile
var profilesListPayload: JsonObject = buildJsonObject {
put("profiles", JsonArray(listOf(buildJsonObject {
put("name", "operator")
put("model", "gpt-5.6")
put("provider", "openai")
put("description", "Android operator")
put("skill_count", 3)
put("has_avatar", true)
put("ui_meta", buildJsonObject { put("accent", "#ff5500") })
})))
}
@Volatile
var profileCreatePayload: JsonObject = buildJsonObject {
put("ok", true)
put("name", "operator")
put("soul_written", true)
put("model_set", true)
put("mirrored", buildJsonObject {
put("env", true)
put("auth", "shared")
put("model_inherited", false)
put("voice", true)
})
}
@Volatile
var profileGetAssetPayload: JsonObject = buildJsonObject { put("found", false) }
/** Methods answered with JSON-RPC -32601 — exercises the legacy-name fallback. */
val methodNotFound: MutableSet<String> = ConcurrentHashMap.newKeySet()
@@ -209,12 +286,26 @@ class GatewayClientHarness(
"session.create" -> buildJsonObject {
put("session_id", "live-1")
put("stored_session_id", "20260612_120000_abc123")
if (!omitSessionProfileMetadata) {
put("info", buildJsonObject {
put(
"profile_name",
createdSessionProfileName
?: sessionProfileOverride
?: (params["profile"] as? JsonPrimitive)?.contentOrNull
?: "default",
)
})
}
}
"session.resume" ->
if (resumeFails) null
else {
val storedId = (params["session_id"] as? JsonPrimitive)?.contentOrNull
recoveryPayload(resumeLiveSessionIds[storedId] ?: "live-resumed")
recoveryPayload(
resumeLiveSessionIds[storedId] ?: "live-resumed",
(params["profile"] as? JsonPrimitive)?.contentOrNull,
)
}
"session.activate" -> recoveryPayload(
(params["session_id"] as? JsonPrimitive)?.contentOrNull ?: "live-activated",
@@ -273,10 +364,14 @@ class GatewayClientHarness(
"image.attach_bytes", "image.attach.bytes" -> buildJsonObject {
put("attached", true)
put("count", 1)
put("path", "/session/images/upload.png")
}
"pdf.attach" -> buildJsonObject {
put("attached", true)
put("pages", 1)
put("pages", JsonArray(listOf(buildJsonObject {
put("path", "/session/images/page-1.png")
put("page", 1)
})))
}
"file.attach" -> fileAttachPayload
"clarify.respond", "sudo.respond", "secret.respond" ->
@@ -308,6 +403,22 @@ class GatewayClientHarness(
})))
put("toolsets_pinned", true)
}
"profiles.list" -> profilesListPayload
"profiles.create" -> profileCreatePayload
"profiles.get_asset" -> profileGetAssetPayload
"profiles.set_asset" -> buildJsonObject {
put("ok", true)
put("asset", "avatar")
val data = (params["data"] as? JsonPrimitive)?.contentOrNull
put(
"size",
if ((params["clear"] as? JsonPrimitive)?.booleanOrNull == true) {
0
} else {
java.util.Base64.getDecoder().decode(data.orEmpty().substringAfter(";base64,")).size
},
)
}
"profiles.configure" -> buildJsonObject {
put("ok", false)
put("applied", buildJsonObject {
@@ -315,9 +426,14 @@ class GatewayClientHarness(
if (params.containsKey("provider")) put("model", false)
if (params.containsKey("disabled_skills")) put("skills", true)
if (params.containsKey("enabled_toolsets")) put("toolsets", true)
if (params.containsKey("enabled_mcp_servers")) put("mcp_servers", true)
if (params.containsKey("ui_meta")) put("ui_meta", false)
})
}
"pet.thumb" -> petThumbPayload
"pet.info" -> petInfoPayload
"pet.gallery" -> petGalleryPayload
"pet.select", "pet.disable" -> buildJsonObject { put("ok", true) }
"model.options" -> buildJsonObject {
put("model", "gpt-5.5")
put("provider", "openai")
@@ -353,9 +469,22 @@ 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 ?: "")
"model" -> {
val confirmation = modelConfirmationMessage
val requestedValue = (params["value"] as? JsonPrimitive)?.contentOrNull ?: ""
val confirmed = (params["confirm_expensive_model"] as? JsonPrimitive)
?.booleanOrNull == true
if (confirmation != null && requestedValue in modelsRequiringConfirmation && !confirmed) {
buildJsonObject {
put("confirm_required", true)
put("confirm_message", confirmation)
}
} else {
buildJsonObject {
put("key", "model")
put("value", requestedValue)
}
}
}
"reasoning" -> {
reasoningEffort = (params["value"] as? JsonPrimitive)?.contentOrNull ?: reasoningEffort
@@ -403,12 +532,17 @@ class GatewayClientHarness(
private val autoRespondEnabled = autoRespond
private fun recoveryPayload(sessionId: String): JsonObject = buildJsonObject {
private fun recoveryPayload(sessionId: String, requestedProfile: String? = null): JsonObject = buildJsonObject {
put("session_id", sessionId)
put("running", recoveryRunning)
put("status", if (recoveryRunning) "streaming" else "idle")
recoveryProject?.let { project ->
put("info", buildJsonObject { put("project", project) })
if (!omitSessionProfileMetadata || recoveryProject != null) {
put("info", buildJsonObject {
if (!omitSessionProfileMetadata) {
put("profile_name", sessionProfileOverride ?: requestedProfile ?: "default")
}
recoveryProject?.let { put("project", it) }
})
}
val inflightStreaming = recoveryInflightStreaming ?: recoveryRunning
if (recoveryRunning || recoveryInflightStreaming != null || recoveryInflightError != null) {
@@ -628,6 +762,17 @@ class GatewayChatClientTest {
turnIdleTimeoutMs = turnIdleTimeoutMs,
)
private fun awaitCondition(
timeoutMs: Long = 5_000L,
condition: () -> Boolean,
) {
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs)
while (!condition() && System.nanoTime() < deadline) {
Thread.sleep(10)
}
assertTrue("condition did not settle within ${timeoutMs}ms", condition())
}
/**
* Swap in a client with shortened timeout seams. Mints a FRESH scope:
* shutdown() cancels the scope's Job, and the replacement client must
@@ -710,6 +855,198 @@ class GatewayChatClientTest {
assertTrue(harness.rpcLog.none { it.first == "profiles.describe" })
}
@Test
fun `profile configure method not found becomes sticky read only capability`() = runBlocking {
client.describeProfile("operator").getOrThrow()
harness.methodNotFound += "profiles.configure"
assertTrue(
client.configureProfile(
"operator",
com.hermesandroid.relay.data.GatewayProfilePatch(description = "Updated"),
).exceptionOrNull() is com.hermesandroid.relay.data.GatewayProfileEditorUnsupportedException,
)
harness.rpcLog.clear()
assertTrue(
client.configureProfile(
"operator",
com.hermesandroid.relay.data.GatewayProfilePatch(description = "Again"),
).exceptionOrNull() is com.hermesandroid.relay.data.GatewayProfileEditorUnsupportedException,
)
assertTrue(harness.rpcLog.none { it.first == "profiles.configure" })
assertEquals("operator", client.describeProfile("operator").getOrThrow().name)
}
@Test
fun `profile list requests bounded roster metadata without sessions`() = runBlocking {
val profiles = client.listProfiles().getOrThrow()
assertEquals(1, profiles.size)
assertEquals("operator", profiles.single().name)
assertEquals("openai", profiles.single().provider)
assertTrue(profiles.single().hasAvatar)
assertEquals("#ff5500", (profiles.single().uiMeta["accent"] as JsonPrimitive).content)
assertEquals(false, (harness.awaitRpc("profiles.list")["include_sessions"] as JsonPrimitive).booleanOrNull)
}
@Test
fun `profile list drops oversized ui meta without dropping profile`() = runBlocking {
harness.profilesListPayload = buildJsonObject {
put("profiles", JsonArray(listOf(buildJsonObject {
put("name", "operator")
put("model", "gpt")
put("ui_meta", buildJsonObject { put("blob", "x".repeat(65_537)) })
})))
}
val profile = client.listProfiles().getOrThrow().single()
assertTrue(profile.uiMeta.isEmpty())
}
@Test
fun `profile create serializes every auth choice explicitly`() = runBlocking {
val cases = listOf(
com.hermesandroid.relay.data.GatewayProfileAuthChoice.Shared to (true to true),
com.hermesandroid.relay.data.GatewayProfileAuthChoice.Copied to (true to false),
com.hermesandroid.relay.data.GatewayProfileAuthChoice.Isolated to (false to false),
)
cases.forEach { (choice, _) ->
client.createProfile(
com.hermesandroid.relay.data.GatewayProfileCreateRequest(
name = "operator",
description = "Mobile operator",
cloneFrom = "default",
noSkills = false,
soul = "# Operator",
provider = "openai",
model = "gpt-5.6",
authChoice = choice,
),
).getOrThrow()
}
harness.awaitRpcCount("profiles.create", cases.size).zip(cases).forEach { (params, case) ->
val expected = case.second
assertEquals(expected.first, (params["mirror_credentials"] as JsonPrimitive).booleanOrNull)
assertEquals(expected.second, (params["share_auth"] as JsonPrimitive).booleanOrNull)
}
}
@Test
fun `profile create exposes partial server results`() = runBlocking {
harness.profileCreatePayload = buildJsonObject {
put("ok", true)
put("name", "operator")
put("soul_written", false)
put("model_set", false)
put("mirrored", buildJsonObject {
put("env", false)
put("auth", false)
put("voice", false)
})
}
val request = com.hermesandroid.relay.data.GatewayProfileCreateRequest(
name = "operator",
soul = "# Operator",
provider = "openai",
model = "gpt-5.6",
authChoice = com.hermesandroid.relay.data.GatewayProfileAuthChoice.Copied,
)
val partial = client.createProfile(request).getOrThrow().partialMessages(request)
assertTrue(partial.any { it.contains("SOUL") })
assertTrue(partial.any { it.contains("model") })
assertTrue(partial.any { it.contains("credential source") })
}
@Test
fun `profile asset get distinguishes absent and validates bytes`() = runBlocking {
assertNull(client.getProfileAvatar("operator").getOrThrow())
val png = byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
harness.profileGetAssetPayload = buildJsonObject {
put("found", true)
put("mime", "image/png")
put("size", png.size)
put("data", "data:image/png;base64,${java.util.Base64.getEncoder().encodeToString(png)}")
}
val asset = client.getProfileAvatar("operator").getOrThrow()
assertEquals("image/png", asset?.mime)
assertTrue(png.contentEquals(asset?.data))
}
@Test
fun `profile asset get rejects malformed and magic mismatch responses`() = runBlocking {
harness.profileGetAssetPayload = buildJsonObject {
put("found", true)
put("mime", "image/png")
put("data", "data:image/png;base64,not-base64!")
}
assertTrue(client.getProfileAvatar("operator").isFailure)
harness.profileGetAssetPayload = buildJsonObject {
put("found", true)
put("mime", "image/png")
put("data", "data:image/png;base64,${java.util.Base64.getEncoder().encodeToString("not an image".toByteArray())}")
}
assertTrue(client.getProfileAvatar("operator").isFailure)
}
@Test
fun `profile asset set accepts exact boundary rejects overflow and clears`() = runBlocking {
val boundary = ByteArray(GatewayChatClient.PROFILE_AVATAR_MAX_BYTES)
byteArrayOf(0x89.toByte(), 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)
.copyInto(boundary)
assertTrue(client.setProfileAvatar("operator", boundary).isSuccess)
assertTrue(client.setProfileAvatar("operator", boundary.copyOf(boundary.size + 1)).isFailure)
assertEquals(0, client.clearProfileAvatar("operator").getOrThrow())
assertEquals(
true,
(harness.awaitRpcCount("profiles.set_asset", 2).last()["clear"] as JsonPrimitive).booleanOrNull,
)
}
@Test
fun `profile management method not found becomes sticky per operation`() = runBlocking {
harness.methodNotFound += "profiles.list"
assertTrue(client.listProfiles().exceptionOrNull() is com.hermesandroid.relay.data.GatewayProfileManagementUnsupportedException)
harness.rpcLog.clear()
assertTrue(client.listProfiles().isFailure)
assertTrue(harness.rpcLog.none { it.first == "profiles.list" })
harness.methodNotFound += "profiles.get_asset"
assertTrue(client.getProfileAvatar("operator").isFailure)
harness.rpcLog.clear()
assertTrue(client.getProfileAvatar("operator").isFailure)
assertTrue(harness.rpcLog.none { it.first == "profiles.get_asset" })
}
@Test
fun `profile configure reports ui meta partial apply`() = runBlocking {
client.describeProfile("operator").getOrThrow()
val result = client.configureProfile(
"operator",
com.hermesandroid.relay.data.GatewayProfilePatch(
uiMeta = buildJsonObject { put("accent", "#ff5500") },
),
).getOrThrow()
assertEquals(setOf(com.hermesandroid.relay.data.GatewayProfileSection.UiMeta), result.failed)
}
@Test
fun `profile ui meta rejects embedded image data`() = runBlocking {
client.describeProfile("operator").getOrThrow()
listOf("data:image/png;base64,AAAA", "iVBORw0KGgoAAAA", "UEsDBAAAA").forEach { embedded ->
val result = client.configureProfile(
"operator",
com.hermesandroid.relay.data.GatewayProfilePatch(
uiMeta = buildJsonObject { put("asset", embedded) },
),
)
assertTrue(result.isFailure)
}
assertTrue(harness.rpcLog.none { it.first == "profiles.configure" })
}
@Test
fun `happy path - ticket, ready, create, submit, stream, complete`() {
val r = Recorder()
@@ -1537,6 +1874,34 @@ class GatewayChatClientTest {
assertEquals("mizu", (create["profile"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `session create rejects a different authoritative profile owner`() {
val r = Recorder()
client.sessionProfileProvider = { "mizu" }
harness.sessionProfileOverride = "default"
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
awaitCondition { r.preflightFailures.isNotEmpty() }
assertTrue(r.preflightFailures.single().contains("refusing to use it as selected profile 'mizu'"))
assertTrue(harness.rpcLog.none { it.first == "prompt.submit" })
}
@Test
fun `session create rejects older gateway without profile ownership metadata`() {
val r = Recorder()
client.sessionProfileProvider = { "mizu" }
harness.omitSessionProfileMetadata = true
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
harness.awaitServerSocket()
awaitCondition { r.preflightFailures.isNotEmpty() }
assertTrue(r.preflightFailures.single().contains("did not confirm profile ownership"))
assertTrue(harness.rpcLog.none { it.first == "prompt.submit" })
}
@Test
fun `session create omits profile when none is selected`() {
val r = Recorder()
@@ -1549,12 +1914,10 @@ class GatewayChatClientTest {
assertEquals(null, create["profile"])
}
// --- Model-bound sessions (upstream tui_gateway: session.create honors
// `model`/`provider` → the new session's model_override). Without this a
// fresh chat ignores the in-chat picker and runs on the global default. ---
// --- Confirmation-safe model sessions ---
@Test
fun `session create binds the picked model and provider`() {
fun `session create never binds an unconfirmed model or provider`() {
val r = Recorder()
client.sessionModelProvider = { GatewaySessionModel("grok-4.3", "xai") }
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
@@ -1562,12 +1925,12 @@ class GatewayChatClientTest {
harness.awaitRpc("prompt.submit")
val create = harness.awaitRpc("session.create")
assertEquals("grok-4.3", (create["model"] as? JsonPrimitive)?.contentOrNull)
assertEquals("xai", (create["provider"] as? JsonPrimitive)?.contentOrNull)
assertEquals(null, create["model"])
assertEquals(null, create["provider"])
}
@Test
fun `session create binds model without provider when provider is null`() {
fun `session create never binds an unconfirmed providerless model`() {
val r = Recorder()
client.sessionModelProvider = { GatewaySessionModel("gpt-5.5", null) }
client.sendTurn(null, "hi", null, r.callbacks) { r.preflightFailures += it }
@@ -1575,7 +1938,7 @@ class GatewayChatClientTest {
harness.awaitRpc("prompt.submit")
val create = harness.awaitRpc("session.create")
assertEquals("gpt-5.5", (create["model"] as? JsonPrimitive)?.contentOrNull)
assertEquals(null, create["model"])
assertEquals(null, create["provider"])
}
@@ -1609,7 +1972,8 @@ class GatewayChatClientTest {
harness.awaitRpc("prompt.submit")
val create = harness.awaitRpc("session.create")
assertEquals("grok-4.3", (create["model"] as? JsonPrimitive)?.contentOrNull)
assertFalse(create.containsKey("model"))
assertFalse(create.containsKey("provider"))
assertEquals("high", (create["reasoning_effort"] as? JsonPrimitive)?.contentOrNull)
assertEquals(true, (create["fast"] as? JsonPrimitive)?.booleanOrNull)
}
@@ -1657,7 +2021,8 @@ class GatewayChatClientTest {
harness.awaitRpc("prompt.submit")
val create = harness.awaitRpc("session.create")
assertEquals("gpt-5.5", (create["model"] as? JsonPrimitive)?.contentOrNull)
assertFalse(create.containsKey("model"))
assertFalse(create.containsKey("provider"))
assertFalse(create.containsKey("reasoning_effort"))
assertFalse(create.containsKey("fast"))
}
@@ -1880,6 +2245,65 @@ class GatewayChatClientTest {
assertTrue(harness.rpcLog.none { it.first == "prompt.submit" })
}
@Test
fun `attachment failure never falls through to a transport that drops the file`() {
harness.methodNotFound.add("file.attach")
val r = Recorder()
val attachmentFailures = mutableListOf<String>()
client.sendTurn(
sessionId = null,
text = "use both files",
newSessionTitle = null,
callbacks = r.callbacks,
attachments = listOf(
GatewayAttachment("shot.png", "QQ==", "png", "image/png", sizeBytes = 1),
GatewayAttachment("notes.txt", "Qg==", "txt", "text/plain", sizeBytes = 1),
),
onAttachmentFailure = {
attachmentFailures += it
r.completeLatch.countDown()
},
onPreflightFailure = { r.preflightFailures += it },
)
assertTrue(r.completeLatch.await(5, TimeUnit.SECONDS))
assertTrue(attachmentFailures.single().contains("file.attach"))
assertTrue(r.preflightFailures.isEmpty())
assertTrue(harness.rpcLog.none { it.first == "prompt.submit" })
val detach = harness.awaitRpc("image.detach")
assertEquals("/session/images/upload.png", (detach["path"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `attachment sizes are derived from base64 and bounded before upload`() {
assertEquals(1L, decodedBase64Size("QQ=="))
assertEquals(2L, decodedBase64Size("QUI="))
assertEquals(3L, decodedBase64Size("QUJD"))
val r = Recorder()
client.sendTurn(
sessionId = null,
text = "oversized",
newSessionTitle = null,
callbacks = r.callbacks,
attachments = listOf(
GatewayAttachment(
name = "huge.png",
base64 = "QQ==",
ext = "png",
contentType = "image/png",
sizeBytes = 25L * 1024L * 1024L + 1L,
),
),
onAttachmentFailure = { r.completeLatch.countDown() },
onPreflightFailure = { r.preflightFailures += it },
)
assertTrue(r.completeLatch.await(5, TimeUnit.SECONDS))
assertTrue(harness.rpcLog.none { it.first.startsWith("image.attach") })
assertTrue(harness.rpcLog.none { it.first == "prompt.submit" })
}
// --- Ask responders ---
@Test
@@ -1978,6 +2402,43 @@ class GatewayChatClientTest {
assertTrue(harness.ticketMints.get() >= 1)
}
@Test
fun `cron creation uses upstream manage rpc with finite repeat and profile`() {
val result = runBlocking {
client.createCronJob(
CronCreationDraft(
name = "Two reports",
schedule = "every 1h",
prompt = "Send a concise report",
repeat = 2,
profile = "work",
),
)
}
assertTrue(result.isSuccess)
val params = harness.awaitRpc("cron.manage")
assertEquals("add", (params["action"] as? JsonPrimitive)?.contentOrNull)
assertEquals("Two reports", (params["name"] as? JsonPrimitive)?.contentOrNull)
assertEquals("every 1h", (params["schedule"] as? JsonPrimitive)?.contentOrNull)
assertEquals("Send a concise report", (params["prompt"] as? JsonPrimitive)?.contentOrNull)
assertEquals(2, (params["repeat"] as? JsonPrimitive)?.intOrNull)
assertEquals("work", (params["profile"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `invalid finite repeat is rejected before opening gateway`() {
val result = runBlocking {
client.createCronJob(
CronCreationDraft("Bad", "every 1h", "Run", repeat = 0),
)
}
assertTrue(result.isFailure)
assertEquals(0, harness.ticketMints.get())
assertTrue(harness.rpcLog.none { it.first == "cron.manage" })
}
// --- Pet thumbnails ---
@Test
@@ -1986,7 +2447,7 @@ class GatewayChatClientTest {
harness.awaitRpc("session.resume")
harness.awaitRpc("prompt.submit")
val result = runBlocking { client.reactToNewest("assistant", "👍") }
val result = runBlocking { client.reactToMessage(null, "assistant", "👍") }
assertTrue(result.isSuccess)
val params = harness.awaitRpc("message.react")
@@ -1997,6 +2458,20 @@ class GatewayChatClientTest {
assertFalse("row_id" in params)
}
@Test
fun `message reaction targets durable row when history provides it`() {
client.sendTurn("stored-1", "hi", null, Recorder().callbacks) {}
harness.awaitRpc("session.resume")
harness.awaitRpc("prompt.submit")
val result = runBlocking { client.reactToMessage(42L, "assistant", "❤️") }
assertTrue(result.isSuccess)
val params = harness.awaitRpc("message.react")
assertEquals(42L, (params["row_id"] as? JsonPrimitive)?.longOrNull)
assertFalse("newest_role" in params)
}
@Test
fun `pet thumbnail connects on demand and sends upstream params`() {
client.sessionProfileProvider = { "work" }
@@ -2075,6 +2550,81 @@ class GatewayChatClientTest {
assertTrue(wrongMediaType.exceptionOrNull() is GatewayRpcException)
}
@Test
fun `pet info parses the upstream renderer payload with profile and revision`() = runBlocking {
val info = client.petInfo(profile = "work", knownRevision = "old:1").getOrThrow()
val params = harness.awaitRpc("pet.info")
assertEquals("work", (params["profile"] as? JsonPrimitive)?.contentOrNull)
assertEquals("old:1", (params["knownRevision"] as? JsonPrimitive)?.contentOrNull)
assertTrue(info.enabled)
assertEquals("boba", info.slug)
assertEquals("123:8", info.spritesheetRevision)
assertEquals(192, info.frameWidth)
assertEquals(8, info.framesByRow["running"])
assertArrayEquals(java.util.Base64.getDecoder().decode("iVBORw0KGgo="), info.spritesheet)
}
@Test
fun `pet info accepts upstream unchanged response without duplicate sheet`() = runBlocking {
harness.petInfoPayload = buildJsonObject {
put("enabled", true)
put("slug", "boba")
put("displayName", "Boba")
put("spritesheetRevision", "123:8")
put("spritesheetUnchanged", true)
put("frameW", 192)
put("frameH", 208)
put("framesPerState", 8)
put("loopMs", 1100)
put("stateRows", JsonArray(listOf(JsonPrimitive("idle"))))
}
val info = client.petInfo(knownRevision = "123:8").getOrThrow()
assertTrue(info.spritesheetUnchanged)
assertNull(info.spritesheet)
}
@Test
fun `pet gallery and mutations preserve profile scope`() = runBlocking {
val gallery = client.petGallery(profile = "work", localOnly = true).getOrThrow()
assertEquals("boba", gallery.active)
assertEquals("Boba", gallery.pets.single().displayName)
val galleryParams = harness.awaitRpc("pet.gallery")
assertEquals("work", (galleryParams["profile"] as? JsonPrimitive)?.contentOrNull)
assertTrue((galleryParams["localOnly"] as? JsonPrimitive)?.booleanOrNull == true)
client.selectPet("boba", profile = "work").getOrThrow()
val selectParams = harness.awaitRpc("pet.select")
assertEquals("boba", (selectParams["slug"] as? JsonPrimitive)?.contentOrNull)
assertEquals("work", (selectParams["profile"] as? JsonPrimitive)?.contentOrNull)
client.disablePet(profile = "work").getOrThrow()
val disableParams = harness.awaitRpc("pet.disable")
assertEquals("work", (disableParams["profile"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `pet contract rejects malformed renderer and unsafe mutation`() = runBlocking {
harness.petInfoPayload = buildJsonObject {
put("enabled", true)
put("slug", "boba")
put("spritesheetRevision", "1:1")
put("spritesheetBase64", "not base64")
put("mime", "image/png")
put("frameW", 192)
put("frameH", 208)
put("framesPerState", 8)
put("loopMs", 1100)
put("stateRows", JsonArray(listOf(JsonPrimitive("idle"))))
}
assertTrue(client.petInfo().isFailure)
assertTrue(client.selectPet("../boba").exceptionOrNull() is IllegalArgumentException)
assertEquals(0, harness.rpcLog.count { it.first == "pet.select" })
}
// --- Reasoning config ---
@Test
@@ -2302,6 +2852,44 @@ class GatewayChatClientTest {
assertFalse(rpc.containsKey("scope"))
}
@Test
fun `confirmed model update echoes upstream confirmation field`() {
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", confirmSelection = true)
}
assertTrue(result.isSuccess)
val rpc = harness.awaitRpc("config.set")
assertEquals("model", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
assertEquals(true, (rpc["confirm_expensive_model"] as? JsonPrimitive)?.booleanOrNull)
assertEquals("live-resumed", (rpc["session_id"] as? JsonPrimitive)?.contentOrNull)
}
@Test
fun `same confirmed draft session is reusable across rapid model picks`() {
client.sessionProfileProvider = { "mizu" }
harness.createdSessionProfileName = "mizu"
val results = runBlocking {
val first = async(Dispatchers.IO) { client.prepareModelSelectionSession(null) }
val second = async(Dispatchers.IO) { client.prepareModelSelectionSession(null) }
listOf(first.await(), second.await())
}
assertTrue(results.all { it.isSuccess })
assertEquals(
listOf("20260612_120000_abc123", "20260612_120000_abc123"),
results.map { it.getOrThrow() },
)
assertEquals(1, harness.rpcLog.count { it.first == "session.create" })
assertEquals(0, harness.rpcLog.count { it.first == "config.set" })
}
@Test
fun `fast update targets live session without global scope`() {
val r = Recorder()
@@ -2394,6 +2982,25 @@ class GatewayChatClientTest {
assertEquals(listOf(101L, null, null), rebound.get())
}
@Test
fun `row id only rewind still carries explicit truncation consent`() {
val r = Recorder()
client.sendTurn(
sessionId = "stored-1",
text = "edited message",
newSessionTitle = null,
callbacks = r.callbacks,
truncateBeforeRowId = 73L,
onPreflightFailure = { r.preflightFailures += it },
)
val submit = harness.awaitRpc("prompt.submit")
assertFalse(submit.containsKey("truncate_before_user_ordinal"))
assertEquals(73L, (submit["truncate_before_row_id"] as? JsonPrimitive)?.longOrNull)
assertEquals(true, (submit["confirm_truncate"] as? JsonPrimitive)?.booleanOrNull)
assertFalse(submit.containsKey("confirm_empty_truncate"))
}
@Test
fun `first user truncate carries empty-history confirmation`() {
val r = Recorder()
@@ -2665,6 +3272,41 @@ class GatewayChatClientTest {
assertEquals(listOf("recovered"), recorder.textDeltas.toList())
}
@Test
fun `profile mismatch clears buffered recovery events before retry`() = runBlocking {
client.sessionProfileProvider = { "mizu" }
harness.sessionProfileOverride = "default"
harness.recoveryRunning = true
harness.suppressAckMethods += "session.resume"
val rejectedRecorder = Recorder()
val rejected = async(Dispatchers.IO) {
client.recoverTurn("stored-42", null, rejectedRecorder.callbacks)
}
val ack = harness.awaitPendingAck()
ack.ws.send(
harness.eventFrame(
"message.delta",
buildJsonObject { put("text", "stale mismatch delta") },
"live-resumed",
),
)
harness.releaseAck(ack, harness.recoveryResult("live-resumed"))
assertTrue(rejected.await().exceptionOrNull() is GatewayPreflightException)
assertTrue(rejectedRecorder.textDeltas.isEmpty())
harness.suppressAckMethods -= "session.resume"
harness.sessionProfileOverride = "mizu"
val retryRecorder = Recorder()
val retry = client.recoverTurn("stored-42", null, retryRecorder.callbacks).getOrThrow()
assertTrue(retry.running)
assertTrue(retryRecorder.textDeltas.isEmpty())
retry.handle?.detach()
Unit
}
@Test
fun `recoverTurn keeps queued-only resume live`() {
harness.recoveryQueuedUser = "do this next"
@@ -3130,6 +3772,7 @@ class GatewayChatClientTest {
val backgroundEvents = ConcurrentLinkedQueue<GatewayBackgroundInteractionEvent>()
client.setBackgroundInteractionListener(backgroundEvents::add)
client.sessionProfileProvider = { "work" }
harness.sessionProfileOverride = "work"
client.sendTurn(null, "long task", null, original.callbacks) {
original.preflightFailures += it
}
@@ -76,13 +76,32 @@ class NativeDashboardAuthTest {
assertEquals("/prefix/auth/native/authorize", url.path)
assertEquals("S256", query["code_challenge_method"])
assertEquals("http://127.0.0.1:43123/callback", query["redirect_uri"])
assertEquals("nous", query["provider"])
assertEquals(null, query["provider"])
assertTrue(query.getValue("state").length >= 32)
assertEquals(43, query.getValue("code_challenge").length)
assertFalse(query.getValue("code_challenge").contains('='))
assertNotEquals(query["state"], query["code_challenge"])
}
@Test
fun beginAuthorization_keepsExplicitSelectorForNonNousProviders() {
val client = NativeDashboardAuthClient(server.url("/").toString(), store)
val authorization = client.beginAuthorization(
redirectUri = "http://127.0.0.1:43123/callback",
provider = "oidc",
)
val query = java.net.URI(authorization.authorizationUrl).rawQuery
.split("&")
.associate {
val pair = it.split("=", limit = 2)
java.net.URLDecoder.decode(pair[0], "UTF-8") to
java.net.URLDecoder.decode(pair[1], "UTF-8")
}
assertEquals("oidc", query["provider"])
}
@Test
fun canonicalNousCallbackBase_usesSecurePublicOriginAndPreservesPrefix() {
val location = "https://portal.nousresearch.com/oauth/authorize" +
@@ -124,8 +124,8 @@ class NativeDashboardSignInCoordinatorTest {
)
assertTrue(response.startsWith("HTTP/1.1 400"))
assertTrue(response.contains("Sign-in needs another try"))
assertTrue(response.contains("No session details were saved"))
assertTrue(response.contains("Hermes rejected the sign-in code"))
assertTrue(response.contains("hosted Hermes could not exchange its one-time callback code"))
assertTrue(response.contains("Referrer-Policy: no-referrer"))
assertTrue(response.contains("X-Content-Type-Options: nosniff"))
assertTrue(response.contains("Permissions-Policy:"))
@@ -215,7 +215,11 @@ class NativeDashboardSignInCoordinatorTest {
}
val authorize = URI(authorizationUrl.await())
val authorizeQuery = query(authorize)
assertEquals(provider, authorizeQuery["provider"])
if (provider.equals("nous", ignoreCase = true)) {
assertEquals(null, authorizeQuery["provider"])
} else {
assertEquals(provider, authorizeQuery["provider"])
}
assertEquals("S256", authorizeQuery["code_challenge_method"])
val redirect = URI(authorizeQuery["redirect_uri"]!!)
assertEquals("127.0.0.1", redirect.host)
@@ -1,14 +1,17 @@
package com.hermesandroid.relay.network.upstream.models
import kotlinx.serialization.encodeToString
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.assertThrows
import org.junit.Before
import org.junit.Test
@@ -71,7 +74,10 @@ class SessionModelsTest {
"message_count": 5,
"tool_call_count": 2,
"input_tokens": 100,
"output_tokens": 200
"output_tokens": 200,
"actual_cost_usd": 1.25,
"estimated_cost_usd": 1.50,
"is_active": true
}
""".trimIndent()
@@ -82,6 +88,11 @@ class SessionModelsTest {
assertEquals(1700000900.0, item.resolvedLastActivity!!, 0.001)
assertEquals(5, item.messageCount)
assertEquals(2, item.toolCallCount)
assertEquals(100, item.inputTokens)
assertEquals(200, item.outputTokens)
assertEquals(1.25, item.actualCostUsd!!, 0.001)
assertEquals(1.50, item.estimatedCostUsd!!, 0.001)
assertTrue(item.isActive)
}
@Test
@@ -94,6 +105,42 @@ class SessionModelsTest {
assertTrue(item.archived)
}
@Test
fun sessionItem_deserializesNumericSessionFlags() {
val item = json.decodeFromString<SessionItem>(
"""{"id":"s1","has_model_config":1,"pinned":0,"archived":1}""",
)
assertTrue(item.hasModelConfig)
assertFalse(item.pinned)
assertTrue(item.archived)
}
@Test
fun sessionItem_deserializesStringBooleanSessionFlags() {
val item = json.decodeFromString<SessionItem>(
"""{"id":"s1","has_model_config":"false","pinned":"1","archived":"0"}""",
)
assertFalse(item.hasModelConfig)
assertTrue(item.pinned)
assertFalse(item.archived)
}
@Test
fun sessionItem_rejectsNonBooleanSessionFlagValues() {
assertThrows(SerializationException::class.java) {
json.decodeFromString<SessionItem>(
"""{"id":"s1","pinned":2}""",
)
}
assertThrows(SerializationException::class.java) {
json.decodeFromString<SessionItem>(
"""{"id":"s1","archived":"yes"}""",
)
}
}
@Test
fun sessionItem_deserializesOptionalWorkspaceMetadata() {
val item = json.decodeFromString<SessionItem>(
@@ -320,6 +367,25 @@ class SessionModelsTest {
"""{"role":"user","row_id":{"unexpected":true}}""",
).rowId,
)
assertEquals(
75L,
json.decodeFromString<MessageItem>(
"""{"id":75,"role":"assistant"}""",
).resolvedRowId,
)
}
@Test
fun messageItem_readsReactionsFromObjectOrRawJsonMetadata() {
val objectBacked = json.decodeFromString<MessageItem>(
"""{"role":"assistant","display_metadata":{"reactions":[{"emoji":"👍","author":"user","at":1.0}]}}""",
)
val stringBacked = json.decodeFromString<MessageItem>(
"""{"role":"assistant","display_metadata":"{\"reactions\":[{\"emoji\":\"❤️\",\"author\":\"user\",\"at\":2.0}]}"}""",
)
assertEquals("👍", objectBacked.reactions.single().emoji)
assertEquals("❤️", stringBacked.reactions.single().emoji)
}
@Test
@@ -0,0 +1,29 @@
package com.hermesandroid.relay.reliability
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionResetEvidenceTest {
@Test
fun `checkpoint contains only bounded structural evidence`() {
val text = SessionResetEvidence(
reason = "New Chat / user",
transport = "gateway",
messageCount = 12,
toolCount = 4,
queuedCount = 1,
pendingAttachmentCount = 2,
hadStoredSession = true,
turnActive = false,
askPending = true,
).technicalDetail()
assertTrue(text.contains("reason=new_chat___user"))
assertTrue(text.contains("messages=12"))
assertTrue(text.contains("pending_attachments=2"))
assertFalse(text.contains("prompt"))
assertFalse(text.contains("session_id"))
assertFalse(text.contains("profile"))
}
}
@@ -1,5 +1,6 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
@@ -58,6 +59,39 @@ class CollapsibleAttachmentGroupTest {
)
}
@Test
fun `every attachment render mode keeps the same disclosure affordance`() {
val mimeToMode = mapOf(
"image/png" to AttachmentRenderMode.IMAGE,
"video/mp4" to AttachmentRenderMode.VIDEO,
"audio/mpeg" to AttachmentRenderMode.AUDIO,
"application/pdf" to AttachmentRenderMode.PDF,
"text/markdown" to AttachmentRenderMode.TEXT,
"application/octet-stream" to AttachmentRenderMode.GENERIC,
)
compose.setContent {
MaterialTheme {
Column {
mimeToMode.forEach { (mime, mode) ->
CollapsibleAttachmentGroup(
messageKey = mode.name,
attachments = listOf(Attachment(contentType = mime, content = "QQ==")),
) {
Text("Preview ${mode.name}")
}
}
}
}
}
mimeToMode.values.forEach { mode ->
compose.onNodeWithText("Preview ${mode.name}").assertExists()
compose.onNodeWithTag("attachment-group-toggle-${mode.name}").performClick()
compose.onNodeWithText("Preview ${mode.name}").assertDoesNotExist()
}
}
@Test
fun `collapsed group stays collapsed when attachment lifecycle updates`() {
var attachments by mutableStateOf(
@@ -17,6 +17,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.ChatQuoteReference
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.MessageReaction
import com.hermesandroid.relay.data.buildChatQuotedPrompt
import org.junit.Assert.assertEquals
import org.junit.Rule
@@ -117,6 +118,65 @@ class MessageBubbleInteractionTest {
compose.onNodeWithText("Quote in reply").assertIsDisplayed().assertHasClickAction()
}
@Test
fun reactionsStayOutsideBubbleAndOpenAsFloatingTapbacks() {
val reactions = mutableListOf<String?>()
val message = ChatMessage(
id = "assistant-reactions",
role = MessageRole.ASSISTANT,
content = "React to this response.",
timestamp = 1_700_000_000_000L,
)
compose.setContent {
MaterialTheme {
MessageBubble(
message = message,
onReact = { reactions += it },
)
}
}
compose.onNodeWithContentDescription("React with 👍").assertDoesNotExist()
compose.onNodeWithText("Remove reaction").assertDoesNotExist()
compose.onNodeWithContentDescription("assistant message: ${message.content}")
.performTouchInput { longClick() }
compose.onNodeWithContentDescription("React with 👍")
.assertIsDisplayed()
.assertHasClickAction()
.performClick()
compose.runOnIdle { assertEquals(listOf("👍"), reactions) }
compose.onNodeWithContentDescription("React with 👍").assertDoesNotExist()
}
@Test
fun landedReactionStaysPinnedToTheBubbleAndReopensPicker() {
val message = ChatMessage(
id = "assistant-landed-reaction",
role = MessageRole.ASSISTANT,
content = "A reacted response.",
timestamp = 1_700_000_000_000L,
reactions = listOf(MessageReaction("❤️", "user", 1_700_000_000.0)),
)
compose.setContent {
MaterialTheme {
MessageBubble(message = message, onReact = {})
}
}
compose.onNodeWithContentDescription("Reactions: ❤️")
.assertIsDisplayed()
.assertHasClickAction()
.performClick()
compose.onNodeWithContentDescription("React with ❤️").assertIsDisplayed()
compose.onNodeWithText("Remove reaction").assertIsDisplayed()
}
@Test
fun quotedReplyRendersStructuredReferenceAndKeepsMarkupOutOfActions() {
var quotedContent: String? = null
@@ -0,0 +1,135 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.data.SessionActivityState
import org.junit.Assert.assertEquals
import org.junit.Test
class SessionDrawerPolicyTest {
@Test
fun `sessions are ungrouped by default`() {
assertEquals(SessionDrawerGrouping.None, SessionDrawerViewOptions().grouping)
}
@Test
fun `cross profile rows retain composite identity`() {
val alpha = row("alpha", "same")
val beta = row("beta", "same")
assertEquals("alpha:same", sessionRowKey(alpha))
assertEquals("beta:same", sessionRowKey(beta))
}
@Test
fun `profile project status and pull request filters compose`() {
val wanted = row(
profile = "work",
id = "wanted",
repo = "/src/hermes-relay",
prNumber = 347,
prState = "open",
)
val wrongProfile = row("personal", "other", repo = "/src/hermes-relay", prNumber = 22)
val wrongProject = row("work", "notes", repo = "/src/notes", prNumber = 23)
val states = mapOf(sessionRowKey(wanted) to SessionActivityState.NeedsInput)
val filtered = filterAndSortSessionRows(
rows = listOf(wrongProfile, wrongProject, wanted),
options = SessionDrawerViewOptions(
profiles = setOf("work"),
projects = setOf("hermes-relay"),
statuses = setOf(SessionDrawerStatus.NeedsInput),
pullRequests = setOf(SessionDrawerPrState.Open),
),
activityStates = states,
)
assertEquals(listOf("wanted"), filtered.map { it.session.sessionId })
}
@Test
fun `token and cost ordering use authoritative session metrics`() {
val small = row("default", "small", inputTokens = 10, outputTokens = 20, cost = 3.0)
val large = row("default", "large", inputTokens = 500, outputTokens = 600, cost = 1.0)
assertEquals(
listOf("large", "small"),
filterAndSortSessionRows(
listOf(small, large),
SessionDrawerViewOptions(ordering = SessionDrawerOrdering.Tokens),
).map { it.session.sessionId },
)
assertEquals(
listOf("small", "large"),
filterAndSortSessionRows(
listOf(small, large),
SessionDrawerViewOptions(ordering = SessionDrawerOrdering.Cost),
).map { it.session.sessionId },
)
}
@Test
fun `desktop style grouping supports project profile status and updated buckets`() {
val now = 10 * DAY
val working = row("work", "working", repo = "/src/hermes-relay", updatedAt = now - 1_000)
val idle = row("personal", "idle", repo = "/src/notes", updatedAt = now - 3 * DAY)
val states = mapOf(sessionRowKey(working) to SessionActivityState.Working)
assertEquals(
listOf("hermes-relay", "notes"),
groupSessionRows(listOf(working, idle), SessionDrawerGrouping.Project, states, now)
.mapNotNull { it.label },
)
assertEquals(
listOf("work", "personal"),
groupSessionRows(listOf(working, idle), SessionDrawerGrouping.Profile, states, now)
.mapNotNull { it.label },
)
assertEquals(
listOf("Working", "Idle"),
groupSessionRows(listOf(working, idle), SessionDrawerGrouping.Status, states, now)
.mapNotNull { it.label },
)
assertEquals(
listOf("Today", "Last 7 days"),
groupSessionRows(listOf(working, idle), SessionDrawerGrouping.Updated, states, now)
.mapNotNull { it.label },
)
assertEquals(
listOf(null),
groupSessionRows(listOf(working, idle), SessionDrawerGrouping.None, states, now)
.map { it.label },
)
}
private fun row(
profile: String,
id: String,
repo: String? = null,
prNumber: Int? = null,
prState: String? = null,
inputTokens: Int = 0,
outputTokens: Int = 0,
cost: Double? = null,
updatedAt: Long = 0L,
) = ProfileSessionRow(
profile = profile,
session = ChatSession(
sessionId = id,
title = id,
model = null,
gitRepoRoot = repo,
pullRequestNumber = prNumber,
pullRequestState = prState,
inputTokens = inputTokens,
outputTokens = outputTokens,
actualCostUsd = cost,
lastActivityAt = updatedAt,
),
)
private companion object {
const val DAY = 24L * 60L * 60L * 1_000L
}
}
@@ -8,10 +8,15 @@ import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performScrollToNode
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.hermesandroid.relay.data.ChatSession
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
import org.junit.Rule
import org.junit.Test
import org.junit.Assert.assertEquals
@@ -66,6 +71,42 @@ class SessionDrawerTest {
listOf("hermes-relay", "feature/android-session-context", "PR #134 · Open"),
sessionWorkLabels(session),
)
assertEquals(
listOf(
SessionWorkBadgeKind.PROJECT,
SessionWorkBadgeKind.BRANCH,
SessionWorkBadgeKind.PULL_REQUEST,
),
sessionWorkBadges(session).map(SessionWorkBadge::kind),
)
}
@Test
fun `session rows identify project and branch badges`() {
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = listOf(
ChatSession(
sessionId = "coding-1",
title = "Ship it",
model = null,
gitRepoRoot = "/work/hermes-relay",
gitBranch = "feature/chat-polish",
),
),
currentSessionId = null,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithText("hermes-relay").assertIsDisplayed()
compose.onNodeWithContentDescription("Project: hermes-relay").assertIsDisplayed()
compose.onNodeWithContentDescription("Branch: feature/chat-polish").assertIsDisplayed()
}
@Test
@@ -120,4 +161,168 @@ class SessionDrawerTest {
compose.onNodeWithText("Now latest").assertIsDisplayed()
}
@Test
fun `all profiles toggle renders duplicate ids together in the primary list`() {
var pinned: Triple<String, String, Boolean>? = null
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = listOf(ChatSession("same", "Current", null)),
currentSessionId = null,
activeProfileName = "alpha",
allProfilesSupported = true,
allProfileSessions = listOf(
ProfileSessionRow("alpha", ChatSession("same", "Alpha session", null)),
ProfileSessionRow("beta", ChatSession("same", "Beta session", null)),
),
onRefreshAllProfiles = {},
onSelectProfileSession = { _, _ -> },
onSetProfileSessionPinned = { profile, sessionId, value ->
pinned = Triple(profile, sessionId, value)
},
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithText("All Profiles").performClick()
compose.onNodeWithText("Alpha session").assertIsDisplayed()
compose.onNodeWithText("Beta session").assertIsDisplayed()
compose.onAllNodesWithContentDescription("Session actions")[0]
.assertIsDisplayed()
.performClick()
compose.onNodeWithText("Pin session").performClick()
compose.runOnIdle { assertEquals(Triple("alpha", "same", true), pinned) }
}
@Test
fun `opening an owned session keeps all profiles browsing selected`() {
var scopeTitle by mutableStateOf("Mizu Sessions")
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = emptyList(),
currentSessionId = null,
scopeTitle = scopeTitle,
activeProfileName = "mizu",
allProfilesSupported = true,
allProfileSessions = listOf(
ProfileSessionRow("mizu", ChatSession("m", "Mizu chat", null)),
ProfileSessionRow("x-bot", ChatSession("x", "X Bot chat", null)),
),
onRefreshAllProfiles = {},
onSelectProfileSession = { _, _ -> scopeTitle = "X Bot Sessions" },
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithText("All Profiles").performClick()
compose.onNodeWithText("X Bot chat").performClick()
compose.onNodeWithText("Mizu chat").assertIsDisplayed()
compose.onNodeWithText("X Bot Sessions").assertDoesNotExist()
compose.onNodeWithText("2 profiles · 2 sessions").assertIsDisplayed()
}
@Test
fun `new chat from all profiles requests an explicit default draft`() {
var scopedNewChats = 0
var defaultNewChats = 0
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = emptyList(),
currentSessionId = null,
allProfilesSupported = true,
allProfileSessions = listOf(
ProfileSessionRow("default", ChatSession("d", "Default chat", null)),
),
onRefreshAllProfiles = {},
onSelectProfileSession = { _, _ -> },
onNewChat = { scopedNewChats++ },
onNewDefaultChat = { defaultNewChats++ },
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithText("All Profiles").performClick()
compose.waitForIdle()
compose.onNodeWithText("New Chat").performClick()
compose.runOnIdle {
assertEquals(0, scopedNewChats)
assertEquals(1, defaultNewChats)
}
}
@Test
fun `customize sessions exposes desktop backed view variants`() {
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = listOf(ChatSession("one", "One", null)),
currentSessionId = null,
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithText("Customize sessions").performClick()
compose.onNodeWithText("Group by").assertIsDisplayed()
compose.onNodeWithText("Order by").assertIsDisplayed()
compose.onNodeWithText("Show details").assertIsDisplayed()
compose.onNodeWithText("Filters").assertIsDisplayed()
}
@Test
fun `all profiles customization can override a profile identity color`() {
var changed: Pair<String, String?>? = null
compose.setContent {
MaterialTheme {
SessionDrawerContent(
sessions = emptyList(),
currentSessionId = null,
allProfilesSupported = true,
allProfileSessions = listOf(
ProfileSessionRow("alpha", ChatSession("a", "Alpha session", null)),
ProfileSessionRow("beta", ChatSession("b", "Beta session", null)),
),
onProfileColorChange = { profile, color -> changed = profile to color },
onRefreshAllProfiles = {},
onSelectProfileSession = { _, _ -> },
onNewChat = {},
onSelectSession = {},
onDeleteSession = {},
onRenameSession = { _, _ -> },
)
}
}
compose.onNodeWithText("All Profiles").performClick()
compose.onNodeWithText("Customize sessions").performClick()
compose.onNodeWithText("Profile colors").performScrollTo().assertIsDisplayed()
compose.onNodeWithContentDescription(
"Set alpha profile color to ${ProfileAccentSwatches.first()}",
).performScrollTo().performClick()
compose.runOnIdle {
assertEquals("alpha" to ProfileAccentSwatches.first(), changed)
}
}
}
@@ -0,0 +1,44 @@
package com.hermesandroid.relay.ui.components.avatar
import com.hermesandroid.relay.ui.components.SphereState
import com.hermesandroid.relay.viewmodel.connection.ProfileController
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class HermesPetAdapterTest {
@get:Rule
val temp = TemporaryFolder()
@Test
fun `upstream rows keep geometry and map activity aliases`() {
val sheet = temp.newFile("pet.png").apply { writeBytes(byteArrayOf(1)) }
val presentation = ProfileController.HermesPetPresentation(
connectionId = "connection",
profileName = "work",
slug = "boba",
displayName = "Boba",
spritesheetPath = sheet.absolutePath,
spritesheetRevision = "1:1",
frameWidth = 192,
frameHeight = 208,
framesPerState = 8,
framesByState = mapOf("idle" to 6, "run" to 7, "review" to 4),
framesByRow = mapOf("idle" to 5, "running" to 8),
loopMs = 1_000,
scale = 1f,
stateRows = listOf("idle", "running", "review"),
)
val avatar = presentation.toAvatar()
assertNotNull(avatar)
assertEquals("hermes:boba", avatar?.id)
assertEquals(5, avatar?.activityClips?.get(SphereState.Idle)?.frameCount)
assertEquals(8, avatar?.activityClips?.get(SphereState.Streaming)?.frameCount)
assertEquals("running", avatar?.activityClipSources?.get(SphereState.Streaming))
assertEquals("review", avatar?.activityClipSources?.get(SphereState.Thinking))
}
}
@@ -11,7 +11,6 @@ class ChatHeaderSubtitleTest {
resolveChatHeaderSubtitle(
isStreaming = true,
statusText = "Streaming",
projectName = "Hermes Relay",
personalityName = "Victor",
modelName = "GPT-5.6",
),
@@ -19,13 +18,12 @@ class ChatHeaderSubtitleTest {
}
@Test
fun `idle subtitle retains project personality and model metadata`() {
fun `idle subtitle retains only personality and model metadata`() {
assertEquals(
"Hermes Relay \u00B7 Victor \u00B7 GPT-5.6",
"Victor \u00B7 GPT-5.6",
resolveChatHeaderSubtitle(
isStreaming = false,
statusText = "Connected",
projectName = "Hermes Relay",
personalityName = "Victor",
modelName = "GPT-5.6",
),
@@ -5,35 +5,34 @@ import org.junit.Test
class DashboardSignInPolicyTest {
@Test
fun transportFailure_usesActionableRetryCopy_withoutChangingOtherFailures() {
val retry = "Tap sign in to retry"
fun nativeFailuresMapToActionableSecretFreeMessageKinds() {
assertEquals(
retry,
nativeDashboardSignInActionMessage(
failureStage = "token_transport_socket",
errorMessage = "secret transport detail",
fallbackMessage = "fallback",
transportRetryMessage = retry,
),
NativeDashboardSignInMessageKind.CallbackRejected,
nativeDashboardSignInMessageKind("callback_error"),
)
assertEquals(
"HTTP 400",
nativeDashboardSignInActionMessage(
failureStage = "token_http_400",
errorMessage = "HTTP 400",
fallbackMessage = "fallback",
transportRetryMessage = retry,
),
NativeDashboardSignInMessageKind.CodeRejected,
nativeDashboardSignInMessageKind("token_http_400"),
)
assertEquals(
"fallback",
nativeDashboardSignInActionMessage(
failureStage = "token_shape",
errorMessage = null,
fallbackMessage = "fallback",
transportRetryMessage = retry,
),
NativeDashboardSignInMessageKind.RateLimited,
nativeDashboardSignInMessageKind("token_http_429"),
)
assertEquals(
NativeDashboardSignInMessageKind.GatewayUnavailable,
nativeDashboardSignInMessageKind("token_http_503"),
)
assertEquals(
NativeDashboardSignInMessageKind.ResponseUnsupported,
nativeDashboardSignInMessageKind("token_shape"),
)
assertEquals(
NativeDashboardSignInMessageKind.Transport,
nativeDashboardSignInMessageKind("token_transport_socket"),
)
assertEquals(
NativeDashboardSignInMessageKind.SecureStorage,
nativeDashboardSignInMessageKind("token_store"),
)
}
}
@@ -0,0 +1,72 @@
package com.hermesandroid.relay.ui.screens
import com.hermesandroid.relay.data.GatewayProfileAuthChoice
import com.hermesandroid.relay.data.GatewayProfileCreateRequest
import com.hermesandroid.relay.data.GatewayProfileManagementUnsupportedException
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ProfileCreateFallbackPolicyTest {
private val unsupported = GatewayProfileManagementUnsupportedException("profiles.create")
@Test
fun `legacy shared create may fall back to authenticated Dashboard`() {
assertTrue(
shouldUseLegacyDashboardProfileCreate(
GatewayProfileCreateRequest(name = "operator", cloneFrom = "default"),
unsupported,
explicitlyAllowed = true,
),
)
}
@Test
fun `explicit isolation and extended fields never degrade to legacy mutation`() {
assertFalse(
shouldUseLegacyDashboardProfileCreate(
GatewayProfileCreateRequest(
name = "operator",
authChoice = GatewayProfileAuthChoice.Isolated,
),
unsupported,
explicitlyAllowed = true,
),
)
assertFalse(
shouldUseLegacyDashboardProfileCreate(
GatewayProfileCreateRequest(name = "operator", noSkills = true),
unsupported,
explicitlyAllowed = true,
),
)
assertFalse(
shouldUseLegacyDashboardProfileCreate(
GatewayProfileCreateRequest(name = "operator", cloneAll = true),
unsupported,
explicitlyAllowed = true,
),
)
assertFalse(
shouldUseLegacyDashboardProfileCreate(
GatewayProfileCreateRequest(name = "operator", model = "model", provider = "provider"),
unsupported,
explicitlyAllowed = true,
),
)
assertFalse(
shouldUseLegacyDashboardProfileCreate(
GatewayProfileCreateRequest(name = "operator"),
IllegalStateException("network failed"),
explicitlyAllowed = true,
),
)
assertFalse(
shouldUseLegacyDashboardProfileCreate(
GatewayProfileCreateRequest(name = "operator"),
unsupported,
explicitlyAllowed = false,
),
)
}
}
@@ -0,0 +1,25 @@
package com.hermesandroid.relay.ui.theme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ProfileAccentTest {
@Test
fun namedProfilesReceiveStableDistinctColors() {
assertEquals(deterministicProfileAccent("alpha"), deterministicProfileAccent("alpha"))
assertNotEquals(deterministicProfileAccent("alpha"), deterministicProfileAccent("beta"))
}
@Test
fun overrideWinsAndDefaultRemainsNeutral() {
assertEquals(accentColor("#356CFF"), resolveProfileAccent("alpha", mapOf("alpha" to "#356CFF")))
assertNull(resolveProfileAccent("default", mapOf("default" to "#356CFF")))
}
@Test
fun pickerOffersDesktopSizedPalette() {
assertEquals(12, ProfileAccentSwatches.distinct().size)
}
}
@@ -0,0 +1,25 @@
package com.hermesandroid.relay.util
import java.io.ByteArrayInputStream
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class BoundedBase64InputTest {
@Test
fun `encodes incrementally through the exact byte limit`() {
val result = readBase64Bounded(ByteArrayInputStream("hello".toByteArray()), 5)
assertEquals("aGVsbG8=", result.base64)
assertEquals(5L, result.sizeBytes)
}
@Test
fun `rejects before buffering bytes beyond the limit`() {
val error = assertThrows(AttachmentTooLargeException::class.java) {
readBase64Bounded(ByteArrayInputStream(ByteArray(8_193)), 8_192)
}
assertEquals(8_192L, error.limitBytes)
}
}
@@ -12,6 +12,7 @@ import com.hermesandroid.relay.data.ChatTurnToolCheckpoint
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
import com.hermesandroid.relay.data.HermesCardDispatch
import com.hermesandroid.relay.data.MessageRole
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
@@ -40,6 +41,7 @@ import okhttp3.mockwebserver.SocketPolicy
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -148,6 +150,75 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals("victor", gatewayClient.sessionProfileProvider())
}
@Test
fun allProfilesOpenKeepsGlobalSelectionButScopesHistoryResumeAndSendToOwner() {
val global = Profile(name = "mizu", model = "grok-4.5", description = "Mizu")
val owner = Profile(name = "x-bot", model = "grok-4.3", description = "X Bot")
var loadedProfile: String? = null
var persistedSession = "unchanged"
viewModel.setSelectedProfileProvider { global }
viewModel.setSessionProfileNameProvider { global.name }
viewModel.onSessionChanged = { persistedSession = it ?: "cleared" }
viewModel.setProfileMessageLoaderWithMode { profileName, _, _ ->
loadedProfile = profileName
Result.success(emptyList())
}
viewModel.openProfileSession(
profileName = owner.name,
profile = owner,
contextKey = AgentDisplay.profileContextKey("connection-a", owner.name),
sessionId = "x-bot-session",
)
awaitCondition { loadedProfile == owner.name }
assertEquals(owner.name, viewModel.openedSessionProfileName.value)
assertEquals(owner.name, gatewayClient.sessionProfileProvider())
assertEquals("X-bot", handler.activeAgentName)
assertEquals("unchanged", persistedSession)
viewModel.switchProfileContext(
AgentDisplay.profileContextKey("connection-a", global.name),
sessionId = null,
)
assertEquals(null, viewModel.openedSessionProfileName.value)
assertEquals(global.name, gatewayClient.sessionProfileProvider())
viewModel.openProfileSession(
profileName = owner.name,
profile = owner,
contextKey = AgentDisplay.profileContextKey("connection-a", owner.name),
sessionId = "x-bot-session",
)
assertEquals(owner.name, viewModel.openedSessionProfileName.value)
viewModel.createNewChat()
assertEquals(null, viewModel.openedSessionProfileName.value)
assertEquals(global.name, gatewayClient.sessionProfileProvider())
}
@Test
fun allProfilesNewChatUsesLiteralDefaultWithoutChangingGlobalSelection() {
val global = Profile(name = "victor", model = "grok-4.5", description = "Victor")
val rootDefault = Profile(name = "default", model = "gpt-5.5", description = "Hermes")
var persistedSession = "unchanged"
viewModel.setSelectedProfileProvider { global }
viewModel.setSessionProfileNameProvider { global.name }
viewModel.onSessionChanged = { persistedSession = it ?: "cleared" }
viewModel.createProfileChat(
profileName = "default",
profile = rootDefault,
contextKey = AgentDisplay.profileContextKey("connection-a", "default"),
)
assertEquals("default", viewModel.openedSessionProfileName.value)
assertEquals("default", gatewayClient.sessionProfileProvider())
assertEquals(null, handler.currentSessionId.value)
assertEquals("Hermes", handler.activeAgentName)
assertEquals("unchanged", persistedSession)
}
@Test
fun gatewaySessionCreateProviderPreservesExplicitFastFalse() {
serverWs.send(
@@ -280,6 +351,97 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals(null, viewModel.approvalMode.value)
}
@Test
fun freshChatRiskyModelCreatesDefaultSessionBeforeConfirmationPreflight() {
handler.setSessionId(null)
gatewayClient.clearSession()
gatewayHarness.modelConfirmationMessage = "This tier may train on your data."
gatewayHarness.modelsRequiringConfirmation += "risky-model --provider risky-provider"
viewModel.selectModel("risky-model", "risky-provider")
val create = gatewayHarness.awaitRpc("session.create")
awaitCondition { gatewayHarness.rpcLog.count { it.first == "config.set" } >= 1 }
val configSet = gatewayHarness.awaitRpcCount("config.set", 1).single()
awaitCondition { viewModel.modelSelectionConfirmation.value != null }
assertFalse(create.containsKey("model"))
assertFalse(create.containsKey("provider"))
assertEquals("model", (configSet["key"] as JsonPrimitive).content)
assertEquals("live-1", (configSet["session_id"] as JsonPrimitive).content)
assertEquals("20260612_120000_abc123", handler.currentSessionId.value)
assertNull(viewModel.selectedModelOverride.value)
assertEquals(
"This tier may train on your data.",
viewModel.modelSelectionConfirmation.value?.message,
)
}
@Test
fun freshNamedProfileModelPickRejectsDifferentConfirmedOwnerBeforeConfigSet() {
handler.setSessionId(null)
gatewayClient.clearSession()
viewModel.setSelectedProfileProvider {
Profile(name = "mizu", model = "safe-default", description = "Mizu")
}
viewModel.setSessionProfileNameProvider { "mizu" }
gatewayHarness.createdSessionProfileName = "victor"
viewModel.selectModel("risky-model", "risky-provider")
gatewayHarness.awaitRpc("session.create")
awaitCondition { viewModel.selectedModelOverride.value == null }
assertNull(handler.currentSessionId.value)
assertFalse(gatewayHarness.rpcLog.any { it.first == "config.set" })
}
@Test
fun freshNamedProfileModelPickRejectsAbsentConfirmedOwnerBeforeConfigSet() {
handler.setSessionId(null)
gatewayClient.clearSession()
viewModel.setSelectedProfileProvider {
Profile(name = "mizu", model = "safe-default", description = "Mizu")
}
viewModel.setSessionProfileNameProvider { "mizu" }
gatewayHarness.omitSessionProfileMetadata = true
viewModel.selectModel("risky-model", "risky-provider")
gatewayHarness.awaitRpc("session.create")
awaitCondition { viewModel.selectedModelOverride.value == null }
assertNull(handler.currentSessionId.value)
assertFalse(gatewayHarness.rpcLog.any { it.first == "config.set" })
}
@Test
fun serverDefaultModelUsesSameConfirmationAwareTransition() {
viewModel.selectModel("safe-model", "safe-provider")
gatewayHarness.awaitRpcCount("config.set", 1)
awaitCondition { viewModel.selectedModelOverride.value == "safe-model" }
viewModel.setSelectedProfileProvider {
Profile(name = "default", model = "risky-default", description = "Hermes")
}
gatewayHarness.modelConfirmationMessage = "This default has high usage cost."
gatewayHarness.modelsRequiringConfirmation += "risky-default"
viewModel.selectModel(null)
awaitCondition { gatewayHarness.rpcLog.count { it.first == "config.set" } >= 2 }
val configSet = gatewayHarness.awaitRpcCount("config.set", 2).last()
awaitCondition { viewModel.modelSelectionConfirmation.value != null }
assertEquals("risky-default", (configSet["value"] as JsonPrimitive).content)
assertEquals("live-resumed", (configSet["session_id"] as JsonPrimitive).content)
assertEquals("safe-model", viewModel.selectedModelOverride.value)
assertNull(viewModel.modelSelectionConfirmation.value?.modelOverride)
assertEquals(
"This default has high usage cost.",
viewModel.modelSelectionConfirmation.value?.message,
)
}
@Test
fun connectionCatalogResetPreventsDashboardOnlyLeakage() {
viewModel.updateApiClient(null)
@@ -1383,38 +1545,12 @@ class ChatViewModelGatewayInboundTurnTest {
}
@Test
fun unsolicitedTurnDoesNotReplaceAnActiveForcedSseTurn() {
holdCompletionsStream = true
fun gatewayVoiceTurnDoesNotRequireApiFallback() {
viewModel.sendVoiceMessage("local voice turn", "Respond for spoken playback")
awaitCondition { handler.isStreaming.value }
val localPlaceholderId = handler.messages.value.last().id
val params = gatewayHarness.awaitRpc("prompt.submit")
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
serverWs.send(
gatewayHarness.eventFrame(
"message.delta",
buildJsonObject { put("text", BACKGROUND_ANSWER) },
"live-resumed",
),
)
persistedHistory = persistedAnswerHistory()
serverWs.send(
gatewayHarness.eventFrame(
"message.complete",
buildJsonObject { put("text", BACKGROUND_ANSWER) },
"live-resumed",
),
)
Thread.sleep(150)
shadowOf(Looper.getMainLooper()).idle()
assertTrue("the forced SSE turn must still own streaming", handler.isStreaming.value)
assertTrue(handler.messages.value.any { it.id == localPlaceholderId && it.isStreaming })
assertFalse(handler.messages.value.any { it.content == BACKGROUND_ANSWER })
viewModel.cancelStream()
awaitCondition { !handler.isStreaming.value }
awaitCondition { handler.messages.value.any { it.content == BACKGROUND_ANSWER } }
assertEquals(JsonPrimitive("local voice turn"), params["text"])
assertEquals(0, apiCompletionsRequestCount.get())
}
@Test
@@ -0,0 +1,115 @@
package com.hermesandroid.relay.viewmodel
import android.net.Uri
import android.os.Looper
import com.hermesandroid.relay.data.AttachmentState
import com.hermesandroid.relay.data.ChatMessage
import com.hermesandroid.relay.data.MediaSettingsRepository
import com.hermesandroid.relay.network.relay.RelayHttpClient
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.util.MediaCacheWriter
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ChatViewModelMediaStateTest {
private lateinit var server: MockWebServer
private lateinit var handler: ChatHandler
private lateinit var viewModel: ChatViewModel
private lateinit var cache: MediaCacheWriter
@Before
fun setUp() {
server = MockWebServer().apply { start() }
val context = RuntimeEnvironment.getApplication()
val relay = RelayHttpClient(
okHttpClient = OkHttpClient(),
relayUrlProvider = {
server.url("/").toString()
.replaceFirst("http://", "ws://")
.trimEnd('/')
},
sessionTokenProvider = { "paired-session" },
)
cache = mockk()
coEvery { cache.cache(any(), any(), any()) } returns
Uri.parse("content://com.axiomlabs.hermesrelay.fileprovider/hermes-media/photo.jpg")
handler = ChatHandler()
viewModel = ChatViewModel().also {
it.initialize(apiClient = null, chatHandler = handler)
it.initializeMedia(
context = context,
relayHttpClient = relay,
mediaSettingsRepo = MediaSettingsRepository(context),
mediaCacheWriter = cache,
)
it.cellularNetworkOverride = true
}
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun persistedUserImageCellularGateAndManualFetchReachActionableStates() {
handler.loadMessageHistory(
listOf(
MessageItem(
id = "user-image-1",
role = "user",
content = JsonPrimitive("@image:/tmp/photo.jpg"),
)
)
)
val deferred = awaitMessage {
it.attachments.singleOrNull()?.errorMessage == "Tap to download"
}
assertEquals(AttachmentState.FAILED, deferred.attachments.single().state)
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "image/jpeg")
.setHeader("Content-Disposition", "inline; filename=\"photo.jpg\"")
.setBody("image-bytes")
)
viewModel.cellularNetworkOverride = false
viewModel.manualFetchAttachment("user-image-1", 0)
val loaded = awaitMessage {
it.attachments.singleOrNull()?.state == AttachmentState.LOADED
}.attachments.single()
assertEquals("image/jpeg", loaded.contentType)
assertEquals("photo.jpg", loaded.fileName)
assertEquals("content://com.axiomlabs.hermesrelay.fileprovider/hermes-media/photo.jpg", loaded.cachedUri)
}
private fun awaitMessage(predicate: (ChatMessage) -> Boolean): ChatMessage {
val deadline = System.nanoTime() + 5_000_000_000L
while (System.nanoTime() < deadline) {
shadowOf(Looper.getMainLooper()).idle()
handler.messages.value.singleOrNull()?.let { if (predicate(it)) return it }
Thread.sleep(20)
}
throw AssertionError("Timed out waiting for media attachment state: ${handler.messages.value}")
}
}
@@ -0,0 +1,37 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.network.upstream.DashboardDiskStatus
import com.hermesandroid.relay.network.upstream.DashboardMemoryStatus
import com.hermesandroid.relay.network.upstream.DashboardStatus
import org.junit.Assert.assertEquals
import org.junit.Test
class HostResourcePressureStatusTest {
@Test
fun `older host without resource fields stays quiet`() {
val pressure = DashboardStatus(authRequired = false).hostResourcePressure()
assertEquals(false, pressure.needsAttention)
assertEquals(false, pressure.critical)
}
@Test
fun `server pressure enums drive severity without client thresholds`() {
val pressure = DashboardStatus(
authRequired = false,
memory = DashboardMemoryStatus(
pressure = "elevated",
systemAvailableMb = 96,
),
disk = DashboardDiskStatus(
pressure = "critical",
freeMb = 220,
),
).hostResourcePressure()
assertEquals(true, pressure.needsAttention)
assertEquals(true, pressure.critical)
assertEquals(96, pressure.memoryAvailableMb)
assertEquals(220, pressure.diskFreeMb)
}
}
@@ -51,6 +51,7 @@ class ProfileInspectorViewModelTest {
viewModel.loadAll()
advanceUntilIdle()
assertTrue(viewModel.gatewayWritable.value)
viewModel.beginConfigEdit()
viewModel.updateConfigDescriptionDraft("Updated")
viewModel.updateConfigModelDraft("new")
@@ -64,6 +65,32 @@ class ProfileInspectorViewModelTest {
assertEquals("old", viewModel.gatewayDescription.value?.model)
}
@Test
fun `configure capability failure keeps gateway reads and disables writes`() = runTest(dispatcher) {
val gateway = object : GatewayProfileEditorClient {
override suspend fun describeProfile(profileName: String) =
Result.success(description(description = "Read only", model = "stable"))
override suspend fun configureProfile(profileName: String, patch: GatewayProfilePatch) =
Result.failure<GatewayProfileConfigureResult>(GatewayProfileEditorUnsupportedException())
}
val viewModel = viewModel(gateway, FakeLegacy())
viewModel.loadAll()
advanceUntilIdle()
viewModel.beginConfigEdit()
viewModel.updateConfigDescriptionDraft("Retained draft")
viewModel.saveConfigEdit()
advanceUntilIdle()
assertEquals(ProfileInspectorSource.Gateway, viewModel.source.value)
assertEquals("Read only", viewModel.gatewayDescription.value?.description)
assertEquals("Retained draft", viewModel.configDescriptionDraft.value)
assertTrue(viewModel.configEditing.value)
assertEquals(false, viewModel.gatewayWritable.value)
assertEquals(false, viewModel.skillToggleSupported.value)
}
@Test
fun `older gateway falls back to legacy inspector without changing profile namespace`() = runTest(dispatcher) {
val legacy = FakeLegacy()
@@ -0,0 +1,51 @@
package com.hermesandroid.relay.viewmodel.connection
import com.hermesandroid.relay.data.Profile
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ProfileAvatarRefreshPolicyTest {
@Test
fun `only latest avatar refresh for exact connection may publish`() {
assertTrue(isCurrentProfileAvatarRefresh("conn-1", "conn-1", 4, 4))
assertFalse(isCurrentProfileAvatarRefresh("conn-1", "conn-2", 4, 4))
assertFalse(isCurrentProfileAvatarRefresh("conn-1", "conn-1", 3, 4))
}
@Test
fun `gateway roster keeps relay only routing fields by exact profile name`() {
val gateway = Profile(
name = "operator",
model = "gpt-5.6",
provider = "openai",
description = "Current",
hasAvatar = true,
)
val relay = Profile(
name = "operator",
model = "old",
description = "Old",
systemMessage = "SOUL",
gatewayRunning = true,
apiServerEnabled = true,
apiServerUrl = "https://host/p/operator",
)
val merged = mergeGatewayProfileRoster(listOf(gateway), listOf(relay)).single()
assertEquals("gpt-5.6", merged.model)
assertEquals("Current", merged.description)
assertTrue(merged.hasAvatar)
assertEquals("SOUL", merged.systemMessage)
assertEquals("https://host/p/operator", merged.apiServerUrl)
}
@Test
fun `successful empty gateway roster does not resurrect fallback profiles`() {
val fallback = listOf(Profile(name = "stale", model = "old"))
assertTrue(selectProfileRoster(true, emptyList(), fallback).isEmpty())
assertEquals(fallback, selectProfileRoster(false, emptyList(), fallback))
}
}
@@ -241,6 +241,35 @@ class ProfileControllerLockTest {
}
}
@Test
fun explicitProfileSessionMutations_useOwningProfileWithoutChangingSelection() {
dashboardUrl = "https://dashboard.example"
coEvery { dashboardClient.deleteSession("session-2", profile = "coder") } returns
Result.success(buildJsonObject { })
coEvery { dashboardClient.renameSession("session-2", "Updated", profile = "coder") } returns
Result.success(buildJsonObject { })
coEvery { dashboardClient.setSessionPinned("session-2", true, profile = "coder") } returns
Result.success(buildJsonObject { })
coEvery { dashboardClient.setSessionArchived("session-2", true, profile = "coder") } returns
Result.success(buildJsonObject { })
assertTrue(runBlocking { controller.deleteSession("coder", "session-2") })
assertTrue(runBlocking { controller.renameSession("coder", "session-2", "Updated") })
assertTrue(runBlocking { controller.setSessionPinned("coder", "session-2", true) })
assertTrue(runBlocking { controller.setSessionArchived("coder", "session-2", true) })
coVerify(exactly = 1) { dashboardClient.deleteSession("session-2", profile = "coder") }
coVerify(exactly = 1) {
dashboardClient.renameSession("session-2", "Updated", profile = "coder")
}
coVerify(exactly = 1) {
dashboardClient.setSessionPinned("session-2", true, profile = "coder")
}
coVerify(exactly = 1) {
dashboardClient.setSessionArchived("session-2", true, profile = "coder")
}
}
// --- selectProfile gating while locked ----------------------------------
@Test
@@ -6,6 +6,7 @@ import org.junit.Assert.assertNotSame
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.TimeUnit
class UpstreamTransportControllerAuthClientTest {
@Test
@@ -25,6 +26,10 @@ class UpstreamTransportControllerAuthClientTest {
dashboardUrl = "https://hermes.example.test/alternate"
val moved = controller.dashboardHttpClientForActive(dashboardUrl)
assertNotSame(first, moved)
assertTrue(first.dispatcher.executorService.isShutdown)
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (!first.dispatcher.executorService.isShutdown && System.nanoTime() < deadline) {
Thread.yield()
}
assertTrue("replaced dashboard client was not disposed", first.dispatcher.executorService.isShutdown)
}
}
+1
View File
@@ -0,0 +1 @@
1.3.14

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