Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
545d238fd2 | ||
|
|
9b36a34e2a | ||
|
|
7c35a51aed | ||
|
|
474147cb62 | ||
|
|
0bd246586a | ||
|
|
fc6aaffc11 | ||
|
|
197b23f344 | ||
|
|
0862b4c346 | ||
|
|
9c7bc46c7c | ||
|
|
9554eab757 | ||
|
|
3cacc7d6d6 | ||
|
|
311888a9fb | ||
|
|
96dfe472ef | ||
|
|
7d6a0215ba | ||
|
|
51ec55e08c | ||
|
|
4edcd18398 | ||
|
|
3f50a94d03 | ||
|
|
94d2231a80 | ||
|
|
5ac798d5b3 | ||
|
|
ebe1a8ebc4 | ||
|
|
fc3a1c169b | ||
|
|
6eb2c8bd9e | ||
|
|
cbe323cf6d | ||
|
|
5401e8f274 | ||
|
|
0b62afa6c3 | ||
|
|
02801dda70 | ||
|
|
39011d5600 | ||
|
|
e50c644d09 | ||
|
|
3ff4e7cf8e | ||
|
|
683d9712d8 | ||
|
|
7ae4a3987e | ||
|
|
9dcb280eff | ||
|
|
a90067292a | ||
|
|
4447886e80 | ||
|
|
5d00885291 | ||
|
|
9fdb11ae94 | ||
|
|
fd35c9b2b6 | ||
|
|
9dc625ddd0 | ||
|
|
0e6d64f987 | ||
|
|
e3bc816624 |
@@ -1,65 +0,0 @@
|
||||
name: Issue Triage
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue_number:
|
||||
description: "Issue number to label again"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: issue-triage-${{ github.event.issue.number || github.event.inputs.issue_number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
auto-label:
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issues' && github.event.issue.user.type != 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Label from title prefix and issue area
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue_number }}
|
||||
with:
|
||||
script: |
|
||||
const issue_number = Number(process.env.ISSUE_NUMBER);
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner: context.repo.owner, repo: context.repo.repo, issue_number,
|
||||
});
|
||||
const title = (issue.title || '').toLowerCase();
|
||||
const body = (issue.body || '').toLowerCase();
|
||||
const haystack = `${title}\n${body}`;
|
||||
const labels = [];
|
||||
|
||||
if (title.startsWith('[bug]')) labels.push('bug');
|
||||
else if (title.startsWith('[feature]') || title.startsWith('[feat]')) labels.push('enhancement');
|
||||
else if (title.startsWith('[docs]')) labels.push('documentation');
|
||||
|
||||
if (/\b(cli|desktop|terminal|daemon|pty|hermes-relay (install|binary|tray))\b/.test(haystack)) labels.push('area:cli');
|
||||
else if (/\b(dashboard|plugin ui|react)\b/.test(haystack)) labels.push('area:dashboard');
|
||||
else if (/\b(relay|plugin|aiohttp|python|pairing|voice (transcribe|synthesize)|bridge (endpoint|route))\b/.test(haystack)) labels.push('area:plugin');
|
||||
else if (/\b(readme|user-?docs|documentation)\b/.test(haystack)) labels.push('area:docs');
|
||||
else if (/\b(android|app|compose|apk|phone|samsung|gradle|chat|voice|notification|sphere|keystore)\b/.test(haystack)) labels.push('area:android');
|
||||
|
||||
if (!labels.length) {
|
||||
core.info('No deterministic label matched; leaving the issue for maintainer triage.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner, repo: context.repo.repo, issue_number, labels,
|
||||
});
|
||||
core.info(`Applied labels: ${labels.join(', ')}`);
|
||||
} catch (error) {
|
||||
core.warning(`Could not apply ${labels.join(', ')}: ${error.message}`);
|
||||
}
|
||||
@@ -191,6 +191,19 @@ jobs:
|
||||
# app/build/outputs/bundle/sideloadRelease/hermes-relay-<version>-sideload-release.aab
|
||||
run: ./gradlew bundleRelease assembleRelease
|
||||
|
||||
# The Play AAB carries its mapping for Play Console deobfuscation, but
|
||||
# sideload issue reports need the exact mapping from this immutable build.
|
||||
# Keep both variants as a workflow artifact (not a public release asset).
|
||||
- name: Retain R8 mappings for retrace
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: android-r8-mappings-${{ needs.validate.outputs.version }}-${{ github.sha }}
|
||||
path: |
|
||||
app/build/outputs/mapping/googlePlayRelease/mapping.txt
|
||||
app/build/outputs/mapping/sideloadRelease/mapping.txt
|
||||
if-no-files-found: error
|
||||
retention-days: 90
|
||||
|
||||
- name: Scan release DEX for unsupported collection APIs
|
||||
run: |
|
||||
python3 scripts/check-android-collection-apis.py \
|
||||
|
||||
@@ -8,8 +8,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Issue area labels require maintainer review.** The unreliable keyword-based auto-labeling workflow no longer assigns ownership from ambiguous issue text.
|
||||
- **Windows-trusted certificates work in the desktop CLI.** The packaged Windows binary and newer Node runtimes add the Windows certificate store without dropping bundled or operator-supplied roots, while TLS verification and Relay certificate pinning remain enforced.
|
||||
|
||||
## [Android 1.7.0] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Android exposes provider-aware reasoning controls.** The effort drawer consumes exact upstream or optional Relay capability metadata for each provider/model identity, while unmodified or older Hermes installations retain a fail-soft standard fallback including `max` and `ultra`.
|
||||
- **Android support information is local, redacted, and reviewable.** Fatal crashes and handled failures share a bounded on-device record, Diagnostics can copy or share the exact reviewed text, and nothing is uploaded automatically.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android chat chrome follows its active interaction state.** Opening the session drawer dismisses the composer keyboard, refreshed sessions keep their newest row visible, and floating pets wait for measured chat terrain, sit flush on supported rails, and treat the complete scroll-to-bottom control as forbidden space.
|
||||
- **Android pets and optional model discovery initialize quietly.** Floating companions wait for a measured overlay before taking their home position, and background API model-inventory failures retain actionable local diagnostics without interrupting chat with a generic notice.
|
||||
- **Android chat and Voice stay precisely bottom-pinned through replies, restores, and layout changes.** The active tail keeps its stable live renderer until another row takes ownership, restored sessions follow late composer and message measurement without overriding a reader, and bottom-owned transcripts settle to the exact list boundary after replies and keyboard animations instead of leaving a small hidden remainder.
|
||||
- **Android Focus voice controls remain responsive.** The modal click-through guard now sits behind the voice UI instead of consuming pointer events from the mic, close, expansion, and panel controls.
|
||||
- **Android diagnostics explain what failed and what to try next.** Relay, route, WebSocket, and API checks distinguish the saved route from the redacted request they actually attempted, name the operation, and provide targeted guidance for connection, DNS, timeout, TLS, authentication, rate-limit, and server failures.
|
||||
- **Android chat and Voice keep one render identity through recovery.** Checkpoint restore, streamed callbacks, server-ID adoption, and replay now resolve the same owned transcript row before publication, preventing recurring Compose duplicate-key crashes.
|
||||
- **Android crash reports retain actionable release context.** Reports identify the Android surface, avoid exposing hosts and credentials, migrate earlier local crash records, and release automation retains exact Play and sideload R8 mappings for retrace.
|
||||
|
||||
## [1.6.0] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Relay supplies exact provider/model reasoning capabilities when providers expose them.** The bounded, profile-aware overlay resolves dynamic catalogs for OpenAI Codex, Copilot, LM Studio, and Ollama Cloud, keeps provider credentials on the host, and leaves unknown or unavailable catalogs on the advisory fallback.
|
||||
|
||||
## [Android 1.6.1] - 2026-08-03
|
||||
|
||||
### Fixed
|
||||
|
||||
+32
-7
@@ -17,17 +17,41 @@ That's it — no extra setup or credentials required for a debug build.
|
||||
Helper scripts for common development tasks:
|
||||
|
||||
```bash
|
||||
scripts/dev.bat build # Build debug APK
|
||||
scripts/dev.bat build # Build the sideload debug APK
|
||||
scripts/dev.bat compile # Compile sideload Kotlin only
|
||||
scripts/dev.bat test-one "com.hermesandroid.relay.SomeTest" # Run one test class
|
||||
scripts/dev.bat install-fast # Build arm64 only + install + launch
|
||||
scripts/dev.bat release # Build signed release APK
|
||||
scripts/dev.bat bundle # Build release AAB for Google Play
|
||||
scripts/dev.bat run # Build + install + launch + logcat
|
||||
scripts/dev.bat test # Run unit tests
|
||||
scripts/dev.bat run # Build sideload + install + launch + logcat
|
||||
scripts/dev.bat test # Run sideload debug unit tests
|
||||
scripts/dev.bat version # Show current version
|
||||
scripts/dev.bat relay # Start relay server (dev, no TLS)
|
||||
```
|
||||
|
||||
Linux/macOS equivalent lives at `scripts/dev.sh`.
|
||||
|
||||
### Fast Android iteration
|
||||
|
||||
Gradle's daemon, local build cache, configuration cache, and parallel task
|
||||
execution are enabled for repeat local builds. Keep the same Gradle JVM
|
||||
configuration between invocations and do not add `--no-daemon` to normal dev
|
||||
commands; a different heap or Java home starts a separate daemon and discards
|
||||
the warm-process benefit.
|
||||
|
||||
Use the narrowest command that proves the change:
|
||||
|
||||
1. `scripts/dev.bat compile` for a Kotlin compile check.
|
||||
2. `scripts/dev.bat test-one "<fully-qualified-class-or-pattern>"` for a focused regression.
|
||||
3. `scripts/dev.bat install-fast` when the result must run on the connected
|
||||
arm64 phone. This passes `-Phermes.devAbi=arm64-v8a`, avoiding the x86,
|
||||
x86_64, and armeabi-v7a native libraries in the local APK.
|
||||
4. `scripts/dev.bat prepush` before pushing Android work.
|
||||
|
||||
`install-fast` is intentionally phone-specific. Use `install` for a universal
|
||||
sideload debug APK or when the target ABI is not arm64. Release builds remain
|
||||
universal and are unaffected unless `-Phermes.devAbi` is explicitly supplied.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
@@ -51,12 +75,12 @@ The legacy `relay_server/` directory is a thin compatibility shim around `plugin
|
||||
|
||||
| Component | Stack |
|
||||
|-----------|-------|
|
||||
| **Android App** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Android App** | Kotlin 2.4, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Relay Server** | Python 3.11+, aiohttp |
|
||||
| **Serialization** | kotlinx.serialization |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 |
|
||||
| **Build** | AGP 9.3.1, Gradle 9.6.1, JVM toolchain 17 |
|
||||
| **CI/CD** | GitHub Actions (lint, build, test, signed APK artifacts) |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 35 |
|
||||
| **Min SDK** | 26 (Android 8.0) / Target SDK 36 |
|
||||
|
||||
## Running the Relay Locally
|
||||
|
||||
@@ -174,7 +198,8 @@ Release notes (`RELEASE_NOTES.md`, `app/src/main/assets/whats_new.txt`, `docs/pl
|
||||
CI in one cached Gradle invocation. Run it before pushing Android PR updates
|
||||
to catch common hosted failures without waiting for another full Actions
|
||||
cycle; hosted CI remains the exhaustive all-variant gate.
|
||||
- **Android unit tests:** `scripts/dev.bat test` (runs JUnit + MockK + Compose testing)
|
||||
- **Focused Android unit test:** `scripts/dev.bat test-one "<fully-qualified-class-or-pattern>"`
|
||||
- **Android unit tests:** `scripts/dev.bat test` (runs the sideload debug JUnit + MockK + Compose suite)
|
||||
- **Python tests:** `python -m unittest plugin.tests.test_<name>` from the repo root with the hermes-agent venv active. `pytest` works too but the pre-existing `conftest.py` imports a module that isn't always installed — `unittest` avoids that entirely.
|
||||
|
||||
CI is split into path-filtered workflows: `.github/workflows/ci-android.yml` (lint + build + test on app/Gradle changes), `.github/workflows/ci-server.yml` (syntax check + focused server tests on plugin/Python changes), and `.github/workflows/ci-desktop.yml` (desktop type/build/smoke checks). They run on pushes to `main` and `dev` and on PRs targeting either when their paths are touched.
|
||||
|
||||
@@ -1,5 +1,153 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-08-05 — Restored chat bottom ownership and effort fallback clarity
|
||||
|
||||
Opening an existing Android session now retains exact bottom ownership through
|
||||
late, non-streaming layout changes. Composer capability controls, status rows,
|
||||
and restored message content can finish measuring after history first reaches
|
||||
the footer; a session-scoped geometry observer corrects those changes without
|
||||
using a fixed delay. New-message following remains governed by the smooth
|
||||
auto-scroll setting, while a real drag, IME ownership, and the Voice dock keep
|
||||
their existing anchors.
|
||||
|
||||
The advisory effort drawer now states that Hermes does not advertise exact
|
||||
levels for the selected model before explaining why standard options are shown.
|
||||
The wording is consistent across all shipped Android locales.
|
||||
|
||||
## 2026-08-05 — Provider-aware reasoning effort discovery
|
||||
|
||||
The optional Relay plugin now exposes a bearer-protected, profile-aware model
|
||||
capability overlay without requiring changes to upstream Hermes. Android merges
|
||||
that overlay with the standard `model.options` inventory using exact provider
|
||||
and model identities, while older or unpaired Relay installations continue with
|
||||
the canonical advisory fallback.
|
||||
|
||||
Dynamic LM Studio, Ollama Cloud, and Copilot discovery is bounded by a shared
|
||||
network limiter, cached by profile, endpoint, model, and credential fingerprint,
|
||||
and fenced across refresh generations. Neither credentials nor internal cache
|
||||
scope are returned to clients. Composer controls, Agent Passport, session
|
||||
creation, and asynchronous server reconciliation share the same capability
|
||||
resolver so a displayed effort cannot silently differ from the value sent.
|
||||
|
||||
## 2026-08-05 — Chat drawer and companion terrain ownership
|
||||
|
||||
The Chat screen now clears composer focus when the session drawer commits to
|
||||
opening, dismissing the IME without continuously clearing focus from drawer
|
||||
search or rename fields. Drawer refreshes override keyed list anchoring only
|
||||
when the leading session identity changes, keeping the newest row visible after
|
||||
activity-based reordering.
|
||||
|
||||
Floating companions wait for Chat's measured composer rail before publishing
|
||||
their first roaming position. Their collision footprint contains both the
|
||||
pointer target and rendered sprite, and the complete scroll-to-bottom control
|
||||
envelope is an obstacle rather than a landing perch. Supported rails add no
|
||||
visual lift, and the floating-only renderer aligns each frame's opaque bottom
|
||||
edge to its canvas baseline so transparent atlas padding cannot make pets hover;
|
||||
centered previews and message avatars remain unchanged.
|
||||
|
||||
## 2026-08-05 — Measured pet placement and passive model sync
|
||||
|
||||
The floating pet now remains unpublished until the app-level overlay has a
|
||||
positive measured viewport. Its initial home coordinate is therefore derived
|
||||
from the real safe bounds instead of the zero-size pre-measure bounds that
|
||||
collapsed to the top-left corner.
|
||||
|
||||
API provider inventory remains an optional background catalog on Gateway-led
|
||||
connections. A timeout, refusal, or unavailable optional route no longer emits
|
||||
a global chat notice during initialization, reconnection, or connection-sheet
|
||||
refresh. The failure is retained as a contextual warning in local Diagnostics,
|
||||
including the operation, endpoint role, redacted stack trace, preserved network
|
||||
cause, and targeted troubleshooting guidance. Cached and Gateway-owned model
|
||||
options remain unchanged.
|
||||
|
||||
## 2026-08-05 — Stable chat-tail completion
|
||||
|
||||
Chat and Voice now treat the active streamed reply as the owner of its live
|
||||
renderer until a different row becomes the conversation tail. Stream
|
||||
completion retains the existing Compose subtree and list anchor; the full
|
||||
Markdown renderer is deferred until the row is no longer active or the session
|
||||
is revisited.
|
||||
|
||||
The last-in-group timestamp occupies its final geometry from the first
|
||||
streaming frame and is only revealed at completion. Measured positive growth
|
||||
during an active stream continues to follow the bottom without replacing the
|
||||
logical anchor. Once completion layout stabilizes, a bottom-owned transcript
|
||||
settles to the exact LazyColumn boundary; proximity slop is reserved for
|
||||
retaining follow intent during motion and cannot define the final position. The
|
||||
visible footer supplies the exact remaining distance so rounding or adjacent
|
||||
layout changes cannot leave a residual forward range.
|
||||
|
||||
IME expansion participates in that same viewport owner. A transcript already
|
||||
at the bottom advances by the measured viewport-height loss throughout the
|
||||
keyboard animation, then settles exactly after inset updates stop on both open
|
||||
and close. A transcript being read above the bottom preserves its existing
|
||||
anchor, and a real drag cancels keyboard follow immediately. Host-side coverage
|
||||
verifies renderer ownership, unchanged bubble height, exact footer settling,
|
||||
keyboard arming, viewport loss, completion/IME settlement ownership, and
|
||||
history-reading behavior.
|
||||
|
||||
## 2026-08-05 — Focus voice input boundary repair
|
||||
|
||||
The Focus voice presentation remains modal without installing a consuming
|
||||
pointer handler on the full overlay ancestor. Its click-through guard is now a
|
||||
behind-content sibling: empty-space gestures cannot reach the chat or drawer,
|
||||
while the mic, close, expand/collapse, and panel controls receive their full
|
||||
pointer sequence.
|
||||
|
||||
Host-side Compose coverage injects real touch events instead of invoking
|
||||
semantic click actions. It verifies both child callback delivery and the modal
|
||||
background boundary so the two requirements cannot regress independently.
|
||||
|
||||
## 2026-08-05 — Actionable Android connection diagnostics
|
||||
|
||||
Android diagnostic entries now separate the configured route from the exact
|
||||
request operation and path used to test it. Relay health checks identify the
|
||||
HTTP `/health` probe that precedes a WebSocket connection, route selection
|
||||
records its Dashboard, API, or Relay probe, and WebSocket and API checks name
|
||||
their handshake or authentication stage.
|
||||
|
||||
Known network and HTTP failure classes attach a bounded next step for refused
|
||||
listeners, DNS, routing, timeouts, TLS, credentials, rate limits, missing
|
||||
routes, and server failures. The activity list, status timeline, detail dialog,
|
||||
copy text, and GitHub issue prefill all carry the same context. Public issue
|
||||
text preserves protocol and request paths while redacting hosts, credentials,
|
||||
queries, and user information.
|
||||
|
||||
## 2026-08-04 — Android transcript identity ownership
|
||||
|
||||
ChatHandler now owns one render identity for every published transcript row.
|
||||
Checkpoint recovery and all streamed message mutations resolve both the mutable
|
||||
server/domain ID and the stable UI identity, so history adoption cannot leave a
|
||||
stale client reference that appends a second row. The publication boundary also
|
||||
coalesces repeated render identities before Chat or Voice can observe them,
|
||||
while keeping the first transcript position and latest state.
|
||||
|
||||
Focused coverage composes history reconciliation with checkpoint restore,
|
||||
exercises stale post-adoption callbacks, and runs deterministic transition
|
||||
sequences across restore, replay, deltas, thinking, and usage updates. Voice's
|
||||
temporary transcript row now occupies an auxiliary key namespace disjoint from
|
||||
real message rows.
|
||||
|
||||
## 2026-08-04 — Android reliability and support foundation
|
||||
|
||||
Android fatal capture and centrally classified handled failures now converge on
|
||||
a versioned, allowlisted reliability record. Reports are redacted before local
|
||||
persistence, capped at 20 records with 14-day retention, written atomically,
|
||||
and correlated only with random app/report identifiers. Expected cancellation
|
||||
and permission denial remain non-reportable. The pre-existing one-file crash
|
||||
format migrates locally on first launch.
|
||||
|
||||
Crash recovery leads with the recovery outcome and no-upload guarantee, then
|
||||
requires an explicit review before copy, share, or GitHub actions. Diagnostics
|
||||
adds an offline support-information review using the same exact redacted text.
|
||||
Android issue prefills now request the Android area while repository-wide issue
|
||||
ownership remains maintainer-reviewed, and the release workflow retains both
|
||||
variant R8 mappings for deterministic retrace.
|
||||
|
||||
The architecture audit defers an ANR watchdog, richer allowlisted breadcrumbs,
|
||||
hashed product correlation, and OOM emergency writing until their lifecycle,
|
||||
privacy, and false-positive behavior can be validated on devices.
|
||||
|
||||
## 2026-08-02 — Android Russian localization
|
||||
|
||||
Android now ships complete Russian catalogs for the main and sideload builds.
|
||||
|
||||
+10
-5
@@ -1,17 +1,22 @@
|
||||
# Hermes-Relay-Server v__VERSION__
|
||||
|
||||
**Release Date:** August 3, 2026
|
||||
**Release Date:** August 6, 2026
|
||||
|
||||
This patch makes intentional re-pairing repair the existing device record instead of accumulating duplicate Relay sessions.
|
||||
This patch adds an optional Relay capability overlay so clients can present the reasoning effort levels supported by an exact provider and model without changing upstream model selection or chat behavior.
|
||||
|
||||
Standard chat, session history, and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Fixed
|
||||
### Added
|
||||
|
||||
- **Re-pairing replaces stale credentials for the same device.** After the host approves a new pair, Relay revokes older sessions and refresh credentials that belong to that device before issuing the replacement.
|
||||
- **Existing and unrelated sessions remain operator-controlled.** The Dashboard and `/relay revoke <token-prefix>` continue to provide explicit cleanup without treating optional Relay pairing as a requirement.
|
||||
- **Profile-aware reasoning capability resolution.** `POST /relay/model-capabilities` accepts exact provider/model pairs and returns ordered effort choices with explicit exactness and source metadata.
|
||||
- **Bounded live discovery for local and authenticated providers.** Relay can query OpenAI Codex, GitHub Copilot, LM Studio, and Ollama Cloud capability surfaces with short timeouts, concurrency limits, isolated caches, and an explicit refresh option.
|
||||
- **Capability advertisement.** `/relay/info` now advertises `model_reasoning_capabilities_v1` so clients can detect the optional overlay before using it.
|
||||
|
||||
### Security
|
||||
|
||||
- **Provider credentials remain host-local.** Remote requests require a paired session with the chat grant, and responses never serialize provider credentials.
|
||||
|
||||
## Install / update
|
||||
|
||||
|
||||
@@ -267,11 +267,14 @@ Already installed? The same recipe is auto-loaded as a Hermes skill — invoke `
|
||||
|
||||
```bash
|
||||
# Android: open the repo root in Android Studio, wait for Gradle sync, Run (Shift+F10).
|
||||
scripts/dev.bat build # Build debug APK
|
||||
scripts/dev.bat build # Build sideload debug APK
|
||||
scripts/dev.bat compile # Compile sideload Kotlin only
|
||||
scripts/dev.bat test-one "com.hermesandroid.relay.SomeTest" # Focused unit test
|
||||
scripts/dev.bat install-fast # arm64 phone build + install + launch
|
||||
scripts/dev.bat release # Build signed release APK
|
||||
scripts/dev.bat bundle # Build release AAB for Google Play
|
||||
scripts/dev.bat run # Build + install + launch + logcat
|
||||
scripts/dev.bat test # Run unit tests
|
||||
scripts/dev.bat run # Build sideload + install + launch + logcat
|
||||
scripts/dev.bat test # Run sideload debug unit tests
|
||||
scripts/dev.bat version # Show current version
|
||||
scripts/dev.bat relay # Start the relay server (dev, no TLS)
|
||||
```
|
||||
@@ -280,13 +283,13 @@ scripts/dev.bat relay # Start the relay server (dev, no TLS)
|
||||
|
||||
| Component | Stack |
|
||||
|-----------|-------|
|
||||
| **Android app** | Kotlin 2.0, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Android app** | Kotlin 2.4, Jetpack Compose, Material 3, OkHttp |
|
||||
| **Hermes-Relay CLI** | TypeScript, Bun-compiled native binary, Node ≥21 (source/dev), zero runtime deps |
|
||||
| **Server / plugin** | Python 3.11+, aiohttp |
|
||||
| **Serialization** | kotlinx.serialization (Android) |
|
||||
| **Build** | AGP 9, Gradle 8.13, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (CLI) |
|
||||
| **Build** | AGP 9.3.1, Gradle 9.6.1, JVM toolchain 17 (Android); `tsc` + `bun build --compile` (CLI) |
|
||||
| **CI/CD** | GitHub Actions — lint, build, test, APK artifact, CLI binaries per platform |
|
||||
| **Min SDK** | 26 (Android 8.0) · Target SDK 35 |
|
||||
| **Min SDK** | 26 (Android 8.0) · Target SDK 36 |
|
||||
|
||||
<details>
|
||||
<summary><b>Repository structure</b></summary>
|
||||
|
||||
+13
-1
@@ -494,6 +494,14 @@ the new app version and a higher `appVersionCode`.
|
||||
in `app/build.gradle.kts`. Never rename the sideload APK — the
|
||||
in-app update checker matches assets by `.apk` + `sideload` in the
|
||||
name, and user-docs verify steps cite the filename.
|
||||
The release workflow also retains
|
||||
`app/build/outputs/mapping/{googlePlayRelease,sideloadRelease}/mapping.txt`
|
||||
for 90 days in the `android-r8-mappings-<version>-<sha>` workflow
|
||||
artifact. It is intentionally not a GitHub Release asset. To symbolicate an
|
||||
in-app or sideload report, download the artifact for the exact version/SHA and
|
||||
run Android's retrace tool with the matching flavor mapping:
|
||||
`retrace <mapping.txt> <obfuscated-trace.txt>`. Play reports can additionally
|
||||
use the mapping bundled into the uploaded AAB through Play Console.
|
||||
- `app/src/main/assets/whats_new.txt` — in-app "What's New" content
|
||||
shown in the settings/about screen. Update with the version number
|
||||
and a brief feature summary. Gets stale silently if forgotten
|
||||
@@ -509,7 +517,11 @@ the new app version and a higher `appVersionCode`.
|
||||
the version reference and the "Release Notes" section that gets
|
||||
pasted into the Play Console "What's new" field. Keep the Play
|
||||
"What's new" within **500 characters** and framed around the
|
||||
release's themes, not a feature dump.
|
||||
release's themes, not a feature dump. Compare its **Foreground service
|
||||
permissions** section with the merged `googlePlayRelease` manifest and
|
||||
complete Play Console declarations for every declared service type before
|
||||
approval; the Publisher API can upload a draft and still reject promotion
|
||||
when an App content declaration is missing.
|
||||
|
||||
#### Scrub for public distribution
|
||||
|
||||
|
||||
+33
-13
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay-Android v1.6.1
|
||||
# Hermes-Relay-Android v1.7.0
|
||||
|
||||
**Release Date:** August 3, 2026
|
||||
**Release Date:** August 6, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.6.1-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.7.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,19 +12,39 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This patch clarifies optional Relay recovery, restores the full session drawer, and fixes several chat and Voice regressions.
|
||||
This patch makes model reasoning controls provider-aware, keeps active and
|
||||
restored conversations stable, and adds private, actionable support information.
|
||||
|
||||
## Added
|
||||
|
||||
- Reasoning effort choices now follow the selected provider and model when an
|
||||
exact list is available. Standard choices remain available as an advisory
|
||||
fallback on unmodified Hermes or without the optional Relay capability overlay.
|
||||
- The session drawer can search sessions and shows which conversations are
|
||||
working or waiting for input.
|
||||
- Diagnostics now provides a locally redacted support report that can be
|
||||
reviewed before copy, share, or opening GitHub. Nothing uploads automatically.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Optional Relay failures stay within Relay-only surfaces, use consistent status labels, and only request re-pairing when the stored Relay credential actually needs it.
|
||||
- Foreground recovery reconnects immediately after ordinary backoff, while retained credentials are described as stored details rather than an active in-memory session.
|
||||
- Session history loads its 200-row drawer window through upstream-compatible 100-row pages instead of failing with HTTP 422.
|
||||
- Selecting text remains stable when a streamed response changes from live text to rendered Markdown.
|
||||
- Manual Voice recording waits for barge-in microphone teardown and gives a useful recovery message when the microphone is unavailable.
|
||||
- New-chat coaching yields while Voice owns the composer, so it no longer covers the expanding Voice drawer.
|
||||
- Restored and completed conversations remain at the exact bottom through late
|
||||
message, composer, keyboard, and status-row layout changes without overriding
|
||||
someone who intentionally scrolls up.
|
||||
- Chat recovery keeps one stable rendered row through checkpoint restore,
|
||||
streaming callbacks, server reconciliation, and replay, preventing recurring
|
||||
duplicate-row crashes in Chat and Voice.
|
||||
- Focus Voice controls receive taps again while the modal background continues
|
||||
to block interaction with the chat behind it.
|
||||
- Connection diagnostics identify the attempted route and operation, redact host
|
||||
details, and give targeted guidance for DNS, timeout, TLS, authentication,
|
||||
rate-limit, missing-route, and server failures.
|
||||
- The session drawer dismisses the composer keyboard when opening, preserves the
|
||||
newest row during refresh, and keeps floating pets on measured safe terrain.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.6.1** (versionCode **38**).
|
||||
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
- Same-device Relay re-pair replacement requires the optional Server 1.5.1 plugin; Dashboard and `/relay revoke <token-prefix>` remain available for explicit cleanup.
|
||||
- App version: **1.7.0** (versionCode **39**).
|
||||
- Standard Chat, model selection, and Vanilla Hermes voice continue to work
|
||||
against unmodified upstream Hermes.
|
||||
- Relay capability discovery is optional and fail-soft; unavailable or older
|
||||
Relay installations retain the standard reasoning choices.
|
||||
|
||||
@@ -24,6 +24,22 @@ multi-session protocol before treating `lifecycle=session` as an isolation claim
|
||||
|
||||
---
|
||||
|
||||
## Split fast Android unit tests from resource and screenshot tests
|
||||
|
||||
The quick-loop commands now narrow execution to the sideload debug variant and
|
||||
support one-class filtering, but all `:app` unit tests still share one Android
|
||||
test variant. That variant includes merged Android resources, gives every test
|
||||
worker a 2 GiB heap, runs on JDK 21, and enables Roborazzi recording because a
|
||||
small subset of Robolectric/screenshot tests requires those settings.
|
||||
|
||||
Create a separate resource/screenshot test lane so pure state, parser, routing,
|
||||
and formatting tests can run as ordinary JVM tests without Android resource
|
||||
packaging. Keep golden-image recording explicit rather than applying it to all
|
||||
unit tests, preserve a CI task that runs both lanes, and benchmark cold plus
|
||||
warm focused-test latency before adopting the split.
|
||||
|
||||
---
|
||||
|
||||
## Verify Android native dashboard sign-in on device
|
||||
|
||||
Android now selects Custom Tab + PKCE for HTTPS gateways that advertise
|
||||
|
||||
@@ -7,6 +7,15 @@ plugins {
|
||||
alias(libs.plugins.play.publisher)
|
||||
}
|
||||
|
||||
val supportedHermesDevAbis = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||
val hermesDevAbi = providers.gradleProperty("hermes.devAbi").orNull
|
||||
hermesDevAbi?.let { requestedAbi ->
|
||||
require(requestedAbi in supportedHermesDevAbis) {
|
||||
"Unsupported hermes.devAbi '$requestedAbi'. Expected one of: " +
|
||||
supportedHermesDevAbis.sorted().joinToString()
|
||||
}
|
||||
}
|
||||
|
||||
// Rename output artifacts to include the app version. AGP respects
|
||||
// `archivesName` for both APK (assemble*) and AAB (bundle*) outputs, so
|
||||
// this single line produces `hermes-relay-<version>-<flavor>-<buildType>`
|
||||
@@ -43,6 +52,17 @@ android {
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// Optional local-only fast path for device iteration. Native voice/VAD
|
||||
// dependencies make the universal sideload APK very large, while a
|
||||
// connected phone needs only its own ABI. Release and normal debug
|
||||
// builds remain universal unless the developer explicitly supplies
|
||||
// -Phermes.devAbi=<abi>.
|
||||
hermesDevAbi?.let { requestedAbi ->
|
||||
ndk {
|
||||
abiFilters += requestedAbi
|
||||
}
|
||||
}
|
||||
|
||||
// Feature flags — DEV_MODE enables all experimental features in debug builds
|
||||
buildConfigField("boolean", "DEV_MODE", "false")
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
Optional Relay recovery now stays in the right surfaces with clear status labels and immediate foreground retry. Session history again loads its full window through upstream-compatible paging. Streamed text selection, Voice microphone handoff, and the expanding Voice drawer are also more reliable.
|
||||
Smarter reasoning controls now match the selected provider and model, with standard choices when exact levels are unavailable. The session drawer shows working and needs-input chats. Restored and completed conversations stay bottom-pinned without overriding scrollback. Redacted local support information and clearer connection diagnostics make problems easier to resolve. Focus Voice controls and recovered chat rows are more reliable.
|
||||
|
||||
@@ -101,8 +101,8 @@
|
||||
<!-- Protects user-started active turns automatically; the optional
|
||||
"Persistent connection" setting extends the same foreground
|
||||
protection to idle/background connectivity (and relay-paired
|
||||
device features). In main so BOTH flavors ship it. specialUse
|
||||
needs a Play Console foreground-service declaration at submission. -->
|
||||
device features). In main so BOTH flavors ship it. Every Play
|
||||
foreground-service type needs its matching App content declaration. -->
|
||||
<service
|
||||
android:name=".network.upstream.GatewayKeepAliveService"
|
||||
android:exported="false"
|
||||
@@ -113,7 +113,8 @@
|
||||
</service>
|
||||
|
||||
<!-- Experimental, explicitly user-started on-device wake-word listener.
|
||||
Audio remains local and the service is never boot/restart started. -->
|
||||
Audio remains local and the service is never boot/restart started.
|
||||
The Play build's microphone type needs an App content declaration. -->
|
||||
<service
|
||||
android:name=".wake.WakeWordForegroundService"
|
||||
android:exported="false"
|
||||
@@ -122,7 +123,8 @@
|
||||
|
||||
<!-- User-started protection for voice capture from the system overlay.
|
||||
The service does not own AudioRecord; it keeps foreground-only
|
||||
microphone app-ops available while Hermes is behind another app. -->
|
||||
microphone app-ops available while Hermes is behind another app.
|
||||
Include this use case in the Play microphone declaration. -->
|
||||
<service
|
||||
android:name=".voice.VoiceOverlayForegroundService"
|
||||
android:exported="false"
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
{
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.7.0",
|
||||
"title": "Smarter controls, steadier sessions",
|
||||
"date": "2026-08-06",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Model controls fit the model",
|
||||
"bullets": [
|
||||
"Reasoning effort choices follow the selected provider and model when an exact supported list is available.",
|
||||
"Unmodified Hermes and setups without the optional Relay capability overlay keep a fail-soft standard choice list."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Active chats stay easy to follow",
|
||||
"bullets": [
|
||||
"The searchable session drawer shows which conversations are working or waiting for input.",
|
||||
"Restored and completed chats remain bottom-pinned through late layout changes without overriding intentional scrollback.",
|
||||
"Chat and Voice keep stable rows through recovery, and Focus Voice controls receive taps normally."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Support stays private and useful",
|
||||
"bullets": [
|
||||
"Review locally redacted support information before choosing to copy, share, or open GitHub; nothing uploads automatically.",
|
||||
"Connection diagnostics identify the failed operation and offer targeted guidance without exposing hosts or credentials."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.6.1",
|
||||
"title": "Clearer recovery, steadier chat",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
v1.6.1 - Clearer recovery, steadier chat
|
||||
v1.7.0 - Smarter controls, steadier sessions
|
||||
|
||||
* Keep optional Relay recovery scoped to Relay surfaces with clear status labels.
|
||||
* Restore the 200-session drawer through upstream-compatible paging.
|
||||
* Stabilize streamed text selection and Voice microphone handoff.
|
||||
* Keep new-chat coaching clear of the expanding Voice drawer.
|
||||
* Match reasoning levels to the selected provider and model, with a standard fallback.
|
||||
* See working and needs-input activity in the searchable session drawer.
|
||||
* Keep restored and completed chats bottom-pinned without overriding scrollback.
|
||||
* Review redacted local support information and actionable connection guidance.
|
||||
* Restore Focus Voice controls and stabilize recovered chat rows.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/** Live activity surfaced beside a session without conflating it with selection. */
|
||||
enum class SessionActivityState {
|
||||
Working,
|
||||
NeedsInput,
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.hermesandroid.relay.diagnostics
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import com.hermesandroid.relay.reliability.ReliabilityCenter
|
||||
import com.hermesandroid.relay.reliability.ReliabilityRedactor
|
||||
|
||||
enum class DiagnosticCategory(val label: String) {
|
||||
Api("API"),
|
||||
@@ -25,7 +27,16 @@ data class DiagnosticLogEntry(
|
||||
val severity: DiagnosticSeverity,
|
||||
val title: String,
|
||||
val detail: String? = null,
|
||||
/** Human-readable action that produced this event, not merely its subsystem. */
|
||||
val operation: String? = null,
|
||||
val endpointRole: String? = null,
|
||||
/** User/configuration-facing route before protocol/path normalization. */
|
||||
val configuredUrl: String? = null,
|
||||
/** Exact sanitized URL attempted on the wire, including the diagnostic path. */
|
||||
val requestUrl: String? = null,
|
||||
/** Concrete next troubleshooting step for failures with a known interpretation. */
|
||||
val suggestion: String? = null,
|
||||
/** Legacy single-URL field retained for diagnostics that have not needed split context. */
|
||||
val url: String? = null,
|
||||
val elapsedMs: Long? = null,
|
||||
/**
|
||||
@@ -34,7 +45,11 @@ data class DiagnosticLogEntry(
|
||||
* the detail view shows this. Null for non-error / manually-recorded entries.
|
||||
*/
|
||||
val stacktrace: String? = null,
|
||||
)
|
||||
) {
|
||||
/** Best route for mode inference and compact list rendering. */
|
||||
val primaryUrl: String?
|
||||
get() = configuredUrl ?: requestUrl ?: url
|
||||
}
|
||||
|
||||
/**
|
||||
* Current health of a single subsystem on the Diagnostics status timeline.
|
||||
@@ -81,19 +96,29 @@ object DiagnosticsLog {
|
||||
severity: DiagnosticSeverity = DiagnosticSeverity.Info,
|
||||
title: String,
|
||||
detail: String? = null,
|
||||
operation: String? = null,
|
||||
endpointRole: String? = null,
|
||||
configuredUrl: String? = null,
|
||||
requestUrl: String? = null,
|
||||
suggestion: String? = null,
|
||||
url: String? = null,
|
||||
elapsedMs: Long? = null,
|
||||
stacktrace: String? = null,
|
||||
) {
|
||||
val safeConfiguredUrl = sanitizeUrl(configuredUrl)
|
||||
val safeRequestUrl = sanitizeUrl(requestUrl)
|
||||
val entry = DiagnosticLogEntry(
|
||||
timestampMs = System.currentTimeMillis(),
|
||||
category = category,
|
||||
severity = severity,
|
||||
title = clean(title) ?: title.take(MAX_TEXT_LENGTH),
|
||||
detail = clean(detail),
|
||||
operation = clean(operation),
|
||||
endpointRole = clean(endpointRole),
|
||||
url = sanitizeUrl(url),
|
||||
configuredUrl = safeConfiguredUrl,
|
||||
requestUrl = safeRequestUrl,
|
||||
suggestion = clean(suggestion),
|
||||
url = if (safeConfiguredUrl == null && safeRequestUrl == null) sanitizeUrl(url) else null,
|
||||
elapsedMs = elapsedMs,
|
||||
stacktrace = redactTrace(stacktrace),
|
||||
)
|
||||
@@ -121,20 +146,40 @@ object DiagnosticsLog {
|
||||
title: String,
|
||||
detail: String? = null,
|
||||
throwable: Throwable? = null,
|
||||
operation: String? = null,
|
||||
endpointRole: String? = null,
|
||||
configuredUrl: String? = null,
|
||||
requestUrl: String? = null,
|
||||
suggestion: String? = null,
|
||||
url: String? = null,
|
||||
elapsedMs: Long? = null,
|
||||
reliabilityContext: String? = null,
|
||||
) {
|
||||
record(
|
||||
category = category,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = title,
|
||||
detail = detail ?: throwable?.message,
|
||||
operation = operation,
|
||||
endpointRole = endpointRole,
|
||||
configuredUrl = configuredUrl,
|
||||
requestUrl = requestUrl,
|
||||
suggestion = suggestion,
|
||||
url = url,
|
||||
elapsedMs = elapsedMs,
|
||||
stacktrace = throwable?.let { stackTraceText(it) },
|
||||
)
|
||||
if (throwable != null) {
|
||||
runCatching {
|
||||
ReliabilityCenter.recordHandled(
|
||||
title = title,
|
||||
detail = detail ?: throwable.message,
|
||||
throwable = throwable,
|
||||
context = reliabilityContext,
|
||||
routeRole = endpointRole,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stackTraceText(t: Throwable): String =
|
||||
@@ -167,10 +212,8 @@ object DiagnosticsLog {
|
||||
val prefix = noQuery.substring(0, schemeEnd + 3)
|
||||
val rest = noQuery.substring(schemeEnd + 3)
|
||||
val slash = rest.indexOf('/').let { if (it < 0) rest.length else it }
|
||||
val authority = rest.substring(0, slash)
|
||||
val path = rest.substring(slash)
|
||||
val safeAuthority = authority.substringAfterLast('@')
|
||||
prefix + safeAuthority + path
|
||||
prefix + "[host]" + path
|
||||
} else {
|
||||
noQuery
|
||||
}
|
||||
@@ -197,7 +240,7 @@ object DiagnosticsLog {
|
||||
*/
|
||||
private fun redactTrace(value: String?): String? {
|
||||
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
val redacted = redact(trimmed)
|
||||
val redacted = ReliabilityRedactor.redact(trimmed, MAX_TRACE_LENGTH)
|
||||
return if (redacted.length > MAX_TRACE_LENGTH) {
|
||||
redacted.take(MAX_TRACE_LENGTH) + "\n… (truncated)"
|
||||
} else {
|
||||
@@ -205,8 +248,5 @@ object DiagnosticsLog {
|
||||
}
|
||||
}
|
||||
|
||||
private fun redact(value: String): String =
|
||||
value.replace(Regex("""(?i)(bearer|token|api[_-]?key|session[_-]?token)\s*[:=]\s*\S+""")) {
|
||||
"${it.groupValues[1]}=[hidden]"
|
||||
}
|
||||
private fun redact(value: String): String = ReliabilityRedactor.redact(value, MAX_TRACE_LENGTH)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.hermesandroid.relay.diagnostics
|
||||
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLException
|
||||
|
||||
/**
|
||||
* Maps network failure classes to narrow, truthful next steps.
|
||||
*
|
||||
* These messages are diagnostic guidance, not recovery behavior: callers still
|
||||
* own retries, routing, and authentication. Walking the cause chain preserves
|
||||
* useful classification when OkHttp or a coroutine boundary wraps the socket
|
||||
* exception in a higher-level failure.
|
||||
*/
|
||||
object NetworkDiagnosticGuidance {
|
||||
fun forThrowable(throwable: Throwable, target: String): String? {
|
||||
val causes = generateSequence(throwable as Throwable?) { it.cause }.take(12).toList()
|
||||
return when {
|
||||
causes.any { it is ConnectException } ->
|
||||
"Verify $target is running and listening on the configured host and port."
|
||||
causes.any { it is UnknownHostException } ->
|
||||
"Verify the configured hostname resolves from this device."
|
||||
causes.any { it is NoRouteToHostException } ->
|
||||
"Verify the device has a network path to the configured host."
|
||||
causes.any { it is SocketTimeoutException } ->
|
||||
"Check network routing or firewall rules between this device and $target."
|
||||
causes.any { it is SSLException } ->
|
||||
"Verify the TLS scheme, certificate, and trust configuration for $target."
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun forHttpStatus(statusCode: Int, target: String): String? = when (statusCode) {
|
||||
401, 403 -> "Verify the configured $target credentials or pair the device again."
|
||||
404 -> "Verify this URL points to the expected $target route and version."
|
||||
429 -> "Wait for the server's backoff period before retrying."
|
||||
in 500..599 -> "Check the $target server logs for the failing request."
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import com.hermesandroid.relay.network.shared.EndpointResolver
|
||||
import com.hermesandroid.relay.network.shared.EndpointSurface
|
||||
@@ -441,7 +442,9 @@ class ConnectionManager(
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = context?.getString(R.string.conn_diag_socket_blocked) ?: "Relay socket blocked",
|
||||
detail = "ws:// is disabled",
|
||||
url = url,
|
||||
operation = "Open Relay WebSocket",
|
||||
configuredUrl = url,
|
||||
suggestion = "Use wss:// or explicitly allow plain ws:// for a trusted LAN or VPN.",
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -452,7 +455,9 @@ class ConnectionManager(
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = context?.getString(R.string.conn_diag_url_invalid) ?: "Relay socket URL invalid",
|
||||
detail = "URL must start with ws:// or wss://",
|
||||
url = url,
|
||||
operation = "Open Relay WebSocket",
|
||||
configuredUrl = url,
|
||||
suggestion = "Edit or re-pair the Relay route with a ws:// or wss:// URL.",
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -488,14 +493,18 @@ class ConnectionManager(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.conn_diag_opening_insecure) ?: "Opening insecure relay socket",
|
||||
url = normalized,
|
||||
operation = "Open Relay WebSocket",
|
||||
configuredUrl = url,
|
||||
requestUrl = normalized,
|
||||
)
|
||||
} else {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = context?.getString(R.string.conn_diag_opening_socket) ?: "Opening relay socket",
|
||||
url = normalized,
|
||||
operation = "Open Relay WebSocket",
|
||||
configuredUrl = url,
|
||||
requestUrl = normalized,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -985,7 +994,9 @@ class ConnectionManager(
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Invalid relay URL",
|
||||
detail = "The relay address could not be parsed; re-pair to refresh it.",
|
||||
url = url,
|
||||
operation = "Build Relay WebSocket request",
|
||||
configuredUrl = url,
|
||||
suggestion = "Edit or re-pair the Relay route to replace the invalid address.",
|
||||
)
|
||||
authenticated = false
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
@@ -1018,7 +1029,8 @@ class ConnectionManager(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = context?.getString(R.string.conn_diag_connected) ?: "Relay socket connected",
|
||||
url = url,
|
||||
operation = "Relay WebSocket handshake",
|
||||
requestUrl = url,
|
||||
)
|
||||
|
||||
// TOFU: record the peer cert fingerprint if we don't have one
|
||||
@@ -1079,7 +1091,9 @@ class ConnectionManager(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.conn_diag_closed) ?: "Relay socket closed",
|
||||
detail = "code=$code reason=$reason",
|
||||
url = url,
|
||||
operation = "Relay WebSocket session",
|
||||
requestUrl = url,
|
||||
suggestion = if (code == 1000) null else "Check the Relay server logs for the matching close code and reason.",
|
||||
)
|
||||
authenticated = false
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
@@ -1102,7 +1116,11 @@ class ConnectionManager(
|
||||
t.message,
|
||||
code?.let { "HTTP $it" },
|
||||
).joinToString(": "),
|
||||
url = url,
|
||||
operation = "Relay WebSocket handshake",
|
||||
requestUrl = url,
|
||||
suggestion = code?.let {
|
||||
NetworkDiagnosticGuidance.forHttpStatus(it, "Relay")
|
||||
} ?: NetworkDiagnosticGuidance.forThrowable(t, "Relay"),
|
||||
)
|
||||
lastUpgradeResponseCode = code
|
||||
if (response == null) {
|
||||
|
||||
@@ -7,10 +7,12 @@ import com.hermesandroid.relay.auth.PairedDeviceInfo
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
@@ -59,6 +61,10 @@ class RelayHttpClient(
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RelayHttpClient"
|
||||
const val MAX_MODEL_CAPABILITY_ROWS = 64
|
||||
private const val MAX_MODEL_CAPABILITY_PROVIDER_CHARS = 128
|
||||
private const val MAX_MODEL_CAPABILITY_MODEL_CHARS = 512
|
||||
private const val MAX_MODEL_CAPABILITY_PROFILE_CHARS = 128
|
||||
private val sessionsJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
@@ -710,6 +716,37 @@ class RelayHttpClient(
|
||||
@SerialName("age_seconds") val ageSeconds: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ModelCapabilityRequestRow(
|
||||
val provider: String,
|
||||
val model: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ModelCapabilitiesRequest(
|
||||
@SerialName("schema_version") val schemaVersion: Int = 1,
|
||||
val profile: String? = null,
|
||||
val refresh: Boolean = false,
|
||||
val models: List<ModelCapabilityRequestRow>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ModelCapabilityRow(
|
||||
val provider: String,
|
||||
val model: String,
|
||||
val reasoning: Boolean? = null,
|
||||
@SerialName("reasoning_efforts") val reasoningEfforts: List<String> = emptyList(),
|
||||
@SerialName("reasoning_efforts_exact") val reasoningEffortsExact: Boolean = false,
|
||||
val source: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ModelCapabilitiesResponse(
|
||||
@SerialName("schema_version") val schemaVersion: Int = 1,
|
||||
@SerialName("contract_version") val contractVersion: String = "",
|
||||
val capabilities: List<ModelCapabilityRow> = emptyList(),
|
||||
)
|
||||
|
||||
/** Fetch the installed plugin/protocol/profile capability contract. */
|
||||
suspend fun fetchRelayInfo(): Result<RelayInfo?> = withContext(Dispatchers.IO) {
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
@@ -743,6 +780,64 @@ class RelayHttpClient(
|
||||
}
|
||||
}
|
||||
|
||||
/** Optional provider/model reasoning overlay; 404 and no pairing are fail-soft. */
|
||||
suspend fun fetchModelCapabilities(
|
||||
models: List<ModelCapabilityRequestRow>,
|
||||
profile: String? = null,
|
||||
refresh: Boolean = false,
|
||||
): Result<ModelCapabilitiesResponse?> = withContext(Dispatchers.IO) {
|
||||
val boundedModels = models
|
||||
.asSequence()
|
||||
.map {
|
||||
ModelCapabilityRequestRow(
|
||||
it.provider.trim().take(MAX_MODEL_CAPABILITY_PROVIDER_CHARS),
|
||||
it.model.trim().take(MAX_MODEL_CAPABILITY_MODEL_CHARS),
|
||||
)
|
||||
}
|
||||
.filter { it.provider.isNotEmpty() && it.model.isNotEmpty() }
|
||||
.distinct()
|
||||
.take(MAX_MODEL_CAPABILITY_ROWS)
|
||||
.toList()
|
||||
if (boundedModels.isEmpty()) return@withContext Result.success(null)
|
||||
val relayUrl = relayUrlProvider()?.trim().orEmpty()
|
||||
val token = sessionTokenProvider()
|
||||
if (relayUrl.isEmpty() || token.isNullOrBlank()) return@withContext Result.success(null)
|
||||
val base = relayUrl
|
||||
.replace(Regex("^wss://", RegexOption.IGNORE_CASE), "https://")
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
val url = runCatching { "$base/relay/model-capabilities".toHttpUrl() }.getOrElse {
|
||||
return@withContext Result.success(null)
|
||||
}
|
||||
val payload = ModelCapabilitiesRequest(
|
||||
profile = profile?.trim()?.take(MAX_MODEL_CAPABILITY_PROFILE_CHARS)
|
||||
?.takeIf { it.isNotEmpty() },
|
||||
refresh = refresh,
|
||||
models = boundedModels,
|
||||
)
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(sessionsJson.encodeToString(payload).toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer $token")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
try {
|
||||
okHttpClient.newBuilder().callTimeout(4, java.util.concurrent.TimeUnit.SECONDS).build()
|
||||
.newCall(request).execute().use { response ->
|
||||
if (response.code == 404) return@withContext Result.success(null)
|
||||
if (!response.isSuccessful) return@withContext Result.failure(IOException("HTTP ${response.code}"))
|
||||
val body = response.body?.string().orEmpty()
|
||||
val parsed = body.takeIf { it.isNotBlank() }?.let {
|
||||
sessionsJson.decodeFromString(ModelCapabilitiesResponse.serializer(), it)
|
||||
}
|
||||
if (parsed?.schemaVersion != 1) Result.success(null) else Result.success(parsed)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "fetchModelCapabilities failed: ${e.message}")
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the relay whether a newer plugin release is available — it compares its
|
||||
* installed version against the latest `plugin-v*` GitHub release (cached an
|
||||
@@ -1139,6 +1234,7 @@ class RelayHttpClient(
|
||||
logSuccess: Boolean = true,
|
||||
): Result<RelayHealth> = withContext(Dispatchers.IO) {
|
||||
val trimmed = relayUrl.trim()
|
||||
val operation = "Relay health probe before WebSocket connection"
|
||||
if (trimmed.isEmpty()) {
|
||||
return@withContext Result.failure(
|
||||
IllegalArgumentException("Relay URL is empty")
|
||||
@@ -1159,7 +1255,9 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = context?.getString(R.string.http_diag_url_invalid) ?: "Relay URL invalid",
|
||||
detail = e.message,
|
||||
url = relayUrl,
|
||||
operation = operation,
|
||||
configuredUrl = relayUrl,
|
||||
suggestion = "Enter a Relay URL beginning with ws:// or wss://.",
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Invalid relay URL: ${e.message}")
|
||||
@@ -1180,6 +1278,7 @@ class RelayHttpClient(
|
||||
.get()
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
val requestUrl = request.url.toString()
|
||||
|
||||
try {
|
||||
fastClient.newCall(request).execute().use { response ->
|
||||
@@ -1189,8 +1288,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = "HTTP ${response.code}",
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = NetworkDiagnosticGuidance.forHttpStatus(response.code, "Relay"),
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay responded HTTP ${response.code}")
|
||||
@@ -1203,8 +1305,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = "Empty response",
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = "Verify this route points to a Hermes-Relay server and inspect its logs.",
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay returned an empty response")
|
||||
@@ -1220,8 +1325,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = "Non-JSON response",
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = "Verify this route points to a Hermes-Relay server rather than another HTTP service.",
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay returned non-JSON: ${e.message ?: "parse error"}")
|
||||
@@ -1234,8 +1342,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = "status=${status ?: "missing"}",
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = "Check the Relay service health and server logs.",
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Relay reports status=${status ?: "missing"} (expected 'ok')")
|
||||
@@ -1248,8 +1359,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = "Missing version field",
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = "Verify this route points to a current Hermes-Relay server.",
|
||||
)
|
||||
return@withContext Result.failure(
|
||||
IOException("Response doesn't look like a hermes-relay — missing 'version' field")
|
||||
@@ -1265,7 +1379,9 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = context?.getString(R.string.http_diag_health_ok) ?: "Relay health ok",
|
||||
detail = "version=$version clients=$clients sessions=$sessions",
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
)
|
||||
}
|
||||
@@ -1278,8 +1394,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.http_diag_health_timeout) ?: "Relay health timeout",
|
||||
detail = "No HTTP response in 3s",
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
|
||||
)
|
||||
Result.failure(IOException("Relay is not responding (3s timeout)"))
|
||||
} catch (e: java.net.ConnectException) {
|
||||
@@ -1289,8 +1408,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = context?.getString(R.string.http_diag_conn_refused) ?: "Relay connection refused",
|
||||
detail = e.message,
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
|
||||
)
|
||||
Result.failure(IOException("Connection refused — is the relay running on this URL?"))
|
||||
} catch (e: IOException) {
|
||||
@@ -1300,8 +1422,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = e.message ?: "Network error",
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
|
||||
)
|
||||
Result.failure(IOException("Network error: ${e.message ?: "unreachable"}"))
|
||||
} catch (e: Exception) {
|
||||
@@ -1311,8 +1436,11 @@ class RelayHttpClient(
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = context?.getString(R.string.http_diag_health_failed) ?: "Relay health failed",
|
||||
detail = e.message ?: e.javaClass.simpleName,
|
||||
url = httpBase,
|
||||
operation = operation,
|
||||
configuredUrl = trimmed,
|
||||
requestUrl = requestUrl,
|
||||
elapsedMs = System.currentTimeMillis() - startedAtMs,
|
||||
suggestion = NetworkDiagnosticGuidance.forThrowable(e, "Relay"),
|
||||
)
|
||||
Result.failure(e)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.hermesandroid.relay.data.routeAuthority
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.async
|
||||
@@ -353,6 +354,10 @@ class EndpointResolver(
|
||||
surface: EndpointSurface,
|
||||
): Boolean {
|
||||
val startedAtMs = clock()
|
||||
val operation = when (surface) {
|
||||
EndpointSurface.Standard -> "Dashboard or API route health probe"
|
||||
EndpointSurface.Relay -> "Relay route health probe"
|
||||
}
|
||||
val target = probeTarget(candidate, surface)
|
||||
val url = target?.requestUrl?.toHttpUrlOrNull()
|
||||
?: run {
|
||||
@@ -362,8 +367,10 @@ class EndpointResolver(
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = context?.getString(R.string.endpoint_diag_probe_invalid) ?: "Endpoint probe invalid",
|
||||
detail = "No valid Dashboard, API, or Relay URL",
|
||||
operation = operation,
|
||||
endpointRole = candidate.role,
|
||||
url = candidate.primaryRouteUrl(),
|
||||
configuredUrl = candidate.primaryRouteUrl(),
|
||||
suggestion = "Edit or re-pair this route so it contains a valid service URL.",
|
||||
)
|
||||
recordOutcome(candidate, surface, reachable = false, detail = "Invalid route URL")
|
||||
return false
|
||||
@@ -397,9 +404,16 @@ class EndpointResolver(
|
||||
severity = if (ok) DiagnosticSeverity.Info else DiagnosticSeverity.Warning,
|
||||
title = probeTitle,
|
||||
detail = if (ok) null else "HTTP ${resp.code}",
|
||||
operation = operation,
|
||||
endpointRole = candidate.role,
|
||||
url = target.baseUrl,
|
||||
configuredUrl = target.baseUrl,
|
||||
requestUrl = target.requestUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
suggestion = if (ok) {
|
||||
null
|
||||
} else {
|
||||
NetworkDiagnosticGuidance.forHttpStatus(resp.code, surface.diagnosticTarget())
|
||||
},
|
||||
)
|
||||
recordOutcome(
|
||||
candidate,
|
||||
@@ -415,9 +429,12 @@ class EndpointResolver(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
|
||||
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
|
||||
operation = operation,
|
||||
endpointRole = candidate.role,
|
||||
url = target.baseUrl,
|
||||
configuredUrl = target.baseUrl,
|
||||
requestUrl = target.requestUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
suggestion = "Check network routing or firewall rules between this device and ${surface.diagnosticTarget()}.",
|
||||
)
|
||||
recordOutcome(candidate, surface, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
false
|
||||
@@ -428,9 +445,12 @@ class EndpointResolver(
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.endpoint_diag_probe_timeout) ?: "Endpoint probe timeout",
|
||||
detail = "No ${target.path} response in ${PROBE_TIMEOUT_MS}ms",
|
||||
operation = operation,
|
||||
endpointRole = candidate.role,
|
||||
url = target.baseUrl,
|
||||
configuredUrl = target.baseUrl,
|
||||
requestUrl = target.requestUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
suggestion = "Check network routing or firewall rules between this device and ${surface.diagnosticTarget()}.",
|
||||
)
|
||||
recordOutcome(candidate, surface, reachable = false, detail = PROBE_TIMEOUT_DETAIL)
|
||||
false
|
||||
@@ -441,10 +461,13 @@ class EndpointResolver(
|
||||
category = DiagnosticCategory.Endpoint,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = context?.getString(R.string.endpoint_diag_probe_failed) ?: "Endpoint probe failed",
|
||||
detail = e.javaClass.simpleName,
|
||||
detail = humanProbeFailure(e),
|
||||
operation = operation,
|
||||
endpointRole = candidate.role,
|
||||
url = target.baseUrl,
|
||||
configuredUrl = target.baseUrl,
|
||||
requestUrl = target.requestUrl,
|
||||
elapsedMs = clock() - startedAtMs,
|
||||
suggestion = NetworkDiagnosticGuidance.forThrowable(e, surface.diagnosticTarget()),
|
||||
)
|
||||
recordOutcome(candidate, surface, reachable = false, detail = humanProbeFailure(e))
|
||||
false
|
||||
@@ -521,6 +544,11 @@ class EndpointResolver(
|
||||
else -> e.javaClass.simpleName
|
||||
}
|
||||
|
||||
private fun EndpointSurface.diagnosticTarget(): String = when (this) {
|
||||
EndpointSurface.Standard -> "Dashboard or API server"
|
||||
EndpointSurface.Relay -> "Relay"
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark [candidate] unreachable without re-probing. Called from
|
||||
* `ConnectionManager`'s `NetworkCallback.onLost` so the next resolve()
|
||||
|
||||
@@ -218,8 +218,14 @@ class ChatHandler {
|
||||
*/
|
||||
private val activeAnnotationTools = mutableMapOf<String, String>()
|
||||
|
||||
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
|
||||
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
|
||||
// All Chat and Voice transcript publications pass through this invariant
|
||||
// boundary. A caller may address a row by its mutable server/domain id or
|
||||
// its retained client uiKey, but Compose must never observe both aliases.
|
||||
private val _messages = RenderedMessageState(emptyList())
|
||||
val messages: StateFlow<List<ChatMessage>> = _messages.flow
|
||||
|
||||
private fun ChatMessage.matchesIdentity(reference: String): Boolean =
|
||||
id == reference || uiKey == reference
|
||||
|
||||
/**
|
||||
* Latest gateway `status.update` lifecycle line for the in-flight turn
|
||||
@@ -324,8 +330,8 @@ class ChatHandler {
|
||||
// message.started can replace that domain id with the server id while
|
||||
// uiKey deliberately retains the client id. Resolve the current domain
|
||||
// id before every event so the tail keeps mutating the same visible row.
|
||||
val currentMessageId = _messages.value.findLast { message ->
|
||||
message.id == messageId || message.uiKey == messageId
|
||||
val currentMessageId = _messages.value.findLast {
|
||||
it.matchesIdentity(messageId)
|
||||
}?.id ?: messageId
|
||||
|
||||
when (envelope.event) {
|
||||
@@ -392,7 +398,7 @@ class ChatHandler {
|
||||
*/
|
||||
fun updateDeliveryStatus(messageId: String, status: MessageDeliveryStatus) {
|
||||
_messages.update { list ->
|
||||
list.map { if (it.id == messageId) it.copy(deliveryStatus = status) else it }
|
||||
list.map { if (it.matchesIdentity(messageId)) it.copy(deliveryStatus = status) else it }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +406,7 @@ class ChatHandler {
|
||||
fun setBackgroundTask(messageId: String, task: BackgroundTaskState) {
|
||||
_messages.update { list ->
|
||||
list.map { message ->
|
||||
if (message.id == messageId) message.copy(backgroundTask = task) else message
|
||||
if (message.matchesIdentity(messageId)) message.copy(backgroundTask = task) else message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,7 +418,7 @@ class ChatHandler {
|
||||
) {
|
||||
_messages.update { list ->
|
||||
list.map { message ->
|
||||
if (message.id == messageId && message.backgroundTask != null) {
|
||||
if (message.matchesIdentity(messageId) && message.backgroundTask != null) {
|
||||
message.copy(backgroundTask = transform(message.backgroundTask))
|
||||
} else {
|
||||
message
|
||||
@@ -493,7 +499,7 @@ class ChatHandler {
|
||||
*/
|
||||
fun appendAskCardMessage(messageId: String, card: HermesCard) {
|
||||
_messages.update { list ->
|
||||
if (list.any { it.id == messageId }) return@update list
|
||||
if (list.any { it.matchesIdentity(messageId) }) return@update list
|
||||
val msg = ChatMessage(
|
||||
id = messageId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
@@ -515,7 +521,7 @@ class ChatHandler {
|
||||
*/
|
||||
fun truncateMessagesFrom(messageId: String) {
|
||||
_messages.update { list ->
|
||||
val idx = list.indexOfFirst { it.id == messageId }
|
||||
val idx = list.indexOfFirst { it.matchesIdentity(messageId) }
|
||||
if (idx < 0) list else list.take(idx)
|
||||
}
|
||||
}
|
||||
@@ -523,7 +529,7 @@ class ChatHandler {
|
||||
fun replaceMessageContent(messageId: String, content: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { message ->
|
||||
if (message.id == messageId) {
|
||||
if (message.matchesIdentity(messageId)) {
|
||||
message.copy(content = content)
|
||||
} else {
|
||||
message
|
||||
@@ -543,8 +549,9 @@ class ChatHandler {
|
||||
content: String,
|
||||
) {
|
||||
_messages.update { messages ->
|
||||
val interim = messages.firstOrNull { it.id == interimMessageId } ?: return@update messages
|
||||
val current = messages.firstOrNull { it.id == currentMessageId }
|
||||
val interim = messages.firstOrNull { it.matchesIdentity(interimMessageId) }
|
||||
?: return@update messages
|
||||
val current = messages.firstOrNull { it.matchesIdentity(currentMessageId) }
|
||||
val mergedTools = (interim.toolCalls + current?.toolCalls.orEmpty())
|
||||
.distinctBy { it.id ?: "${it.name}:${it.startedAt}" }
|
||||
val merged = interim.copy(
|
||||
@@ -563,14 +570,18 @@ class ChatHandler {
|
||||
backgroundTask = current?.backgroundTask ?: interim.backgroundTask,
|
||||
)
|
||||
messages
|
||||
.filterNot { it.id == currentMessageId && currentMessageId != interimMessageId }
|
||||
.map { if (it.id == interimMessageId) merged else it }
|
||||
.filterNot {
|
||||
current != null &&
|
||||
current.uiKey != interim.uiKey &&
|
||||
it.matchesIdentity(currentMessageId)
|
||||
}
|
||||
.map { if (it.matchesIdentity(interimMessageId)) merged else it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a provisional client-side message that never became a real turn. */
|
||||
fun removeMessage(messageId: String) {
|
||||
_messages.update { messages -> messages.filterNot { it.id == messageId } }
|
||||
_messages.update { messages -> messages.filterNot { it.matchesIdentity(messageId) } }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -752,7 +763,7 @@ class ChatHandler {
|
||||
_messages.update { messages ->
|
||||
var changed = false
|
||||
val mapped = messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
changed = true
|
||||
// A realtimeTurn trace is attached ONLY for provider-only
|
||||
// (non-Hermes-backed) turns — Hermes-backed ones leave it
|
||||
@@ -971,7 +982,12 @@ class ChatHandler {
|
||||
val user = checkpoint.user
|
||||
val assistant = checkpoint.assistant
|
||||
val upstreamText = upstreamAssistantText.orEmpty()
|
||||
val currentAssistant = _messages.value.lastOrNull { it.id == assistant.id }
|
||||
// A history poll may already have adopted the server id while the
|
||||
// checkpoint still names the original client identity. They are one
|
||||
// logical row, resolved through either side of that alias.
|
||||
val currentAssistant = _messages.value.lastOrNull {
|
||||
it.matchesIdentity(assistant.id) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
val restoredContent = listOf(
|
||||
assistant.content,
|
||||
upstreamText,
|
||||
@@ -1053,7 +1069,8 @@ class ChatHandler {
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?: checkpointMoaReferences
|
||||
val restoredAssistant = ChatMessage(
|
||||
id = assistant.id,
|
||||
id = currentAssistant?.id ?: assistant.id,
|
||||
uiKey = currentAssistant?.uiKey ?: assistant.id,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = restoredContent,
|
||||
timestamp = assistant.timestamp,
|
||||
@@ -1080,10 +1097,12 @@ class ChatHandler {
|
||||
|
||||
activeAgentName = restoredAssistant.agentName ?: activeAgentName
|
||||
_messages.update { current ->
|
||||
val withoutOldAssistant = current.filterNot { it.id == assistant.id }
|
||||
val withoutOldAssistant = current.filterNot {
|
||||
it.matchesIdentity(assistant.id) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
val users = withoutOldAssistant.filter { it.role == MessageRole.USER }
|
||||
val positionalUser = users.getOrNull(checkpoint.priorUserMessageCount)
|
||||
val hasUser = withoutOldAssistant.any { it.id == user.id } ||
|
||||
val hasUser = withoutOldAssistant.any { it.matchesIdentity(user.id) } ||
|
||||
positionalUser?.content?.trim() == user.content.trim()
|
||||
val withUser = if (hasUser) {
|
||||
withoutOldAssistant
|
||||
@@ -1100,7 +1119,7 @@ class ChatHandler {
|
||||
// those corrections as ordinary user bubbles before the assistant
|
||||
// row. Consume matching already-present rows first so repeated
|
||||
// resume/checkpoint passes cannot duplicate them.
|
||||
val originalUserIndex = withUser.indexOfFirst { it.id == user.id }
|
||||
val originalUserIndex = withUser.indexOfFirst { it.matchesIdentity(user.id) }
|
||||
.takeIf { it >= 0 }
|
||||
?: withUser.indexOfFirst {
|
||||
it.role == MessageRole.USER && it.content.trim() == user.content.trim()
|
||||
@@ -1213,7 +1232,7 @@ class ChatHandler {
|
||||
fun replaceMessageId(oldId: String, newId: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == oldId && msg.content.isBlank() && msg.isStreaming) {
|
||||
if (msg.matchesIdentity(oldId) && msg.content.isBlank() && msg.isStreaming) {
|
||||
msg.copy(id = newId)
|
||||
} else msg
|
||||
}
|
||||
@@ -1236,7 +1255,7 @@ class ChatHandler {
|
||||
fun mutateMessage(messageId: String, transform: (ChatMessage) -> ChatMessage) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) transform(msg) else msg
|
||||
if (msg.matchesIdentity(messageId)) transform(msg) else msg
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2024,12 +2043,12 @@ class ChatHandler {
|
||||
|
||||
_messages.update { messages ->
|
||||
val existing = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
|
||||
if (existing != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
if (msg.matchesIdentity(messageId)) {
|
||||
msg.copy(content = msg.content + processedDelta)
|
||||
} else {
|
||||
msg
|
||||
@@ -2262,7 +2281,7 @@ class ChatHandler {
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
msg.copy(cards = msg.cards + card)
|
||||
} else msg
|
||||
}
|
||||
@@ -2288,7 +2307,7 @@ class ChatHandler {
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
if (!msg.matchesIdentity(messageId) || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
var cleaned = msg.content
|
||||
var changed = false
|
||||
for (rawLine in msg.content.lines()) {
|
||||
@@ -2355,7 +2374,7 @@ class ChatHandler {
|
||||
private fun stripLineFromContent(messageId: String, line: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
// Remove the line (with surrounding newlines) from content
|
||||
val cleaned = msg.content
|
||||
.replace("\n$line\n", "\n")
|
||||
@@ -2492,7 +2511,7 @@ class ChatHandler {
|
||||
// that raced with stripLineFromContent during streaming.
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
if (!msg.matchesIdentity(messageId) || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
var cleaned = msg.content
|
||||
var changed = false
|
||||
for (rawLine in msg.content.lines()) {
|
||||
@@ -2546,7 +2565,7 @@ class ChatHandler {
|
||||
private fun finalizeAnnotations(messageId: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
if (!msg.matchesIdentity(messageId) || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
|
||||
val existingToolNames = msg.toolCalls.map { it.name }.toSet()
|
||||
val newToolCalls = mutableListOf<ToolCall>()
|
||||
@@ -2629,7 +2648,7 @@ class ChatHandler {
|
||||
.take(4)
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
msg.copy(badges = cleaned)
|
||||
} else {
|
||||
msg
|
||||
@@ -2645,7 +2664,7 @@ class ChatHandler {
|
||||
if (cleaned.isEmpty()) return
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
msg.copy(badges = (msg.badges + cleaned).distinct().take(4))
|
||||
} else {
|
||||
msg
|
||||
@@ -2679,11 +2698,11 @@ class ChatHandler {
|
||||
)
|
||||
_messages.update { messages ->
|
||||
val target = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (target != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
if (msg.matchesIdentity(messageId)) {
|
||||
msg.copy(toolCalls = msg.toolCalls + placeholder)
|
||||
} else {
|
||||
msg
|
||||
@@ -2714,7 +2733,7 @@ class ChatHandler {
|
||||
|
||||
_messages.update { messages ->
|
||||
val target = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (target != null) {
|
||||
// Adopt a pending "preparing" placeholder for this name
|
||||
@@ -2728,7 +2747,7 @@ class ChatHandler {
|
||||
.indexOfFirst { it.isGenerating && !it.isComplete && it.name.isEmpty() }
|
||||
.takeIf { it >= 0 }
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId) return@map msg
|
||||
if (!msg.matchesIdentity(messageId)) return@map msg
|
||||
if (genIdx != null) {
|
||||
val calls = msg.toolCalls.toMutableList()
|
||||
calls[genIdx] = calls[genIdx].copy(
|
||||
@@ -2786,7 +2805,7 @@ class ChatHandler {
|
||||
fun onMoaReference(messageId: String, event: GatewayMoaReference) {
|
||||
_messages.update { messages ->
|
||||
val targetIndex = messages.indexOfLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (targetIndex < 0) return@update messages
|
||||
_isStreaming.value = true
|
||||
@@ -2861,11 +2880,11 @@ class ChatHandler {
|
||||
)
|
||||
_messages.update { messages ->
|
||||
val target = messages.findLast {
|
||||
it.id == messageId && it.role == MessageRole.ASSISTANT
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (target != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId) return@map msg
|
||||
if (!msg.matchesIdentity(messageId)) return@map msg
|
||||
val closed = msg.toolCalls.map { call ->
|
||||
if (call.taskIndex == event.taskIndex && !call.isComplete) {
|
||||
call.copy(
|
||||
@@ -2900,7 +2919,7 @@ class ChatHandler {
|
||||
val summaryId = "subagent-${event.taskIndex}-${syntheticToolSeq++}"
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
if (!msg.matchesIdentity(messageId) || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
val hasLaneCalls = msg.toolCalls.any { it.taskIndex == event.taskIndex }
|
||||
val closed = msg.toolCalls.map { call ->
|
||||
if (call.taskIndex == event.taskIndex && !call.isComplete) {
|
||||
@@ -2951,14 +2970,14 @@ class ChatHandler {
|
||||
// Snapshot the matching tool call's name BEFORE mutating — we need it
|
||||
// to decide whether to emit a phone-action result bubble below.
|
||||
val toolName = _messages.value
|
||||
.firstOrNull { it.id == messageId && it.role == MessageRole.ASSISTANT }
|
||||
.firstOrNull { it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT }
|
||||
?.toolCalls
|
||||
?.firstOrNull { it.id == toolCallId }
|
||||
?.name
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
val updatedCalls = msg.toolCalls.map { call ->
|
||||
if (call.id == toolCallId && !call.isComplete) {
|
||||
call.copy(
|
||||
@@ -2995,14 +3014,14 @@ class ChatHandler {
|
||||
// Snapshot tool name before the update so the phone-action bubble
|
||||
// can label the failure correctly.
|
||||
val toolName = _messages.value
|
||||
.firstOrNull { it.id == messageId && it.role == MessageRole.ASSISTANT }
|
||||
.firstOrNull { it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT }
|
||||
?.toolCalls
|
||||
?.firstOrNull { it.id == toolCallId }
|
||||
?.name
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && msg.role == MessageRole.ASSISTANT) {
|
||||
if (msg.matchesIdentity(messageId) && msg.role == MessageRole.ASSISTANT) {
|
||||
val updatedCalls = msg.toolCalls.map { call ->
|
||||
if (call.id == toolCallId && !call.isComplete) {
|
||||
call.copy(
|
||||
@@ -3035,7 +3054,7 @@ class ChatHandler {
|
||||
fun onToolOutputRisk(messageId: String, outputRisk: GatewayToolOutputRisk) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
if (!msg.matchesIdentity(messageId) || msg.role != MessageRole.ASSISTANT) return@map msg
|
||||
val updatedCalls = msg.toolCalls.map { call ->
|
||||
if (call.id == outputRisk.toolCallId) {
|
||||
call.copy(
|
||||
@@ -3066,7 +3085,7 @@ class ChatHandler {
|
||||
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
if (msg.matchesIdentity(messageId)) {
|
||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
||||
} else {
|
||||
msg
|
||||
@@ -3095,7 +3114,7 @@ class ChatHandler {
|
||||
fun markStopped(messageId: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && "Stopped" !in msg.badges) {
|
||||
if (msg.matchesIdentity(messageId) && "Stopped" !in msg.badges) {
|
||||
msg.copy(badges = msg.badges + "Stopped")
|
||||
} else {
|
||||
msg
|
||||
@@ -3122,7 +3141,7 @@ class ChatHandler {
|
||||
fun markError(messageId: String) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId && "Error" !in msg.badges) {
|
||||
if (msg.matchesIdentity(messageId) && "Error" !in msg.badges) {
|
||||
msg.copy(badges = msg.badges + "Error", clientOnly = true)
|
||||
} else {
|
||||
msg
|
||||
@@ -3148,7 +3167,7 @@ class ChatHandler {
|
||||
_messages.update { messages ->
|
||||
messages
|
||||
.filterNot { msg ->
|
||||
msg.id == messageId &&
|
||||
msg.matchesIdentity(messageId) &&
|
||||
msg.role == MessageRole.ASSISTANT &&
|
||||
msg.toolCalls.isEmpty() &&
|
||||
msg.backgroundTask == null &&
|
||||
@@ -3156,7 +3175,7 @@ class ChatHandler {
|
||||
(msg.content.isBlank() || isIntentionalSilenceMarker(msg.content))
|
||||
}
|
||||
.map { msg ->
|
||||
if (msg.id == messageId || msg.isStreaming) {
|
||||
if (msg.matchesIdentity(messageId) || msg.isStreaming) {
|
||||
msg.copy(isStreaming = false, isThinkingStreaming = false)
|
||||
} else {
|
||||
msg
|
||||
@@ -3220,10 +3239,12 @@ class ChatHandler {
|
||||
fun onThinkingDelta(messageId: String, delta: String) {
|
||||
_isStreaming.value = true
|
||||
_messages.update { messages ->
|
||||
val existing = messages.findLast { it.id == messageId && it.role == MessageRole.ASSISTANT }
|
||||
val existing = messages.findLast {
|
||||
it.matchesIdentity(messageId) && it.role == MessageRole.ASSISTANT
|
||||
}
|
||||
if (existing != null) {
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
if (msg.matchesIdentity(messageId)) {
|
||||
msg.copy(
|
||||
thinkingContent = msg.thinkingContent + delta,
|
||||
isThinkingStreaming = true
|
||||
@@ -3248,7 +3269,7 @@ class ChatHandler {
|
||||
fun onUsageReceived(messageId: String, inputTokens: Int?, outputTokens: Int?, totalTokens: Int?, cost: Double?) {
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
if (msg.matchesIdentity(messageId)) {
|
||||
msg.copy(
|
||||
inputTokens = inputTokens,
|
||||
outputTokens = outputTokens,
|
||||
@@ -3281,7 +3302,7 @@ class ChatHandler {
|
||||
)
|
||||
_messages.update { messages ->
|
||||
messages.map { msg ->
|
||||
if (msg.id != messageId) return@map msg
|
||||
if (!msg.matchesIdentity(messageId)) return@map msg
|
||||
val alreadyDispatched = msg.cardDispatches.any {
|
||||
it.cardKey == cardKey && it.actionValue == actionValue
|
||||
}
|
||||
|
||||
@@ -260,6 +260,9 @@ class GatewayChatClient(
|
||||
private val _serverProvider = MutableStateFlow<String?>(null)
|
||||
val serverProvider: StateFlow<String?> = _serverProvider.asStateFlow()
|
||||
|
||||
private val _serverModelIdentity = MutableStateFlow<GatewayModelIdentity?>(null)
|
||||
val serverModelIdentity: StateFlow<GatewayModelIdentity?> = _serverModelIdentity.asStateFlow()
|
||||
|
||||
/**
|
||||
* Active reasoning EFFORT from `session.info` (string; "" when reasoning is
|
||||
* disabled). The reasoning DISPLAY mode is NOT on session.info — it stays a
|
||||
@@ -269,6 +272,10 @@ class GatewayChatClient(
|
||||
private val _serverReasoningEffort = MutableStateFlow<String?>(null)
|
||||
val serverReasoningEffort: StateFlow<String?> = _serverReasoningEffort.asStateFlow()
|
||||
|
||||
private val _serverReasoningIdentity = MutableStateFlow<GatewayReasoningIdentity?>(null)
|
||||
val serverReasoningIdentity: StateFlow<GatewayReasoningIdentity?> =
|
||||
_serverReasoningIdentity.asStateFlow()
|
||||
|
||||
/**
|
||||
* Server-reported credential warning (upstream `session.info.credential_warning`)
|
||||
* — present ONLY when the active provider's key is missing/invalid, absent
|
||||
@@ -1421,20 +1428,7 @@ class GatewayChatClient(
|
||||
return rpc("model.options", params).map { result ->
|
||||
val providers = (result["providers"] as? JsonArray).orEmpty().mapNotNull { el ->
|
||||
val obj = el as? JsonObject ?: return@mapNotNull null
|
||||
val slug = obj.stringField("slug") ?: return@mapNotNull null
|
||||
GatewayModelProvider(
|
||||
name = obj.stringField("name") ?: slug,
|
||||
slug = slug,
|
||||
models = (obj["models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
warning = obj.stringField("warning"),
|
||||
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
|
||||
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull() ?: 0,
|
||||
)
|
||||
parseGatewayModelProvider(obj)
|
||||
}
|
||||
GatewayModelOptions(
|
||||
providers = providers,
|
||||
@@ -1784,12 +1778,23 @@ class GatewayChatClient(
|
||||
_serverPersonality.value =
|
||||
(info.stringField("personality") ?: "").ifBlank { "none" }
|
||||
}
|
||||
info.stringField("model")?.takeIf { it.isNotBlank() }?.let { _serverModel.value = it }
|
||||
info.stringField("provider")?.takeIf { it.isNotBlank() }?.let { _serverProvider.value = it }
|
||||
val model = info.stringField("model")?.takeIf { it.isNotBlank() }
|
||||
val provider = info.stringField("provider")?.takeIf { it.isNotBlank() }
|
||||
model?.let { _serverModel.value = it }
|
||||
provider?.let { _serverProvider.value = it }
|
||||
if (model != null && provider != null) {
|
||||
_serverModelIdentity.value = GatewayModelIdentity(model = model, provider = provider)
|
||||
}
|
||||
// reasoning effort: ignore "" (reasoning disabled) so it can't clobber
|
||||
// the chip; display mode is config.get-only, not here.
|
||||
info.stringField("reasoning_effort")?.takeIf { it.isNotBlank() }
|
||||
?.let { _serverReasoningEffort.value = it }
|
||||
val reasoningEffort = info.stringField("reasoning_effort")?.takeIf { it.isNotBlank() }
|
||||
reasoningEffort?.let { _serverReasoningEffort.value = it }
|
||||
if (model != null && provider != null && reasoningEffort != null) {
|
||||
_serverReasoningIdentity.value = GatewayReasoningIdentity(
|
||||
identity = GatewayModelIdentity(model = model, provider = provider),
|
||||
effort = reasoningEffort,
|
||||
)
|
||||
}
|
||||
// credential_warning: present only when the provider key is missing/
|
||||
// invalid. ABSENT means healthy — clear to null so it self-resolves.
|
||||
_serverCredentialWarning.value =
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.models.UsageInfo
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
/**
|
||||
* Shared types for the Gateway chat transport — upstream hermes-agent's
|
||||
@@ -347,8 +352,45 @@ data class GatewayModelProvider(
|
||||
val unavailableModels: List<String> = emptyList(),
|
||||
val freeTier: Boolean = false,
|
||||
val totalModels: Int = 0,
|
||||
/** Per-model capability rows keyed by the exact model id. */
|
||||
val capabilities: Map<String, GatewayModelCapabilities> = emptyMap(),
|
||||
)
|
||||
|
||||
/** Shared tolerant parser for the gateway RPC and API-server REST twins. */
|
||||
internal fun parseGatewayModelProvider(obj: JsonObject): GatewayModelProvider? {
|
||||
val slug = (obj["slug"] as? JsonPrimitive)?.contentOrNull
|
||||
?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||
val capabilities = (obj["capabilities"] as? JsonObject).orEmpty().mapNotNull { (model, raw) ->
|
||||
val row = raw as? JsonObject ?: return@mapNotNull null
|
||||
val effortsElement = row["reasoning_efforts"]
|
||||
val efforts = if (effortsElement is JsonArray) {
|
||||
effortsElement.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
model to GatewayModelCapabilities(
|
||||
reasoning = (row["reasoning"] as? JsonPrimitive)?.booleanOrNull,
|
||||
reasoningEfforts = efforts,
|
||||
reasoningEffortsExact =
|
||||
(row["reasoning_efforts_exact"] as? JsonPrimitive)?.booleanOrNull,
|
||||
)
|
||||
}.toMap()
|
||||
return GatewayModelProvider(
|
||||
name = (obj["name"] as? JsonPrimitive)?.contentOrNull ?: slug,
|
||||
slug = slug,
|
||||
models = (obj["models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
warning = (obj["warning"] as? JsonPrimitive)?.contentOrNull,
|
||||
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
|
||||
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull() ?: 0,
|
||||
capabilities = capabilities,
|
||||
)
|
||||
}
|
||||
|
||||
data class GatewayMoaReference(
|
||||
val index: Int?,
|
||||
val count: Int?,
|
||||
@@ -364,6 +406,15 @@ data class GatewayModelOptions(
|
||||
val currentProvider: String,
|
||||
)
|
||||
|
||||
/** Coherent model identity from a single `session.info` payload. */
|
||||
data class GatewayModelIdentity(val model: String, val provider: String)
|
||||
|
||||
/** Model identity and effort observed together in one `session.info` payload. */
|
||||
data class GatewayReasoningIdentity(
|
||||
val identity: GatewayModelIdentity,
|
||||
val effort: String,
|
||||
)
|
||||
|
||||
/** Reject provider catalogs that completed after a profile/context switch. */
|
||||
internal fun isCurrentModelOptionsResponse(
|
||||
requestGeneration: Long,
|
||||
|
||||
@@ -215,22 +215,7 @@ internal fun parseApiProviderModelOptionsBody(
|
||||
val rows = root["providers"] as? JsonArray ?: return null
|
||||
val providers = rows.mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val slug = (obj["slug"] as? JsonPrimitive)?.contentOrNull
|
||||
?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
GatewayModelProvider(
|
||||
name = (obj["name"] as? JsonPrimitive)?.contentOrNull ?: slug,
|
||||
slug = slug,
|
||||
models = (obj["models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
isCurrent = (obj["is_current"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
warning = (obj["warning"] as? JsonPrimitive)?.contentOrNull,
|
||||
authenticated = (obj["authenticated"] as? JsonPrimitive)?.booleanOrNull ?: true,
|
||||
unavailableModels = (obj["unavailable_models"] as? JsonArray).orEmpty()
|
||||
.mapNotNull { (it as? JsonPrimitive)?.contentOrNull },
|
||||
freeTier = (obj["free_tier"] as? JsonPrimitive)?.booleanOrNull ?: false,
|
||||
totalModels = (obj["total_models"] as? JsonPrimitive)?.contentOrNull
|
||||
?.toIntOrNull() ?: 0,
|
||||
)
|
||||
parseGatewayModelProvider(obj)
|
||||
}
|
||||
return ApiProviderModelOptions(
|
||||
providers = providers,
|
||||
@@ -254,7 +239,8 @@ enum class ApiModelRoutingErrorCode {
|
||||
class ApiModelRoutingException(
|
||||
val code: ApiModelRoutingErrorCode,
|
||||
message: String,
|
||||
) : IOException(message)
|
||||
cause: Throwable? = null,
|
||||
) : IOException(message, cause)
|
||||
|
||||
sealed interface ApiModelSelectionAck {
|
||||
data object ServerDefault : ApiModelSelectionAck
|
||||
@@ -815,6 +801,7 @@ class HermesApiClient(
|
||||
ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||
"Model inventory could not be loaded.",
|
||||
e,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
/** Canonical reasoning-effort values accepted by upstream Hermes. */
|
||||
object ReasoningEfforts {
|
||||
const val DEFAULT = "medium"
|
||||
|
||||
val canonical: List<String> =
|
||||
listOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
||||
|
||||
fun normalize(value: String?): String {
|
||||
val normalized = value?.trim()?.lowercase().orEmpty()
|
||||
return normalized.takeIf { it in canonical } ?: DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider/model capability row advertised by upstream `model.options`. */
|
||||
data class GatewayModelCapabilities(
|
||||
/** Legacy capability flag. Null means the server did not advertise support either way. */
|
||||
val reasoning: Boolean? = null,
|
||||
/** Exact selectable values on newer servers. Null means use the canonical compatibility list. */
|
||||
val reasoningEfforts: List<String>? = null,
|
||||
/** Explicit false means the advertised list is advisory rather than selectable. */
|
||||
val reasoningEffortsExact: Boolean? = null,
|
||||
)
|
||||
|
||||
data class ReasoningEffortAvailability(
|
||||
val supported: Boolean?,
|
||||
val choices: List<String>,
|
||||
val exact: Boolean,
|
||||
) {
|
||||
fun accepts(effort: String): Boolean = supported != false && (!exact || effort in choices)
|
||||
}
|
||||
|
||||
/** Exact provider/model identity to which a confirmed effort belongs. */
|
||||
data class ReasoningEffortIdentity(val provider: String, val model: String)
|
||||
|
||||
/**
|
||||
* Resolve the active provider/model's advertised reasoning contract.
|
||||
*
|
||||
* Older servers expose either no capability entry or only `reasoning: true`;
|
||||
* those receive the full canonical compatibility list. An explicit false
|
||||
* disables the control. A `reasoning_efforts` array is authoritative.
|
||||
*/
|
||||
fun resolveReasoningEffortAvailability(
|
||||
providers: List<GatewayModelProvider>,
|
||||
provider: String?,
|
||||
model: String?,
|
||||
relayCapabilities: Map<ReasoningEffortIdentity, GatewayModelCapabilities> = emptyMap(),
|
||||
): ReasoningEffortAvailability {
|
||||
val normalizedProvider = provider?.trim().orEmpty()
|
||||
val normalizedModel = model?.trim().orEmpty()
|
||||
if (normalizedProvider.isEmpty() || normalizedModel.isEmpty()) {
|
||||
return ReasoningEffortAvailability(
|
||||
supported = null,
|
||||
choices = ReasoningEfforts.canonical,
|
||||
exact = false,
|
||||
)
|
||||
}
|
||||
val providerRow = providers.firstOrNull {
|
||||
it.slug.equals(normalizedProvider, ignoreCase = true)
|
||||
}
|
||||
|
||||
val upstream = providerRow?.capabilities?.get(normalizedModel)
|
||||
?: providerRow?.capabilities?.entries?.firstOrNull {
|
||||
it.key.equals(normalizedModel, ignoreCase = true)
|
||||
}?.value
|
||||
val identity = ReasoningEffortIdentity(
|
||||
provider = normalizedProvider.lowercase(),
|
||||
model = normalizedModel,
|
||||
)
|
||||
val relay = relayCapabilities[identity]
|
||||
|
||||
fun exactAvailability(capabilities: GatewayModelCapabilities): ReasoningEffortAvailability {
|
||||
val choices = capabilities.reasoningEfforts.orEmpty()
|
||||
.map { it.trim().lowercase() }
|
||||
.filter { it in ReasoningEfforts.canonical }
|
||||
.distinct()
|
||||
return ReasoningEffortAvailability(
|
||||
supported = capabilities.reasoning ?: choices.isNotEmpty(),
|
||||
choices = choices,
|
||||
exact = true,
|
||||
)
|
||||
}
|
||||
|
||||
// Contract precedence: authoritative upstream, authoritative Relay overlay,
|
||||
// explicit upstream suppression, then compatibility fallback.
|
||||
if (upstream?.reasoningEfforts != null && upstream.reasoningEffortsExact == true) {
|
||||
return exactAvailability(upstream)
|
||||
}
|
||||
if (relay?.reasoningEfforts != null && relay.reasoningEffortsExact == true) {
|
||||
return exactAvailability(relay)
|
||||
}
|
||||
if (upstream?.reasoning == false) {
|
||||
return ReasoningEffortAvailability(supported = false, choices = emptyList(), exact = false)
|
||||
}
|
||||
|
||||
return ReasoningEffortAvailability(
|
||||
supported = upstream?.reasoning,
|
||||
choices = ReasoningEfforts.canonical,
|
||||
exact = false,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
/**
|
||||
* The publication boundary for Chat and Voice transcript rows.
|
||||
*
|
||||
* [ChatMessage.id] may change when the server adopts a client-created row,
|
||||
* while [ChatMessage.uiKey] is that row's stable render identity. Every value
|
||||
* emitted from this state therefore has exactly one row per render identity.
|
||||
*/
|
||||
internal class RenderedMessageState(initialValue: List<ChatMessage>) {
|
||||
private val mutable = MutableStateFlow(normalizeRenderedMessages(initialValue))
|
||||
val flow: StateFlow<List<ChatMessage>> = mutable.asStateFlow()
|
||||
|
||||
var value: List<ChatMessage>
|
||||
get() = mutable.value
|
||||
set(value) {
|
||||
mutable.value = normalizeRenderedMessages(value)
|
||||
}
|
||||
|
||||
fun update(transform: (List<ChatMessage>) -> List<ChatMessage>) {
|
||||
mutable.update { current -> normalizeRenderedMessages(transform(current)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesce aliases before they can escape to keyed Compose consumers.
|
||||
*
|
||||
* The first slot owns transcript position and the latest snapshot owns row
|
||||
* state. This is the same ordering contract used for replayed server history.
|
||||
*/
|
||||
internal fun normalizeRenderedMessages(messages: List<ChatMessage>): List<ChatMessage> {
|
||||
if (messages.size < 2) return messages
|
||||
|
||||
val firstSlotByUiKey = HashMap<String, Int>()
|
||||
val normalized = ArrayList<ChatMessage>(messages.size)
|
||||
for (message in messages) {
|
||||
val existingSlot = firstSlotByUiKey[message.uiKey]
|
||||
if (existingSlot == null) {
|
||||
firstSlotByUiKey[message.uiKey] = normalized.size
|
||||
normalized += message
|
||||
} else {
|
||||
normalized[existingSlot] = message
|
||||
}
|
||||
}
|
||||
return if (normalized.size == messages.size) messages else normalized
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package com.hermesandroid.relay.reliability
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.hermesandroid.relay.BuildConfig
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Android boundary for the local reliability store. Nothing in this object has
|
||||
* a network path; writes stay in app-private storage until a user explicitly
|
||||
* reviews and shares text through the UI.
|
||||
*/
|
||||
object ReliabilityCenter {
|
||||
private val writer = Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "hermes-reliability-writer").apply { isDaemon = true }
|
||||
}
|
||||
private val appSessionId = ReliabilityReport.newId("app")
|
||||
|
||||
@Volatile
|
||||
private var store: ReliabilityStore? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
if (store != null) return
|
||||
synchronized(this) {
|
||||
if (store == null) {
|
||||
store = ReliabilityStore(
|
||||
java.io.File(context.applicationContext.filesDir, "reliability/reports-v1.json"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun recordFatal(
|
||||
context: Context,
|
||||
throwable: Throwable,
|
||||
threadName: String,
|
||||
timeIso: String = Instant.now().toString(),
|
||||
): ReliabilityReport {
|
||||
initialize(context)
|
||||
val summary = buildString {
|
||||
append(throwable.javaClass.simpleName.ifBlank { "Unexpected crash" })
|
||||
throwable.message?.takeIf { it.isNotBlank() }?.let { append(": ").append(it) }
|
||||
}
|
||||
val report = ReliabilityReport(
|
||||
reportId = ReliabilityReport.newId(),
|
||||
appSessionId = appSessionId,
|
||||
timeIso = timeIso,
|
||||
kind = ReliabilityKind.FatalCrash,
|
||||
owner = ReliabilityOwner.Android,
|
||||
severity = ReliabilitySeverity.Fatal,
|
||||
summary = summary,
|
||||
recovery = "The app restarted. Work already running on Hermes may still be active.",
|
||||
reportRecommended = true,
|
||||
technicalDetail = "Thread: $threadName\n${stackTraceText(throwable)}",
|
||||
environment = environment(),
|
||||
pendingReview = true,
|
||||
)
|
||||
// Fatal capture must complete before the platform terminates the process.
|
||||
store?.append(report)
|
||||
return report
|
||||
}
|
||||
|
||||
fun recordHandled(
|
||||
title: String,
|
||||
detail: String?,
|
||||
throwable: Throwable,
|
||||
context: String?,
|
||||
routeRole: String? = null,
|
||||
) {
|
||||
val target = store ?: return
|
||||
val classification = ReliabilityClassifier.classify(throwable, context)
|
||||
if (!classification.shouldPersist) return
|
||||
val report = ReliabilityReport(
|
||||
reportId = ReliabilityReport.newId(),
|
||||
appSessionId = appSessionId,
|
||||
timeIso = Instant.now().toString(),
|
||||
kind = classification.kind,
|
||||
owner = classification.owner,
|
||||
severity = ReliabilitySeverity.Error,
|
||||
summary = title,
|
||||
recovery = detail ?: "The failure was handled; retry or review Diagnostics if it continues.",
|
||||
reportRecommended = classification.reportRecommended,
|
||||
technicalDetail = stackTraceText(throwable),
|
||||
routeRole = routeRole,
|
||||
environment = environment(),
|
||||
)
|
||||
writer.execute { runCatching { target.append(report) } }
|
||||
}
|
||||
|
||||
fun reports(context: Context): List<ReliabilityReport> {
|
||||
initialize(context)
|
||||
return store?.readAll().orEmpty()
|
||||
}
|
||||
|
||||
fun pendingCrash(context: Context): ReliabilityReport? =
|
||||
reports(context).lastOrNull { it.kind == ReliabilityKind.FatalCrash && it.pendingReview }
|
||||
|
||||
fun markReviewed(context: Context, reportId: String) {
|
||||
initialize(context)
|
||||
store?.markReviewed(reportId)
|
||||
}
|
||||
|
||||
fun import(context: Context, report: ReliabilityReport) {
|
||||
initialize(context)
|
||||
store?.append(report)
|
||||
}
|
||||
|
||||
fun environment(): ReliabilityEnvironment = ReliabilityEnvironment(
|
||||
versionName = BuildConfig.VERSION_NAME,
|
||||
versionCode = BuildConfig.VERSION_CODE,
|
||||
flavor = BuildConfig.FLAVOR,
|
||||
manufacturer = Build.MANUFACTURER.orEmpty().ifBlank { "?" },
|
||||
model = Build.MODEL.orEmpty().ifBlank { "?" },
|
||||
androidRelease = Build.VERSION.RELEASE.orEmpty().ifBlank { "?" },
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
)
|
||||
|
||||
private fun stackTraceText(throwable: Throwable): String =
|
||||
StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package com.hermesandroid.relay.reliability
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CancellationException
|
||||
|
||||
const val RELIABILITY_SCHEMA_VERSION = 1
|
||||
|
||||
@Serializable
|
||||
enum class ReliabilityKind {
|
||||
FatalCrash,
|
||||
AnrSignal,
|
||||
RecoverableProductError,
|
||||
Connectivity,
|
||||
Authentication,
|
||||
RateLimit,
|
||||
ServiceUnavailable,
|
||||
ExpectedCancellation,
|
||||
UserDenial,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ReliabilityOwner(val label: String) {
|
||||
Android("Android"),
|
||||
Dashboard("Dashboard"),
|
||||
Api("API"),
|
||||
Relay("Relay"),
|
||||
UpstreamGateway("Upstream Gateway"),
|
||||
Voice("Voice"),
|
||||
Unknown("Unknown"),
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ReliabilitySeverity { Info, Warning, Error, Fatal }
|
||||
|
||||
@Serializable
|
||||
data class ReliabilityEnvironment(
|
||||
val versionName: String,
|
||||
val versionCode: Int,
|
||||
val flavor: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val androidRelease: String,
|
||||
val sdkInt: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Allowlisted local reliability record. There are deliberately no fields for
|
||||
* prompts, messages, profile names, product session IDs, URLs, media, or paths.
|
||||
*/
|
||||
@Serializable
|
||||
data class ReliabilityReport(
|
||||
val schemaVersion: Int = RELIABILITY_SCHEMA_VERSION,
|
||||
val reportId: String,
|
||||
val appSessionId: String,
|
||||
val timeIso: String,
|
||||
val kind: ReliabilityKind,
|
||||
val owner: ReliabilityOwner,
|
||||
val severity: ReliabilitySeverity,
|
||||
val summary: String,
|
||||
val recovery: String,
|
||||
val reportRecommended: Boolean,
|
||||
val technicalDetail: String? = null,
|
||||
val routeRole: String? = null,
|
||||
val environment: ReliabilityEnvironment,
|
||||
val pendingReview: Boolean = false,
|
||||
) {
|
||||
fun shortTitle(): String = summary.lineSequence().firstOrNull().orEmpty().ifBlank {
|
||||
kind.name
|
||||
}.take(90)
|
||||
|
||||
fun versionLine(): String =
|
||||
"${environment.versionName} (code ${environment.versionCode}) ${environment.flavor}"
|
||||
|
||||
fun environmentBlock(): String = buildString {
|
||||
appendLine("- Hermes-Relay version/tag: ${environment.versionName} (code ${environment.versionCode})")
|
||||
appendLine(
|
||||
"- Install surface: " +
|
||||
if (environment.flavor.equals("sideload", ignoreCase = true)) "sideload APK" else "Google Play",
|
||||
)
|
||||
appendLine(
|
||||
"- Android device and OS: ${environment.manufacturer} ${environment.model} — " +
|
||||
"Android ${environment.androidRelease} (SDK ${environment.sdkInt})",
|
||||
)
|
||||
append("- Connection mode: ${routeRole ?: "unknown"}")
|
||||
}
|
||||
|
||||
/** Exact local review/copy/share payload. Redaction is repeated for legacy defense in depth. */
|
||||
fun toPlainText(): String = ReliabilityRedactor.redact(
|
||||
buildString {
|
||||
appendLine("Hermes-Relay support information")
|
||||
appendLine("Report: $reportId")
|
||||
appendLine("Session: $appSessionId")
|
||||
appendLine("Time: $timeIso")
|
||||
appendLine("Type: ${kind.name}")
|
||||
appendLine("Owner: ${owner.label}")
|
||||
appendLine("App: ${versionLine()}")
|
||||
appendLine(
|
||||
"Device: ${environment.manufacturer} ${environment.model} — " +
|
||||
"Android ${environment.androidRelease} (SDK ${environment.sdkInt})",
|
||||
)
|
||||
routeRole?.let { appendLine("Route: $it") }
|
||||
appendLine()
|
||||
appendLine("What happened: $summary")
|
||||
appendLine("Recovery: $recovery")
|
||||
technicalDetail?.let {
|
||||
appendLine()
|
||||
appendLine("Technical detail (redacted)")
|
||||
append(it)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun newId(prefix: String = "rpt"): String =
|
||||
"$prefix-${UUID.randomUUID().toString().replace("-", "").take(16)}"
|
||||
}
|
||||
}
|
||||
|
||||
/** Old `files/crash/last-crash.json` shape, retained only for one-way migration. */
|
||||
@Serializable
|
||||
data class LegacyCrashSnapshot(
|
||||
val timeIso: String,
|
||||
val versionName: String,
|
||||
val versionCode: Int,
|
||||
val flavor: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val androidRelease: String,
|
||||
val sdkInt: Int,
|
||||
val threadName: String,
|
||||
val exceptionSummary: String,
|
||||
val stackTrace: String,
|
||||
)
|
||||
|
||||
fun migrateLegacyCrash(
|
||||
old: LegacyCrashSnapshot,
|
||||
reportId: String = ReliabilityReport.newId(),
|
||||
appSessionId: String = ReliabilityReport.newId("legacy"),
|
||||
): ReliabilityReport = ReliabilityReport(
|
||||
reportId = reportId,
|
||||
appSessionId = appSessionId,
|
||||
timeIso = runCatching { Instant.parse(old.timeIso).toString() }.getOrDefault(old.timeIso),
|
||||
kind = ReliabilityKind.FatalCrash,
|
||||
owner = ReliabilityOwner.Android,
|
||||
severity = ReliabilitySeverity.Fatal,
|
||||
summary = ReliabilityRedactor.redact(old.exceptionSummary, 240),
|
||||
recovery = "The app restarted. Work already running on Hermes may still be active.",
|
||||
reportRecommended = true,
|
||||
technicalDetail = ReliabilityRedactor.redact("Thread: ${old.threadName}\n${old.stackTrace}"),
|
||||
environment = ReliabilityEnvironment(
|
||||
old.versionName, old.versionCode, old.flavor, old.manufacturer, old.model,
|
||||
old.androidRelease, old.sdkInt,
|
||||
),
|
||||
pendingReview = true,
|
||||
)
|
||||
|
||||
data class ReliabilityClassification(
|
||||
val kind: ReliabilityKind,
|
||||
val owner: ReliabilityOwner,
|
||||
val reportRecommended: Boolean,
|
||||
val shouldPersist: Boolean,
|
||||
)
|
||||
|
||||
object ReliabilityClassifier {
|
||||
fun classify(throwable: Throwable, context: String? = null): ReliabilityClassification {
|
||||
val message = throwable.message.orEmpty().lowercase()
|
||||
val owner = ownerForContext(context)
|
||||
return when {
|
||||
throwable is CancellationException -> ReliabilityClassification(
|
||||
ReliabilityKind.ExpectedCancellation, owner, reportRecommended = false, shouldPersist = false,
|
||||
)
|
||||
throwable is SecurityException && ("denied" in message || "permission" in message) ->
|
||||
ReliabilityClassification(
|
||||
ReliabilityKind.UserDenial, ReliabilityOwner.Android,
|
||||
reportRecommended = false, shouldPersist = false,
|
||||
)
|
||||
"429" in message || "rate limit" in message || "too many requests" in message ->
|
||||
ReliabilityClassification(
|
||||
ReliabilityKind.RateLimit, owner, reportRecommended = false, shouldPersist = true,
|
||||
)
|
||||
"401" in message || "403" in message || "unauthorized" in message || "forbidden" in message ->
|
||||
ReliabilityClassification(
|
||||
ReliabilityKind.Authentication, owner, reportRecommended = false, shouldPersist = true,
|
||||
)
|
||||
throwable is java.net.UnknownHostException ||
|
||||
throwable is java.net.ConnectException ||
|
||||
throwable is java.net.SocketTimeoutException ||
|
||||
"timeout" in message -> ReliabilityClassification(
|
||||
ReliabilityKind.Connectivity, owner, reportRecommended = false, shouldPersist = true,
|
||||
)
|
||||
"503" in message || "service unavailable" in message || "gateway_draining" in message ->
|
||||
ReliabilityClassification(
|
||||
ReliabilityKind.ServiceUnavailable, owner,
|
||||
reportRecommended = false, shouldPersist = true,
|
||||
)
|
||||
else -> ReliabilityClassification(
|
||||
ReliabilityKind.RecoverableProductError, owner,
|
||||
reportRecommended = true, shouldPersist = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun ownerForContext(context: String?): ReliabilityOwner = when (context?.lowercase()) {
|
||||
"dashboard", "manage", "dashboard_auth" -> ReliabilityOwner.Dashboard
|
||||
"gateway", "gateway_chat", "upstream_gateway" -> ReliabilityOwner.UpstreamGateway
|
||||
"transcribe", "synthesize", "voice_config", "record", "voice" -> ReliabilityOwner.Voice
|
||||
"pair", "save_and_test", "media_fetch", "relay" -> ReliabilityOwner.Relay
|
||||
"send_message", "load_sessions", "create_session", "api" -> ReliabilityOwner.Api
|
||||
"android", "permission", "ui" -> ReliabilityOwner.Android
|
||||
else -> ReliabilityOwner.Unknown
|
||||
}
|
||||
}
|
||||
|
||||
/** Local, deterministic redaction. It runs before persistence and again before export. */
|
||||
object ReliabilityRedactor {
|
||||
const val MAX_TECHNICAL_LENGTH = 8_000
|
||||
private const val HIDDEN = "[hidden]"
|
||||
|
||||
private val secretAssignment = Regex(
|
||||
"""(?i)\b(authorization|bearer|cookie|set-cookie|token|api[_-]?key|session[_-]?token|pairing[_-]?code|password|secret|oauth[_-]?code)\s*[:=]\s*((?:Bearer\s+)?[^\s,;]+)""",
|
||||
)
|
||||
private val sensitiveHeader = Regex("""(?im)^\s*(authorization|cookie|set-cookie)\s*:\s*.+$""")
|
||||
private val standaloneBearer = Regex("""(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+""")
|
||||
private val sensitivePayload = Regex(
|
||||
"""(?i)\b(prompt|message|content|transcript|reasoning|tool[_-]?(args|result)|profile[_-]?name)\s*[:=]\s*([^\r\n]+)""",
|
||||
)
|
||||
private val url = Regex("""(?i)\b(?:https?|wss?)://[^\s)\]}>,]+""")
|
||||
private val ipv4 = Regex("""(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?(?![\w.])""")
|
||||
private val uuid = Regex("""(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b""")
|
||||
private val email = Regex("""(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b""")
|
||||
private val namedHost = Regex("""(?i)\b(host|hostname)\s*[:=]\s*[^\s,;]+""")
|
||||
private val unresolvedHost = Regex("""(?i)(?:resolve|resolved|host)\s+[\"']([^\"']+)[\"']""")
|
||||
private val windowsPath = Regex("""(?i)\b[A-Z]:\\(?:[^\s\\]+\\)+[^\s]+""")
|
||||
private val unixPrivatePath = Regex("""(?i)(?:/home/|/Users/|/data/user/\d+/|/sdcard/)[^\s]+""")
|
||||
|
||||
fun redact(value: String, maxLength: Int = MAX_TECHNICAL_LENGTH): String {
|
||||
var result = value
|
||||
result = sensitiveHeader.replace(result) { "${it.groupValues[1]}: $HIDDEN" }
|
||||
result = secretAssignment.replace(result) { "${it.groupValues[1]}=$HIDDEN" }
|
||||
result = standaloneBearer.replace(result, "Bearer $HIDDEN")
|
||||
result = sensitivePayload.replace(result) { "${it.groupValues[1]}=$HIDDEN" }
|
||||
result = url.replace(result, "[url hidden]")
|
||||
result = ipv4.replace(result, "[host hidden]")
|
||||
result = uuid.replace(result, "[id hidden]")
|
||||
result = email.replace(result, "[email hidden]")
|
||||
result = namedHost.replace(result) { "${it.groupValues[1]}=$HIDDEN" }
|
||||
result = unresolvedHost.replace(result, "host \"$HIDDEN\"")
|
||||
result = windowsPath.replace(result, "[path hidden]")
|
||||
result = unixPrivatePath.replace(result, "[path hidden]")
|
||||
return if (result.length > maxLength) {
|
||||
result.take(maxLength) + "\n… (truncated)"
|
||||
} else {
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class ReliabilityEnvelope(
|
||||
val schemaVersion: Int = RELIABILITY_SCHEMA_VERSION,
|
||||
val reports: List<ReliabilityReport> = emptyList(),
|
||||
)
|
||||
|
||||
/** Pure file store so retention, bounds, and migration behavior are JVM-testable. */
|
||||
class ReliabilityStore(
|
||||
private val file: File,
|
||||
private val maxReports: Int = 20,
|
||||
private val retentionDays: Long = 14,
|
||||
) {
|
||||
private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }
|
||||
private val lock = Any()
|
||||
|
||||
fun readAll(now: Instant = Instant.now()): List<ReliabilityReport> = synchronized(lock) {
|
||||
val decoded = decode()
|
||||
val retained = prune(decoded, now)
|
||||
if (retained != decoded) write(retained)
|
||||
retained
|
||||
}
|
||||
|
||||
fun append(report: ReliabilityReport, now: Instant = Instant.now()) = synchronized(lock) {
|
||||
write(prune(decode() + sanitize(report), now))
|
||||
}
|
||||
|
||||
fun markReviewed(reportId: String, now: Instant = Instant.now()) = synchronized(lock) {
|
||||
write(
|
||||
prune(
|
||||
decode().map { if (it.reportId == reportId) it.copy(pendingReview = false) else it },
|
||||
now,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sanitize(report: ReliabilityReport): ReliabilityReport = report.copy(
|
||||
summary = ReliabilityRedactor.redact(report.summary, 240),
|
||||
recovery = ReliabilityRedactor.redact(report.recovery, 240),
|
||||
technicalDetail = report.technicalDetail?.let(ReliabilityRedactor::redact),
|
||||
routeRole = report.routeRole?.let { ReliabilityRedactor.redact(it, 40) },
|
||||
)
|
||||
|
||||
private fun prune(reports: List<ReliabilityReport>, now: Instant): List<ReliabilityReport> {
|
||||
val cutoff = now.minusSeconds(retentionDays * 24 * 60 * 60)
|
||||
return reports
|
||||
.distinctBy { it.reportId }
|
||||
.filter { report -> runCatching { Instant.parse(report.timeIso) >= cutoff }.getOrDefault(true) }
|
||||
.sortedBy { it.timeIso }
|
||||
.takeLast(maxReports.coerceAtLeast(1))
|
||||
}
|
||||
|
||||
private fun decode(): List<ReliabilityReport> = runCatching {
|
||||
if (!file.isFile) return emptyList()
|
||||
json.decodeFromString<ReliabilityEnvelope>(file.readText()).reports
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun write(reports: List<ReliabilityReport>) {
|
||||
file.parentFile?.mkdirs()
|
||||
val temp = File(file.parentFile, "${file.name}.tmp")
|
||||
temp.writeText(json.encodeToString(ReliabilityEnvelope(reports = reports)))
|
||||
runCatching {
|
||||
java.nio.file.Files.move(
|
||||
temp.toPath(),
|
||||
file.toPath(),
|
||||
java.nio.file.StandardCopyOption.ATOMIC_MOVE,
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
}.recoverCatching {
|
||||
java.nio.file.Files.move(
|
||||
temp.toPath(),
|
||||
file.toPath(),
|
||||
java.nio.file.StandardCopyOption.REPLACE_EXISTING,
|
||||
)
|
||||
}.getOrThrow()
|
||||
}
|
||||
}
|
||||
|
||||
object SupportBundleBuilder {
|
||||
const val MAX_REPORTS = 10
|
||||
|
||||
fun build(reports: List<ReliabilityReport>): String {
|
||||
val selected = reports.sortedByDescending { it.timeIso }.take(MAX_REPORTS)
|
||||
return ReliabilityRedactor.redact(
|
||||
buildString {
|
||||
appendLine("Hermes-Relay support bundle")
|
||||
appendLine("Local-only export · review before sharing")
|
||||
appendLine("Reports: ${selected.size}")
|
||||
selected.forEachIndexed { index, report ->
|
||||
appendLine()
|
||||
appendLine("===== Report ${index + 1} =====")
|
||||
append(report.toPlainText())
|
||||
appendLine()
|
||||
}
|
||||
},
|
||||
maxLength = 64_000,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -167,11 +167,12 @@ fun ChatInputBar(
|
||||
onModelOptionSelected: (ChatInputPickerOption) -> Unit = {},
|
||||
onModelPickerClick: (() -> Unit)? = null,
|
||||
effortControl: ChatInputPickerControl? = null,
|
||||
onEffortOptionSelected: (ChatInputPickerOption) -> Unit = {},
|
||||
onEffortPickerClick: (() -> Unit)? = null,
|
||||
topContent: (@Composable () -> Unit)? = null,
|
||||
topContentVisible: Boolean = topContent != null,
|
||||
suppressVoiceTrailing: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
surfaceModifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
// Keep the last caption around so the AnimatedVisibility exit doesn't
|
||||
@@ -255,7 +256,8 @@ fun ChatInputBar(
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||
.then(surfaceModifier),
|
||||
) {
|
||||
Column {
|
||||
if (topContent != null) {
|
||||
@@ -397,8 +399,9 @@ fun ChatInputBar(
|
||||
if (effortControl != null) {
|
||||
ChatInputPickerChip(
|
||||
control = effortControl,
|
||||
onSelect = onEffortOptionSelected,
|
||||
onSelect = {},
|
||||
modifier = Modifier.widthIn(max = 104.dp),
|
||||
onClickOverride = onEffortPickerClick,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -707,12 +707,14 @@ fun AgentInfoSheet(
|
||||
val personalityNames by chatViewModel.personalityNames.collectAsState()
|
||||
val defaultPersonality by chatViewModel.defaultPersonality.collectAsState()
|
||||
val selectedModel by chatViewModel.selectedModelOverride.collectAsState()
|
||||
val selectedProvider by chatViewModel.selectedProviderOverride.collectAsState()
|
||||
val serverModel by chatViewModel.serverModelName.collectAsState()
|
||||
val gatewayModel by chatViewModel.gatewayCurrentModel.collectAsState()
|
||||
val gatewayProvider by chatViewModel.gatewayCurrentProvider.collectAsState()
|
||||
val modelProviders by chatViewModel.modelProviders.collectAsState()
|
||||
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
|
||||
val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState()
|
||||
val reasoningCapabilityRevision by chatViewModel.reasoningCapabilityRevision.collectAsState()
|
||||
val selectedReasoning by chatViewModel.selectedReasoningEffort.collectAsState()
|
||||
val approvalMode by chatViewModel.approvalMode.collectAsState()
|
||||
val approvalCapability by chatViewModel.approvalModeCapability.collectAsState()
|
||||
@@ -742,6 +744,18 @@ fun AgentInfoSheet(
|
||||
connectionViewModel.connectionStore.activeConnectionId.collectAsState()
|
||||
val authState by connectionViewModel.authState.collectAsState()
|
||||
|
||||
val reasoningAvailability = remember(
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
gatewayModel,
|
||||
gatewayProvider,
|
||||
modelProviders,
|
||||
apiModelOptions,
|
||||
reasoningCapabilityRevision,
|
||||
) {
|
||||
chatViewModel.reasoningEffortAvailability()
|
||||
}
|
||||
|
||||
var selectedTab by remember { mutableStateOf(0) }
|
||||
var activePicker by remember { mutableStateOf<AgentPassportPicker?>(null) }
|
||||
var showIdentityEditor by remember { mutableStateOf(false) }
|
||||
@@ -1014,15 +1028,21 @@ fun AgentInfoSheet(
|
||||
enabled = !isStreaming,
|
||||
onClick = { activePicker = AgentPassportPicker.Model },
|
||||
)
|
||||
PassportDivider()
|
||||
PassportConfigRow(
|
||||
icon = Icons.Filled.Psychology,
|
||||
title = stringResource(R.string.chat_select_reasoning_effort),
|
||||
value = reasoningLabel(selectedReasoning),
|
||||
expanded = false,
|
||||
enabled = gatewayControlsAvailable && !isStreaming,
|
||||
onClick = { activePicker = AgentPassportPicker.Reasoning },
|
||||
)
|
||||
if (
|
||||
reasoningAvailability.supported != false &&
|
||||
reasoningAvailability.choices.isNotEmpty()
|
||||
) {
|
||||
PassportDivider()
|
||||
PassportConfigRow(
|
||||
icon = Icons.Filled.Psychology,
|
||||
title = stringResource(R.string.chat_select_reasoning_effort),
|
||||
value = selectedReasoning?.let { reasoningEffortLabel(it) }
|
||||
?: serverDefaultLabel,
|
||||
expanded = false,
|
||||
enabled = gatewayControlsAvailable && !isStreaming,
|
||||
onClick = { activePicker = AgentPassportPicker.Reasoning },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1120,7 +1140,7 @@ fun AgentInfoSheet(
|
||||
sessionProfileName = effectiveSessionProfileName,
|
||||
modelLabel = sessionModelLabel,
|
||||
providerLabel = sessionProviderLabel,
|
||||
reasoningLabel = selectedReasoning?.let(::reasoningLabel)
|
||||
reasoningLabel = selectedReasoning?.let { reasoningEffortLabel(it) }
|
||||
?: stringResource(R.string.conn_info_server_default),
|
||||
approvalMode = approvalMode,
|
||||
approvalCapability = approvalCapability,
|
||||
@@ -1232,29 +1252,38 @@ fun AgentInfoSheet(
|
||||
},
|
||||
onDismiss = { activePicker = null },
|
||||
)
|
||||
AgentPassportPicker.Reasoning -> OptionPickerSheet(
|
||||
title = stringResource(R.string.chat_select_reasoning_effort),
|
||||
options = listOf(
|
||||
"none" to stringResource(R.string.chat_reasoning_none),
|
||||
"minimal" to stringResource(R.string.chat_reasoning_minimal),
|
||||
"low" to stringResource(R.string.chat_reasoning_low),
|
||||
"medium" to stringResource(R.string.chat_reasoning_medium),
|
||||
"high" to stringResource(R.string.chat_reasoning_high),
|
||||
"xhigh" to "XHigh",
|
||||
).map { (value, label) ->
|
||||
AgentPassportPicker.Reasoning -> {
|
||||
val reasoningOptions = reasoningAvailability.choices.map { value ->
|
||||
ChatInputPickerOption(
|
||||
label = label,
|
||||
label = reasoningEffortLabel(value),
|
||||
value = value,
|
||||
selected = selectedReasoning == value,
|
||||
enabled = gatewayControlsAvailable && !isStreaming,
|
||||
enabled = reasoningAvailability.supported != false &&
|
||||
gatewayControlsAvailable && !isStreaming,
|
||||
)
|
||||
},
|
||||
onSelect = { option ->
|
||||
option.value?.let(chatViewModel::selectReasoningEffort)
|
||||
activePicker = null
|
||||
},
|
||||
onDismiss = { activePicker = null },
|
||||
)
|
||||
}
|
||||
OptionPickerSheet(
|
||||
title = stringResource(R.string.chat_select_reasoning_effort),
|
||||
subtitle = when {
|
||||
reasoningAvailability.exact &&
|
||||
selectedReasoning != null &&
|
||||
selectedReasoning !in reasoningAvailability.choices -> stringResource(
|
||||
R.string.reasoning_effort_current_outside_supported,
|
||||
reasoningEffortLabel(selectedReasoning),
|
||||
reasoningOptions.joinToString { it.label },
|
||||
)
|
||||
!reasoningAvailability.exact ->
|
||||
stringResource(R.string.reasoning_effort_standard_levels_notice)
|
||||
else -> null
|
||||
},
|
||||
options = reasoningOptions,
|
||||
onSelect = { option ->
|
||||
option.value?.let(chatViewModel::selectReasoningEffort)
|
||||
activePicker = null
|
||||
},
|
||||
onDismiss = { activePicker = null },
|
||||
)
|
||||
}
|
||||
null -> Unit
|
||||
}
|
||||
|
||||
@@ -2094,9 +2123,6 @@ private fun compactTokenCount(value: Int): String = when {
|
||||
else -> value.toString()
|
||||
}
|
||||
|
||||
private fun reasoningLabel(value: String?): String =
|
||||
value?.replaceFirstChar { it.uppercase() } ?: "Medium"
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LegacyAgentInfoSheet(
|
||||
|
||||
@@ -46,7 +46,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.util.CrashReport
|
||||
import com.hermesandroid.relay.reliability.ReliabilityReport
|
||||
import com.hermesandroid.relay.util.CrashReporter
|
||||
import com.hermesandroid.relay.util.IssueReport
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -57,16 +57,16 @@ import kotlinx.coroutines.withContext
|
||||
* crashed). Render it inside the app theme so the dialog picks up Material
|
||||
* colors — see RelayApp.
|
||||
*
|
||||
* Peeks the report on first composition (does NOT delete on read) and clears it
|
||||
* only when the user acknowledges it (Dismiss/Report). A report the user merely
|
||||
* Peeks the report on first composition (does not mark it reviewed on read) and
|
||||
* acknowledges it only on Dismiss/Report. A report the user merely
|
||||
* glanced at — or never reached because the app was backgrounded — therefore
|
||||
* survives relaunches instead of being lost after one view; once acknowledged
|
||||
* it's deleted and won't reappear.
|
||||
* survives relaunches instead of being lost after one view. Once acknowledged,
|
||||
* it remains in bounded Diagnostics history but does not interrupt startup again.
|
||||
*/
|
||||
@Composable
|
||||
fun CrashReportGate() {
|
||||
val context = LocalContext.current
|
||||
var report by remember { mutableStateOf<CrashReport?>(null) }
|
||||
var report by remember { mutableStateOf<ReliabilityReport?>(null) }
|
||||
var checked by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -79,19 +79,26 @@ fun CrashReportGate() {
|
||||
CrashReportDialog(
|
||||
report = pending,
|
||||
onDismiss = {
|
||||
// Acknowledged (Dismiss/Report) → delete so it won't reappear.
|
||||
// Acknowledged (Dismiss/Report) → retain as reviewed history without showing again.
|
||||
// Copy does NOT route through here, so the report stays available
|
||||
// across relaunches until the user actually dismisses or reports it.
|
||||
CrashReporter.clearPending(context)
|
||||
CrashReporter.clearPending(context, pending.reportId)
|
||||
report = null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
private fun CrashReportDialog(report: ReliabilityReport, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val reportText = remember(report) { report.toPlainText() }
|
||||
var showDetails by remember(report.reportId) { mutableStateOf(false) }
|
||||
val copiedMessage = stringResource(R.string.crash_toast_copied)
|
||||
val noShareMessage = stringResource(R.string.crash_toast_no_share)
|
||||
val shareTitle = stringResource(R.string.crash_share_title)
|
||||
val reportSubject = stringResource(R.string.crash_share_subject, report.shortTitle())
|
||||
val openedMessage = stringResource(R.string.crash_toast_opened)
|
||||
val noBrowserMessage = stringResource(R.string.crash_toast_no_browser)
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -126,25 +133,34 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 300.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = reportText,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.crash_privacy),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
|
||||
if (showDetails) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 300.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = reportText,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,45 +173,45 @@ private fun CrashReportDialog(report: CrashReport, onDismiss: () -> Unit) {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, "Crash report copied")
|
||||
},
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
if (!showDetails) {
|
||||
Button(onClick = { showDetails = true }) {
|
||||
Text(stringResource(R.string.crash_review))
|
||||
}
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, copiedMessage)
|
||||
},
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
// Universal, GitHub-free path: hand the full report to the
|
||||
// system share sheet (email, chat apps, notes, Drive…). The
|
||||
// user picks the destination, so nothing leaves the device
|
||||
// until they choose to send it — same privacy posture as Copy.
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val shared = IssueReport.share(
|
||||
context,
|
||||
"Hermes-Relay crash report — ${report.shortTitle()}",
|
||||
reportText,
|
||||
chooserTitle = "Share crash report",
|
||||
)
|
||||
if (!shared) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val shared = IssueReport.share(
|
||||
context,
|
||||
reportSubject,
|
||||
reportText,
|
||||
chooserTitle = shareTitle,
|
||||
)
|
||||
if (!shared) {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, noShareMessage)
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_share)) }
|
||||
Button(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
toast(context, "Report copied — no app found to share to")
|
||||
}
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_share)) }
|
||||
Button(
|
||||
onClick = {
|
||||
// Copy the FULL report first; the URL only carries the
|
||||
// head of the trace, so the user can paste the rest.
|
||||
IssueReport.copyToClipboard(context, reportText)
|
||||
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
|
||||
toast(
|
||||
context,
|
||||
if (opened) "Full report copied — paste into the issue if it's truncated"
|
||||
else "Report copied — no browser found to open GitHub",
|
||||
)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_report)) }
|
||||
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
|
||||
toast(context, if (opened) openedMessage else noBrowserMessage)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_report)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-2
@@ -106,9 +106,27 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
MetaRow("When", DateFormat.format("yyyy-MM-dd HH:mm:ss", entry.timestampMs).toString())
|
||||
MetaRow("Severity", severityName)
|
||||
MetaRow("Category", entry.category.label)
|
||||
entry.operation?.let { MetaRow("Operation", it) }
|
||||
entry.endpointRole?.let { MetaRow("Route", it) }
|
||||
entry.url?.let { MetaRow("URL", it) }
|
||||
entry.configuredUrl?.let { MetaRow("Configured URL", it) }
|
||||
entry.requestUrl?.let { MetaRow("Request", it) }
|
||||
if (entry.configuredUrl == null && entry.requestUrl == null) {
|
||||
entry.url?.let { MetaRow("URL", it) }
|
||||
}
|
||||
entry.elapsedMs?.let { MetaRow("Elapsed", "${it}ms") }
|
||||
entry.suggestion?.let { suggestion ->
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = "Suggested next step",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = suggestion,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(14.dp))
|
||||
val body = entry.stacktrace ?: entry.detail
|
||||
@@ -269,9 +287,15 @@ private fun DiagnosticLogEntry.toPlainText(): String = buildString {
|
||||
appendLine("Severity: ${severity.name}")
|
||||
appendLine("Time: ${DateFormat.format("yyyy-MM-dd HH:mm:ss", timestampMs)}")
|
||||
appendLine("App: ${BuildConfig.VERSION_NAME} (code ${BuildConfig.VERSION_CODE}) ${BuildConfig.FLAVOR}")
|
||||
operation?.let { appendLine("Operation: $it") }
|
||||
endpointRole?.let { appendLine("Route: $it") }
|
||||
url?.let { appendLine("URL: $it") }
|
||||
configuredUrl?.let { appendLine("Configured URL: $it") }
|
||||
requestUrl?.let { appendLine("Request: $it") }
|
||||
if (configuredUrl == null && requestUrl == null) {
|
||||
url?.let { appendLine("URL: $it") }
|
||||
}
|
||||
elapsedMs?.let { appendLine("Elapsed: ${it}ms") }
|
||||
suggestion?.let { appendLine("Suggested next step: $it") }
|
||||
detail?.let {
|
||||
appendLine()
|
||||
appendLine("Detail:")
|
||||
|
||||
@@ -218,8 +218,10 @@ private fun severityColor(severity: DiagnosticSeverity): Color = when (severity)
|
||||
private fun DiagnosticLogEntry.detailLine(): String? {
|
||||
val pieces = listOfNotNull(
|
||||
detail,
|
||||
suggestion,
|
||||
operation,
|
||||
endpointRole?.let { "route=$it" },
|
||||
url,
|
||||
primaryUrl,
|
||||
elapsedMs?.let { "${it}ms" },
|
||||
)
|
||||
return pieces.joinToString(" - ").takeIf { it.isNotBlank() }
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
@@ -54,6 +55,7 @@ import com.hermesandroid.relay.data.PetBehaviorPreferences
|
||||
import com.hermesandroid.relay.data.PetTemperament
|
||||
import com.hermesandroid.relay.ui.components.avatar.AgentAvatar
|
||||
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
|
||||
import com.hermesandroid.relay.ui.components.avatar.LocalPetGroundOpaqueBottom
|
||||
import com.hermesandroid.relay.ui.components.avatar.PetLocomotion
|
||||
import com.hermesandroid.relay.ui.components.pet.LocalPetSafeAreaRegistry
|
||||
import com.hermesandroid.relay.ui.components.pet.PetLayoutDirection
|
||||
@@ -106,6 +108,7 @@ import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
internal const val FLOATING_PET_COMPACT_HEIGHT_DP = 700
|
||||
internal const val FLOATING_PET_SUPPORTED_RAIL_CLEARANCE_DP = 0f
|
||||
internal const val CHAT_PET_WALK_REGION = "chat-composer-perch"
|
||||
internal const val CHAT_PET_MESSAGE_PERCH_PREFIX = "chat-message-perch:"
|
||||
internal const val CHAT_PET_ASSISTANT_MESSAGE_PERCH_PREFIX = "${CHAT_PET_MESSAGE_PERCH_PREFIX}assistant:"
|
||||
@@ -220,6 +223,18 @@ internal fun floatingPetAcceptsPointerInput(
|
||||
surfaceScrolling: Boolean,
|
||||
): Boolean = positioned && !surfaceScrolling
|
||||
|
||||
/** The overlay's pre-measure zero size is not a valid coordinate space. */
|
||||
internal fun shouldInitializeFloatingPet(
|
||||
positioned: Boolean,
|
||||
viewportWidth: Int,
|
||||
viewportHeight: Int,
|
||||
terrainReady: Boolean = true,
|
||||
): Boolean = !positioned && viewportWidth > 0 && viewportHeight > 0 && terrainReady
|
||||
|
||||
/** Collision geometry must contain both the pointer target and rendered art. */
|
||||
internal fun floatingPetCollisionSizePx(targetSizePx: Float, visualSizePx: Float): Float =
|
||||
maxOf(targetSizePx, visualSizePx)
|
||||
|
||||
internal fun floatingPetRoamDelayMs(
|
||||
hasMoved: Boolean,
|
||||
roamIntervalMs: Long = PET_ROAM_REPEAT_DELAY_MS,
|
||||
@@ -490,12 +505,17 @@ fun FloatingPetCompanion(
|
||||
val visualSize = dimensions.visualSizeDp.dp
|
||||
val targetSizePx = with(density) { targetSize.toPx() }
|
||||
val visualSizePx = with(density) { visualSize.toPx() }
|
||||
val collisionSizePx = floatingPetCollisionSizePx(targetSizePx, visualSizePx)
|
||||
val collisionSize = with(density) { collisionSizePx.toDp() }
|
||||
val heldLiftPx = with(density) { 6.dp.toPx() }
|
||||
val scrollReactionLiftPx = with(density) { PET_SCROLL_REACTION_LIFT_DP.dp.toPx() }
|
||||
val maximumDirectHopPx = with(density) { PET_MAX_DIRECT_HOP_DP.dp.toPx() }
|
||||
val maximumMessageHopPx = with(density) { PET_MAX_CLEAR_MESSAGE_HOP_DP.dp.toPx() }
|
||||
val safeMarginPx = with(density) { 12.dp.toPx() }
|
||||
val perchClearancePx = with(density) { 6.dp.toPx() }
|
||||
// A supported collision box sits directly on its measured rail. Obstacle
|
||||
// clearance remains part of [footprint]; adding it again here makes the
|
||||
// visible pet hover above the surface.
|
||||
val perchClearancePx = with(density) { FLOATING_PET_SUPPORTED_RAIL_CLEARANCE_DP.dp.toPx() }
|
||||
val topClearancePx = with(density) { 76.dp.toPx() }
|
||||
val bottomClearancePx = with(density) { (if (compact) 84.dp else 104.dp).toPx() }
|
||||
val radius = targetSizePx / 2f
|
||||
@@ -514,8 +534,8 @@ fun FloatingPetCompanion(
|
||||
PetSafeBounds(left, top, right, bottom)
|
||||
}
|
||||
val registry = LocalPetSafeAreaRegistry.current
|
||||
val footprint = remember(targetSizePx, safeMarginPx) {
|
||||
PetFootprint(targetSizePx, targetSizePx, safeMarginPx / 2f)
|
||||
val footprint = remember(collisionSizePx, safeMarginPx) {
|
||||
PetFootprint(collisionSizePx, collisionSizePx, safeMarginPx / 2f)
|
||||
}
|
||||
// Bubble-edge hops are visual traversal, not persistent placement. Keep
|
||||
// the larger accessible target for controls and viewport containment while
|
||||
@@ -1583,8 +1603,26 @@ fun FloatingPetCompanion(
|
||||
return true
|
||||
}
|
||||
|
||||
LaunchedEffect(pet.id, homePoint) {
|
||||
if (!positioned) {
|
||||
// Chat roaming owns a measured composer rail. Publishing before that rail
|
||||
// exists lets initialization race route registration and can strand the
|
||||
// pet on transient fallback geometry until direct manipulation.
|
||||
val initialTerrainReady = route != "chat" || !roamingEnabled || !roamingAllowed ||
|
||||
composerRails.isNotEmpty()
|
||||
LaunchedEffect(
|
||||
pet.id,
|
||||
homePoint,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
initialTerrainReady,
|
||||
) {
|
||||
if (
|
||||
shouldInitializeFloatingPet(
|
||||
positioned = positioned,
|
||||
viewportWidth = viewportWidth,
|
||||
viewportHeight = viewportHeight,
|
||||
terrainReady = initialTerrainReady,
|
||||
)
|
||||
) {
|
||||
x.snapTo(homePoint.x)
|
||||
y.snapTo(homePoint.y)
|
||||
positioned = true
|
||||
@@ -2211,11 +2249,14 @@ fun FloatingPetCompanion(
|
||||
.offset {
|
||||
val displayed = draggedPoint ?: PetPoint(x.value, y.value)
|
||||
IntOffset(
|
||||
(displayed.x - targetSizePx / 2f).roundToInt(),
|
||||
(displayed.y - targetSizePx / 2f).roundToInt(),
|
||||
(displayed.x - collisionSizePx / 2f).roundToInt(),
|
||||
(displayed.y - collisionSizePx / 2f).roundToInt(),
|
||||
)
|
||||
}
|
||||
.size(targetSize)
|
||||
// The host, route footprint, and visible art share one center.
|
||||
// A smaller pointer-only host shifts bottom-aligned art upward
|
||||
// whenever the authored visual is larger than its touch target.
|
||||
.size(collisionSize)
|
||||
.alpha(if (positioned) 1f else 0f)
|
||||
.graphicsLayer {
|
||||
val scale = 1f + heldProgress * 0.10f
|
||||
@@ -2354,22 +2395,26 @@ fun FloatingPetCompanion(
|
||||
),
|
||||
)
|
||||
key(pet.id) {
|
||||
pet.Render(
|
||||
state = state.copy(
|
||||
petLocomotion = presentedPetLocomotion(
|
||||
dragging = dragging,
|
||||
dropping = pendingDrop != null,
|
||||
agentState = state.state,
|
||||
movement = locomotion,
|
||||
CompositionLocalProvider(LocalPetGroundOpaqueBottom provides true) {
|
||||
pet.Render(
|
||||
state = state.copy(
|
||||
petLocomotion = presentedPetLocomotion(
|
||||
dragging = dragging,
|
||||
dropping = pendingDrop != null,
|
||||
agentState = state.state,
|
||||
movement = locomotion,
|
||||
),
|
||||
paused = shouldPauseFloatingPet(
|
||||
alreadyPaused = state.paused || !accessibleMotion.osAnimations ||
|
||||
accessibleMotion.touchExploration,
|
||||
animationEnabled = animationEnabled,
|
||||
),
|
||||
),
|
||||
paused = shouldPauseFloatingPet(
|
||||
alreadyPaused = state.paused || !accessibleMotion.osAnimations ||
|
||||
accessibleMotion.touchExploration,
|
||||
animationEnabled = animationEnabled,
|
||||
),
|
||||
),
|
||||
modifier = Modifier.size(visualSize),
|
||||
)
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.size(visualSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
|
||||
@@ -58,6 +58,7 @@ import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
@@ -748,16 +749,21 @@ fun MessageBubble(
|
||||
// burst of fragments doesn't stack three near-touching time labels.
|
||||
// Grouping breaks on a >5min gap (ChatScreen), so every pause still
|
||||
// surfaces its own time. Alpha floored at 0.6 for 11sp contrast.
|
||||
// Keep the live bubble's footer structurally quiet. Showing a
|
||||
// timestamp while text is still growing makes it chase every
|
||||
// token and exaggerates any single-frame layout lag. Reveal it
|
||||
// once the message settles into its final layout.
|
||||
if (isLastInGroup && !message.isStreaming) {
|
||||
// Reserve the footer from the first streaming frame so
|
||||
// completion is a color-only transition and cannot resize the
|
||||
// row. Hide the reserved timestamp from accessibility until it
|
||||
// becomes visible.
|
||||
if (isLastInGroup) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = timeFormat.format(Date(message.timestamp)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
|
||||
modifier = if (message.isStreaming) {
|
||||
Modifier.clearAndSetSemantics { }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.network.upstream.ReasoningEfforts
|
||||
|
||||
/** One localized label source shared by the composer and Agent Passport. */
|
||||
@Composable
|
||||
fun reasoningEffortLabel(value: String?): String = when (ReasoningEfforts.normalize(value)) {
|
||||
"none" -> stringResource(R.string.chat_reasoning_none)
|
||||
"minimal" -> stringResource(R.string.chat_reasoning_minimal)
|
||||
"low" -> stringResource(R.string.chat_reasoning_low)
|
||||
"medium" -> stringResource(R.string.chat_reasoning_medium)
|
||||
"high" -> stringResource(R.string.chat_reasoning_high)
|
||||
"xhigh" -> stringResource(R.string.chat_reasoning_xhigh)
|
||||
"max" -> stringResource(R.string.chat_reasoning_max)
|
||||
"ultra" -> stringResource(R.string.chat_reasoning_ultra)
|
||||
else -> error("ReasoningEfforts.normalize returned a non-canonical value")
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.SweepGradient
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -57,13 +65,23 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLocale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -77,6 +95,8 @@ private enum class SessionDrawerFilter {
|
||||
Archive,
|
||||
}
|
||||
|
||||
internal const val SESSION_DRAWER_LIST_TAG = "session-drawer-list"
|
||||
|
||||
@Composable
|
||||
fun SessionDrawerContent(
|
||||
sessions: List<ChatSession>,
|
||||
@@ -85,6 +105,8 @@ fun SessionDrawerContent(
|
||||
scopeSubtitle: String? = null,
|
||||
isLoading: Boolean = false,
|
||||
isOpen: Boolean = true,
|
||||
activityStates: Map<String, SessionActivityState> = emptyMap(),
|
||||
animationEnabled: Boolean = true,
|
||||
autoTitlesSupported: Boolean = true,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
onNewChat: () -> Unit,
|
||||
@@ -115,11 +137,11 @@ fun SessionDrawerContent(
|
||||
var sourceFilterOpen by remember { mutableStateOf(false) }
|
||||
var deleteDialogSession by remember { mutableStateOf<ChatSession?>(null) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
var searchExpanded by remember { mutableStateOf(false) }
|
||||
var filter by remember { mutableStateOf(SessionDrawerFilter.All) }
|
||||
var pinnedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var archivedSessionIds by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val listState = rememberLazyListState()
|
||||
var scrollToTopPending by remember { mutableStateOf(false) }
|
||||
val trimmedQuery = query.trim()
|
||||
// Threads affordance shows when the capability is active OR there's already at least one
|
||||
// agent Thread (source=phone) in the list. If the filter is on Threads but they've
|
||||
@@ -174,27 +196,12 @@ fun SessionDrawerContent(
|
||||
.toList()
|
||||
val topVisibleSessionId = visibleSessions.firstOrNull()?.sessionId
|
||||
|
||||
LaunchedEffect(isOpen) {
|
||||
scrollToTopPending = isOpen
|
||||
if (isOpen && visibleSessions.isNotEmpty()) {
|
||||
listState.scrollToItem(0)
|
||||
scrollToTopPending = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(filter, trimmedQuery) {
|
||||
if (isOpen && visibleSessions.isNotEmpty()) {
|
||||
listState.scrollToItem(0)
|
||||
scrollToTopPending = false
|
||||
} else if (isOpen) {
|
||||
scrollToTopPending = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isOpen, topVisibleSessionId, visibleSessions.size) {
|
||||
if (isOpen && scrollToTopPending && visibleSessions.isNotEmpty()) {
|
||||
listState.scrollToItem(0)
|
||||
scrollToTopPending = false
|
||||
LaunchedEffect(isOpen, activeFilter, trimmedQuery, topVisibleSessionId) {
|
||||
if (isOpen && topVisibleSessionId != null) {
|
||||
// A drawer-open refresh can reorder rows after the initial scroll.
|
||||
// Override LazyColumn's normal key anchoring so the new leading
|
||||
// session is visible instead of being retained above the viewport.
|
||||
listState.requestScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,6 +305,21 @@ fun SessionDrawerContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
onClick = { searchExpanded = !searchExpanded },
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Search,
|
||||
contentDescription = stringResource(R.string.drawer_search_sessions),
|
||||
tint = if (searchExpanded || query.isNotBlank()) {
|
||||
RelayRefresh.Relay
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
// Manual re-pull: the server titles a session asynchronously after
|
||||
// the first turn (and never pushes a rename), so a refresh is the
|
||||
// way to pick up a title the auto-reconcile window missed.
|
||||
@@ -335,17 +357,19 @@ fun SessionDrawerContent(
|
||||
Text(stringResource(R.string.drawer_new_chat))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Search, contentDescription = null)
|
||||
},
|
||||
placeholder = { Text(stringResource(R.string.drawer_search_placeholder)) },
|
||||
)
|
||||
if (searchExpanded || query.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Search, contentDescription = null)
|
||||
},
|
||||
placeholder = { Text(stringResource(R.string.drawer_search_placeholder)) },
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
@@ -462,11 +486,16 @@ fun SessionDrawerContent(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(state = listState) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.testTag(SESSION_DRAWER_LIST_TAG),
|
||||
) {
|
||||
items(visibleSessions, key = { it.sessionId }) { session ->
|
||||
SessionItem(
|
||||
session = session,
|
||||
isActive = session.sessionId == currentSessionId,
|
||||
activityState = activityStates[session.sessionId],
|
||||
animationEnabled = animationEnabled,
|
||||
pinned = session.sessionId in pinnedSessionIds,
|
||||
archived = session.sessionId in archivedSessionIds,
|
||||
onClick = { onSelectSession(session.sessionId) },
|
||||
@@ -590,6 +619,8 @@ fun SessionDrawerContent(
|
||||
private fun SessionItem(
|
||||
session: ChatSession,
|
||||
isActive: Boolean,
|
||||
activityState: SessionActivityState?,
|
||||
animationEnabled: Boolean,
|
||||
pinned: Boolean,
|
||||
archived: Boolean,
|
||||
onClick: () -> Unit,
|
||||
@@ -603,6 +634,12 @@ private fun SessionItem(
|
||||
val locale = LocalLocale.current.platformLocale
|
||||
val context = LocalContext.current
|
||||
val untitledLabel = stringResource(R.string.drawer_untitled)
|
||||
val activityLabel = when (activityState) {
|
||||
SessionActivityState.Working -> stringResource(R.string.drawer_activity_working)
|
||||
SessionActivityState.NeedsInput -> stringResource(R.string.drawer_activity_needs_input)
|
||||
null -> null
|
||||
}
|
||||
val motion = rememberAccessibleMotionState()
|
||||
val backgroundColor = if (isActive) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
@@ -613,6 +650,13 @@ private fun SessionItem(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor)
|
||||
.sessionActivityBorder(
|
||||
state = activityState,
|
||||
animated = animationEnabled && motion.osAnimations && !motion.touchExploration,
|
||||
)
|
||||
.semantics {
|
||||
activityLabel?.let { stateDescription = it }
|
||||
}
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
@@ -637,6 +681,17 @@ private fun SessionItem(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (activityState != null && activityLabel != null) {
|
||||
SessionActivityIndicator(activityState, activityLabel)
|
||||
}
|
||||
if (pinned) {
|
||||
Icon(
|
||||
Icons.Filled.Star,
|
||||
contentDescription = null,
|
||||
tint = RelayRefresh.Amber,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
}
|
||||
// Agent Thread tag — the clean spool + "Thread", so a source=phone
|
||||
// conversation reads as its own lane in the unified session list (ADR 12).
|
||||
if (isThreadSource(session.source)) {
|
||||
@@ -687,18 +742,6 @@ private fun SessionItem(
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onTogglePinned,
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Star,
|
||||
contentDescription = if (pinned) stringResource(R.string.drawer_unpin_session) else stringResource(R.string.drawer_pin_session),
|
||||
tint = if (pinned) RelayRefresh.Amber else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(19.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { menuOpen = true },
|
||||
@@ -715,6 +758,32 @@ private fun SessionItem(
|
||||
expanded = menuOpen,
|
||||
onDismissRequest = { menuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
if (pinned) {
|
||||
stringResource(R.string.drawer_unpin_session)
|
||||
} else {
|
||||
stringResource(R.string.drawer_pin_session)
|
||||
}
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Filled.Star,
|
||||
contentDescription = null,
|
||||
tint = if (pinned) {
|
||||
RelayRefresh.Amber
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
onTogglePinned()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.chat_copy_session_id)) },
|
||||
leadingIcon = {
|
||||
@@ -777,6 +846,147 @@ private fun SessionItem(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionActivityIndicator(state: SessionActivityState, label: String) {
|
||||
val color = when (state) {
|
||||
SessionActivityState.Working -> RelayRefresh.Relay
|
||||
SessionActivityState.NeedsInput -> RelayRefresh.Amber
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(7.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(color),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = relayMetadataStyle(),
|
||||
color = color,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Modifier.sessionActivityBorder(
|
||||
state: SessionActivityState?,
|
||||
animated: Boolean,
|
||||
): Modifier {
|
||||
if (state == null) return this
|
||||
val color = when (state) {
|
||||
SessionActivityState.Working -> RelayRefresh.Relay
|
||||
SessionActivityState.NeedsInput -> RelayRefresh.Amber
|
||||
}
|
||||
val shouldRotate = animated && state == SessionActivityState.Working
|
||||
val phase = if (shouldRotate) {
|
||||
val transition = rememberInfiniteTransition(label = "session-activity")
|
||||
transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 2_230, easing = LinearEasing),
|
||||
),
|
||||
label = "session-activity-glow",
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
return drawWithCache {
|
||||
val coreWidth = 1.25.dp.toPx()
|
||||
val coreInset = coreWidth / 2f
|
||||
val haloWidth = 5.dp.toPx()
|
||||
val haloInset = haloWidth / 2f
|
||||
val radius = 12.dp.toPx()
|
||||
// Matching transparent endpoints make phase 0 and 1 identical, so the
|
||||
// full shader rotation loops without the dash-pattern reset of the old ring.
|
||||
val animatedShader = SweepGradient(
|
||||
size.width / 2f,
|
||||
size.height / 2f,
|
||||
intArrayOf(
|
||||
color.copy(alpha = 0f).toArgb(),
|
||||
color.copy(alpha = 0f).toArgb(),
|
||||
color.copy(alpha = 0.16f).toArgb(),
|
||||
color.copy(alpha = 0.72f).toArgb(),
|
||||
color.toArgb(),
|
||||
color.copy(alpha = 0.34f).toArgb(),
|
||||
color.copy(alpha = 0f).toArgb(),
|
||||
),
|
||||
floatArrayOf(0f, 0.52f, 0.66f, 0.78f, 0.84f, 0.92f, 1f),
|
||||
)
|
||||
val shaderMatrix = Matrix()
|
||||
val haloPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = haloWidth
|
||||
alpha = 88
|
||||
shader = animatedShader
|
||||
}
|
||||
val middlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 2.75.dp.toPx()
|
||||
alpha = 150
|
||||
shader = animatedShader
|
||||
}
|
||||
val corePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = coreWidth
|
||||
shader = animatedShader
|
||||
}
|
||||
val haloRect = RectF(
|
||||
haloInset,
|
||||
haloInset,
|
||||
size.width - haloInset,
|
||||
size.height - haloInset,
|
||||
)
|
||||
val middleInset = middlePaint.strokeWidth / 2f
|
||||
val middleRect = RectF(
|
||||
middleInset,
|
||||
middleInset,
|
||||
size.width - middleInset,
|
||||
size.height - middleInset,
|
||||
)
|
||||
val coreRect = RectF(
|
||||
coreInset,
|
||||
coreInset,
|
||||
size.width - coreInset,
|
||||
size.height - coreInset,
|
||||
)
|
||||
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
if (phase != null) {
|
||||
shaderMatrix.setRotate(
|
||||
phase.value * 360f - 90f,
|
||||
size.width / 2f,
|
||||
size.height / 2f,
|
||||
)
|
||||
animatedShader.setLocalMatrix(shaderMatrix)
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
drawRoundRect(haloRect, radius, radius, haloPaint)
|
||||
drawRoundRect(middleRect, radius, radius, middlePaint)
|
||||
drawRoundRect(coreRect, radius, radius, corePaint)
|
||||
}
|
||||
} else {
|
||||
drawRoundRect(
|
||||
brush = Brush.linearGradient(
|
||||
listOf(color.copy(alpha = 0.72f), color.copy(alpha = 0.28f))
|
||||
),
|
||||
topLeft = androidx.compose.ui.geometry.Offset(coreInset, coreInset),
|
||||
size = androidx.compose.ui.geometry.Size(
|
||||
width = size.width - coreWidth,
|
||||
height = size.height - coreWidth,
|
||||
),
|
||||
cornerRadius = CornerRadius(radius),
|
||||
style = Stroke(width = coreWidth),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sessionTimestampText(session: ChatSession, locale: Locale, context: Context): String? {
|
||||
val timestamp = session.activityTimestamp
|
||||
if (timestamp <= 0L) return null
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.reliability.ReliabilityReport
|
||||
import com.hermesandroid.relay.reliability.SupportBundleBuilder
|
||||
import com.hermesandroid.relay.util.IssueReport
|
||||
|
||||
data class SupportReviewState(
|
||||
val reportCount: Int,
|
||||
val text: String,
|
||||
val shareEnabled: Boolean,
|
||||
)
|
||||
|
||||
internal fun buildSupportReviewState(reports: List<ReliabilityReport>): SupportReviewState =
|
||||
SupportReviewState(
|
||||
reportCount = reports.takeLast(SupportBundleBuilder.MAX_REPORTS).size,
|
||||
text = SupportBundleBuilder.build(reports),
|
||||
shareEnabled = reports.isNotEmpty(),
|
||||
)
|
||||
|
||||
/** Exact review surface for the local text handed to clipboard/share. */
|
||||
@Composable
|
||||
fun SupportBundleDialog(state: SupportReviewState, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val copied = stringResource(R.string.support_bundle_copied)
|
||||
val noShare = stringResource(R.string.support_bundle_no_share)
|
||||
val chooser = stringResource(R.string.support_bundle_share_title)
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.94f),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(stringResource(R.string.support_bundle_title), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.support_bundle_privacy, state.reportCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 160.dp, max = 420.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
|
||||
RoundedCornerShape(12.dp),
|
||||
),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = state.text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
|
||||
OutlinedButton(
|
||||
enabled = state.shareEnabled,
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, state.text)
|
||||
Toast.makeText(context, copied, Toast.LENGTH_LONG).show()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
Button(
|
||||
enabled = state.shareEnabled,
|
||||
onClick = {
|
||||
if (!IssueReport.share(
|
||||
context,
|
||||
subject = chooser,
|
||||
text = state.text,
|
||||
chooserTitle = chooser,
|
||||
)
|
||||
) {
|
||||
IssueReport.copyToClipboard(context, state.text)
|
||||
Toast.makeText(context, noShare, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(R.string.common_share)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,27 +194,29 @@ fun VoiceModeOverlay(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(if (focusMode) surface.copy(alpha = 0.96f) else Color.Transparent)
|
||||
// Click-through fix: in focus mode the overlay is a true modal.
|
||||
// Consume any pointer event a child (mic button, transcript,
|
||||
// chips, pill) didn't handle so stray taps/swipes don't fall
|
||||
// through to the chat + session drawer behind it. Children run on
|
||||
// the same Main pass leaf-first, so this only catches the gaps.
|
||||
// In Conversation the overlay is intentionally transparent and the
|
||||
// chat stays interactive, so no scrim is installed.
|
||||
.then(
|
||||
if (focusMode) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
) {
|
||||
if (focusMode) {
|
||||
// Focus is modal, but its click-through guard must be a sibling
|
||||
// behind the controls. A consuming pointerInput on the root is an
|
||||
// ancestor of every button and cancels their gesture detectors on
|
||||
// later pointer passes, leaving ripples without firing callbacks.
|
||||
// This scrim wins hit-testing only where no foreground control did,
|
||||
// so blank-space gestures stay off the chat underneath while mic,
|
||||
// close, pill, and expanded-panel controls remain interactive.
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.testTag("voiceFocusInputScrim")
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent().changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
},
|
||||
)
|
||||
) {
|
||||
}
|
||||
|
||||
if (focusMode) {
|
||||
VoiceSessionPill(
|
||||
uiState = uiState,
|
||||
@@ -427,7 +429,7 @@ fun VoiceModeOverlay(
|
||||
)
|
||||
}
|
||||
if (pendingTranscriptText != null) {
|
||||
item(key = "pending-voice-transcript") {
|
||||
item(key = "aux:pending-voice-transcript") {
|
||||
CompactTranscriptRow(
|
||||
message = ChatMessage(
|
||||
id = "pending-voice-transcript",
|
||||
@@ -741,7 +743,8 @@ internal fun pendingVoiceTranscriptText(
|
||||
return if (alreadyRendered) null else transcribed
|
||||
}
|
||||
|
||||
internal fun voiceTranscriptItemKey(message: ChatMessage): String = message.uiKey
|
||||
/** Message rows and Voice-only auxiliary rows occupy disjoint key namespaces. */
|
||||
internal fun voiceTranscriptItemKey(message: ChatMessage): String = "message:${message.uiKey}"
|
||||
|
||||
/**
|
||||
* Conversation-mode voice controls that live inside the chat composer.
|
||||
|
||||
@@ -143,3 +143,11 @@ val LocalPetPlaybackSpeed = staticCompositionLocalOf { 1f }
|
||||
* on; the sphere ignores it.
|
||||
*/
|
||||
val LocalPetStabilize = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* Grounds decoded pet art on the bottom of its render canvas. The floating
|
||||
* companion enables this at its host boundary so transparent atlas padding
|
||||
* cannot make a supported pet appear to hover. Pickers, previews, and message
|
||||
* avatars retain their centered rendering.
|
||||
*/
|
||||
val LocalPetGroundOpaqueBottom = staticCompositionLocalOf { false }
|
||||
|
||||
@@ -214,10 +214,10 @@ class PetAvatar(
|
||||
private val decodedSheets = LinkedHashMap<String, DecodedSheet>(4, 0.75f, true)
|
||||
private val decodedClips = LinkedHashMap<PetDecodeKey, PetFrames>(8, 0.75f, true)
|
||||
|
||||
private fun decode(clip: PetClip, stabilize: Boolean): PetFrames? {
|
||||
val key = PetDecodeKey(clip, stabilize)
|
||||
private fun decode(clip: PetClip, stabilize: Boolean, groundOpaqueBottom: Boolean): PetFrames? {
|
||||
val key = PetDecodeKey(clip, stabilize, groundOpaqueBottom)
|
||||
synchronized(decodedClips) { decodedClips[key] }?.let { return it }
|
||||
val decoded = decodeClip(clip, stabilize, decodedSheets) ?: return null
|
||||
val decoded = decodeClip(clip, stabilize, groundOpaqueBottom, decodedSheets) ?: return null
|
||||
synchronized(decodedClips) {
|
||||
decodedClips[key] = decoded
|
||||
while (decodedClips.size > PET_DECODED_CLIP_CACHE_ENTRIES) {
|
||||
@@ -363,18 +363,21 @@ class PetAvatar(
|
||||
// character that floats/jumps cell-to-cell). Global Appearance toggle;
|
||||
// flipping it selects a separately cached decode.
|
||||
val stabilize = LocalPetStabilize.current
|
||||
val groundOpaqueBottom = LocalPetGroundOpaqueBottom.current
|
||||
|
||||
// Decode the active clip off the main thread. Keep the last complete
|
||||
// visual while a new state is loading so greet/done/locomotion swaps do
|
||||
// not expose the Canvas as a blank frame.
|
||||
var displayedClip by remember(id, stabilize) {
|
||||
var displayedClip by remember(id, stabilize, groundOpaqueBottom) {
|
||||
mutableStateOf<DisplayedPetClip?>(null)
|
||||
}
|
||||
LaunchedEffect(id, clip, stabilize, mirrorHorizontally) {
|
||||
LaunchedEffect(id, clip, stabilize, groundOpaqueBottom, mirrorHorizontally) {
|
||||
val decoded = if (clip == null) {
|
||||
null
|
||||
} else {
|
||||
withContext(Dispatchers.IO) { runCatching { decode(clip, stabilize) }.getOrNull() }
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { decode(clip, stabilize, groundOpaqueBottom) }.getOrNull()
|
||||
}
|
||||
?.let { DisplayedPetClip(it, mirrorHorizontally) }
|
||||
}
|
||||
displayedClip = retainPetFrameDuringDecode(
|
||||
@@ -484,6 +487,7 @@ private data class DisplayedPetClip(
|
||||
private data class PetDecodeKey(
|
||||
val clip: PetClip,
|
||||
val stabilize: Boolean,
|
||||
val groundOpaqueBottom: Boolean,
|
||||
)
|
||||
|
||||
/** One decoded atlas per selected pet; all of its row clips share these pixels. */
|
||||
@@ -492,6 +496,7 @@ private data class DecodedSheet(val bitmap: Bitmap, val image: ImageBitmap)
|
||||
private fun decodeClip(
|
||||
clip: PetClip,
|
||||
stabilize: Boolean,
|
||||
groundOpaqueBottom: Boolean,
|
||||
decodedSheets: MutableMap<String, DecodedSheet>,
|
||||
): PetFrames? = when (clip) {
|
||||
is FrameSequenceClip -> {
|
||||
@@ -530,7 +535,13 @@ private fun decodeClip(
|
||||
frameHeight = bitmaps.first().height,
|
||||
frameCount = bitmaps.size,
|
||||
fps = clip.fps,
|
||||
centerOffsets = if (stabilize) bitmaps.map { bitmapRecenter(it) } else null,
|
||||
centerOffsets = if (stabilize || groundOpaqueBottom) {
|
||||
bitmaps.map { bitmap ->
|
||||
bitmapContentAlignment(bitmap, stabilize, groundOpaqueBottom)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -583,8 +594,17 @@ private fun decodeClip(
|
||||
frameCount = count,
|
||||
fps = clip.fps,
|
||||
startFrame = clip.startFrame,
|
||||
centerOffsets = if (stabilize) {
|
||||
sheetRecenter(bmp, cols, clip.frameWidth, clip.frameHeight, clip.startFrame, count)
|
||||
centerOffsets = if (stabilize || groundOpaqueBottom) {
|
||||
sheetContentAlignment(
|
||||
bmp,
|
||||
cols,
|
||||
clip.frameWidth,
|
||||
clip.frameHeight,
|
||||
clip.startFrame,
|
||||
count,
|
||||
stabilize,
|
||||
groundOpaqueBottom,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
@@ -593,33 +613,65 @@ private fun decodeClip(
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-cell recenter offsets for a sprite sheet (see [contentRecenter]). */
|
||||
private fun sheetRecenter(
|
||||
/** Per-cell alignment offsets for a sprite sheet (see [contentAlignment]). */
|
||||
private fun sheetContentAlignment(
|
||||
bmp: Bitmap,
|
||||
cols: Int,
|
||||
fw: Int,
|
||||
fh: Int,
|
||||
startFrame: Int,
|
||||
count: Int,
|
||||
stabilize: Boolean,
|
||||
groundOpaqueBottom: Boolean,
|
||||
): List<IntOffset> {
|
||||
val buf = IntArray(fw * fh)
|
||||
return (0 until count).map { i ->
|
||||
val cell = startFrame + i
|
||||
contentRecenter(bmp, (cell % cols) * fw, (cell / cols) * fh, fw, fh, buf)
|
||||
contentAlignment(
|
||||
bmp,
|
||||
(cell % cols) * fw,
|
||||
(cell / cols) * fh,
|
||||
fw,
|
||||
fh,
|
||||
buf,
|
||||
stabilize,
|
||||
groundOpaqueBottom,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Recenter offset for one standalone frame bitmap. */
|
||||
private fun bitmapRecenter(bmp: Bitmap): IntOffset =
|
||||
contentRecenter(bmp, 0, 0, bmp.width, bmp.height, IntArray(bmp.width * bmp.height))
|
||||
/** Content alignment offset for one standalone frame bitmap. */
|
||||
private fun bitmapContentAlignment(
|
||||
bmp: Bitmap,
|
||||
stabilize: Boolean,
|
||||
groundOpaqueBottom: Boolean,
|
||||
): IntOffset = contentAlignment(
|
||||
bmp,
|
||||
0,
|
||||
0,
|
||||
bmp.width,
|
||||
bmp.height,
|
||||
IntArray(bmp.width * bmp.height),
|
||||
stabilize,
|
||||
groundOpaqueBottom,
|
||||
)
|
||||
|
||||
/**
|
||||
* Scan the [w]×[h] region at ([x0],[y0]) of [bmp] for opaque pixels and return the
|
||||
* offset (source px) that moves their bounding-box center to the region center —
|
||||
* cancelling per-frame positional drift. [buf] (size ≥ [w]×[h]) is reused scratch.
|
||||
* Returns [IntOffset.Zero] for a fully-transparent region.
|
||||
* offset (source px) that horizontally stabilizes the content and, when asked,
|
||||
* grounds its opaque bottom. [buf] (size ≥ [w]×[h]) is reused scratch. Returns
|
||||
* [IntOffset.Zero] for a fully-transparent region.
|
||||
*/
|
||||
private fun contentRecenter(bmp: Bitmap, x0: Int, y0: Int, w: Int, h: Int, buf: IntArray): IntOffset {
|
||||
private fun contentAlignment(
|
||||
bmp: Bitmap,
|
||||
x0: Int,
|
||||
y0: Int,
|
||||
w: Int,
|
||||
h: Int,
|
||||
buf: IntArray,
|
||||
stabilize: Boolean,
|
||||
groundOpaqueBottom: Boolean,
|
||||
): IntOffset {
|
||||
bmp.getPixels(buf, 0, w, x0, y0, w, h)
|
||||
var minX = w
|
||||
var minY = h
|
||||
@@ -638,7 +690,44 @@ private fun contentRecenter(bmp: Bitmap, x0: Int, y0: Int, w: Int, h: Int, buf:
|
||||
}
|
||||
}
|
||||
if (maxX < 0) return IntOffset.Zero
|
||||
return IntOffset(w / 2 - (minX + maxX) / 2, h / 2 - (minY + maxY) / 2)
|
||||
return petContentAlignmentOffset(
|
||||
width = w,
|
||||
height = h,
|
||||
minX = minX,
|
||||
minY = minY,
|
||||
maxX = maxX,
|
||||
maxY = maxY,
|
||||
stabilize = stabilize,
|
||||
groundOpaqueBottom = groundOpaqueBottom,
|
||||
)
|
||||
}
|
||||
|
||||
/** Pure alignment policy shared by atlas and standalone frames. */
|
||||
internal fun petContentAlignmentOffset(
|
||||
width: Int,
|
||||
height: Int,
|
||||
minX: Int,
|
||||
minY: Int,
|
||||
maxX: Int,
|
||||
maxY: Int,
|
||||
stabilize: Boolean,
|
||||
groundOpaqueBottom: Boolean,
|
||||
): IntOffset {
|
||||
if (
|
||||
width <= 0 || height <= 0 || minX < 0 || minY < 0 ||
|
||||
maxX < minX || maxY < minY || maxX >= width || maxY >= height
|
||||
) {
|
||||
return IntOffset.Zero
|
||||
}
|
||||
val x = if (stabilize) width / 2 - (minX + maxX) / 2 else 0
|
||||
val y = if (groundOpaqueBottom) {
|
||||
height - 1 - maxY
|
||||
} else if (stabilize) {
|
||||
height / 2 - (minY + maxY) / 2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
return IntOffset(x, y)
|
||||
}
|
||||
|
||||
/** Draw frame [index] of [f], contain-fit + centered, scaled by [bounce]. */
|
||||
|
||||
@@ -87,6 +87,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
@@ -122,9 +123,11 @@ import com.hermesandroid.relay.ui.theme.radialNavyBackground
|
||||
import com.hermesandroid.relay.network.upstream.ApiModelOption
|
||||
import com.hermesandroid.relay.network.upstream.ChatMode
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.upstream.ReasoningEfforts
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceClient
|
||||
import com.hermesandroid.relay.network.relay.RealtimeVoiceConfig
|
||||
import com.hermesandroid.relay.network.relay.VoiceOutputConfig
|
||||
import com.hermesandroid.relay.ui.components.reasoningEffortLabel
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
@@ -163,6 +166,7 @@ import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.HermesCardAction
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
@@ -178,6 +182,7 @@ import com.hermesandroid.relay.ui.components.ChatInputTrailing
|
||||
import com.hermesandroid.relay.ui.components.CommandPalette
|
||||
import com.hermesandroid.relay.ui.components.KeepScreenOnWhile
|
||||
import com.hermesandroid.relay.ui.components.ModelPickerSheet
|
||||
import com.hermesandroid.relay.ui.components.OptionPickerSheet
|
||||
import com.hermesandroid.relay.ui.components.ConnectionStatusBadge
|
||||
import com.hermesandroid.relay.ui.components.CommandRow
|
||||
import com.hermesandroid.relay.ui.components.CompactToolCall
|
||||
@@ -249,10 +254,24 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val DEFAULT_CHAR_LIMIT = 4096
|
||||
private const val CHAT_SCROLL_TO_BOTTOM_PET_PERCH = "chat-scroll-to-bottom-perch"
|
||||
private const val CHAT_SCROLL_TO_BOTTOM_PET_OBSTACLE = "chat-scroll-to-bottom-obstacle"
|
||||
private val CHAT_PET_ROUTES = setOf("chat")
|
||||
|
||||
internal fun resolveSessionActivityStates(
|
||||
background: Map<String, SessionActivityState>,
|
||||
currentSessionId: String?,
|
||||
isStreaming: Boolean,
|
||||
needsInput: Boolean,
|
||||
): Map<String, SessionActivityState> = background.toMutableMap().apply {
|
||||
currentSessionId?.let { sessionId ->
|
||||
when {
|
||||
needsInput -> put(sessionId, SessionActivityState.NeedsInput)
|
||||
isStreaming -> put(sessionId, SessionActivityState.Working)
|
||||
else -> remove(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun resolveChatHeaderSubtitle(
|
||||
isStreaming: Boolean,
|
||||
statusText: String,
|
||||
@@ -299,33 +318,131 @@ internal data class ChatScrollSnapshot(
|
||||
val isStreaming: Boolean
|
||||
)
|
||||
|
||||
internal fun ChatScrollSnapshot.isCompletionAfter(previous: ChatScrollSnapshot?): Boolean =
|
||||
previous?.isStreaming == true &&
|
||||
!isStreaming &&
|
||||
previous.messageCount == messageCount &&
|
||||
previous.lastMessageUiKey == lastMessageUiKey
|
||||
|
||||
internal fun releaseRetainedLiveTail(
|
||||
internal fun retainedLiveTailAfterTransition(
|
||||
retainedUiKey: String?,
|
||||
completedUiKey: String?,
|
||||
): String? = retainedUiKey?.takeUnless { it == completedUiKey }
|
||||
|
||||
internal fun tailEndScrollOffset(
|
||||
tailSizePx: Int,
|
||||
footerSizePx: Int,
|
||||
viewportSizePx: Int,
|
||||
): Int = (tailSizePx + footerSizePx - viewportSizePx).coerceAtLeast(0)
|
||||
streamStarted: Boolean,
|
||||
lastMessageUiKey: String?,
|
||||
): String? = when {
|
||||
streamStarted -> lastMessageUiKey
|
||||
retainedUiKey != null && retainedUiKey != lastMessageUiKey -> null
|
||||
else -> retainedUiKey
|
||||
}
|
||||
|
||||
private class ChatTailTransitionRef(
|
||||
var snapshot: ChatScrollSnapshot? = null,
|
||||
)
|
||||
|
||||
private data class ChatTailLayoutSnapshot(
|
||||
val uiKey: String?,
|
||||
val measuredSizePx: Int?,
|
||||
val shouldFollowGrowth: Boolean,
|
||||
internal data class ChatViewportFollowSnapshot(
|
||||
val totalItemsCount: Int,
|
||||
val tailUiKey: String?,
|
||||
val tailSizePx: Int?,
|
||||
val viewportHeightPx: Int,
|
||||
val visibleBottomDistancePx: Int?,
|
||||
val followTailGrowth: Boolean,
|
||||
val followViewportResize: Boolean,
|
||||
)
|
||||
|
||||
internal fun shouldCorrectConversationBottomAfterLayout(
|
||||
previous: ChatViewportFollowSnapshot?,
|
||||
current: ChatViewportFollowSnapshot,
|
||||
atExactBottom: Boolean,
|
||||
userScrolledAway: Boolean,
|
||||
userDragging: Boolean,
|
||||
isStreaming: Boolean,
|
||||
smoothAutoScroll: Boolean,
|
||||
viewportFollowAllowed: Boolean,
|
||||
): Boolean {
|
||||
val old = previous ?: return false
|
||||
if (
|
||||
atExactBottom || userScrolledAway || userDragging || !viewportFollowAllowed ||
|
||||
(isStreaming && !smoothAutoScroll)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
old.totalItemsCount != current.totalItemsCount ||
|
||||
old.tailUiKey == null ||
|
||||
old.tailUiKey != current.tailUiKey
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return old.tailSizePx != current.tailSizePx ||
|
||||
old.viewportHeightPx != current.viewportHeightPx ||
|
||||
old.visibleBottomDistancePx != current.visibleBottomDistancePx
|
||||
}
|
||||
|
||||
internal fun requiredBottomFollowScroll(
|
||||
previous: ChatViewportFollowSnapshot?,
|
||||
current: ChatViewportFollowSnapshot,
|
||||
): Int {
|
||||
if (!current.followTailGrowth && !current.followViewportResize) return 0
|
||||
|
||||
// When the footer is visible, its trailing edge is the authoritative
|
||||
// distance to the exact bottom. This also consumes rounding and any small
|
||||
// non-tail layout changes that a tail-height delta cannot represent.
|
||||
current.visibleBottomDistancePx?.let { return it.coerceAtLeast(0) }
|
||||
|
||||
val previousSnapshot = previous ?: return 0
|
||||
val tailGrowthPx = if (
|
||||
current.followTailGrowth &&
|
||||
previousSnapshot.tailUiKey == current.tailUiKey
|
||||
) {
|
||||
((current.tailSizePx ?: 0) - (previousSnapshot.tailSizePx ?: 0)).coerceAtLeast(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
val viewportLossPx = if (current.followViewportResize) {
|
||||
(previousSnapshot.viewportHeightPx - current.viewportHeightPx).coerceAtLeast(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
return maxOf(tailGrowthPx, viewportLossPx)
|
||||
}
|
||||
|
||||
internal fun ownedBottomFollowScroll(
|
||||
previous: ChatViewportFollowSnapshot?,
|
||||
current: ChatViewportFollowSnapshot,
|
||||
): Int {
|
||||
val sameTranscript = previous != null &&
|
||||
previous.totalItemsCount == current.totalItemsCount &&
|
||||
previous.tailUiKey == current.tailUiKey
|
||||
// A structural/new-tail transition belongs to the existing streaming
|
||||
// owner. Ordinary restore-layout following must never opt a reader into
|
||||
// new-message auto-follow when smooth auto-scroll is disabled.
|
||||
if (!sameTranscript && !current.followTailGrowth) return 0
|
||||
return requiredBottomFollowScroll(previous, current)
|
||||
}
|
||||
|
||||
internal fun shouldFollowImeAfterInsetChange(
|
||||
wasFollowing: Boolean,
|
||||
previousImeBottomPx: Int,
|
||||
currentImeBottomPx: Int,
|
||||
wasAtBottom: Boolean,
|
||||
userDragging: Boolean,
|
||||
): Boolean = when {
|
||||
currentImeBottomPx == 0 || userDragging -> false
|
||||
previousImeBottomPx == 0 -> wasAtBottom
|
||||
else -> wasFollowing
|
||||
}
|
||||
|
||||
internal fun shouldExactlySettleConversation(
|
||||
autoFollowEnabled: Boolean,
|
||||
userScrolledAway: Boolean,
|
||||
userDragging: Boolean,
|
||||
hasMessages: Boolean,
|
||||
): Boolean = autoFollowEnabled && hasMessages && !userScrolledAway && !userDragging
|
||||
|
||||
internal fun shouldFollowConversationViewportResize(
|
||||
userScrolledAway: Boolean,
|
||||
userDragging: Boolean,
|
||||
imeBottomPx: Int,
|
||||
followImeResize: Boolean,
|
||||
voiceDockAnchorTransitionActive: Boolean,
|
||||
): Boolean = !userScrolledAway &&
|
||||
!userDragging &&
|
||||
!voiceDockAnchorTransitionActive &&
|
||||
(followImeResize || imeBottomPx == 0)
|
||||
|
||||
private fun LazyListState.isAtConversationBottom(slopPx: Int): Boolean {
|
||||
val layout = layoutInfo
|
||||
if (layout.totalItemsCount == 0) return true
|
||||
@@ -334,6 +451,14 @@ private fun LazyListState.isAtConversationBottom(slopPx: Int): Boolean {
|
||||
(last.offset + last.size) - layout.viewportEndOffset <= slopPx
|
||||
}
|
||||
|
||||
private fun LazyListState.visibleConversationBottomDistancePx(): Int? {
|
||||
val layout = layoutInfo
|
||||
val lastIndex = layout.totalItemsCount - 1
|
||||
if (lastIndex < 0) return 0
|
||||
val footer = layout.visibleItemsInfo.firstOrNull { it.index == lastIndex } ?: return null
|
||||
return ((footer.offset + footer.size) - layout.viewportEndOffset).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
private suspend fun LazyListState.scrollToConversationBottom(
|
||||
animated: Boolean,
|
||||
slopPx: Int,
|
||||
@@ -619,8 +744,24 @@ fun ChatScreen(
|
||||
val chatMode by connectionViewModel.chatMode.collectAsState()
|
||||
val error by chatViewModel.error.collectAsState()
|
||||
val sessions by chatViewModel.sessions.collectAsState()
|
||||
val backgroundSessionActivityStates by
|
||||
chatViewModel.backgroundSessionActivityStates.collectAsState()
|
||||
val serverAutoTitles by chatViewModel.serverAutoTitles.collectAsState()
|
||||
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
|
||||
val pendingAsk by chatViewModel.pendingAsk.collectAsState()
|
||||
val sessionActivityStates = remember(
|
||||
backgroundSessionActivityStates,
|
||||
currentSessionId,
|
||||
isStreaming,
|
||||
pendingAsk,
|
||||
) {
|
||||
resolveSessionActivityStates(
|
||||
background = backgroundSessionActivityStates,
|
||||
currentSessionId = currentSessionId,
|
||||
isStreaming = isStreaming,
|
||||
needsInput = pendingAsk != null,
|
||||
)
|
||||
}
|
||||
val backgroundProcesses by chatViewModel.backgroundProcesses.collectAsState()
|
||||
val backgroundProcessesLoading by chatViewModel.backgroundProcessesLoading.collectAsState()
|
||||
val stoppingProcessIds by chatViewModel.stoppingProcessIds.collectAsState()
|
||||
@@ -645,8 +786,11 @@ fun ChatScreen(
|
||||
val apiModelOptions by chatViewModel.apiModelOptions.collectAsState()
|
||||
val modelProviders by chatViewModel.modelProviders.collectAsState()
|
||||
val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState()
|
||||
val reasoningCapabilityRevision by chatViewModel.reasoningCapabilityRevision.collectAsState()
|
||||
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
|
||||
val selectedProviderOverride by chatViewModel.selectedProviderOverride.collectAsState()
|
||||
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
|
||||
val gatewayCurrentProvider by chatViewModel.gatewayCurrentProvider.collectAsState()
|
||||
val gatewayProjectName by chatViewModel.gatewayProjectName.collectAsState()
|
||||
val selectedReasoningEffort by chatViewModel.selectedReasoningEffort.collectAsState()
|
||||
val showThinking by connectionViewModel.showThinking.collectAsState()
|
||||
@@ -796,6 +940,7 @@ fun ChatScreen(
|
||||
var inputText by remember { mutableStateOf("") }
|
||||
var showCommandPalette by remember { mutableStateOf(false) }
|
||||
var showModelSheet by remember { mutableStateOf(false) }
|
||||
var showEffortSheet by remember { mutableStateOf(false) }
|
||||
var showAgentInfo by remember { mutableStateOf(false) }
|
||||
var showBackgroundProcesses by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -1169,7 +1314,25 @@ fun ChatScreen(
|
||||
var isUserDragging by remember(currentSessionId) { mutableStateOf(false) }
|
||||
var programmaticBottomScroll by remember { mutableStateOf(false) }
|
||||
var retainedLiveTailUiKey by remember(currentSessionId) { mutableStateOf<String?>(null) }
|
||||
var completionSettlingUiKey by remember(currentSessionId) { mutableStateOf<String?>(null) }
|
||||
val density = LocalDensity.current
|
||||
val imeBottomPx = WindowInsets.ime.getBottom(density)
|
||||
val latestImeBottomPx by rememberUpdatedState(imeBottomPx)
|
||||
var previousImeBottomPx by remember(currentSessionId) { mutableStateOf(imeBottomPx) }
|
||||
var followImeResize by remember(currentSessionId) { mutableStateOf(false) }
|
||||
SideEffect {
|
||||
followImeResize = shouldFollowImeAfterInsetChange(
|
||||
wasFollowing = followImeResize,
|
||||
previousImeBottomPx = previousImeBottomPx,
|
||||
currentImeBottomPx = imeBottomPx,
|
||||
// At the first non-zero IME inset, LazyColumn still exposes the
|
||||
// pre-resize layout. Capture bottom ownership before the viewport
|
||||
// starts losing height.
|
||||
wasAtBottom = !userScrolledAway &&
|
||||
listState.isAtConversationBottom(atBottomSlopPx),
|
||||
userDragging = isUserDragging,
|
||||
)
|
||||
previousImeBottomPx = imeBottomPx
|
||||
}
|
||||
val currentUnreadSnapshot = remember(messages) { messages.toUnreadSnapshot() }
|
||||
var lastReadSnapshot by remember(currentSessionId) {
|
||||
mutableStateOf(currentUnreadSnapshot)
|
||||
@@ -1194,7 +1357,9 @@ fun ChatScreen(
|
||||
try {
|
||||
listState.scrollToConversationBottom(
|
||||
animated = animated,
|
||||
slopPx = atBottomSlopPx,
|
||||
// Slop preserves follow ownership during motion; an explicit
|
||||
// settlement must reach the real LazyColumn boundary.
|
||||
slopPx = 0,
|
||||
)
|
||||
userScrolledAway = false
|
||||
} finally {
|
||||
@@ -1216,6 +1381,7 @@ fun ChatScreen(
|
||||
when (interaction) {
|
||||
is DragInteraction.Start -> {
|
||||
isUserDragging = true
|
||||
followImeResize = false
|
||||
}
|
||||
is DragInteraction.Stop, is DragInteraction.Cancel -> {
|
||||
isUserDragging = false
|
||||
@@ -1225,6 +1391,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
var voiceDockAnchorGuardReady by remember { mutableStateOf(false) }
|
||||
var voiceDockAnchorTransitionActive by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(conversationVoiceDockVisible) {
|
||||
if (!voiceDockAnchorGuardReady) {
|
||||
voiceDockAnchorGuardReady = true
|
||||
@@ -1238,14 +1405,19 @@ fun ChatScreen(
|
||||
// every animation frame and appears to scroll when voice mode opens.
|
||||
val anchorIndex = listState.firstVisibleItemIndex
|
||||
val anchorOffset = listState.firstVisibleItemScrollOffset
|
||||
var firstFrameNanos = 0L
|
||||
var frameNanos: Long
|
||||
do {
|
||||
if (isUserDragging) return@LaunchedEffect
|
||||
listState.requestScrollToItem(anchorIndex, anchorOffset)
|
||||
frameNanos = withFrameNanos { it }
|
||||
if (firstFrameNanos == 0L) firstFrameNanos = frameNanos
|
||||
} while (frameNanos - firstFrameNanos < 300_000_000L)
|
||||
voiceDockAnchorTransitionActive = true
|
||||
try {
|
||||
var firstFrameNanos = 0L
|
||||
var frameNanos: Long
|
||||
do {
|
||||
if (isUserDragging) return@LaunchedEffect
|
||||
listState.requestScrollToItem(anchorIndex, anchorOffset)
|
||||
frameNanos = withFrameNanos { it }
|
||||
if (firstFrameNanos == 0L) firstFrameNanos = frameNanos
|
||||
} while (frameNanos - firstFrameNanos < 300_000_000L)
|
||||
} finally {
|
||||
voiceDockAnchorTransitionActive = false
|
||||
}
|
||||
}
|
||||
// Reaching the bottom by any means (user, follow-pin, content shrank)
|
||||
// always re-arms auto-follow.
|
||||
@@ -1253,6 +1425,27 @@ fun ChatScreen(
|
||||
if (isAtBottom) userScrolledAway = false
|
||||
}
|
||||
|
||||
// IME insets arrive as an animation, not one layout. Wait until inset
|
||||
// updates pause, then remove any rounding/late-measurement residue if the
|
||||
// conversation still owns bottom-follow. This runs for both opening and
|
||||
// closing without moving a transcript whose reader scrolled away.
|
||||
var lastImeSettleTargetPx by remember(currentSessionId) { mutableStateOf(imeBottomPx) }
|
||||
LaunchedEffect(imeBottomPx) {
|
||||
if (imeBottomPx == lastImeSettleTargetPx) return@LaunchedEffect
|
||||
lastImeSettleTargetPx = imeBottomPx
|
||||
delay(96)
|
||||
if (
|
||||
shouldExactlySettleConversation(
|
||||
autoFollowEnabled = true,
|
||||
userScrolledAway = userScrolledAway,
|
||||
userDragging = isUserDragging,
|
||||
hasMessages = messages.isNotEmpty(),
|
||||
)
|
||||
) {
|
||||
scrollConversationToBottom(animated = false)
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll-to-bottom FAB visibility. The button means "you've scrolled up —
|
||||
// tap to catch up", so it must NOT flash while we're auto-pinning to the
|
||||
// bottom. Suppress it when:
|
||||
@@ -1267,12 +1460,10 @@ fun ChatScreen(
|
||||
derivedStateOf {
|
||||
val retainingVisibleTail = retainedLiveTailUiKey != null &&
|
||||
messages.lastOrNull()?.uiKey == retainedLiveTailUiKey
|
||||
val settlingVisibleTail = completionSettlingUiKey != null &&
|
||||
messages.lastOrNull()?.uiKey == completionSettlingUiKey
|
||||
messages.isNotEmpty() &&
|
||||
!isAtBottom &&
|
||||
!programmaticBottomScroll &&
|
||||
!((isStreaming || retainingVisibleTail || settlingVisibleTail) &&
|
||||
!((isStreaming || retainingVisibleTail) &&
|
||||
smoothAutoScroll &&
|
||||
!userScrolledAway)
|
||||
}
|
||||
@@ -1422,6 +1613,22 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// The drawer and composer share this screen's focus owner. Clear the
|
||||
// composer's input focus as soon as an open transition is committed so
|
||||
// menu activation, accessibility activation, and edge swipes all dismiss
|
||||
// the IME without leaving the obscured composer ready for hardware input.
|
||||
// Observe the target rather than isOpen so the keyboard closes alongside
|
||||
// the drawer animation, not after it settles.
|
||||
LaunchedEffect(drawerState, focusManager) {
|
||||
snapshotFlow { drawerState.targetValue }
|
||||
.distinctUntilChanged()
|
||||
.collect { target ->
|
||||
if (target == DrawerValue.Open) {
|
||||
focusManager.clearFocus(force = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Opening the drawer re-syncs the list — so a session created on another
|
||||
// device (or one whose optimistic row was dropped on a profile switch)
|
||||
// shows up without a manual reload. Cheap dashboard read; the optimistic
|
||||
@@ -1450,13 +1657,12 @@ fun ChatScreen(
|
||||
)
|
||||
val tailTransitionRef = remember(currentSessionId) { ChatTailTransitionRef() }
|
||||
|
||||
// New rows and streaming -> final Markdown are structural transitions.
|
||||
// Anchor their trailing spacer in SideEffect so the request participates in
|
||||
// the very next remeasure instead of correcting an already-drawn frame.
|
||||
// A live tail owns its stable renderer until another row becomes the tail.
|
||||
// Completion is deliberately not a structural transition: changing the
|
||||
// renderer or list anchor at that boundary caused a visible scroll jump.
|
||||
SideEffect {
|
||||
val previous = tailTransitionRef.snapshot
|
||||
val streamStarted = tailTransition.isStreaming && previous?.isStreaming != true
|
||||
val completed = tailTransition.isCompletionAfter(previous)
|
||||
val tailStructureChanged = tailTransition.lastMessageUiKey != null &&
|
||||
(previous == null ||
|
||||
previous.messageCount != tailTransition.messageCount ||
|
||||
@@ -1467,21 +1673,17 @@ fun ChatScreen(
|
||||
// transcript had previously been left above the bottom. Do not
|
||||
// clear isUserDragging: a real finger keeps priority until release.
|
||||
userScrolledAway = false
|
||||
retainedLiveTailUiKey = tailTransition.lastMessageUiKey
|
||||
} else if (completed) {
|
||||
completionSettlingUiKey = tailTransition.lastMessageUiKey
|
||||
} else if (
|
||||
tailStructureChanged &&
|
||||
retainedLiveTailUiKey != null &&
|
||||
retainedLiveTailUiKey != tailTransition.lastMessageUiKey
|
||||
) {
|
||||
retainedLiveTailUiKey = null
|
||||
}
|
||||
retainedLiveTailUiKey = retainedLiveTailAfterTransition(
|
||||
retainedUiKey = retainedLiveTailUiKey,
|
||||
streamStarted = streamStarted,
|
||||
lastMessageUiKey = tailTransition.lastMessageUiKey,
|
||||
)
|
||||
|
||||
val shouldAnchor = smoothAutoScroll &&
|
||||
!isUserDragging &&
|
||||
(!userScrolledAway || streamStarted) &&
|
||||
(streamStarted || completed || tailStructureChanged)
|
||||
(streamStarted || tailStructureChanged)
|
||||
if (shouldAnchor) {
|
||||
listState.requestScrollToItem(tailTransition.messageCount + 1)
|
||||
}
|
||||
@@ -1489,123 +1691,17 @@ fun ChatScreen(
|
||||
tailTransitionRef.snapshot = tailTransition
|
||||
}
|
||||
|
||||
// Completion adds the timestamp/footer after the final token. Keep the
|
||||
// stable live renderer, then consume any small remaining forward range for
|
||||
// two settled frames. scrollBy preserves the current item anchor and is
|
||||
// visually inert when already at the exact bottom; unlike scrollToItem it
|
||||
// cannot align the top of a tall response with the viewport.
|
||||
LaunchedEffect(
|
||||
completionSettlingUiKey,
|
||||
smoothAutoScroll,
|
||||
userScrolledAway,
|
||||
isUserDragging,
|
||||
) {
|
||||
val settlingKey = completionSettlingUiKey ?: return@LaunchedEffect
|
||||
if (!smoothAutoScroll || userScrolledAway || isUserDragging) {
|
||||
// Retention is only a completion-transition aid. Never leave the
|
||||
// finalized tail on the plain streaming renderer just because the
|
||||
// user disabled follow-scroll or is reading above the bottom.
|
||||
retainedLiveTailUiKey = releaseRetainedLiveTail(
|
||||
retainedUiKey = retainedLiveTailUiKey,
|
||||
completedUiKey = settlingKey,
|
||||
)
|
||||
completionSettlingUiKey = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
var settledFrames = 0
|
||||
var previousMarkdownTailSize: Int? = null
|
||||
var previousMarkdownFooterSize: Int? = null
|
||||
val markdownWasAlreadyReleased = retainedLiveTailUiKey != settlingKey
|
||||
repeat(60) completionFrame@{
|
||||
withFrameNanos { }
|
||||
if (messages.lastOrNull()?.uiKey != settlingKey) {
|
||||
completionSettlingUiKey = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (!markdownWasAlreadyReleased && retainedLiveTailUiKey == settlingKey) {
|
||||
if (listState.canScrollForward) {
|
||||
settledFrames = 0
|
||||
val viewportHeight = listState.layoutInfo.viewportSize.height
|
||||
if (viewportHeight > 0) {
|
||||
listState.scroll(MutatePriority.Default) {
|
||||
scrollBy(viewportHeight.toFloat())
|
||||
}
|
||||
}
|
||||
return@completionFrame
|
||||
}
|
||||
|
||||
settledFrames += 1
|
||||
if (settledFrames < 2) return@completionFrame
|
||||
retainedLiveTailUiKey = releaseRetainedLiveTail(
|
||||
retainedUiKey = retainedLiveTailUiKey,
|
||||
completedUiKey = settlingKey,
|
||||
)
|
||||
settledFrames = 0
|
||||
return@completionFrame
|
||||
}
|
||||
|
||||
// Once Markdown owns the row, position its measured trailing edge
|
||||
// explicitly. `canScrollForward` is insufficient here: LazyColumn
|
||||
// may preserve the leading edge of a tall item while reporting an
|
||||
// otherwise valid item anchor. Repeating catches deferred parsing,
|
||||
// highlighted code, and attachment measurement without competing
|
||||
// with the ordinary streaming-growth coroutine.
|
||||
val layout = listState.layoutInfo
|
||||
val tailIndex = messages.size // header item + zero-based messages
|
||||
val footerIndex = tailIndex + 1
|
||||
val tailInfo = layout.visibleItemsInfo.firstOrNull { it.index == tailIndex }
|
||||
val footerInfo = layout.visibleItemsInfo.firstOrNull { it.index == footerIndex }
|
||||
if (tailInfo == null) {
|
||||
listState.scrollToItem(tailIndex)
|
||||
settledFrames = 0
|
||||
return@completionFrame
|
||||
}
|
||||
|
||||
val viewportHeight = layout.viewportSize.height
|
||||
if (viewportHeight <= 0) return@completionFrame
|
||||
val desiredOffset = tailEndScrollOffset(
|
||||
tailSizePx = tailInfo.size,
|
||||
footerSizePx = footerInfo?.size ?: 0,
|
||||
viewportSizePx = viewportHeight,
|
||||
)
|
||||
if (desiredOffset == 0) {
|
||||
listState.scrollToItem(footerIndex)
|
||||
} else {
|
||||
listState.scrollToItem(tailIndex, desiredOffset)
|
||||
}
|
||||
val footerSize = footerInfo?.size ?: 0
|
||||
settledFrames = if (
|
||||
previousMarkdownTailSize == tailInfo.size &&
|
||||
previousMarkdownFooterSize == footerSize
|
||||
) {
|
||||
settledFrames + 1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
previousMarkdownTailSize = tailInfo.size
|
||||
previousMarkdownFooterSize = footerSize
|
||||
if (settledFrames >= 12) {
|
||||
completionSettlingUiKey = null
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
retainedLiveTailUiKey = releaseRetainedLiveTail(
|
||||
retainedUiKey = retainedLiveTailUiKey,
|
||||
completedUiKey = settlingKey,
|
||||
)
|
||||
completionSettlingUiKey = null
|
||||
}
|
||||
|
||||
// Ordinary streaming growth keeps the same row and Text node. Advance the
|
||||
// existing scroll position by exactly the measured positive height delta;
|
||||
// never replace the logical anchor with scrollToItem(). User input has a
|
||||
// higher mutation priority and cancels this work naturally.
|
||||
LaunchedEffect(listState, smoothAutoScroll, userScrolledAway, isUserDragging) {
|
||||
if (!smoothAutoScroll || userScrolledAway || isUserDragging) return@LaunchedEffect
|
||||
|
||||
var previousLayout: ChatTailLayoutSnapshot? = null
|
||||
// One owner follows every bottom-preserving viewport transition. Streaming
|
||||
// growth advances by its measured delta, while any ordinary viewport loss
|
||||
// (IME, late composer controls, status text, or top chrome hydration)
|
||||
// advances by the lost height. This matters on restore: model and effort
|
||||
// controls can finish resolving after history has already reached the
|
||||
// footer. The voice dock's measured 300 ms anchor transition temporarily
|
||||
// owns both follow paths; ordinary late layout correction resumes after it
|
||||
// settles. A visible footer supplies the authoritative final distance. No
|
||||
// transition replaces the logical item anchor.
|
||||
LaunchedEffect(listState, currentSessionId, smoothAutoScroll) {
|
||||
var previousLayout: ChatViewportFollowSnapshot? = null
|
||||
snapshotFlow {
|
||||
val tail = messages.lastOrNull()
|
||||
val tailSize = tail?.uiKey?.let { uiKey ->
|
||||
@@ -1613,39 +1709,80 @@ fun ChatScreen(
|
||||
.firstOrNull { item -> item.key == uiKey }
|
||||
?.size
|
||||
}
|
||||
ChatTailLayoutSnapshot(
|
||||
uiKey = tail?.uiKey,
|
||||
measuredSizePx = tailSize,
|
||||
shouldFollowGrowth = tail?.isStreaming == true ||
|
||||
(retainedLiveTailUiKey != null && tail?.uiKey == retainedLiveTailUiKey),
|
||||
ChatViewportFollowSnapshot(
|
||||
totalItemsCount = listState.layoutInfo.totalItemsCount,
|
||||
tailUiKey = tail?.uiKey,
|
||||
tailSizePx = tailSize,
|
||||
viewportHeightPx = listState.layoutInfo.viewportSize.height,
|
||||
visibleBottomDistancePx = listState.visibleConversationBottomDistancePx(),
|
||||
followTailGrowth = !voiceDockAnchorTransitionActive &&
|
||||
!isUserDragging &&
|
||||
smoothAutoScroll &&
|
||||
!userScrolledAway &&
|
||||
(tail?.isStreaming == true ||
|
||||
(retainedLiveTailUiKey != null && tail?.uiKey == retainedLiveTailUiKey)),
|
||||
followViewportResize = shouldFollowConversationViewportResize(
|
||||
userScrolledAway = userScrolledAway,
|
||||
userDragging = isUserDragging,
|
||||
imeBottomPx = latestImeBottomPx,
|
||||
followImeResize = followImeResize,
|
||||
voiceDockAnchorTransitionActive = voiceDockAnchorTransitionActive,
|
||||
),
|
||||
)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { current ->
|
||||
val previous = previousLayout
|
||||
val scrollPx = ownedBottomFollowScroll(previous, current)
|
||||
val correctLateLayout = shouldCorrectConversationBottomAfterLayout(
|
||||
previous = previous,
|
||||
current = current,
|
||||
atExactBottom = listState.isAtConversationBottom(0),
|
||||
userScrolledAway = userScrolledAway,
|
||||
userDragging = isUserDragging,
|
||||
isStreaming = messages.lastOrNull()?.isStreaming == true,
|
||||
smoothAutoScroll = smoothAutoScroll,
|
||||
viewportFollowAllowed = current.followViewportResize,
|
||||
)
|
||||
previousLayout = current
|
||||
val previousSize = previous?.measuredSizePx ?: return@collect
|
||||
val currentSize = current.measuredSizePx ?: return@collect
|
||||
if (!current.shouldFollowGrowth || previous.uiKey != current.uiKey) return@collect
|
||||
|
||||
val growthPx = currentSize - previousSize
|
||||
if (growthPx > 0) {
|
||||
if (scrollPx > 0) {
|
||||
listState.scroll(MutatePriority.Default) {
|
||||
scrollBy(growthPx.toFloat())
|
||||
scrollBy(scrollPx.toFloat())
|
||||
}
|
||||
} else if (correctLateLayout) {
|
||||
val lastIndex = listState.layoutInfo.totalItemsCount - 1
|
||||
if (lastIndex >= 0) {
|
||||
programmaticBottomScroll = true
|
||||
try {
|
||||
listState.scrollToItem(lastIndex)
|
||||
userScrolledAway = false
|
||||
} finally {
|
||||
programmaticBottomScroll = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Completion haptic only; scroll ownership remains with the transition and
|
||||
// measured-growth paths above.
|
||||
var observedActiveStream by remember { mutableStateOf(false) }
|
||||
// Completion keeps the stable live renderer, then settles the owned
|
||||
// transcript to the exact boundary after its final layout pass.
|
||||
var observedActiveStream by remember(currentSessionId) { mutableStateOf(false) }
|
||||
LaunchedEffect(isStreaming) {
|
||||
if (isStreaming) {
|
||||
observedActiveStream = true
|
||||
} else if (observedActiveStream) {
|
||||
observedActiveStream = false
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
if (
|
||||
shouldExactlySettleConversation(
|
||||
autoFollowEnabled = smoothAutoScroll,
|
||||
userScrolledAway = userScrolledAway,
|
||||
userDragging = isUserDragging,
|
||||
hasMessages = messages.isNotEmpty(),
|
||||
)
|
||||
) {
|
||||
scrollConversationToBottom(animated = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1730,6 +1867,8 @@ fun ChatScreen(
|
||||
scopeSubtitle = drawerSubtitle,
|
||||
isLoading = isLoadingSessions,
|
||||
isOpen = drawerState.isOpen,
|
||||
activityStates = sessionActivityStates,
|
||||
animationEnabled = animationEnabled,
|
||||
autoTitlesSupported = serverAutoTitles,
|
||||
onRefresh = { chatViewModel.refreshSessions() },
|
||||
onNewChat = {
|
||||
@@ -2777,14 +2916,14 @@ fun ChatScreen(
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
// The visible FAB is narrower than the pet's
|
||||
// footprint. Measure a transparent landing
|
||||
// ledge around it so the pet can stand above
|
||||
// the control without covering its touch area.
|
||||
// The complete control envelope is forbidden
|
||||
// terrain. Registering it as a perch invited
|
||||
// the pet onto the button and let sibling
|
||||
// composer routes treat it as walkable terrain.
|
||||
.width(72.dp)
|
||||
.height(48.dp)
|
||||
.petPerchSurface(
|
||||
key = CHAT_SCROLL_TO_BOTTOM_PET_PERCH,
|
||||
.petObstacleSurface(
|
||||
key = CHAT_SCROLL_TO_BOTTOM_PET_OBSTACLE,
|
||||
routes = CHAT_PET_ROUTES,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -2792,14 +2931,6 @@ fun ChatScreen(
|
||||
SmallFloatingActionButton(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
// The surrounding box is a landing ledge, but
|
||||
// the real control remains forbidden space so
|
||||
// composer and bubble routes cannot pass the
|
||||
// pet's complete scaled footprint over it.
|
||||
.petObstacleSurface(
|
||||
key = CHAT_SCROLL_TO_BOTTOM_PET_OBSTACLE,
|
||||
routes = CHAT_PET_ROUTES,
|
||||
)
|
||||
.semantics {
|
||||
contentDescription = if (unreadMessageCount > 0) {
|
||||
"Scroll to bottom, $unreadMessageCount unread " +
|
||||
@@ -3228,22 +3359,37 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
val normalizedEffort = normalizeReasoningEffortForInput(selectedReasoningEffort)
|
||||
val effortLabels = mapOf(
|
||||
"none" to stringResource(R.string.chat_reasoning_none),
|
||||
"minimal" to stringResource(R.string.chat_reasoning_minimal),
|
||||
"low" to stringResource(R.string.chat_reasoning_low),
|
||||
"medium" to stringResource(R.string.chat_reasoning_medium),
|
||||
"high" to stringResource(R.string.chat_reasoning_high),
|
||||
"xhigh" to stringResource(R.string.chat_reasoning_high),
|
||||
)
|
||||
val effortPickerOptions = remember(normalizedEffort) {
|
||||
CHAT_INPUT_REASONING_EFFORTS.map { effort ->
|
||||
// Reads the same provider/model resolver used by session.create.
|
||||
// Collected identity flows above keep this synchronous view reactive.
|
||||
val effortAvailability = remember(
|
||||
selectedModelOverride,
|
||||
selectedProviderOverride,
|
||||
gatewayCurrentModel,
|
||||
gatewayCurrentProvider,
|
||||
modelProviders,
|
||||
apiModelOptions,
|
||||
reasoningCapabilityRevision,
|
||||
) {
|
||||
chatViewModel.reasoningEffortAvailability()
|
||||
}
|
||||
val effortPickerOptions = effortAvailability.choices.map { effort ->
|
||||
ChatInputPickerOption(
|
||||
label = reasoningEffortChipLabel(effort, effortLabels),
|
||||
label = reasoningEffortLabel(effort),
|
||||
value = effort,
|
||||
selected = effort == normalizedEffort,
|
||||
selected = selectedReasoningEffort != null && effort == normalizedEffort,
|
||||
)
|
||||
}
|
||||
}
|
||||
val effortPickerSubtitle = when {
|
||||
effortAvailability.exact &&
|
||||
selectedReasoningEffort != null &&
|
||||
normalizedEffort !in effortAvailability.choices -> stringResource(
|
||||
R.string.reasoning_effort_current_outside_supported,
|
||||
reasoningEffortLabel(normalizedEffort),
|
||||
effortPickerOptions.joinToString { it.label },
|
||||
)
|
||||
!effortAvailability.exact ->
|
||||
stringResource(R.string.reasoning_effort_standard_levels_notice)
|
||||
else -> null
|
||||
}
|
||||
// Show the effort chip as soon as the gateway IS the transport or is
|
||||
// still being probed (Unknown) — so it appears alongside the model
|
||||
@@ -3254,9 +3400,14 @@ fun ChatScreen(
|
||||
// shows the current effort but disabled. Hidden only when the gateway
|
||||
// is definitively unreachable (SSE-only) — the agent sheet carries the
|
||||
// disabled-with-reason version there.
|
||||
val effortControl = if (chatGatewayAvailability != GatewayAvailability.Unreachable) {
|
||||
val effortControl = if (
|
||||
chatGatewayAvailability != GatewayAvailability.Unreachable &&
|
||||
effortAvailability.supported != false &&
|
||||
effortPickerOptions.isNotEmpty()
|
||||
) {
|
||||
ChatInputPickerControl(
|
||||
value = reasoningEffortChipLabel(normalizedEffort, effortLabels),
|
||||
value = selectedReasoningEffort?.let { reasoningEffortLabel(it) }
|
||||
?: stringResource(R.string.conn_info_server_default),
|
||||
contentDescription = stringResource(R.string.chat_select_reasoning_effort),
|
||||
options = effortPickerOptions,
|
||||
enabled = isGatewayTransport && chatReady && !isStreaming,
|
||||
@@ -3364,9 +3515,7 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
effortControl = effortControl,
|
||||
onEffortOptionSelected = { option ->
|
||||
option.value?.let { chatViewModel.selectReasoningEffort(it) }
|
||||
},
|
||||
onEffortPickerClick = { showEffortSheet = true },
|
||||
topContent = {
|
||||
ConversationVoiceDock(
|
||||
uiState = voiceUiState,
|
||||
@@ -3391,10 +3540,10 @@ fun ChatScreen(
|
||||
},
|
||||
topContentVisible = conversationVoiceDockVisible,
|
||||
suppressVoiceTrailing = conversationVoiceDockVisible,
|
||||
// Measure the existing composer as a Desktop-style ledge. The
|
||||
// floating host stands on its top edge; no transcript space is
|
||||
// reserved and the composer controls remain unobstructed.
|
||||
modifier = Modifier.petPerchSurface(
|
||||
// Measure the visible composer Surface as a Desktop-style
|
||||
// ledge. Registering the outer input column includes its 6dp
|
||||
// visual margin and makes a correctly grounded pet look raised.
|
||||
surfaceModifier = Modifier.petPerchSurface(
|
||||
key = CHAT_PET_WALK_REGION,
|
||||
routes = CHAT_PET_ROUTES,
|
||||
),
|
||||
@@ -3418,6 +3567,20 @@ fun ChatScreen(
|
||||
onDismiss = { showModelSheet = false },
|
||||
)
|
||||
}
|
||||
if (showEffortSheet) {
|
||||
OptionPickerSheet(
|
||||
title = stringResource(R.string.chat_select_reasoning_effort),
|
||||
subtitle = effortPickerSubtitle,
|
||||
options = effortPickerOptions.map { option ->
|
||||
option.copy(enabled = effortControl?.enabled == true)
|
||||
},
|
||||
onSelect = { option ->
|
||||
showEffortSheet = false
|
||||
option.value?.let(chatViewModel::selectReasoningEffort)
|
||||
},
|
||||
onDismiss = { showEffortSheet = false },
|
||||
)
|
||||
}
|
||||
} // end Column
|
||||
|
||||
// Mic permission denied banner — title + body + Open Settings action.
|
||||
@@ -4179,8 +4342,6 @@ private fun createCameraCaptureUri(context: android.content.Context): Uri {
|
||||
)
|
||||
}
|
||||
|
||||
private val CHAT_INPUT_REASONING_EFFORTS = listOf("none", "minimal", "low", "medium", "high", "xhigh")
|
||||
|
||||
internal fun shouldShowCleanViewHint(
|
||||
hasMessages: Boolean,
|
||||
ambientMode: Boolean,
|
||||
@@ -4195,13 +4356,9 @@ private fun compactModelChipLabel(model: String?, defaultLabel: String): String
|
||||
}
|
||||
|
||||
private fun normalizeReasoningEffortForInput(value: String?): String {
|
||||
val normalized = value?.trim()?.lowercase().orEmpty()
|
||||
return normalized.takeIf { it in CHAT_INPUT_REASONING_EFFORTS } ?: "medium"
|
||||
return ReasoningEfforts.normalize(value)
|
||||
}
|
||||
|
||||
private fun reasoningEffortChipLabel(value: String, labels: Map<String, String>): String =
|
||||
labels[value] ?: labels["medium"] ?: value
|
||||
|
||||
private fun isSameDay(ts1: Long, ts2: Long): Boolean {
|
||||
val d1 = java.time.Instant.ofEpochMilli(ts1).atZone(java.time.ZoneId.systemDefault()).toLocalDate()
|
||||
val d2 = java.time.Instant.ofEpochMilli(ts2).atZone(java.time.ZoneId.systemDefault()).toLocalDate()
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.ui.screens
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -10,6 +11,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -24,6 +26,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -42,6 +45,13 @@ import com.hermesandroid.relay.network.upstream.ServerCapabilities
|
||||
import com.hermesandroid.relay.ui.components.DiagnosticDetailDialog
|
||||
import com.hermesandroid.relay.ui.components.DiagnosticsLogPanel
|
||||
import com.hermesandroid.relay.ui.components.StatusCheckTimeline
|
||||
import com.hermesandroid.relay.ui.components.SupportBundleDialog
|
||||
import com.hermesandroid.relay.ui.components.SupportReviewState
|
||||
import com.hermesandroid.relay.ui.components.buildSupportReviewState
|
||||
import com.hermesandroid.relay.reliability.ReliabilityCenter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.hermesandroid.relay.viewmodel.ChatRuntimeStatus
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportPath
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
@@ -118,6 +128,8 @@ fun DiagnosticsScreen(
|
||||
|
||||
// Tapping a check backed by a concrete log entry opens its full detail.
|
||||
var selectedEntry by remember { mutableStateOf<DiagnosticLogEntry?>(null) }
|
||||
var supportReview by remember { mutableStateOf<SupportReviewState?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -232,6 +244,19 @@ fun DiagnosticsScreen(
|
||||
},
|
||||
)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
supportReview = withContext(Dispatchers.IO) {
|
||||
buildSupportReviewState(ReliabilityCenter.reports(context))
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.support_bundle_review))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.diag_recent_diagnostics),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
@@ -256,6 +281,9 @@ fun DiagnosticsScreen(
|
||||
selectedEntry?.let { entry ->
|
||||
DiagnosticDetailDialog(entry = entry, onDismiss = { selectedEntry = null })
|
||||
}
|
||||
supportReview?.let { state ->
|
||||
SupportBundleDialog(state = state, onDismiss = { supportReview = null })
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -295,7 +323,7 @@ internal fun buildStatusChecks(
|
||||
it.category == category && it.severity == DiagnosticSeverity.Error
|
||||
}
|
||||
|
||||
fun DiagnosticLogEntry.message(): String = detail ?: title
|
||||
fun DiagnosticLogEntry.message(): String = suggestion ?: detail ?: title
|
||||
|
||||
val checks = mutableListOf<StatusCheck>()
|
||||
|
||||
|
||||
@@ -1,81 +1,43 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.BuildConfig
|
||||
import kotlinx.serialization.Serializable
|
||||
import com.hermesandroid.relay.reliability.ReliabilityCenter
|
||||
import com.hermesandroid.relay.reliability.LegacyCrashSnapshot
|
||||
import com.hermesandroid.relay.reliability.ReliabilityRedactor
|
||||
import com.hermesandroid.relay.reliability.ReliabilityReport
|
||||
import com.hermesandroid.relay.reliability.migrateLegacyCrash
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.temporal.ChronoUnit
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
/**
|
||||
* Lightweight, privacy-respecting crash capture — no Firebase/Crashlytics, no
|
||||
* network, no third-party SDK.
|
||||
*
|
||||
* On a fatal uncaught exception we persist a structured report to the app's
|
||||
* private storage, then **re-raise to the platform's previous handler** so the
|
||||
* system "app stopped" dialog still shows and Google Play Android vitals still
|
||||
* records the crash. We only observe; we never swallow.
|
||||
*
|
||||
* On the next launch [CrashReportGate] reads the pending report and offers the
|
||||
* user three GitHub-free-friendly actions (see [CrashReportDialog]): copy the
|
||||
* full report, **share** it via the system sheet (email / chat / notes — the
|
||||
* path for users without a GitHub account and for sideload installs Play vitals
|
||||
* never sees), or open a pre-filled `bug_report.yml` issue. The GitHub path
|
||||
* turns a one-star "it keeps crashing" review into an actionable issue with a
|
||||
* stack trace attached; share/copy cover everyone else. Every outbound path is
|
||||
* user-initiated — nothing is transmitted automatically.
|
||||
*/
|
||||
/** Local-only fatal capture. The platform handler still owns termination and Play vitals. */
|
||||
object CrashReporter {
|
||||
|
||||
private const val TAG = "CrashReporter"
|
||||
private const val DIR = "crash"
|
||||
private const val FILE = "last-crash.json"
|
||||
|
||||
/**
|
||||
* Cap the stack trace we inline into the GitHub URL. Browsers + GitHub
|
||||
* truncate very long URLs, so we ship the head of the trace in the form and
|
||||
* copy the *full* report to the clipboard for the user to paste if needed.
|
||||
*/
|
||||
private const val MAX_TRACE_FOR_URL = 3000
|
||||
|
||||
private val json = Json {
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = false
|
||||
}
|
||||
private const val LEGACY_DIR = "crash"
|
||||
private const val LEGACY_FILE = "last-crash.json"
|
||||
private const val MAX_TRACE_FOR_URL = 3_000
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Volatile
|
||||
private var installed = false
|
||||
|
||||
/**
|
||||
* Install the process-wide uncaught-exception handler. Idempotent; call as
|
||||
* early as possible in [com.hermesandroid.relay.HermesRelayApp.onCreate] so
|
||||
* crashes during the rest of app init are captured too.
|
||||
*/
|
||||
fun install(context: Context) {
|
||||
ReliabilityCenter.initialize(context)
|
||||
migrateLegacy(context)
|
||||
if (installed) return
|
||||
installed = true
|
||||
val appContext = context.applicationContext
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
try {
|
||||
persist(appContext, thread, throwable)
|
||||
} catch (t: Throwable) {
|
||||
// Never let the reporter itself worsen the crash.
|
||||
Log.e(TAG, "Failed to persist crash report", t)
|
||||
ReliabilityCenter.recordFatal(appContext, throwable, thread.name.orEmpty().ifBlank { "?" })
|
||||
} catch (captureFailure: Throwable) {
|
||||
// Never let the reporter worsen or replace the original crash.
|
||||
Log.e(TAG, "Failed to persist local crash report", captureFailure)
|
||||
}
|
||||
// Re-raise so the platform behaves exactly as it would without us:
|
||||
// system dialog + Play vitals collection.
|
||||
if (previous != null) {
|
||||
previous.uncaughtException(thread, throwable)
|
||||
} else {
|
||||
@@ -85,155 +47,70 @@ object CrashReporter {
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist(context: Context, thread: Thread, throwable: Throwable) {
|
||||
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
|
||||
val report = CrashReport(
|
||||
timeIso = isoNow(),
|
||||
versionName = BuildConfig.VERSION_NAME,
|
||||
versionCode = BuildConfig.VERSION_CODE,
|
||||
flavor = BuildConfig.FLAVOR,
|
||||
manufacturer = Build.MANUFACTURER.orEmpty().ifBlank { "?" },
|
||||
model = Build.MODEL.orEmpty().ifBlank { "?" },
|
||||
androidRelease = Build.VERSION.RELEASE.orEmpty().ifBlank { "?" },
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
threadName = thread.name.orEmpty().ifBlank { "?" },
|
||||
exceptionSummary = "${throwable.javaClass.name}: ${throwable.message.orEmpty()}".trim(),
|
||||
stackTrace = trace,
|
||||
)
|
||||
val dir = File(context.filesDir, DIR).apply { mkdirs() }
|
||||
File(dir, FILE).writeText(json.encodeToString(report))
|
||||
fun peekPending(context: Context): ReliabilityReport? {
|
||||
migrateLegacy(context)
|
||||
return ReliabilityCenter.pendingCrash(context)
|
||||
}
|
||||
|
||||
/** Read the pending report without clearing it. */
|
||||
fun peekPending(context: Context): CrashReport? = readFile(context)
|
||||
|
||||
/** Read the pending report and delete it (show-once semantics). */
|
||||
fun consumePending(context: Context): CrashReport? {
|
||||
val report = readFile(context)
|
||||
clearPending(context)
|
||||
return report
|
||||
fun clearPending(context: Context, reportId: String? = null) {
|
||||
val target = reportId ?: peekPending(context)?.reportId ?: return
|
||||
ReliabilityCenter.markReviewed(context, target)
|
||||
}
|
||||
|
||||
fun clearPending(context: Context) {
|
||||
runCatching { reportFile(context).delete() }
|
||||
}
|
||||
|
||||
private fun readFile(context: Context): CrashReport? = runCatching {
|
||||
val file = reportFile(context)
|
||||
if (!file.exists()) return null
|
||||
json.decodeFromString<CrashReport>(file.readText())
|
||||
}.getOrNull()
|
||||
|
||||
private fun reportFile(context: Context): File =
|
||||
File(File(context.filesDir, DIR), FILE)
|
||||
|
||||
/**
|
||||
* Build a pre-filled GitHub "new issue" URL.
|
||||
*
|
||||
* Uses the **stable** classic `title` + `body` + `labels` query params, NOT
|
||||
* issue-form field-`id` prefilling (`template=...&<id>=...`). The latter is
|
||||
* a GitHub public-preview feature and was observed to silently not apply
|
||||
* (only `title` carried), which is unacceptable for a crash reporter that
|
||||
* fires on devices we can't retry from. `blank_issues_enabled: true` in
|
||||
* `.github/ISSUE_TEMPLATE/config.yml` guarantees `?body=` opens a prefilled
|
||||
* issue. The body mirrors `bug_report.yml`'s sections in markdown so triage
|
||||
* structure is preserved without depending on the preview path.
|
||||
*/
|
||||
fun buildGithubIssueUrl(report: CrashReport): String = IssueReport.buildGithubIssueUrl(
|
||||
title = "[Bug]: Crash — ${report.shortTitle()}",
|
||||
fun buildGithubIssueUrl(report: ReliabilityReport): String = IssueReport.buildGithubIssueUrl(
|
||||
title = "[Bug]: Android crash — ${ReliabilityRedactor.redact(report.shortTitle(), 90)}",
|
||||
bodyMarkdown = buildIssueBody(report),
|
||||
labels = "bug",
|
||||
labels = "bug,area:android",
|
||||
)
|
||||
|
||||
private fun buildIssueBody(report: CrashReport): String {
|
||||
val trace = report.stackTrace.let {
|
||||
private fun buildIssueBody(report: ReliabilityReport): String {
|
||||
val trace = report.technicalDetail.orEmpty().let {
|
||||
if (it.length > MAX_TRACE_FOR_URL) {
|
||||
it.take(MAX_TRACE_FOR_URL) + "\n… (truncated — full report copied to your clipboard)"
|
||||
it.take(MAX_TRACE_FOR_URL) + "\n… (truncated — review/copy the local report for the remainder)"
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
return buildString {
|
||||
appendLine(
|
||||
"> ⚠️ Before submitting: remove any secrets, tokens, real hostnames/IPs, " +
|
||||
"or personal data from the trace below.",
|
||||
)
|
||||
appendLine()
|
||||
appendLine("### Affected area")
|
||||
appendLine("Android app")
|
||||
appendLine()
|
||||
appendLine("### What happened?")
|
||||
appendLine("The app closed unexpectedly. Auto-captured crash report below.")
|
||||
appendLine()
|
||||
appendLine("### Environment")
|
||||
appendLine(report.environmentBlock())
|
||||
appendLine()
|
||||
appendLine("### Crash")
|
||||
appendLine("```")
|
||||
appendLine(trace)
|
||||
appendLine("```")
|
||||
appendLine()
|
||||
append("<sub>Captured by the Hermes-Relay in-app crash reporter · ${report.timeIso}</sub>")
|
||||
return ReliabilityRedactor.redact(
|
||||
buildString {
|
||||
appendLine("> No report was uploaded automatically. This is the locally reviewed, redacted copy.")
|
||||
appendLine()
|
||||
appendLine("### Affected area")
|
||||
appendLine("Android app")
|
||||
appendLine()
|
||||
appendLine("### What happened?")
|
||||
appendLine(report.summary)
|
||||
appendLine()
|
||||
appendLine("### Recovery")
|
||||
appendLine(report.recovery)
|
||||
appendLine()
|
||||
appendLine("### Environment")
|
||||
appendLine(report.environmentBlock())
|
||||
appendLine("- Report ID: ${report.reportId}")
|
||||
appendLine("- App session ID: ${report.appSessionId}")
|
||||
appendLine()
|
||||
appendLine("### Redacted technical detail")
|
||||
appendLine("```")
|
||||
appendLine(trace)
|
||||
appendLine("```")
|
||||
appendLine()
|
||||
append("<sub>Captured locally by Hermes-Relay · ${report.timeIso}</sub>")
|
||||
},
|
||||
maxLength = 12_000,
|
||||
)
|
||||
}
|
||||
|
||||
/** Import the pre-v1 one-file crash format once, redacting before the new store sees it. */
|
||||
private fun migrateLegacy(context: Context) {
|
||||
val file = File(File(context.filesDir, LEGACY_DIR), LEGACY_FILE)
|
||||
if (!file.isFile) return
|
||||
runCatching {
|
||||
val old = json.decodeFromString<LegacyCrashSnapshot>(file.readText())
|
||||
val report = migrateLegacyCrash(old)
|
||||
ReliabilityCenter.import(context, report)
|
||||
file.delete()
|
||||
}.onFailure {
|
||||
Log.w(TAG, "Legacy crash report could not be migrated", it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isoNow(): String = runCatching {
|
||||
OffsetDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS).toString()
|
||||
}.getOrDefault(java.util.Date().toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured, serializable crash snapshot. Persisted as JSON between the
|
||||
* crashing session and the next launch.
|
||||
*/
|
||||
@Serializable
|
||||
data class CrashReport(
|
||||
val timeIso: String,
|
||||
val versionName: String,
|
||||
val versionCode: Int,
|
||||
val flavor: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val androidRelease: String,
|
||||
val sdkInt: Int,
|
||||
val threadName: String,
|
||||
val exceptionSummary: String,
|
||||
val stackTrace: String,
|
||||
) {
|
||||
fun deviceLine(): String = "$manufacturer $model"
|
||||
|
||||
fun androidLine(): String = "Android $androidRelease (SDK $sdkInt)"
|
||||
|
||||
fun versionLine(): String = "$versionName (code $versionCode) $flavor"
|
||||
|
||||
/** A short, human title for the GitHub issue — class name + trimmed message. */
|
||||
fun shortTitle(): String {
|
||||
val firstLine = exceptionSummary.lineSequence().firstOrNull().orEmpty()
|
||||
val simpleClass = firstLine.substringBefore(':').substringAfterLast('.').ifBlank { "crash" }
|
||||
val message = firstLine.substringAfter(':', "").trim()
|
||||
return (if (message.isBlank()) simpleClass else "$simpleClass: $message").take(90)
|
||||
}
|
||||
|
||||
/** Full, copy-paste-ready report shown in the dialog and copied to clipboard. */
|
||||
fun toPlainText(): String = buildString {
|
||||
appendLine("Hermes-Relay crash report")
|
||||
appendLine("Time: $timeIso")
|
||||
appendLine("App: ${versionLine()}")
|
||||
appendLine("Device: ${deviceLine()}")
|
||||
appendLine("Android: ${androidLine()}")
|
||||
appendLine("Thread: $threadName")
|
||||
appendLine()
|
||||
append(stackTrace)
|
||||
}
|
||||
|
||||
/** Matches the `environment` textarea default in `bug_report.yml`. */
|
||||
fun environmentBlock(): String = buildString {
|
||||
appendLine("- Hermes-Relay version/tag: $versionName (code $versionCode)")
|
||||
appendLine(
|
||||
"- Install surface: " +
|
||||
if (flavor.equals("sideload", ignoreCase = true)) "sideload APK" else "Google Play",
|
||||
)
|
||||
appendLine("- Android device and OS: ${deviceLine()} — ${androidLine()}")
|
||||
append("- Connection mode: LAN / Tailscale / public TLS / other")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ object DiagnosticIssuePrefill {
|
||||
private const val MAX_TRACE_FOR_URL = 3000
|
||||
|
||||
private const val DEFAULT_WHAT_HAPPENED = "Captured diagnostic from the in-app activity log."
|
||||
private const val CONFIGURED_URL_MARKER = "[diagnostic-configured-url]"
|
||||
private const val REQUEST_URL_MARKER = "[diagnostic-request-url]"
|
||||
private const val LEGACY_URL_MARKER = "[diagnostic-url]"
|
||||
|
||||
/** `[Bug]:` for Error entries, `[Diagnostic]:` for Info/Warning. */
|
||||
fun issueTitle(entry: DiagnosticLogEntry): String = when (entry.severity) {
|
||||
@@ -36,12 +39,13 @@ object DiagnosticIssuePrefill {
|
||||
}
|
||||
|
||||
/**
|
||||
* `bug` for Error entries; `question` (an existing repo label) for
|
||||
* Info/Warning so routine diagnostics don't pollute the bug queue.
|
||||
* `bug,area:android` for Error entries; `question,area:android` for
|
||||
* Info/Warning so routine diagnostics don't pollute the bug queue and all
|
||||
* in-app reports reach the owning Android surface.
|
||||
*/
|
||||
fun issueLabels(entry: DiagnosticLogEntry): String = when (entry.severity) {
|
||||
DiagnosticSeverity.Error -> "bug"
|
||||
else -> "question"
|
||||
DiagnosticSeverity.Error -> "bug,area:android"
|
||||
else -> "question,area:android"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +56,7 @@ object DiagnosticIssuePrefill {
|
||||
*/
|
||||
fun connectionMode(entry: DiagnosticLogEntry): String =
|
||||
entry.endpointRole
|
||||
?: entry.url?.let { Connection.inferRouteRole(it) }
|
||||
?: entry.primaryUrl?.let { Connection.inferRouteRole(it) }
|
||||
?: "unknown"
|
||||
|
||||
/**
|
||||
@@ -76,7 +80,10 @@ object DiagnosticIssuePrefill {
|
||||
val whatHappened = DiagnosticsLog.redactReportText(expectation)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: DEFAULT_WHAT_HAPPENED
|
||||
return buildString {
|
||||
val configuredUrl = reportSafeUrl(entry.configuredUrl)
|
||||
val requestUrl = reportSafeUrl(entry.requestUrl)
|
||||
val legacyUrl = reportSafeUrl(entry.url)
|
||||
val body = buildString {
|
||||
appendLine(
|
||||
"> ⚠️ Before submitting: remove any secrets, tokens, real hostnames/IPs, " +
|
||||
"or personal data from the detail below.",
|
||||
@@ -99,9 +106,15 @@ object DiagnosticIssuePrefill {
|
||||
appendLine("- Title: ${entry.title}")
|
||||
appendLine("- Category: ${entry.category.label}")
|
||||
appendLine("- Severity: ${entry.severity.name}")
|
||||
entry.operation?.let { appendLine("- Operation: $it") }
|
||||
entry.endpointRole?.let { appendLine("- Route: $it") }
|
||||
entry.url?.let { appendLine("- URL: $it") }
|
||||
configuredUrl?.let { appendLine("- Configured URL: $CONFIGURED_URL_MARKER") }
|
||||
requestUrl?.let { appendLine("- Request: $REQUEST_URL_MARKER") }
|
||||
if (configuredUrl == null && requestUrl == null) {
|
||||
legacyUrl?.let { appendLine("- URL: $LEGACY_URL_MARKER") }
|
||||
}
|
||||
entry.elapsedMs?.let { appendLine("- Elapsed: ${it}ms") }
|
||||
entry.suggestion?.let { appendLine("- Suggested next step: $it") }
|
||||
if (trace.isNotBlank()) {
|
||||
appendLine()
|
||||
appendLine("```")
|
||||
@@ -111,5 +124,18 @@ object DiagnosticIssuePrefill {
|
||||
appendLine()
|
||||
append("<sub>Captured by the Hermes-Relay in-app diagnostics log</sub>")
|
||||
}
|
||||
return DiagnosticsLog.redactReportText(body).orEmpty()
|
||||
.replace(CONFIGURED_URL_MARKER, configuredUrl.orEmpty())
|
||||
.replace(REQUEST_URL_MARKER, requestUrl.orEmpty())
|
||||
.replace(LEGACY_URL_MARKER, legacyUrl.orEmpty())
|
||||
}
|
||||
|
||||
private fun reportSafeUrl(value: String?): String? {
|
||||
val sanitized = DiagnosticsLog.sanitizeUrl(value) ?: return null
|
||||
return if (sanitized.contains("://")) {
|
||||
sanitized
|
||||
} else {
|
||||
DiagnosticsLog.redactReportText(sanitized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
@@ -226,17 +227,31 @@ fun classifyError(t: Throwable?, context: String? = null, ctx: Context? = null):
|
||||
// Record after classification so the clean title and the raw trace both
|
||||
// reach the log. Defensive: never let logging turn a handled error fatal.
|
||||
runCatching {
|
||||
val category = categoryForContext(context)
|
||||
DiagnosticsLog.recordError(
|
||||
category = categoryForContext(context),
|
||||
category = category,
|
||||
title = human.title,
|
||||
detail = human.body,
|
||||
throwable = t,
|
||||
operation = context?.diagnosticOperation(),
|
||||
suggestion = NetworkDiagnosticGuidance.forThrowable(t, category.label),
|
||||
reliabilityContext = context,
|
||||
)
|
||||
}
|
||||
}
|
||||
return human
|
||||
}
|
||||
|
||||
private fun String.diagnosticOperation(): String =
|
||||
trim()
|
||||
.split('_', '-', ' ')
|
||||
.filter { it.isNotBlank() }
|
||||
.joinToString(" ") { word ->
|
||||
word.replaceFirstChar { character ->
|
||||
if (character.isLowerCase()) character.titlecase() else character.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?): HumanError {
|
||||
if (t == null) return nullFallback(context, ctx)
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
|
||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.ToolCallEvent
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
@@ -41,6 +42,7 @@ import com.hermesandroid.relay.data.HermesCardInput
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
|
||||
import com.hermesandroid.relay.network.upstream.ActiveTurnHandle
|
||||
import com.hermesandroid.relay.network.upstream.ActiveTurnKeepAliveRegistry
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAsk
|
||||
@@ -56,11 +58,16 @@ import com.hermesandroid.relay.network.upstream.GatewayConnectionState
|
||||
import com.hermesandroid.relay.network.upstream.GatewayEventMapper
|
||||
import com.hermesandroid.relay.network.upstream.GatewayInboundTurnRegistration
|
||||
import com.hermesandroid.relay.network.upstream.GatewayModelProvider
|
||||
import com.hermesandroid.relay.network.upstream.GatewayModelCapabilities
|
||||
import com.hermesandroid.relay.network.upstream.GatewayModelOptions
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcess
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcessCapability
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcessEvent
|
||||
import com.hermesandroid.relay.network.upstream.GatewaySessionModel
|
||||
import com.hermesandroid.relay.network.upstream.ReasoningEffortAvailability
|
||||
import com.hermesandroid.relay.network.upstream.ReasoningEffortIdentity
|
||||
import com.hermesandroid.relay.network.upstream.ReasoningEfforts
|
||||
import com.hermesandroid.relay.network.upstream.resolveReasoningEffortAvailability
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAttachment
|
||||
import com.hermesandroid.relay.network.upstream.GatewayRpcException
|
||||
import com.hermesandroid.relay.network.upstream.GatewayTurnCallbacks
|
||||
@@ -125,6 +132,15 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
internal fun modelInventoryFailureNotice(
|
||||
failure: Throwable,
|
||||
userInitiated: Boolean,
|
||||
): String? = if (userInitiated) {
|
||||
"Couldn't refresh API model inventory: ${failure.message ?: "unknown error"}"
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute per-session context-window usage in tokens — the data behind the
|
||||
* desktop-style "used / max" context bar. [fraction] is the fill 0..1.
|
||||
@@ -199,6 +215,32 @@ class ChatViewModel : ViewModel() {
|
||||
private data class TurnCheckpointKey(val contextKey: String, val sessionId: String)
|
||||
private val backgroundTurnCheckpoints =
|
||||
ConcurrentHashMap<TurnCheckpointKey, ChatTurnCheckpoint>()
|
||||
private val backgroundNeedsInputKeys = ConcurrentHashMap.newKeySet<TurnCheckpointKey>()
|
||||
private val _backgroundSessionActivityStates =
|
||||
MutableStateFlow<Map<String, SessionActivityState>>(emptyMap())
|
||||
val backgroundSessionActivityStates: StateFlow<Map<String, SessionActivityState>> =
|
||||
_backgroundSessionActivityStates.asStateFlow()
|
||||
|
||||
private fun publishBackgroundSessionActivity() {
|
||||
val contextKey = activeProfileContextKey
|
||||
_backgroundSessionActivityStates.value = if (contextKey == null) {
|
||||
emptyMap()
|
||||
} else {
|
||||
backgroundTurnCheckpoints.keys
|
||||
.asSequence()
|
||||
.filter { it.contextKey == contextKey }
|
||||
.associate { key ->
|
||||
key.sessionId to if (
|
||||
key in backgroundNeedsInputKeys ||
|
||||
backgroundPendingInteractions.containsKey(key)
|
||||
) {
|
||||
SessionActivityState.NeedsInput
|
||||
} else {
|
||||
SessionActivityState.Working
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun TurnCheckpointKey.keepAliveKey(): String = "$contextKey::$sessionId"
|
||||
|
||||
@@ -526,6 +568,11 @@ class ChatViewModel : ViewModel() {
|
||||
*/
|
||||
private val _modelProviders = MutableStateFlow<List<GatewayModelProvider>>(emptyList())
|
||||
val modelProviders: StateFlow<List<GatewayModelProvider>> = _modelProviders.asStateFlow()
|
||||
private val relayReasoningCapabilities =
|
||||
MutableStateFlow<Map<ReasoningEffortIdentity, GatewayModelCapabilities>>(emptyMap())
|
||||
private val _reasoningCapabilityRevision = MutableStateFlow(0L)
|
||||
val reasoningCapabilityRevision: StateFlow<Long> = _reasoningCapabilityRevision.asStateFlow()
|
||||
private val relayCapabilityGeneration = AtomicLong(0L)
|
||||
private val modelOptionsGeneration = java.util.concurrent.atomic.AtomicLong(0L)
|
||||
private val modelOptionsByProfile = mutableMapOf<String, GatewayModelOptions>()
|
||||
|
||||
@@ -535,10 +582,16 @@ class ChatViewModel : ViewModel() {
|
||||
return "$connectionProfile::$session"
|
||||
}
|
||||
|
||||
private fun reasoningCapabilityContextKey(): String =
|
||||
activeProfileContextKey ?: "__unbound__"
|
||||
|
||||
private fun activateModelOptionsProfile(profileKey: String) {
|
||||
modelOptionsGeneration.incrementAndGet()
|
||||
val cached = modelOptionsByProfile[profileKey]
|
||||
_modelProviders.value = cached?.providers.orEmpty()
|
||||
relayCapabilityGeneration.incrementAndGet()
|
||||
relayReasoningCapabilities.value = emptyMap()
|
||||
_reasoningCapabilityRevision.value += 1L
|
||||
_gatewayCurrentModel.value = cached?.currentModel.orEmpty()
|
||||
_gatewayCurrentProvider.value = cached?.currentProvider.orEmpty()
|
||||
_apiModelOptions.value = emptyList()
|
||||
@@ -571,6 +624,9 @@ class ChatViewModel : ViewModel() {
|
||||
*/
|
||||
private val _selectedReasoningEffort = MutableStateFlow<String?>(null)
|
||||
val selectedReasoningEffort: StateFlow<String?> = _selectedReasoningEffort.asStateFlow()
|
||||
/** Exact provider/model identity that confirmed the currently displayed effort. */
|
||||
private var selectedReasoningEffortConfirmedIdentity: ReasoningEffortIdentity? = null
|
||||
private val reasoningEffortRevision = AtomicLong(0)
|
||||
|
||||
private val _reasoningDisplay = MutableStateFlow<String?>(null)
|
||||
|
||||
@@ -653,6 +709,7 @@ class ChatViewModel : ViewModel() {
|
||||
_modelProviders.value = it.providers
|
||||
_gatewayCurrentModel.value = it.currentModel
|
||||
_gatewayCurrentProvider.value = it.currentProvider
|
||||
refreshRelayReasoningCapabilities(refresh = refresh)
|
||||
android.util.Log.i(
|
||||
"ChatViewModel",
|
||||
"model.options${if (refresh) " refresh" else ""}: ${it.providers.size} providers, " +
|
||||
@@ -676,10 +733,20 @@ class ChatViewModel : ViewModel() {
|
||||
android.util.Log.i("ChatViewModel", "refreshReasoningSettings: no gateway client")
|
||||
return
|
||||
}
|
||||
val revision = reasoningEffortRevision.get()
|
||||
val identity = reasoningEffortIdentity()
|
||||
viewModelScope.launch {
|
||||
gateway.getReasoningSettings().fold(
|
||||
onSuccess = {
|
||||
if (!isCurrentReasoningResponse(
|
||||
capturedRevision = revision,
|
||||
currentRevision = reasoningEffortRevision.get(),
|
||||
capturedIdentity = identity,
|
||||
activeIdentity = reasoningEffortIdentity(),
|
||||
)
|
||||
) return@fold
|
||||
_selectedReasoningEffort.value = normalizeReasoningEffort(it.effort)
|
||||
selectedReasoningEffortConfirmedIdentity = identity
|
||||
_reasoningDisplay.value = it.display
|
||||
},
|
||||
onFailure = {
|
||||
@@ -797,9 +864,98 @@ class ChatViewModel : ViewModel() {
|
||||
* model override is cleared.
|
||||
*/
|
||||
private val _selectedProviderOverride = MutableStateFlow<String?>(null)
|
||||
val selectedProviderOverride: StateFlow<String?> = _selectedProviderOverride.asStateFlow()
|
||||
private val apiSessionModelLocks = mutableMapOf<String, ApiModelSelectionAck.Locked>()
|
||||
|
||||
fun fetchModels() {
|
||||
/** Active provider/model capability contract used by every reasoning control and send path. */
|
||||
fun reasoningEffortAvailability(): ReasoningEffortAvailability {
|
||||
val identity = reasoningEffortIdentity()
|
||||
return resolveReasoningEffortAvailability(
|
||||
providers = _modelProviders.value,
|
||||
provider = identity?.provider,
|
||||
model = identity?.model,
|
||||
relayCapabilities = relayReasoningCapabilities.value,
|
||||
)
|
||||
}
|
||||
|
||||
private fun reasoningEffortIdentity(): ReasoningEffortIdentity? {
|
||||
val selectedModel = _selectedModelOverride.value
|
||||
val selectedProvider = _selectedProviderOverride.value
|
||||
val aliasRoot = selectedModel?.let { selected ->
|
||||
_apiModelOptions.value.firstOrNull { it.id == selected }?.root
|
||||
}
|
||||
val capabilityModel = aliasRoot ?: selectedModel ?: _gatewayCurrentModel.value
|
||||
val capabilityProvider = selectedProvider ?: aliasRoot?.let { root ->
|
||||
_modelProviders.value.singleOrNull { root in it.models }?.slug
|
||||
} ?: if (selectedModel == null) {
|
||||
_gatewayCurrentProvider.value
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val provider = capabilityProvider?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||
val model = capabilityModel.trim().takeIf { it.isNotEmpty() } ?: return null
|
||||
return ReasoningEffortIdentity(provider = provider.lowercase(), model = model)
|
||||
}
|
||||
|
||||
private fun refreshRelayReasoningCapabilities(
|
||||
focus: ReasoningEffortIdentity? = reasoningEffortIdentity(),
|
||||
refresh: Boolean = false,
|
||||
) {
|
||||
val relay = relayHttpClient ?: return
|
||||
val pairs = buildList {
|
||||
focus?.let(::add)
|
||||
_modelProviders.value.forEach { provider ->
|
||||
provider.models.forEach { model ->
|
||||
add(ReasoningEffortIdentity(provider.slug.lowercase(), model))
|
||||
}
|
||||
}
|
||||
}.distinct().take(RelayHttpClient.MAX_MODEL_CAPABILITY_ROWS)
|
||||
if (pairs.isEmpty()) return
|
||||
val generation = relayCapabilityGeneration.incrementAndGet()
|
||||
val profileKey = reasoningCapabilityContextKey()
|
||||
val profile = sessionProfileNameProvider()
|
||||
viewModelScope.launch {
|
||||
val result = relay.fetchModelCapabilities(
|
||||
models = pairs.map {
|
||||
RelayHttpClient.ModelCapabilityRequestRow(it.provider, it.model)
|
||||
},
|
||||
profile = profile,
|
||||
refresh = refresh,
|
||||
)
|
||||
if (
|
||||
relayHttpClient !== relay ||
|
||||
!isCurrentReasoningCapabilityOverlay(
|
||||
requestGeneration = generation,
|
||||
currentGeneration = relayCapabilityGeneration.get(),
|
||||
requestProfileKey = profileKey,
|
||||
currentProfileKey = reasoningCapabilityContextKey(),
|
||||
)
|
||||
) return@launch
|
||||
val response = result.getOrNull()
|
||||
val requested = pairs.toSet()
|
||||
val capabilities = response?.capabilities.orEmpty()
|
||||
.asSequence()
|
||||
.take(RelayHttpClient.MAX_MODEL_CAPABILITY_ROWS)
|
||||
.mapNotNull { row ->
|
||||
val identity = ReasoningEffortIdentity(
|
||||
provider = row.provider.trim().lowercase(),
|
||||
model = row.model.trim(),
|
||||
)
|
||||
if (identity !in requested) return@mapNotNull null
|
||||
identity to GatewayModelCapabilities(
|
||||
reasoning = row.reasoning,
|
||||
reasoningEfforts = row.reasoningEfforts,
|
||||
reasoningEffortsExact = row.reasoningEffortsExact,
|
||||
)
|
||||
}
|
||||
.toMap()
|
||||
relayReasoningCapabilities.value = capabilities
|
||||
_reasoningCapabilityRevision.value += 1L
|
||||
reconcilePendingReasoningEffortForModel()
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchModels(userInitiated: Boolean = false) {
|
||||
val client = apiClient ?: return
|
||||
val generation = modelOptionsGeneration.incrementAndGet()
|
||||
val profileKey = modelOptionsProfileKey()
|
||||
@@ -832,15 +988,33 @@ class ChatViewModel : ViewModel() {
|
||||
_apiModelOptions.value = aliases
|
||||
_availableModels.value =
|
||||
(options.providers.flatMap { it.models } + aliases.map { it.id }).distinct()
|
||||
refreshRelayReasoningCapabilities()
|
||||
} else {
|
||||
val failure = providerResult.exceptionOrNull()
|
||||
if (
|
||||
failure !is ApiModelRoutingException ||
|
||||
failure.code != ApiModelRoutingErrorCode.INVENTORY_UNSUPPORTED
|
||||
) {
|
||||
_transientNotice.tryEmit(
|
||||
failure?.message ?: "Model inventory could not be loaded.",
|
||||
val inventoryFailure = failure
|
||||
?: ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||
"Model inventory could not be loaded.",
|
||||
)
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Api,
|
||||
severity = DiagnosticSeverity.Warning,
|
||||
title = "Optional model inventory unavailable",
|
||||
detail = inventoryFailure.message,
|
||||
operation = "Load API model inventory",
|
||||
endpointRole = "Optional API server",
|
||||
suggestion = NetworkDiagnosticGuidance.forThrowable(
|
||||
inventoryFailure,
|
||||
"API server",
|
||||
),
|
||||
stacktrace = inventoryFailure.stackTraceToString(),
|
||||
)
|
||||
modelInventoryFailureNotice(inventoryFailure, userInitiated)
|
||||
?.let(_transientNotice::tryEmit)
|
||||
return@launch
|
||||
}
|
||||
// Confirmed older API servers expose only OpenAI-compatible aliases.
|
||||
@@ -876,6 +1050,7 @@ class ChatViewModel : ViewModel() {
|
||||
if (locked != null) {
|
||||
_selectedModelOverride.value = locked.model
|
||||
_selectedProviderOverride.value = locked.provider
|
||||
transitionReasoningEffortIdentity()
|
||||
_transientNotice.tryEmit(
|
||||
"This session is locked to ${locked.model}. Start a new chat to use Server default.",
|
||||
)
|
||||
@@ -889,6 +1064,7 @@ class ChatViewModel : ViewModel() {
|
||||
// model is cleared so the next new session falls back to the default.
|
||||
_selectedProviderOverride.value =
|
||||
provider?.takeIf { it.isNotBlank() && !model.isNullOrBlank() }
|
||||
transitionReasoningEffortIdentity()
|
||||
val gateway = gatewayClient
|
||||
val handler = chatHandler
|
||||
if (model.isNullOrBlank()) {
|
||||
@@ -994,6 +1170,7 @@ class ChatViewModel : ViewModel() {
|
||||
fun selectApiModel(modelId: String) {
|
||||
_selectedModelOverride.value = modelId.trim().takeIf { it.isNotEmpty() }
|
||||
_selectedProviderOverride.value = null
|
||||
transitionReasoningEffortIdentity()
|
||||
refreshActiveAgentName()
|
||||
}
|
||||
|
||||
@@ -1011,6 +1188,10 @@ class ChatViewModel : ViewModel() {
|
||||
*/
|
||||
fun selectReasoningEffort(value: String) {
|
||||
val normalized = normalizeReasoningEffort(value)
|
||||
if (!reasoningEffortAvailability().accepts(normalized)) return
|
||||
val revision = reasoningEffortRevision.incrementAndGet()
|
||||
selectedReasoningEffortConfirmedIdentity = null
|
||||
val identity = reasoningEffortIdentity()
|
||||
_selectedReasoningEffort.value = normalized
|
||||
val gateway = gatewayClient ?: return
|
||||
val handler = chatHandler
|
||||
@@ -1020,9 +1201,17 @@ class ChatViewModel : ViewModel() {
|
||||
gateway.prewarm(handler.currentSessionId.value)
|
||||
gateway.setReasoning(normalized).fold(
|
||||
onSuccess = { result ->
|
||||
if (!isCurrentReasoningResponse(
|
||||
capturedRevision = revision,
|
||||
currentRevision = reasoningEffortRevision.get(),
|
||||
capturedIdentity = identity,
|
||||
activeIdentity = reasoningEffortIdentity(),
|
||||
)
|
||||
) return@fold
|
||||
_selectedReasoningEffort.value = normalizeReasoningEffort(
|
||||
result.stringValue("value") ?: normalized,
|
||||
)
|
||||
selectedReasoningEffortConfirmedIdentity = identity
|
||||
},
|
||||
onFailure = { e ->
|
||||
handler.addSystemNotice(
|
||||
@@ -1034,6 +1223,27 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun transitionReasoningEffortIdentity() {
|
||||
reasoningEffortRevision.incrementAndGet()
|
||||
selectedReasoningEffortConfirmedIdentity = null
|
||||
reconcilePendingReasoningEffortForModel()
|
||||
refreshRelayReasoningCapabilities()
|
||||
}
|
||||
|
||||
private fun reconcilePendingReasoningEffortForModel() {
|
||||
val identity = reasoningEffortIdentity()
|
||||
val reconciled = reconcilePendingReasoningEffort(
|
||||
value = _selectedReasoningEffort.value,
|
||||
confirmedIdentity = selectedReasoningEffortConfirmedIdentity,
|
||||
activeIdentity = identity,
|
||||
availability = reasoningEffortAvailability(),
|
||||
)
|
||||
if (reconciled != _selectedReasoningEffort.value) {
|
||||
reasoningEffortRevision.incrementAndGet()
|
||||
_selectedReasoningEffort.value = reconciled
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle per-session approval bypass (YOLO). Session-scoped + ephemeral the
|
||||
* way the desktop does it — never persists global auto-approve. Optimistic;
|
||||
@@ -1203,6 +1413,8 @@ class ChatViewModel : ViewModel() {
|
||||
// session.info confirm it on the first turn (see the dropped
|
||||
// getReasoningSettings below).
|
||||
_selectedReasoningEffort.value = null
|
||||
reasoningEffortRevision.incrementAndGet()
|
||||
selectedReasoningEffortConfirmedIdentity = null
|
||||
_reasoningDisplay.value = null
|
||||
// The personality overlay is per-profile. On SSE, leaving a stale pick
|
||||
// here would inject the previous profile's overlay onto the new
|
||||
@@ -1369,6 +1581,7 @@ class ChatViewModel : ViewModel() {
|
||||
val model = _selectedModelOverride.value?.takeIf { it.isNotBlank() }
|
||||
val provider = _selectedProviderOverride.value?.takeIf { it.isNotBlank() }
|
||||
val effort = _selectedReasoningEffort.value?.takeIf { it.isNotBlank() }
|
||||
?.takeIf { reasoningEffortAvailability().accepts(it) }
|
||||
// Contract v4 distinguishes all three states: null omits the field
|
||||
// and inherits the profile tier, true pins priority, and false pins
|
||||
// normal. Do not filter false here or a user's explicit Fast-off
|
||||
@@ -1411,15 +1624,17 @@ class ChatViewModel : ViewModel() {
|
||||
if (key != null) {
|
||||
backgroundPendingInteractions[key] =
|
||||
BackgroundPendingInteraction(event.profile, event.ask)
|
||||
backgroundNeedsInputKeys += key
|
||||
ActiveTurnKeepAliveRegistry.setWaiting(key.keepAliveKey(), true)
|
||||
publishBackgroundSessionActivity()
|
||||
}
|
||||
maybeNotifyInteraction(event.storedSessionId, event.ask, event.profile)
|
||||
}
|
||||
is GatewayBackgroundInteractionEvent.Resolved -> {
|
||||
if (key != null) {
|
||||
backgroundNeedsInputKeys -= key
|
||||
backgroundPendingInteractions.computeIfPresent(key) { _, current ->
|
||||
if (current.profile == event.profile &&
|
||||
current.ask.kind == event.ask.kind &&
|
||||
if (current.ask.kind == event.ask.kind &&
|
||||
current.ask.requestId == event.ask.requestId
|
||||
) {
|
||||
null
|
||||
@@ -1428,6 +1643,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
ActiveTurnKeepAliveRegistry.setWaiting(key.keepAliveKey(), false)
|
||||
publishBackgroundSessionActivity()
|
||||
}
|
||||
cancelInteractionNotification(event.storedSessionId, event.ask, event.profile)
|
||||
}
|
||||
@@ -1449,6 +1665,8 @@ class ChatViewModel : ViewModel() {
|
||||
// null = unknown (honest); refreshReasoningSettings/session.info
|
||||
// re-seed it. Never a stale default that could ride session.create.
|
||||
_selectedReasoningEffort.value = null
|
||||
reasoningEffortRevision.incrementAndGet()
|
||||
selectedReasoningEffortConfirmedIdentity = null
|
||||
_reasoningDisplay.value = null
|
||||
}
|
||||
// Catalog fetch must never cold-open /api/ws — only fetch over an
|
||||
@@ -1472,6 +1690,8 @@ class ChatViewModel : ViewModel() {
|
||||
// null = unknown (honest); refreshReasoningSettings/session.info
|
||||
// re-seed it. Never a stale default that could ride session.create.
|
||||
_selectedReasoningEffort.value = null
|
||||
reasoningEffortRevision.incrementAndGet()
|
||||
selectedReasoningEffortConfirmedIdentity = null
|
||||
_reasoningDisplay.value = null
|
||||
}
|
||||
}
|
||||
@@ -1492,9 +1712,11 @@ class ChatViewModel : ViewModel() {
|
||||
if (matching.isEmpty()) return
|
||||
matching.forEach(backgroundTurnCheckpoints::remove)
|
||||
matching.forEach { key ->
|
||||
backgroundNeedsInputKeys -= key
|
||||
backgroundPendingInteractions.remove(key)
|
||||
ActiveTurnKeepAliveRegistry.release(key.keepAliveKey())
|
||||
}
|
||||
publishBackgroundSessionActivity()
|
||||
chatTurnCheckpointStore?.let { store ->
|
||||
viewModelScope.launch {
|
||||
checkpointMutex.withLock {
|
||||
@@ -2316,27 +2538,32 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
launch {
|
||||
client.serverModel.collect { value ->
|
||||
if (gatewayClient !== client || value.isNullOrBlank()) return@collect
|
||||
if (_gatewayCurrentModel.value != value) _gatewayCurrentModel.value = value
|
||||
}
|
||||
}
|
||||
launch {
|
||||
client.serverProvider.collect { value ->
|
||||
if (gatewayClient !== client || value.isNullOrBlank()) return@collect
|
||||
if (_gatewayCurrentProvider.value != value) _gatewayCurrentProvider.value = value
|
||||
}
|
||||
}
|
||||
launch {
|
||||
client.serverReasoningEffort.collect { value ->
|
||||
// Ignore blank (upstream "" = reasoning disabled — never
|
||||
// clobber the chip). Compare on the normalized value, the
|
||||
// same form the optimistic setter + config.get refresh store.
|
||||
if (gatewayClient !== client || value.isNullOrBlank()) return@collect
|
||||
val normalized = normalizeReasoningEffort(value)
|
||||
if (_selectedReasoningEffort.value != normalized) {
|
||||
_selectedReasoningEffort.value = normalized
|
||||
client.serverModelIdentity.collect { identity ->
|
||||
if (gatewayClient !== client || identity == null) return@collect
|
||||
_gatewayCurrentModel.value = identity.model
|
||||
_gatewayCurrentProvider.value = identity.provider
|
||||
// A session.info identity is authoritative and coherent.
|
||||
// Retaining a pending override after an acknowledgement or
|
||||
// external Desktop/TUI switch can otherwise pair its stale
|
||||
// model with the newly reported provider.
|
||||
if (_selectedModelOverride.value != null) {
|
||||
_selectedModelOverride.value = null
|
||||
_selectedProviderOverride.value = null
|
||||
}
|
||||
transitionReasoningEffortIdentity()
|
||||
}
|
||||
}
|
||||
launch {
|
||||
client.serverReasoningIdentity.collect { state ->
|
||||
if (gatewayClient !== client || state == null) return@collect
|
||||
val activeIdentity = reasoningEffortIdentity() ?: return@collect
|
||||
if (
|
||||
!state.identity.provider.equals(activeIdentity.provider, ignoreCase = true) ||
|
||||
state.identity.model != activeIdentity.model
|
||||
) return@collect
|
||||
reasoningEffortRevision.incrementAndGet()
|
||||
selectedReasoningEffortConfirmedIdentity = activeIdentity
|
||||
_selectedReasoningEffort.value = normalizeReasoningEffort(state.effort)
|
||||
}
|
||||
}
|
||||
launch {
|
||||
@@ -2696,6 +2923,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
ensureCheckpointObservers()
|
||||
this.relayHttpClient = relayHttpClient
|
||||
if (_modelProviders.value.isNotEmpty()) refreshRelayReasoningCapabilities()
|
||||
this.mediaSettingsRepo = mediaSettingsRepo
|
||||
this.mediaCacheWriter = mediaCacheWriter
|
||||
|
||||
@@ -2784,6 +3012,10 @@ class ChatViewModel : ViewModel() {
|
||||
sessionRefreshJob?.cancel()
|
||||
_isLoadingSessions.value = false
|
||||
activeProfileContextKey = null
|
||||
relayCapabilityGeneration.incrementAndGet()
|
||||
relayReasoningCapabilities.value = emptyMap()
|
||||
_reasoningCapabilityRevision.value += 1L
|
||||
publishBackgroundSessionActivity()
|
||||
_queuedMessages.value = emptyList()
|
||||
_pendingAttachments.value = emptyList()
|
||||
_steerableTurn.value = false
|
||||
@@ -2800,6 +3032,8 @@ class ChatViewModel : ViewModel() {
|
||||
// reset to unknown/default so neither a stale chip nor a stale
|
||||
// SSE persona overlay carries into the new connection.
|
||||
_selectedReasoningEffort.value = null
|
||||
reasoningEffortRevision.incrementAndGet()
|
||||
selectedReasoningEffortConfirmedIdentity = null
|
||||
_reasoningDisplay.value = null
|
||||
_selectedPersonality.value = "default"
|
||||
pendingTruncateOrdinal = null
|
||||
@@ -2835,6 +3069,8 @@ class ChatViewModel : ViewModel() {
|
||||
) {
|
||||
activateModelOptionsProfile(contextKey)
|
||||
activeProfileContextKey = contextKey
|
||||
refreshRelayReasoningCapabilities()
|
||||
publishBackgroundSessionActivity()
|
||||
activeTurnCheckpointSeed?.contextKey = contextKey
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
selectBackgroundProcessSession(sessionId, contextKey)
|
||||
@@ -2852,6 +3088,8 @@ class ChatViewModel : ViewModel() {
|
||||
_isLoadingSessions.value = false
|
||||
activateModelOptionsProfile(contextKey)
|
||||
activeProfileContextKey = contextKey
|
||||
refreshRelayReasoningCapabilities()
|
||||
publishBackgroundSessionActivity()
|
||||
_queuedMessages.value = emptyList()
|
||||
_pendingAttachments.value = emptyList()
|
||||
_steerableTurn.value = false
|
||||
@@ -2867,10 +3105,12 @@ class ChatViewModel : ViewModel() {
|
||||
// SSE profile switch: reset the reasoning chip to unknown (a sessionless
|
||||
// config.get reads the wrong profile's effort) and the personality to
|
||||
// default so composeInjectedContext can't inject the previous profile's
|
||||
// overlay onto this profile's first SSE turn. The serverReasoningEffort /
|
||||
// serverPersonality collectors (gateway) and session.info reconcile after
|
||||
// the first turn; on pure SSE the new profile's own SOUL carries instead.
|
||||
// overlay onto this profile's first SSE turn. The identity-bound reasoning
|
||||
// and personality collectors reconcile gateway session.info after the
|
||||
// first turn; on pure SSE the new profile's own SOUL carries instead.
|
||||
_selectedReasoningEffort.value = null
|
||||
reasoningEffortRevision.incrementAndGet()
|
||||
selectedReasoningEffortConfirmedIdentity = null
|
||||
_reasoningDisplay.value = null
|
||||
_selectedPersonality.value = "default"
|
||||
// The agent display name was stamped above with the pre-reset persona —
|
||||
@@ -2999,8 +3239,8 @@ class ChatViewModel : ViewModel() {
|
||||
* groups refresh via [refreshModelOptions]; this covers [availableModels] used
|
||||
* when no gateway model.options groups exist. Fetched once otherwise.
|
||||
*/
|
||||
fun refreshModels() {
|
||||
fetchModels()
|
||||
fun refreshModels(userInitiated: Boolean = false) {
|
||||
fetchModels(userInitiated)
|
||||
}
|
||||
|
||||
/** Clear server-owned catalogs before a different connection starts loading. */
|
||||
@@ -3650,7 +3890,11 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId?.let { cancelInteractionNotification(it, pending.ask) }
|
||||
}
|
||||
val activeKey = activeTurnCheckpointKey()
|
||||
if (activeKey != null) backgroundPendingInteractions.remove(activeKey)
|
||||
if (activeKey != null) {
|
||||
backgroundNeedsInputKeys -= activeKey
|
||||
backgroundPendingInteractions.remove(activeKey)
|
||||
publishBackgroundSessionActivity()
|
||||
}
|
||||
val now = restored?.receivedAt ?: System.currentTimeMillis()
|
||||
val cardKey = restored?.cardKey ?: ask.requestId
|
||||
?: "approval-${handler.currentSessionId.value ?: "session"}-$now"
|
||||
@@ -4553,8 +4797,10 @@ class ChatViewModel : ViewModel() {
|
||||
checkpointWriteJob = null
|
||||
if (key != null) {
|
||||
backgroundTurnCheckpoints.remove(key)
|
||||
backgroundNeedsInputKeys -= key
|
||||
backgroundPendingInteractions.remove(key)
|
||||
ActiveTurnKeepAliveRegistry.release(key.keepAliveKey())
|
||||
publishBackgroundSessionActivity()
|
||||
}
|
||||
val store = chatTurnCheckpointStore ?: return
|
||||
viewModelScope.launch {
|
||||
@@ -4589,6 +4835,8 @@ class ChatViewModel : ViewModel() {
|
||||
if (checkpoint != null) {
|
||||
val key = TurnCheckpointKey(checkpoint.contextKey, checkpoint.sessionId)
|
||||
backgroundTurnCheckpoints[key] = checkpoint
|
||||
if (checkpoint.pendingAsk != null) backgroundNeedsInputKeys += key
|
||||
publishBackgroundSessionActivity()
|
||||
chatTurnCheckpointStore?.let { store ->
|
||||
viewModelScope.launch {
|
||||
checkpointMutex.withLock { runCatching { store.write(checkpoint) } }
|
||||
@@ -4669,6 +4917,8 @@ class ChatViewModel : ViewModel() {
|
||||
runCatching { store.read(contextKey, sessionId) }.getOrNull()
|
||||
}
|
||||
?: return false
|
||||
backgroundNeedsInputKeys -= key
|
||||
publishBackgroundSessionActivity()
|
||||
if (checkpoint.transport !in setOf("gateway", "sessions")) return false
|
||||
if (activeStream != null) return true
|
||||
if (streamRecovery != null) {
|
||||
@@ -7813,6 +8063,8 @@ class ChatViewModel : ViewModel() {
|
||||
gatewayClient?.setUnmatchedTurnCompleteListener(null)
|
||||
gatewayClient?.setBackgroundInteractionListener(null)
|
||||
backgroundPendingInteractions.clear()
|
||||
backgroundNeedsInputKeys.clear()
|
||||
publishBackgroundSessionActivity()
|
||||
gatewayHistoryReconcileJob?.cancel()
|
||||
gatewayHistoryReconcileJob = null
|
||||
backgroundProcessSessionJob?.cancel()
|
||||
@@ -8117,12 +8369,32 @@ private fun JsonObject.stringValue(key: String): String? =
|
||||
|
||||
private const val RECENT_PROMPTS_LIMIT = 15
|
||||
private const val MAX_GATEWAY_COMMAND_DISPLAY_CHARS = 2_000
|
||||
private const val DEFAULT_REASONING_EFFORT = "medium"
|
||||
private val VALID_REASONING_EFFORTS = setOf("none", "minimal", "low", "medium", "high", "xhigh")
|
||||
internal fun normalizeReasoningEffort(value: String?): String = ReasoningEfforts.normalize(value)
|
||||
|
||||
private fun normalizeReasoningEffort(value: String?): String {
|
||||
val normalized = value?.trim()?.lowercase().orEmpty()
|
||||
return normalized.takeIf { it in VALID_REASONING_EFFORTS } ?: DEFAULT_REASONING_EFFORT
|
||||
internal fun isCurrentReasoningResponse(
|
||||
capturedRevision: Long,
|
||||
currentRevision: Long,
|
||||
capturedIdentity: ReasoningEffortIdentity?,
|
||||
activeIdentity: ReasoningEffortIdentity?,
|
||||
): Boolean = capturedRevision == currentRevision && capturedIdentity == activeIdentity
|
||||
|
||||
internal fun isCurrentReasoningCapabilityOverlay(
|
||||
requestGeneration: Long,
|
||||
currentGeneration: Long,
|
||||
requestProfileKey: String,
|
||||
currentProfileKey: String,
|
||||
): Boolean = requestGeneration == currentGeneration && requestProfileKey == currentProfileKey
|
||||
|
||||
internal fun reconcilePendingReasoningEffort(
|
||||
value: String?,
|
||||
confirmedIdentity: ReasoningEffortIdentity?,
|
||||
activeIdentity: ReasoningEffortIdentity?,
|
||||
availability: ReasoningEffortAvailability,
|
||||
): String? = when {
|
||||
value == null -> null
|
||||
confirmedIdentity != null && confirmedIdentity == activeIdentity -> value
|
||||
availability.accepts(value) -> value
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun String.compactWords(): String = replace(Regex("\\s+"), " ").trim()
|
||||
|
||||
@@ -5267,12 +5267,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
return@launch
|
||||
}
|
||||
|
||||
val diagnosticApiUrl = effectiveApiServerUrlSnapshot()
|
||||
_apiServerHealth.value = HealthStatus.Probing
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Api,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = ctx.getString(R.string.conn_status_testing_standard),
|
||||
url = effectiveApiServerUrlSnapshot(),
|
||||
operation = "Hermes API health check",
|
||||
configuredUrl = diagnosticApiUrl,
|
||||
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/health",
|
||||
)
|
||||
|
||||
val health = client.checkHealthDetailed()
|
||||
@@ -5284,7 +5287,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = ctx.getString(R.string.conn_status_setup_health_failed),
|
||||
detail = health.message,
|
||||
url = effectiveApiServerUrlSnapshot(),
|
||||
operation = "Hermes API health check",
|
||||
configuredUrl = diagnosticApiUrl,
|
||||
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/health",
|
||||
)
|
||||
onResult(
|
||||
StandardApiSetupResult(
|
||||
@@ -5365,7 +5370,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
severity = if (reachable) DiagnosticSeverity.Info else DiagnosticSeverity.Error,
|
||||
title = if (reachable) ctx.getString(R.string.conn_status_hermes_ok) else ctx.getString(R.string.conn_status_auth_failed),
|
||||
detail = message,
|
||||
url = effectiveApiServerUrlSnapshot(),
|
||||
operation = "Hermes API sessions authentication check",
|
||||
configuredUrl = diagnosticApiUrl,
|
||||
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/api/sessions",
|
||||
)
|
||||
onResult(
|
||||
StandardApiSetupResult(
|
||||
@@ -5587,12 +5594,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
return@launch
|
||||
}
|
||||
|
||||
val diagnosticApiUrl = effectiveApiServerUrlSnapshot()
|
||||
_apiServerHealth.value = HealthStatus.Probing
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Api,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = ctx.getString(R.string.conn_status_testing_api),
|
||||
url = effectiveApiServerUrlSnapshot(),
|
||||
operation = "Hermes API health check",
|
||||
configuredUrl = diagnosticApiUrl,
|
||||
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/health",
|
||||
)
|
||||
val health = client.checkHealthDetailed()
|
||||
if (health is com.hermesandroid.relay.network.upstream.HealthCheckResult.Unhealthy) {
|
||||
@@ -5603,7 +5613,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = ctx.getString(R.string.conn_status_api_health_failed),
|
||||
detail = health.message,
|
||||
url = effectiveApiServerUrlSnapshot(),
|
||||
operation = "Hermes API health check",
|
||||
configuredUrl = diagnosticApiUrl,
|
||||
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/health",
|
||||
)
|
||||
onResult(false, health.message)
|
||||
return@launch
|
||||
@@ -5624,7 +5636,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
severity = if (reachable) DiagnosticSeverity.Info else DiagnosticSeverity.Error,
|
||||
title = if (reachable) ctx.getString(R.string.conn_status_api_test_ok) else ctx.getString(R.string.conn_status_api_test_failed),
|
||||
detail = message,
|
||||
url = effectiveApiServerUrlSnapshot(),
|
||||
operation = "Hermes API sessions authentication check",
|
||||
configuredUrl = diagnosticApiUrl,
|
||||
requestUrl = "${diagnosticApiUrl.trimEnd('/')}/api/sessions",
|
||||
)
|
||||
onResult(reachable, message)
|
||||
}
|
||||
|
||||
@@ -107,6 +107,8 @@
|
||||
<string name="chat_placeholder_message">Mensagem…</string>
|
||||
<string name="chat_edit_busy_snackbar">Não é possível editar agora — aguarde o turno atual terminar</string>
|
||||
<string name="chat_select_reasoning_effort">Selecionar nível de raciocínio</string>
|
||||
<string name="reasoning_effort_standard_levels_notice">O Hermes não informa níveis exatos de esforço para este modelo, então as opções padrão são exibidas.</string>
|
||||
<string name="reasoning_effort_current_outside_supported">Sessão atual: %1$s (informado pelo Hermes). Novas seleções para este modelo estão limitadas a %2$s.</string>
|
||||
<!-- Voice toasts -->
|
||||
<string name="voice_toast_signin_route">A voz exige um login único pela rota %1$s — abra Gerenciar</string>
|
||||
<string name="voice_toast_signin_default">A voz exige login no painel — abra Gerenciar para entrar</string>
|
||||
@@ -806,6 +808,9 @@
|
||||
<string name="drawer_refresh_sessions">Atualizar sessões</string>
|
||||
<string name="drawer_new_chat">Novo Chat</string>
|
||||
<string name="drawer_search_placeholder">Pesquisar sessões ou id...</string>
|
||||
<string name="drawer_search_sessions">Pesquisar sessões</string>
|
||||
<string name="drawer_activity_working">Em andamento</string>
|
||||
<string name="drawer_activity_needs_input">Precisa de resposta</string>
|
||||
<string name="drawer_new_thread">Nova Thread</string>
|
||||
<string name="drawer_chats_not_named">Os chats não recebem nomes automáticos nesta conexão — use ⋮ → Renomear.</string>
|
||||
<string name="drawer_loading_sessions">Carregando sessões…</string>
|
||||
@@ -2518,7 +2523,14 @@
|
||||
<string name="attachment_type_file">Arquivo</string>
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">O Hermes-Relay fechou inesperadamente</string>
|
||||
<string name="crash_body">A última sessão falhou. Enviar este relatório ajuda a corrigir o problema mais rápido.</string>
|
||||
<string name="crash_body">O Hermes-Relay reiniciou após um problema inesperado. Você pode continuar usando o app.</string>
|
||||
<string name="crash_privacy">Nada foi enviado. Revise o relatório editado localmente antes de compartilhar.</string>
|
||||
<string name="crash_review">Revisar relatório</string>
|
||||
<string name="crash_share_title">Compartilhar relatório de falha</string>
|
||||
<string name="crash_share_subject">Relatório de falha do Hermes-Relay — %1$s</string>
|
||||
<string name="crash_toast_no_share">Relatório copiado — nenhum app encontrado para compartilhar</string>
|
||||
<string name="crash_toast_opened">Relatório completo copiado — revise o problema no GitHub antes de enviar</string>
|
||||
<string name="crash_toast_no_browser">Relatório copiado — nenhum navegador encontrado para abrir o GitHub</string>
|
||||
<string name="crash_dismiss">Dispensar</string>
|
||||
<string name="crash_copy">Copiar</string>
|
||||
<string name="crash_share">Compartilhar</string>
|
||||
@@ -2722,6 +2734,9 @@
|
||||
<string name="chat_reasoning_low">Baixo</string>
|
||||
<string name="chat_reasoning_medium">Médio</string>
|
||||
<string name="chat_reasoning_high">Alto</string>
|
||||
<string name="chat_reasoning_xhigh">Extra alto</string>
|
||||
<string name="chat_reasoning_max">Máximo</string>
|
||||
<string name="chat_reasoning_ultra">Ultra</string>
|
||||
<string name="chat_date_today">Hoje</string>
|
||||
<string name="chat_date_yesterday">Ontem</string>
|
||||
<string name="chat_share_subject">Conversa do Hermes</string>
|
||||
@@ -3679,4 +3694,10 @@
|
||||
<string name="plugins_keep">Manter</string>
|
||||
<string name="plugins_remove">Remover</string>
|
||||
<string name="plugins_remove_confirm">Remover “%1$s”? Esta página do plugin não aparecerá mais nos dispositivos Android conectados.</string>
|
||||
<string name="support_bundle_review">Revisar informações de suporte</string>
|
||||
<string name="support_bundle_title">Informações de suporte</string>
|
||||
<string name="support_bundle_privacy">Nada é enviado automaticamente. Revise a exportação local com até %1$d relatórios.</string>
|
||||
<string name="support_bundle_copied">Informações de suporte copiadas</string>
|
||||
<string name="support_bundle_no_share">Informações de suporte copiadas — nenhum app encontrado para compartilhar</string>
|
||||
<string name="support_bundle_share_title">Compartilhar informações de suporte do Hermes-Relay</string>
|
||||
</resources>
|
||||
|
||||
@@ -118,6 +118,8 @@
|
||||
<string name="chat_placeholder_message">输入消息…</string>
|
||||
<string name="chat_edit_busy_snackbar">暂时无法编辑——请等当前这一轮结束</string>
|
||||
<string name="chat_select_reasoning_effort">选择推理强度</string>
|
||||
<string name="reasoning_effort_standard_levels_notice">Hermes 未公布此模型的确切推理强度级别,因此显示标准选项。</string>
|
||||
<string name="reasoning_effort_current_outside_supported">当前会话:%1$s(由 Hermes 报告)。此模型的新选择仅限于 %2$s。</string>
|
||||
|
||||
<!-- 语音提示 -->
|
||||
<string name="voice_toast_signin_route">语音需要在 %1$s 路由上登录一次——打开管理</string>
|
||||
@@ -852,6 +854,9 @@
|
||||
<string name="drawer_refresh_sessions">刷新会话</string>
|
||||
<string name="drawer_new_chat">新对话</string>
|
||||
<string name="drawer_search_placeholder">搜索会话或 ID…</string>
|
||||
<string name="drawer_search_sessions">搜索会话</string>
|
||||
<string name="drawer_activity_working">正在处理</string>
|
||||
<string name="drawer_activity_needs_input">需要输入</string>
|
||||
<string name="drawer_new_thread">新话题</string>
|
||||
<string name="drawer_chats_not_named">此连接上的对话不会自动命名——使用 ⋮ → 重命名。</string>
|
||||
<string name="drawer_loading_sessions">正在加载会话…</string>
|
||||
@@ -2636,7 +2641,14 @@
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay 意外关闭</string>
|
||||
<string name="crash_body">上次会话崩溃了。发送此报告有助于更快修复问题。</string>
|
||||
<string name="crash_body">Hermes-Relay 在意外问题后已重新启动。你可以继续使用应用。</string>
|
||||
<string name="crash_privacy">未发送任何内容。分享前请查看在本地脱敏的报告。</string>
|
||||
<string name="crash_review">查看报告</string>
|
||||
<string name="crash_share_title">分享崩溃报告</string>
|
||||
<string name="crash_share_subject">Hermes-Relay 崩溃报告 — %1$s</string>
|
||||
<string name="crash_toast_no_share">报告已复制 — 未找到可分享的应用</string>
|
||||
<string name="crash_toast_opened">完整报告已复制 — 提交前请检查 GitHub 问题</string>
|
||||
<string name="crash_toast_no_browser">报告已复制 — 未找到可打开 GitHub 的浏览器</string>
|
||||
<string name="crash_dismiss">忽略</string>
|
||||
<string name="crash_copy">复制</string>
|
||||
<string name="crash_share">分享</string>
|
||||
@@ -2853,6 +2865,9 @@
|
||||
<string name="chat_reasoning_low">低</string>
|
||||
<string name="chat_reasoning_medium">中</string>
|
||||
<string name="chat_reasoning_high">高</string>
|
||||
<string name="chat_reasoning_xhigh">特高</string>
|
||||
<string name="chat_reasoning_max">最高</string>
|
||||
<string name="chat_reasoning_ultra">超高</string>
|
||||
<string name="chat_date_today">今天</string>
|
||||
<string name="chat_date_yesterday">昨天</string>
|
||||
<string name="chat_share_subject">Hermes 对话</string>
|
||||
@@ -3767,4 +3782,10 @@
|
||||
<string name="plugins_keep">保留</string>
|
||||
<string name="plugins_remove">移除</string>
|
||||
<string name="plugins_remove_confirm">要移除“%1$s”吗?此插件页面将不再显示在已连接的 Android 设备上。</string>
|
||||
<string name="support_bundle_review">查看支持信息</string>
|
||||
<string name="support_bundle_title">支持信息</string>
|
||||
<string name="support_bundle_privacy">不会自动上传任何内容。请查看最多包含 %1$d 个报告的本地导出。</string>
|
||||
<string name="support_bundle_copied">支持信息已复制</string>
|
||||
<string name="support_bundle_no_share">支持信息已复制 — 未找到可分享的应用</string>
|
||||
<string name="support_bundle_share_title">分享 Hermes-Relay 支持信息</string>
|
||||
</resources>
|
||||
|
||||
@@ -118,6 +118,8 @@
|
||||
<string name="chat_placeholder_message">Nachricht…</string>
|
||||
<string name="chat_edit_busy_snackbar">Bearbeiten derzeit nicht möglich — warte, bis der aktuelle Durchlauf beendet ist</string>
|
||||
<string name="chat_select_reasoning_effort">Denkaufwand auswählen</string>
|
||||
<string name="reasoning_effort_standard_levels_notice">Hermes gibt für dieses Modell keine genauen Aufwandsstufen an. Daher werden Standardoptionen angezeigt.</string>
|
||||
<string name="reasoning_effort_current_outside_supported">Aktuelle Sitzung: %1$s (von Hermes gemeldet). Neue Auswahlen für dieses Modell sind auf %2$s beschränkt.</string>
|
||||
|
||||
<!-- Voice toasts -->
|
||||
<string name="voice_toast_signin_route">Sprache erfordert eine einmalige Anmeldung über die Route %1$s — öffne Verwalten</string>
|
||||
@@ -855,6 +857,9 @@
|
||||
<string name="drawer_refresh_sessions">Sitzungen aktualisieren</string>
|
||||
<string name="drawer_new_chat">Neuer Chat</string>
|
||||
<string name="drawer_search_placeholder">Sitzungen oder ID suchen...</string>
|
||||
<string name="drawer_search_sessions">Sitzungen durchsuchen</string>
|
||||
<string name="drawer_activity_working">Wird bearbeitet</string>
|
||||
<string name="drawer_activity_needs_input">Eingabe erforderlich</string>
|
||||
<string name="drawer_new_thread">Neuer Thread</string>
|
||||
<string name="drawer_chats_not_named">Chats werden bei dieser Verbindung nicht automatisch benannt — verwende ⋮ → Umbenennen.</string>
|
||||
<string name="drawer_loading_sessions">Sitzungen werden geladen…</string>
|
||||
@@ -2640,7 +2645,14 @@
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay wurde unerwartet beendet</string>
|
||||
<string name="crash_body">Die letzte Sitzung ist abgestürzt. Dieser Bericht hilft, den Fehler schneller zu beheben.</string>
|
||||
<string name="crash_body">Hermes-Relay wurde nach einem unerwarteten Problem neu gestartet. Sie können die App weiter verwenden.</string>
|
||||
<string name="crash_privacy">Es wurde nichts gesendet. Prüfen Sie den lokal bereinigten Bericht vor dem Teilen.</string>
|
||||
<string name="crash_review">Bericht prüfen</string>
|
||||
<string name="crash_share_title">Absturzbericht teilen</string>
|
||||
<string name="crash_share_subject">Hermes-Relay-Absturzbericht — %1$s</string>
|
||||
<string name="crash_toast_no_share">Bericht kopiert — keine App zum Teilen gefunden</string>
|
||||
<string name="crash_toast_opened">Vollständiger Bericht kopiert — GitHub-Problem vor dem Senden prüfen</string>
|
||||
<string name="crash_toast_no_browser">Bericht kopiert — kein Browser für GitHub gefunden</string>
|
||||
<string name="crash_dismiss">Schließen</string>
|
||||
<string name="crash_copy">Kopieren</string>
|
||||
<string name="crash_share">Teilen</string>
|
||||
@@ -2857,6 +2869,9 @@
|
||||
<string name="chat_reasoning_low">Niedrig</string>
|
||||
<string name="chat_reasoning_medium">Mittel</string>
|
||||
<string name="chat_reasoning_high">Hoch</string>
|
||||
<string name="chat_reasoning_xhigh">Extra hoch</string>
|
||||
<string name="chat_reasoning_max">Maximum</string>
|
||||
<string name="chat_reasoning_ultra">Ultra</string>
|
||||
<string name="chat_date_today">Heute</string>
|
||||
<string name="chat_date_yesterday">Gestern</string>
|
||||
<string name="chat_share_subject">Hermes-Unterhaltung</string>
|
||||
@@ -3839,4 +3854,10 @@
|
||||
<string name="plugins_keep">Behalten</string>
|
||||
<string name="plugins_remove">Entfernen</string>
|
||||
<string name="plugins_remove_confirm">„%1$s“ entfernen? Diese Plugin-Seite wird auf verbundenen Android-Geräten nicht mehr angezeigt.</string>
|
||||
<string name="support_bundle_review">Supportinformationen prüfen</string>
|
||||
<string name="support_bundle_title">Supportinformationen</string>
|
||||
<string name="support_bundle_privacy">Nichts wird automatisch hochgeladen. Prüfen Sie den lokalen Export mit bis zu %1$d Berichten.</string>
|
||||
<string name="support_bundle_copied">Supportinformationen kopiert</string>
|
||||
<string name="support_bundle_no_share">Supportinformationen kopiert — keine App zum Teilen gefunden</string>
|
||||
<string name="support_bundle_share_title">Hermes-Relay-Supportinformationen teilen</string>
|
||||
</resources>
|
||||
|
||||
@@ -101,6 +101,8 @@
|
||||
<string name="chat_placeholder_message">Mensaje…</string>
|
||||
<string name="chat_edit_busy_snackbar">No se puede editar en este momento: espera a que termine el turno actual</string>
|
||||
<string name="chat_select_reasoning_effort">Seleccionar esfuerzo de razonamiento</string>
|
||||
<string name="reasoning_effort_standard_levels_notice">Hermes no anuncia niveles de esfuerzo exactos para este modelo, por lo que se muestran las opciones estándar.</string>
|
||||
<string name="reasoning_effort_current_outside_supported">Sesión actual: %1$s (informado por Hermes). Las nuevas selecciones para este modelo se limitan a %2$s.</string>
|
||||
<string name="voice_toast_signin_route">Voice necesita un inicio de sesión único en la ruta %1$s: abra Administrar</string>
|
||||
<string name="voice_toast_signin_default">Voice necesita iniciar sesión en el panel: abra Administrar para iniciar sesión</string>
|
||||
<string name="voice_toast_unsupported">Esta compilación Hermes no tiene rutas de voz: actualice el agente hermes o empareje Relay</string>
|
||||
@@ -770,6 +772,9 @@
|
||||
<string name="drawer_refresh_sessions">Actualizar sesiones</string>
|
||||
<string name="drawer_new_chat">Nuevo chat</string>
|
||||
<string name="drawer_search_placeholder">Buscar sesiones o id...</string>
|
||||
<string name="drawer_search_sessions">Buscar sesiones</string>
|
||||
<string name="drawer_activity_working">En curso</string>
|
||||
<string name="drawer_activity_needs_input">Requiere intervención</string>
|
||||
<string name="drawer_new_thread">Nuevo hilo</string>
|
||||
<string name="drawer_chats_not_named">Los chats no tienen nombre automático en esta conexión. Utiliza «→Renombrar».</string>
|
||||
<string name="drawer_loading_sessions">Cargando sesiones…</string>
|
||||
@@ -2393,7 +2398,14 @@
|
||||
<string name="attachment_type_text">Texto</string>
|
||||
<string name="attachment_type_file">Archivo</string>
|
||||
<string name="crash_title">Hermes-Relay cerró inesperadamente</string>
|
||||
<string name="crash_body">La última sesión fracasó. Enviar este informe ayuda a solucionarlo más rápido.</string>
|
||||
<string name="crash_body">Hermes-Relay se reinició tras un problema inesperado. Puedes seguir usando la aplicación.</string>
|
||||
<string name="crash_privacy">No se envió nada. Revisa el informe redactado localmente antes de compartirlo.</string>
|
||||
<string name="crash_review">Revisar informe</string>
|
||||
<string name="crash_share_title">Compartir informe de fallo</string>
|
||||
<string name="crash_share_subject">Informe de fallo de Hermes-Relay — %1$s</string>
|
||||
<string name="crash_toast_no_share">Informe copiado — no se encontró una aplicación para compartir</string>
|
||||
<string name="crash_toast_opened">Informe completo copiado — revisa la incidencia de GitHub antes de enviarla</string>
|
||||
<string name="crash_toast_no_browser">Informe copiado — no se encontró un navegador para GitHub</string>
|
||||
<string name="crash_dismiss">Descartar</string>
|
||||
<string name="crash_copy">Copiar</string>
|
||||
<string name="crash_share">Compartir</string>
|
||||
@@ -2584,6 +2596,9 @@
|
||||
<string name="chat_reasoning_low">Bajo</string>
|
||||
<string name="chat_reasoning_medium">Medio</string>
|
||||
<string name="chat_reasoning_high">Alto</string>
|
||||
<string name="chat_reasoning_xhigh">Muy alto</string>
|
||||
<string name="chat_reasoning_max">Máximo</string>
|
||||
<string name="chat_reasoning_ultra">Ultra</string>
|
||||
<string name="chat_date_today">Hoy</string>
|
||||
<string name="chat_date_yesterday">Ayer</string>
|
||||
<string name="chat_share_subject">Conversación Hermes</string>
|
||||
@@ -3524,4 +3539,10 @@
|
||||
<string name="plugins_keep">Conservar</string>
|
||||
<string name="plugins_remove">Eliminar</string>
|
||||
<string name="plugins_remove_confirm">¿Eliminar «%1$s»? Esta página del plugin dejará de aparecer en los dispositivos Android conectados.</string>
|
||||
<string name="support_bundle_review">Revisar información de soporte</string>
|
||||
<string name="support_bundle_title">Información de soporte</string>
|
||||
<string name="support_bundle_privacy">Nada se sube automáticamente. Revisa la exportación local con hasta %1$d informes.</string>
|
||||
<string name="support_bundle_copied">Información de soporte copiada</string>
|
||||
<string name="support_bundle_no_share">Información de soporte copiada — no se encontró una aplicación para compartir</string>
|
||||
<string name="support_bundle_share_title">Compartir información de soporte de Hermes-Relay</string>
|
||||
</resources>
|
||||
|
||||
@@ -118,6 +118,8 @@
|
||||
<string name="chat_placeholder_message">メッセージ…</string>
|
||||
<string name="chat_edit_busy_snackbar">現在編集できません - 現在のターンが終了するまで待ちます</string>
|
||||
<string name="chat_select_reasoning_effort">推論努力を選択する</string>
|
||||
<string name="reasoning_effort_standard_levels_notice">Hermes はこのモデルの正確な推論レベルを公開していないため、標準オプションを表示しています。</string>
|
||||
<string name="reasoning_effort_current_outside_supported">現在のセッション:%1$s(Hermes から報告)。このモデルで新たに選択できるのは %2$s です。</string>
|
||||
|
||||
<!-- Voice toasts -->
|
||||
<string name="voice_toast_signin_route">Voice には %1$s ルートでの 1 回限りのサインインが必要です — [管理] を開きます</string>
|
||||
@@ -868,6 +870,9 @@
|
||||
<string name="drawer_refresh_sessions">セッションを更新する</string>
|
||||
<string name="drawer_new_chat">新しいチャット</string>
|
||||
<string name="drawer_search_placeholder">セッションまたは ID を検索...</string>
|
||||
<string name="drawer_search_sessions">セッションを検索</string>
|
||||
<string name="drawer_activity_working">処理中</string>
|
||||
<string name="drawer_activity_needs_input">入力が必要</string>
|
||||
<string name="drawer_new_thread">新しいスレッド</string>
|
||||
<string name="drawer_chats_not_named">この接続ではチャットの名前は自動的に付けられません。「⋮」→「名前の変更」を使用してください。</string>
|
||||
<string name="drawer_loading_sessions">セッションを読み込み中…</string>
|
||||
@@ -2650,7 +2655,14 @@
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay が予期せず終了しました</string>
|
||||
<string name="crash_body">最後のセッションがクラッシュしました。このレポートを送信すると、問題をより早く修正できます。</string>
|
||||
<string name="crash_body">予期しない問題の後、Hermes-Relay が再起動しました。アプリは引き続き使用できます。</string>
|
||||
<string name="crash_privacy">何も送信されていません。共有する前に端末内で編集されたレポートを確認してください。</string>
|
||||
<string name="crash_review">レポートを確認</string>
|
||||
<string name="crash_share_title">クラッシュレポートを共有</string>
|
||||
<string name="crash_share_subject">Hermes-Relay クラッシュレポート — %1$s</string>
|
||||
<string name="crash_toast_no_share">レポートをコピーしました — 共有できるアプリがありません</string>
|
||||
<string name="crash_toast_opened">完全なレポートをコピーしました — 送信前に GitHub の内容を確認してください</string>
|
||||
<string name="crash_toast_no_browser">レポートをコピーしました — GitHub を開けるブラウザがありません</string>
|
||||
<string name="crash_dismiss">却下する</string>
|
||||
<string name="crash_copy">コピー</string>
|
||||
<string name="crash_share">共有</string>
|
||||
@@ -2867,6 +2879,9 @@
|
||||
<string name="chat_reasoning_low">低い</string>
|
||||
<string name="chat_reasoning_medium">中くらい</string>
|
||||
<string name="chat_reasoning_high">高い</string>
|
||||
<string name="chat_reasoning_xhigh">非常に高い</string>
|
||||
<string name="chat_reasoning_max">最大</string>
|
||||
<string name="chat_reasoning_ultra">ウルトラ</string>
|
||||
<string name="chat_date_today">今日</string>
|
||||
<string name="chat_date_yesterday">昨日</string>
|
||||
<string name="chat_share_subject">Hermes の会話</string>
|
||||
@@ -3838,4 +3853,10 @@
|
||||
<string name="plugins_keep">保持</string>
|
||||
<string name="plugins_remove">削除</string>
|
||||
<string name="plugins_remove_confirm">「%1$s」を削除しますか?接続された Android 端末にこのプラグインページは表示されなくなります。</string>
|
||||
<string name="support_bundle_review">サポート情報を確認</string>
|
||||
<string name="support_bundle_title">サポート情報</string>
|
||||
<string name="support_bundle_privacy">自動アップロードはありません。最大 %1$d 件のレポートを含む端末内エクスポートを確認してください。</string>
|
||||
<string name="support_bundle_copied">サポート情報をコピーしました</string>
|
||||
<string name="support_bundle_no_share">サポート情報をコピーしました — 共有できるアプリがありません</string>
|
||||
<string name="support_bundle_share_title">Hermes-Relay サポート情報を共有</string>
|
||||
</resources>
|
||||
|
||||
@@ -118,6 +118,8 @@
|
||||
<string name="chat_placeholder_message">Сообщение…</string>
|
||||
<string name="chat_edit_busy_snackbar">Нельзя редактировать сейчас — подождите, пока завершится текущий ход</string>
|
||||
<string name="chat_select_reasoning_effort">Выберите усилия по рассуждению</string>
|
||||
<string name="reasoning_effort_standard_levels_notice">Hermes не сообщает точные уровни усилий для этой модели, поэтому показаны стандартные варианты.</string>
|
||||
<string name="reasoning_effort_current_outside_supported">Текущий сеанс: %1$s (по данным Hermes). Для этой модели новые варианты ограничены: %2$s.</string>
|
||||
<string name="voice_toast_signin_route">Голосу требуется одноразовый вход на маршруте %1$s — откройте Управление</string>
|
||||
<string name="voice_toast_signin_default">Голосу требуется вход в панель управления — откройте Управление для входа</string>
|
||||
<string name="voice_toast_unsupported">Эта сборка Гермеса не имеет голосовых маршрутов — обновите Агента Гермеса или сопрягите плагин Relay</string>
|
||||
@@ -878,6 +880,9 @@
|
||||
<string name="drawer_refresh_sessions">Обновить сессии</string>
|
||||
<string name="drawer_new_chat">Новый чат</string>
|
||||
<string name="drawer_search_placeholder">Поиск сессий или id...</string>
|
||||
<string name="drawer_search_sessions">Поиск сессий</string>
|
||||
<string name="drawer_activity_working">Выполняется</string>
|
||||
<string name="drawer_activity_needs_input">Требуется ввод</string>
|
||||
<string name="drawer_new_thread">Новая ветка</string>
|
||||
<string name="drawer_chats_not_named">Чаты не автоматически именуются на этом соединении — используйте ⋮ → Переименовать.</string>
|
||||
<string name="drawer_loading_sessions">Загрузка сессий…</string>
|
||||
@@ -2600,7 +2605,14 @@
|
||||
<string name="attachment_type_text">Текст</string>
|
||||
<string name="attachment_type_file">Файл</string>
|
||||
<string name="crash_title">Hermes-Relay неожиданно закрылся</string>
|
||||
<string name="crash_body">Последняя сессия завершилась аварийно. Отправка этого отчета поможет быстрее исправить проблему.</string>
|
||||
<string name="crash_body">Hermes-Relay перезапустился после непредвиденной проблемы. Приложением можно продолжать пользоваться.</string>
|
||||
<string name="crash_privacy">Ничего не отправлено. Перед отправкой проверьте локально обезличенный отчёт.</string>
|
||||
<string name="crash_review">Проверить отчёт</string>
|
||||
<string name="crash_share_title">Поделиться отчётом о сбое</string>
|
||||
<string name="crash_share_subject">Отчёт о сбое Hermes-Relay — %1$s</string>
|
||||
<string name="crash_toast_no_share">Отчёт скопирован — приложение для отправки не найдено</string>
|
||||
<string name="crash_toast_opened">Полный отчёт скопирован — проверьте проблему GitHub перед отправкой</string>
|
||||
<string name="crash_toast_no_browser">Отчёт скопирован — браузер для GitHub не найден</string>
|
||||
<string name="crash_dismiss">Закрыть</string>
|
||||
<string name="crash_copy">Скопировать</string>
|
||||
<string name="crash_share">Поделиться</string>
|
||||
@@ -2792,6 +2804,9 @@
|
||||
<string name="chat_reasoning_low">Низкий</string>
|
||||
<string name="chat_reasoning_medium">Средний</string>
|
||||
<string name="chat_reasoning_high">Высокий</string>
|
||||
<string name="chat_reasoning_xhigh">Очень высокий</string>
|
||||
<string name="chat_reasoning_max">Максимальный</string>
|
||||
<string name="chat_reasoning_ultra">Ультра</string>
|
||||
<string name="chat_date_today">Сегодня</string>
|
||||
<string name="chat_date_yesterday">Вчера</string>
|
||||
<string name="chat_share_subject">Разговор с Гермес</string>
|
||||
@@ -3560,4 +3575,10 @@
|
||||
<string name="plugins_keep">Оставить</string>
|
||||
<string name="plugins_remove">Удалить</string>
|
||||
<string name="plugins_remove_confirm">Удалить «%1$s»? Эта страница плагина больше не будет отображаться на подключённых устройствах Android.</string>
|
||||
<string name="support_bundle_review">Проверить сведения для поддержки</string>
|
||||
<string name="support_bundle_title">Сведения для поддержки</string>
|
||||
<string name="support_bundle_privacy">Ничего не загружается автоматически. Проверьте локальный экспорт с не более чем %1$d отчётами.</string>
|
||||
<string name="support_bundle_copied">Сведения для поддержки скопированы</string>
|
||||
<string name="support_bundle_no_share">Сведения для поддержки скопированы — приложение для отправки не найдено</string>
|
||||
<string name="support_bundle_share_title">Поделиться сведениями поддержки Hermes-Relay</string>
|
||||
</resources>
|
||||
|
||||
@@ -118,6 +118,8 @@
|
||||
<string name="chat_placeholder_message">Message…</string>
|
||||
<string name="chat_edit_busy_snackbar">Can\'t edit right now — wait for the current turn to finish</string>
|
||||
<string name="chat_select_reasoning_effort">Select reasoning effort</string>
|
||||
<string name="reasoning_effort_standard_levels_notice">Hermes doesn\'t advertise exact effort levels for this model, so standard options are shown.</string>
|
||||
<string name="reasoning_effort_current_outside_supported">Current session: %1$s (reported by Hermes). New selections for this model are limited to %2$s.</string>
|
||||
|
||||
<!-- Voice toasts -->
|
||||
<string name="voice_toast_signin_route">Voice needs a one-time sign-in on the %1$s route — open Manage</string>
|
||||
@@ -951,6 +953,9 @@
|
||||
<string name="drawer_refresh_sessions">Refresh sessions</string>
|
||||
<string name="drawer_new_chat">New Chat</string>
|
||||
<string name="drawer_search_placeholder">Search sessions or id...</string>
|
||||
<string name="drawer_search_sessions">Search sessions</string>
|
||||
<string name="drawer_activity_working">Working</string>
|
||||
<string name="drawer_activity_needs_input">Needs input</string>
|
||||
<string name="drawer_new_thread">New Thread</string>
|
||||
<string name="drawer_chats_not_named">Chats aren\'t auto-named on this connection — use ⋮ → Rename.</string>
|
||||
<string name="drawer_loading_sessions">Loading sessions…</string>
|
||||
@@ -2772,7 +2777,14 @@
|
||||
|
||||
<!-- CrashReportDialog -->
|
||||
<string name="crash_title">Hermes-Relay closed unexpectedly</string>
|
||||
<string name="crash_body">The last session crashed. Sending this report helps get it fixed faster.</string>
|
||||
<string name="crash_body">Hermes-Relay restarted after an unexpected problem. You can continue using the app.</string>
|
||||
<string name="crash_privacy">Nothing was sent. Review the locally redacted report before choosing copy, share, or GitHub.</string>
|
||||
<string name="crash_review">Review report</string>
|
||||
<string name="crash_share_title">Share crash report</string>
|
||||
<string name="crash_share_subject">Hermes-Relay crash report — %1$s</string>
|
||||
<string name="crash_toast_no_share">Report copied — no app found to share to</string>
|
||||
<string name="crash_toast_opened">Full report copied — review the GitHub issue before submitting</string>
|
||||
<string name="crash_toast_no_browser">Report copied — no browser found to open GitHub</string>
|
||||
<string name="crash_dismiss">Dismiss</string>
|
||||
<string name="crash_copy">Copy</string>
|
||||
<string name="crash_share">Share</string>
|
||||
@@ -2989,6 +3001,9 @@
|
||||
<string name="chat_reasoning_low">Low</string>
|
||||
<string name="chat_reasoning_medium">Medium</string>
|
||||
<string name="chat_reasoning_high">High</string>
|
||||
<string name="chat_reasoning_xhigh">Extra high</string>
|
||||
<string name="chat_reasoning_max">Max</string>
|
||||
<string name="chat_reasoning_ultra">Ultra</string>
|
||||
<string name="chat_date_today">Today</string>
|
||||
<string name="chat_date_yesterday">Yesterday</string>
|
||||
<string name="chat_share_subject">Hermes conversation</string>
|
||||
@@ -3845,4 +3860,10 @@
|
||||
<string name="plugins_keep">Keep</string>
|
||||
<string name="plugins_remove">Remove</string>
|
||||
<string name="plugins_remove_confirm">Remove “%1$s”? This plugin page will no longer appear on connected Android devices.</string>
|
||||
<string name="support_bundle_review">Review support information</string>
|
||||
<string name="support_bundle_title">Support information</string>
|
||||
<string name="support_bundle_privacy">Nothing is uploaded automatically. Review the exact local export below. It contains up to %1$d recent reports.</string>
|
||||
<string name="support_bundle_copied">Support information copied</string>
|
||||
<string name="support_bundle_no_share">Support information copied — no app found to share to</string>
|
||||
<string name="support_bundle_share_title">Share Hermes-Relay support information</string>
|
||||
</resources>
|
||||
|
||||
@@ -8,9 +8,9 @@ import org.junit.Test
|
||||
|
||||
class DiagnosticsLogTest {
|
||||
@Test
|
||||
fun sanitizeUrlDropsSecretsAndKeepsRoute() {
|
||||
fun sanitizeUrlDropsSecretsAndHostWhileKeepingPath() {
|
||||
assertEquals(
|
||||
"https://relay.example.test:8767/health",
|
||||
"https://[host]/health",
|
||||
DiagnosticsLog.sanitizeUrl(
|
||||
"https://user:secret@relay.example.test:8767/health?token=abc#frag",
|
||||
),
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.hermesandroid.relay.diagnostics
|
||||
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.net.UnknownHostException
|
||||
import javax.net.ssl.SSLHandshakeException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NetworkDiagnosticGuidanceTest {
|
||||
|
||||
@Test
|
||||
fun connectionRefusalPointsToTheOwningListener() {
|
||||
val guidance = NetworkDiagnosticGuidance.forThrowable(
|
||||
ConnectException("Failed to connect"),
|
||||
target = "Relay",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"Verify Relay is running and listening on the configured host and port.",
|
||||
guidance,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wrappedNetworkFailuresUseTheRootCause() {
|
||||
val guidance = NetworkDiagnosticGuidance.forThrowable(
|
||||
IllegalStateException("probe failed", UnknownHostException("missing.example")),
|
||||
target = "Dashboard",
|
||||
)
|
||||
|
||||
assertTrue(guidance.orEmpty().contains("hostname"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transportFailuresHaveDistinctNextSteps() {
|
||||
assertTrue(
|
||||
NetworkDiagnosticGuidance.forThrowable(SocketTimeoutException(), "API")
|
||||
.orEmpty().contains("routing or firewall"),
|
||||
)
|
||||
assertTrue(
|
||||
NetworkDiagnosticGuidance.forThrowable(NoRouteToHostException(), "API")
|
||||
.orEmpty().contains("network path"),
|
||||
)
|
||||
assertTrue(
|
||||
NetworkDiagnosticGuidance.forThrowable(SSLHandshakeException("bad cert"), "Relay")
|
||||
.orEmpty().contains("TLS"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun httpStatusGuidanceIsSpecificAndUnknownSuccessHasNone() {
|
||||
assertTrue(NetworkDiagnosticGuidance.forHttpStatus(401, "API").orEmpty().contains("credentials"))
|
||||
assertTrue(NetworkDiagnosticGuidance.forHttpStatus(404, "Relay").orEmpty().contains("route"))
|
||||
assertTrue(NetworkDiagnosticGuidance.forHttpStatus(429, "Relay").orEmpty().contains("backoff"))
|
||||
assertTrue(NetworkDiagnosticGuidance.forHttpStatus(503, "API").orEmpty().contains("server logs"))
|
||||
assertNull(NetworkDiagnosticGuidance.forHttpStatus(204, "API"))
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.hermesandroid.relay.network.relay
|
||||
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import java.net.ServerSocket
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class RelayHttpClientDiagnosticsTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
DiagnosticsLog.clear()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
DiagnosticsLog.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refusedHealthProbeExplainsProtocolConversionAndOwningListener() = runTest {
|
||||
val unusedPort = ServerSocket(0).use { it.localPort }
|
||||
val configuredRelay = "ws://127.0.0.1:$unusedPort"
|
||||
val client = RelayHttpClient(
|
||||
okHttpClient = OkHttpClient(),
|
||||
relayUrlProvider = { configuredRelay },
|
||||
sessionTokenProvider = { null },
|
||||
)
|
||||
|
||||
val result = client.probeHealth(configuredRelay)
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
val entry = DiagnosticsLog.recent(setOf(DiagnosticCategory.Relay)).first()
|
||||
assertEquals("Relay health probe before WebSocket connection", entry.operation)
|
||||
assertEquals("ws://[host]", entry.configuredUrl)
|
||||
assertEquals("http://[host]/health", entry.requestUrl)
|
||||
assertEquals(
|
||||
"Verify Relay is running and listening on the configured host and port.",
|
||||
entry.suggestion,
|
||||
)
|
||||
assertFalse(entry.detail.orEmpty().contains(unusedPort.toString()))
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.hermesandroid.relay.network.relay
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class RelayHttpClientModelCapabilitiesTest {
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun postsBoundedProfileScopedPairsAndParsesExactOverlay() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(200).setBody(
|
||||
"""{
|
||||
"schema_version":1,
|
||||
"contract_version":"relay-reasoning-v1",
|
||||
"capabilities":[{
|
||||
"provider":"opencode",
|
||||
"model":"deepseek-v3",
|
||||
"reasoning":true,
|
||||
"reasoning_efforts":["low","max"],
|
||||
"reasoning_efforts_exact":true,
|
||||
"source":"relay-provider-policy"
|
||||
}]
|
||||
}""".trimIndent(),
|
||||
),
|
||||
)
|
||||
val client = client(token = "paired-token")
|
||||
val rows = (0..130).map {
|
||||
RelayHttpClient.ModelCapabilityRequestRow("provider-$it", "model-$it")
|
||||
}
|
||||
|
||||
val response = client.fetchModelCapabilities(rows, profile = "research").getOrThrow()
|
||||
val request = server.takeRequest()
|
||||
val body = Json.parseToJsonElement(request.body.readUtf8()).jsonObject
|
||||
|
||||
assertEquals("/relay/model-capabilities", request.path)
|
||||
assertEquals("Bearer paired-token", request.getHeader("Authorization"))
|
||||
assertEquals("research", body.getValue("profile").jsonPrimitive.content)
|
||||
assertEquals(
|
||||
RelayHttpClient.MAX_MODEL_CAPABILITY_ROWS,
|
||||
body.getValue("models").jsonArray.size,
|
||||
)
|
||||
assertEquals(listOf("low", "max"), response?.capabilities?.single()?.reasoningEfforts)
|
||||
assertEquals(true, response?.capabilities?.single()?.reasoningEffortsExact)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun olderRelay404IsOptional() = runTest {
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
|
||||
val response = client(token = "paired-token").fetchModelCapabilities(
|
||||
listOf(RelayHttpClient.ModelCapabilityRequestRow("openai", "gpt-5.5")),
|
||||
).getOrThrow()
|
||||
|
||||
assertNull(response)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vanillaUnpairedPathDoesNotCallRelay() = runTest {
|
||||
val response = client(token = null).fetchModelCapabilities(
|
||||
listOf(RelayHttpClient.ModelCapabilityRequestRow("openai", "gpt-5.5")),
|
||||
).getOrThrow()
|
||||
|
||||
assertNull(response)
|
||||
assertEquals(0, server.requestCount)
|
||||
}
|
||||
|
||||
private fun client(token: String?) = RelayHttpClient(
|
||||
okHttpClient = OkHttpClient(),
|
||||
relayUrlProvider = { server.url("/").toString() },
|
||||
sessionTokenProvider = { token },
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import com.hermesandroid.relay.data.ApiEndpoint
|
||||
import com.hermesandroid.relay.data.DashboardEndpoint
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.RelayEndpoint
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
@@ -40,6 +42,7 @@ class EndpointResolverTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
DiagnosticsLog.clear()
|
||||
clockMillis.set(0L)
|
||||
reachableServer = MockWebServer().apply {
|
||||
dispatcher = healthDispatcher(statusCode = 200)
|
||||
@@ -61,6 +64,7 @@ class EndpointResolverTest {
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
DiagnosticsLog.clear()
|
||||
runCatching { reachableServer.shutdown() }
|
||||
runCatching { secondReachableServer.shutdown() }
|
||||
}
|
||||
@@ -370,6 +374,11 @@ class EndpointResolverTest {
|
||||
assertNotNull(request)
|
||||
assertEquals("GET", request!!.method)
|
||||
assertEquals("/api/status", request.path)
|
||||
val diagnostic = DiagnosticsLog.recent(setOf(DiagnosticCategory.Endpoint))
|
||||
.first { it.operation != null }
|
||||
assertEquals("Dashboard or API route health probe", diagnostic.operation)
|
||||
assertEquals("http://[host]", diagnostic.configuredUrl)
|
||||
assertEquals("http://[host]/api/status", diagnostic.requestUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Unit tests for ChatHandler message state management.
|
||||
@@ -2189,6 +2190,165 @@ class ChatHandlerTest {
|
||||
assertEquals("Running terminal", handler.turnStatus.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_thenRestoreCheckpoint_mergesReboundAssistantWithoutDuplicateUiIdentity() {
|
||||
val clientId = "c32c93a8-b54b-4943-87b0-dfd36adb9c3f"
|
||||
val serverId = "server-assistant-id"
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = clientId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "partial answer",
|
||||
timestamp = 2L,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = serverId,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("partial answer"),
|
||||
timestamp = 2.0,
|
||||
),
|
||||
),
|
||||
)
|
||||
assertEquals(serverId, handler.messages.value.single().id)
|
||||
assertEquals(clientId, handler.messages.value.single().uiKey)
|
||||
|
||||
handler.restoreInFlightTurn(
|
||||
ChatTurnCheckpoint(
|
||||
contextKey = "connection/profile",
|
||||
sessionId = "session-1",
|
||||
liveSessionId = "session-1",
|
||||
transport = "gateway",
|
||||
user = ChatTurnUserCheckpoint("user-client-id", "question", 1L),
|
||||
assistant = ChatTurnAssistantCheckpoint(
|
||||
id = clientId,
|
||||
content = "partial answer",
|
||||
timestamp = 2L,
|
||||
thinkingContent = "checkpoint reasoning",
|
||||
),
|
||||
priorUserMessageCount = 0,
|
||||
baselineAssistantCount = 0,
|
||||
startedAt = 1L,
|
||||
updatedAt = 3L,
|
||||
),
|
||||
)
|
||||
|
||||
val assistants = handler.messages.value.filter { it.role == MessageRole.ASSISTANT }
|
||||
assertEquals(1, assistants.size)
|
||||
assertEquals(serverId, assistants.single().id)
|
||||
assertEquals(clientId, assistants.single().uiKey)
|
||||
assertEquals("checkpoint reasoning", assistants.single().thinkingContent)
|
||||
assertEquals(
|
||||
handler.messages.value.size,
|
||||
handler.messages.value.map(ChatMessage::uiKey).distinct().size,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleClientCallbacks_afterServerIdAdoption_mutateTheReboundRow() {
|
||||
val clientId = "b229a0f6-4f15-4f86-bce3-a939d5acce82"
|
||||
val serverId = "server-assistant-id"
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = clientId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
timestamp = 1L,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
handler.replaceMessageId(clientId, serverId)
|
||||
|
||||
// Gateway recovery callbacks retain the checkpoint/client id even
|
||||
// after history or message.started has adopted the server id.
|
||||
handler.onTextDelta(clientId, "answer")
|
||||
handler.onThinkingDelta(clientId, "reasoning")
|
||||
handler.onToolGenerating(clientId, "terminal")
|
||||
|
||||
val assistant = handler.messages.value.single()
|
||||
assertEquals(serverId, assistant.id)
|
||||
assertEquals(clientId, assistant.uiKey)
|
||||
assertEquals("answer", assistant.content)
|
||||
assertEquals("reasoning", assistant.thinkingContent)
|
||||
assertEquals(1, assistant.toolCalls.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveryTransitionModel_neverPublishesDuplicateRenderIdentity() {
|
||||
repeat(64) { seed ->
|
||||
val modelHandler = ChatHandler()
|
||||
val clientId = "client-$seed"
|
||||
val serverId = "server-$seed"
|
||||
val checkpoint = ChatTurnCheckpoint(
|
||||
contextKey = "connection/profile",
|
||||
sessionId = "session-$seed",
|
||||
liveSessionId = "session-$seed",
|
||||
transport = "gateway",
|
||||
user = ChatTurnUserCheckpoint("user-$seed", "question", 1L),
|
||||
assistant = ChatTurnAssistantCheckpoint(
|
||||
id = clientId,
|
||||
content = "partial",
|
||||
timestamp = 2L,
|
||||
thinkingContent = "checkpoint",
|
||||
),
|
||||
priorUserMessageCount = 0,
|
||||
baselineAssistantCount = 0,
|
||||
startedAt = 1L,
|
||||
updatedAt = 2L,
|
||||
)
|
||||
modelHandler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = clientId,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "partial",
|
||||
timestamp = 2L,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
|
||||
var serverIdAdopted = false
|
||||
val random = Random(seed)
|
||||
repeat(20) { step ->
|
||||
when (random.nextInt(5)) {
|
||||
0 -> {
|
||||
val content = modelHandler.messages.value
|
||||
.last { it.role == MessageRole.ASSISTANT }
|
||||
.content
|
||||
modelHandler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = serverId,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(content),
|
||||
timestamp = 2.0,
|
||||
),
|
||||
),
|
||||
)
|
||||
serverIdAdopted = true
|
||||
}
|
||||
1 -> modelHandler.restoreInFlightTurn(checkpoint)
|
||||
2 -> modelHandler.onTextDelta(clientId, ".$step")
|
||||
3 -> modelHandler.onThinkingDelta(clientId, "t$step")
|
||||
else -> modelHandler.onUsageReceived(clientId, step, step + 1, null, null)
|
||||
}
|
||||
|
||||
val snapshot = modelHandler.messages.value
|
||||
assertEquals(
|
||||
"seed=$seed step=$step keys=${snapshot.map(ChatMessage::uiKey)}",
|
||||
snapshot.size,
|
||||
snapshot.map(ChatMessage::uiKey).distinct().size,
|
||||
)
|
||||
val assistants = snapshot.filter { it.role == MessageRole.ASSISTANT }
|
||||
assertEquals("seed=$seed step=$step", 1, assistants.size)
|
||||
assertEquals(clientId, assistants.single().uiKey)
|
||||
if (serverIdAdopted) assertEquals(serverId, assistants.single().id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onMoaReference_upsertsByCanonicalIndexAndResetsOnNewSequence() {
|
||||
handler.addPlaceholderMessage(
|
||||
|
||||
+48
-2
@@ -289,6 +289,12 @@ class GatewayClientHarness(
|
||||
"slug": "openai",
|
||||
"name": "OpenAI",
|
||||
"models": ["gpt-5.5"],
|
||||
"capabilities": {
|
||||
"gpt-5.5": {
|
||||
"reasoning": true,
|
||||
"reasoning_efforts": ["minimal", "medium", "ultra"]
|
||||
}
|
||||
},
|
||||
"is_current": true,
|
||||
"authenticated": true
|
||||
}
|
||||
@@ -858,6 +864,10 @@ class GatewayChatClientTest {
|
||||
val normal = client.modelOptions().getOrThrow()
|
||||
val normalParams = harness.awaitRpc("model.options")
|
||||
assertEquals("gpt-5.5", normal.currentModel)
|
||||
assertEquals(
|
||||
listOf("minimal", "medium", "ultra"),
|
||||
normal.providers.single().capabilities.getValue("gpt-5.5").reasoningEfforts,
|
||||
)
|
||||
assertFalse((normalParams["refresh"] as? JsonPrimitive)?.booleanOrNull == true)
|
||||
|
||||
val refreshed = client.modelOptions(refresh = true).getOrThrow()
|
||||
@@ -1824,18 +1834,54 @@ class GatewayChatClientTest {
|
||||
|
||||
@Test
|
||||
fun `reasoning settings fetch uses config get`() {
|
||||
harness.reasoningEffort = "high"
|
||||
harness.reasoningEffort = "ultra"
|
||||
harness.reasoningDisplay = "show"
|
||||
|
||||
val result = runBlocking { client.getReasoningSettings() }
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals("high", result.getOrThrow().effort)
|
||||
assertEquals("ultra", result.getOrThrow().effort)
|
||||
assertEquals("show", result.getOrThrow().display)
|
||||
val rpc = harness.awaitRpc("config.get")
|
||||
assertEquals("reasoning", (rpc["key"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session info preserves coherent provider model and max effort`() {
|
||||
val recorder = Recorder()
|
||||
client.sendTurn("stored-1", "hi", null, recorder.callbacks) {
|
||||
recorder.preflightFailures += it
|
||||
}
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("session.resume")
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"session.info",
|
||||
buildJsonObject {
|
||||
put("model", "deepseek-v3")
|
||||
put("provider", "opencode")
|
||||
put("reasoning_effort", "max")
|
||||
},
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
waitUntil { client.serverReasoningEffort.value == "max" }
|
||||
assertEquals(
|
||||
GatewayModelIdentity(model = "deepseek-v3", provider = "opencode"),
|
||||
client.serverModelIdentity.value,
|
||||
)
|
||||
assertEquals(
|
||||
GatewayReasoningIdentity(
|
||||
identity = GatewayModelIdentity(model = "deepseek-v3", provider = "opencode"),
|
||||
effort = "max",
|
||||
),
|
||||
client.serverReasoningIdentity.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approval mode get and set use profile config without session yolo scope`() {
|
||||
harness.approvalMode = "smart"
|
||||
|
||||
@@ -273,6 +273,13 @@ class HermesApiClientTest {
|
||||
"authenticated": true,
|
||||
"is_current": true,
|
||||
"models": ["grok-4.3", "grok-4.2"],
|
||||
"capabilities": {
|
||||
"grok-4.3": {
|
||||
"reasoning": true,
|
||||
"reasoning_efforts": ["low", "high", "max"],
|
||||
"reasoning_efforts_exact": true
|
||||
}
|
||||
},
|
||||
"unavailable_models": ["grok-4.2"],
|
||||
"free_tier": true,
|
||||
"total_models": 2
|
||||
@@ -294,6 +301,14 @@ class HermesApiClientTest {
|
||||
assertEquals(listOf("grok-4.2"), parsed?.providers?.first()?.unavailableModels)
|
||||
assertTrue(parsed?.providers?.first()?.authenticated == true)
|
||||
assertFalse(parsed?.providers?.last()?.authenticated == true)
|
||||
assertEquals(
|
||||
listOf("low", "high", "max"),
|
||||
parsed?.providers?.first()?.capabilities?.get("grok-4.3")?.reasoningEfforts,
|
||||
)
|
||||
assertEquals(
|
||||
true,
|
||||
parsed?.providers?.first()?.capabilities?.get("grok-4.3")?.reasoningEffortsExact,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.viewmodel.reconcilePendingReasoningEffort
|
||||
import com.hermesandroid.relay.viewmodel.isCurrentReasoningResponse
|
||||
import com.hermesandroid.relay.viewmodel.isCurrentReasoningCapabilityOverlay
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ReasoningEffortSupportTest {
|
||||
@Test
|
||||
fun canonicalNormalizationPreservesMaxAndUltra() {
|
||||
assertEquals(
|
||||
listOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"),
|
||||
ReasoningEfforts.canonical,
|
||||
)
|
||||
assertEquals("max", ReasoningEfforts.normalize(" MAX "))
|
||||
assertEquals("ultra", ReasoningEfforts.normalize("Ultra"))
|
||||
assertEquals("medium", ReasoningEfforts.normalize("future-value"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exactProviderModelEffortsAreAuthoritative() {
|
||||
val provider = provider(
|
||||
slug = "opencode",
|
||||
model = "deepseek-v3",
|
||||
capabilities = GatewayModelCapabilities(
|
||||
reasoning = true,
|
||||
reasoningEfforts = listOf("low", "high", "max", "max", "unknown"),
|
||||
reasoningEffortsExact = true,
|
||||
),
|
||||
)
|
||||
|
||||
val result = resolveReasoningEffortAvailability(
|
||||
providers = listOf(provider),
|
||||
provider = "opencode",
|
||||
model = "deepseek-v3",
|
||||
)
|
||||
|
||||
assertEquals(listOf("low", "high", "max"), result.choices)
|
||||
assertTrue(result.exact)
|
||||
assertTrue(result.accepts("max"))
|
||||
assertFalse(result.accepts("ultra"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitUpstreamReasoningFalseDisablesWithoutAnExactOverlay() {
|
||||
val result = resolveReasoningEffortAvailability(
|
||||
providers = listOf(
|
||||
provider(
|
||||
slug = "local",
|
||||
model = "plain-model",
|
||||
capabilities = GatewayModelCapabilities(
|
||||
reasoning = false,
|
||||
),
|
||||
),
|
||||
),
|
||||
provider = "local",
|
||||
model = "plain-model",
|
||||
)
|
||||
|
||||
assertEquals(false, result.supported)
|
||||
assertTrue(result.choices.isEmpty())
|
||||
assertFalse(result.accepts("low"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayExactOverlayPrecedesUpstreamFalseButNotUpstreamExact() {
|
||||
val identity = ReasoningEffortIdentity(provider = "opencode", model = "deepseek-v3")
|
||||
val upstreamFalse = provider(
|
||||
slug = identity.provider,
|
||||
model = identity.model,
|
||||
capabilities = GatewayModelCapabilities(reasoning = false),
|
||||
)
|
||||
val relayOverlay = mapOf(
|
||||
identity to GatewayModelCapabilities(
|
||||
reasoning = true,
|
||||
reasoningEfforts = listOf("low", "max"),
|
||||
reasoningEffortsExact = true,
|
||||
),
|
||||
)
|
||||
|
||||
val relayWins = resolveReasoningEffortAvailability(
|
||||
providers = listOf(upstreamFalse),
|
||||
provider = identity.provider,
|
||||
model = identity.model,
|
||||
relayCapabilities = relayOverlay,
|
||||
)
|
||||
assertEquals(listOf("low", "max"), relayWins.choices)
|
||||
assertTrue(relayWins.exact)
|
||||
|
||||
val upstreamExact = upstreamFalse.copy(
|
||||
capabilities = mapOf(
|
||||
identity.model to GatewayModelCapabilities(
|
||||
reasoning = true,
|
||||
reasoningEfforts = listOf("high", "ultra"),
|
||||
reasoningEffortsExact = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
val upstreamWins = resolveReasoningEffortAvailability(
|
||||
providers = listOf(upstreamExact),
|
||||
provider = identity.provider,
|
||||
model = identity.model,
|
||||
relayCapabilities = relayOverlay,
|
||||
)
|
||||
assertEquals(listOf("high", "ultra"), upstreamWins.choices)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingOrNonExactCapabilityFallsBackToCanonicalUnknownContract() {
|
||||
val missing = resolveReasoningEffortAvailability(emptyList(), "openai", "gpt-5.5")
|
||||
assertNull(missing.supported)
|
||||
assertEquals(ReasoningEfforts.canonical, missing.choices)
|
||||
|
||||
val advisory = resolveReasoningEffortAvailability(
|
||||
providers = listOf(
|
||||
provider(
|
||||
slug = "openai",
|
||||
model = "gpt-5.5",
|
||||
capabilities = GatewayModelCapabilities(
|
||||
reasoningEfforts = listOf("high", "max"),
|
||||
reasoningEffortsExact = false,
|
||||
),
|
||||
),
|
||||
),
|
||||
provider = "openai",
|
||||
model = "gpt-5.5",
|
||||
)
|
||||
assertFalse(advisory.exact)
|
||||
assertEquals(ReasoningEfforts.canonical, advisory.choices)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun capabilityLookupNeverBorrowsSameModelFromAnotherProvider() {
|
||||
val openCode = provider(
|
||||
slug = "opencode",
|
||||
model = "deepseek-v3",
|
||||
capabilities = GatewayModelCapabilities(
|
||||
reasoning = true,
|
||||
reasoningEfforts = listOf("low", "max"),
|
||||
reasoningEffortsExact = true,
|
||||
),
|
||||
)
|
||||
val openRouter = provider(
|
||||
slug = "openrouter",
|
||||
model = "deepseek-v3",
|
||||
capabilities = GatewayModelCapabilities(
|
||||
reasoning = true,
|
||||
reasoningEfforts = listOf("low", "ultra"),
|
||||
reasoningEffortsExact = true,
|
||||
),
|
||||
)
|
||||
|
||||
val result = resolveReasoningEffortAvailability(
|
||||
providers = listOf(openCode, openRouter),
|
||||
provider = "openrouter",
|
||||
model = "deepseek-v3",
|
||||
)
|
||||
|
||||
assertEquals(listOf("low", "ultra"), result.choices)
|
||||
val incompleteIdentity = resolveReasoningEffortAvailability(
|
||||
providers = listOf(openCode, openRouter),
|
||||
provider = null,
|
||||
model = "deepseek-v3",
|
||||
)
|
||||
assertNull(incompleteIdentity.supported)
|
||||
assertEquals(ReasoningEfforts.canonical, incompleteIdentity.choices)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelChangeClearsOnlyIncompatiblePendingEffort() {
|
||||
val availability = ReasoningEffortAvailability(
|
||||
supported = true,
|
||||
choices = listOf("low", "high", "max"),
|
||||
exact = true,
|
||||
)
|
||||
val modelA = ReasoningEffortIdentity(provider = "openrouter", model = "model-a")
|
||||
val modelB = ReasoningEffortIdentity(provider = "opencode", model = "model-b")
|
||||
|
||||
assertNull(
|
||||
reconcilePendingReasoningEffort(
|
||||
value = "ultra",
|
||||
confirmedIdentity = null,
|
||||
activeIdentity = modelB,
|
||||
availability = availability,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
isCurrentReasoningResponse(
|
||||
capturedRevision = 7L,
|
||||
currentRevision = 8L,
|
||||
capturedIdentity = modelA,
|
||||
activeIdentity = modelB,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"max",
|
||||
reconcilePendingReasoningEffort(
|
||||
value = "max",
|
||||
confirmedIdentity = null,
|
||||
activeIdentity = modelB,
|
||||
availability = availability,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"ultra",
|
||||
reconcilePendingReasoningEffort(
|
||||
value = "ultra",
|
||||
confirmedIdentity = modelB,
|
||||
activeIdentity = modelB,
|
||||
availability = availability,
|
||||
),
|
||||
)
|
||||
assertNull(
|
||||
reconcilePendingReasoningEffort(
|
||||
value = "ultra",
|
||||
confirmedIdentity = modelA,
|
||||
activeIdentity = modelB,
|
||||
availability = availability,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayOverlayResponseRequiresCurrentGenerationAndProfileContext() {
|
||||
assertTrue(isCurrentReasoningCapabilityOverlay(4L, 4L, "conn::profile", "conn::profile"))
|
||||
assertFalse(isCurrentReasoningCapabilityOverlay(3L, 4L, "conn::profile", "conn::profile"))
|
||||
assertFalse(isCurrentReasoningCapabilityOverlay(4L, 4L, "conn::a", "conn::b"))
|
||||
}
|
||||
|
||||
private fun provider(
|
||||
slug: String,
|
||||
model: String,
|
||||
capabilities: GatewayModelCapabilities,
|
||||
) = GatewayModelProvider(
|
||||
name = slug,
|
||||
slug = slug,
|
||||
models = listOf(model),
|
||||
isCurrent = false,
|
||||
warning = null,
|
||||
capabilities = mapOf(model to capabilities),
|
||||
)
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RenderedMessageStateTest {
|
||||
|
||||
@Test
|
||||
fun publicationBoundary_keepsFirstPositionAndLatestSnapshotForEachUiIdentity() {
|
||||
val state = RenderedMessageState(emptyList())
|
||||
val first = message(id = "client-a", uiKey = "row-a", content = "partial")
|
||||
val middle = message(id = "server-b", uiKey = "row-b", content = "other")
|
||||
val replay = message(id = "server-a", uiKey = "row-a", content = "final")
|
||||
|
||||
state.update { listOf(first, middle, replay) }
|
||||
|
||||
assertEquals(listOf("row-a", "row-b"), state.value.map(ChatMessage::uiKey))
|
||||
assertEquals(listOf("server-a", "server-b"), state.value.map(ChatMessage::id))
|
||||
assertEquals("final", state.value.first().content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun publicationBoundary_normalizesEveryMutationApi() {
|
||||
val first = message(id = "client", uiKey = "stable", content = "partial")
|
||||
val latest = message(id = "server", uiKey = "stable", content = "final")
|
||||
val state = RenderedMessageState(listOf(first, latest))
|
||||
assertEquals(1, state.value.size)
|
||||
|
||||
state.value = listOf(first, latest)
|
||||
assertEquals(1, state.value.size)
|
||||
|
||||
state.update { listOf(first, latest) }
|
||||
assertEquals(1, state.value.size)
|
||||
}
|
||||
|
||||
private fun message(id: String, uiKey: String, content: String) = ChatMessage(
|
||||
id = id,
|
||||
uiKey = uiKey,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = content,
|
||||
timestamp = 1L,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.hermesandroid.relay.reliability
|
||||
|
||||
import java.io.File
|
||||
import java.net.SocketTimeoutException
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.net.URLDecoder
|
||||
import com.hermesandroid.relay.util.CrashReporter
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ReliabilityReportTest {
|
||||
private val environment = ReliabilityEnvironment(
|
||||
versionName = "1.6.1",
|
||||
versionCode = 38,
|
||||
flavor = "sideload",
|
||||
manufacturer = "Example",
|
||||
model = "Phone",
|
||||
androidRelease = "16",
|
||||
sdkInt = 36,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun redactorRemovesSecretsHostsIdentifiersPathsAndContent() {
|
||||
val raw = """
|
||||
authorization: Bearer-secret
|
||||
token=abc123
|
||||
https://private.example.test:8767/path?q=secret
|
||||
host 192.168.1.4:8642
|
||||
session 123e4567-e89b-12d3-a456-426614174000
|
||||
C:\Users\person\private\file.txt
|
||||
prompt=private words from a conversation
|
||||
""".trimIndent()
|
||||
|
||||
val redacted = ReliabilityRedactor.redact(raw)
|
||||
|
||||
listOf(
|
||||
"Bearer-secret", "abc123", "private.example.test", "192.168.1.4",
|
||||
"123e4567-e89b-12d3-a456-426614174000", "person", "private words",
|
||||
).forEach { assertFalse("leaked $it", redacted.contains(it)) }
|
||||
assertTrue(redacted.contains("[url hidden]"))
|
||||
assertTrue(redacted.contains("prompt=[hidden]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun classifierSeparatesCancellationConnectivityAuthRateLimitAndProductErrors() {
|
||||
assertEquals(
|
||||
ReliabilityKind.ExpectedCancellation,
|
||||
ReliabilityClassifier.classify(CancellationException(), "gateway").kind,
|
||||
)
|
||||
assertFalse(ReliabilityClassifier.classify(CancellationException(), "gateway").shouldPersist)
|
||||
assertEquals(
|
||||
ReliabilityKind.Connectivity,
|
||||
ReliabilityClassifier.classify(SocketTimeoutException("slow"), "gateway").kind,
|
||||
)
|
||||
assertEquals(
|
||||
ReliabilityOwner.UpstreamGateway,
|
||||
ReliabilityClassifier.classify(SocketTimeoutException("slow"), "gateway").owner,
|
||||
)
|
||||
assertEquals(
|
||||
ReliabilityKind.Authentication,
|
||||
ReliabilityClassifier.classify(IllegalStateException("HTTP 401"), "dashboard").kind,
|
||||
)
|
||||
assertEquals(
|
||||
ReliabilityKind.RateLimit,
|
||||
ReliabilityClassifier.classify(IllegalStateException("HTTP 429"), "api").kind,
|
||||
)
|
||||
assertTrue(
|
||||
ReliabilityClassifier.classify(IllegalArgumentException("duplicate key"), "ui")
|
||||
.reportRecommended,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun storeEnforcesAgeCountRedactionAndReviewedState() {
|
||||
val dir = kotlin.io.path.createTempDirectory("reliability-store").toFile()
|
||||
val store = ReliabilityStore(File(dir, "reports.json"), maxReports = 3, retentionDays = 14)
|
||||
val now = Instant.parse("2026-08-04T12:00:00Z")
|
||||
|
||||
store.append(report("old", "2026-07-01T00:00:00Z"), now)
|
||||
store.append(report("one", "2026-08-01T00:00:00Z"), now)
|
||||
store.append(report("two", "2026-08-02T00:00:00Z"), now)
|
||||
store.append(report("three", "2026-08-03T00:00:00Z"), now)
|
||||
store.append(
|
||||
report("four", "2026-08-04T00:00:00Z").copy(summary = "token=do-not-store"),
|
||||
now,
|
||||
)
|
||||
|
||||
val stored = store.readAll(now)
|
||||
assertEquals(listOf("two", "three", "four"), stored.map { it.reportId })
|
||||
assertFalse(stored.last().summary.contains("do-not-store"))
|
||||
assertTrue(stored.last().pendingReview)
|
||||
|
||||
store.markReviewed("four", now)
|
||||
assertFalse(store.readAll(now).last().pendingReview)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun legacyMigrationIsVersionedPendingAndRedacted() {
|
||||
val migrated = migrateLegacyCrash(
|
||||
LegacyCrashSnapshot(
|
||||
timeIso = "2026-08-03T15:12:26Z",
|
||||
versionName = "1.6.0",
|
||||
versionCode = 37,
|
||||
flavor = "googlePlay",
|
||||
manufacturer = "Example",
|
||||
model = "Phone",
|
||||
androidRelease = "17",
|
||||
sdkInt = 37,
|
||||
threadName = "main",
|
||||
exceptionSummary = "Crash token=secret-value",
|
||||
stackTrace = "at Example https://private.example.test/path",
|
||||
),
|
||||
reportId = "rpt-test",
|
||||
appSessionId = "legacy-test",
|
||||
)
|
||||
|
||||
assertEquals(RELIABILITY_SCHEMA_VERSION, migrated.schemaVersion)
|
||||
assertEquals(ReliabilityKind.FatalCrash, migrated.kind)
|
||||
assertTrue(migrated.pendingReview)
|
||||
assertFalse(migrated.toPlainText().contains("secret-value"))
|
||||
assertFalse(migrated.toPlainText().contains("private.example.test"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun supportBundleIsBoundedAndUsesExactRedactedReports() {
|
||||
val reports = (1..12).map { index ->
|
||||
report("r$index", "2026-08-${index.toString().padStart(2, '0')}T00:00:00Z")
|
||||
.copy(technicalDetail = "token=secret-$index")
|
||||
}
|
||||
val bundle = SupportBundleBuilder.build(reports)
|
||||
|
||||
assertFalse(bundle.contains("secret-"))
|
||||
assertFalse(bundle.contains("Report: r1\n"))
|
||||
assertTrue(bundle.contains("Report: r12"))
|
||||
assertEquals(10, Regex("===== Report ").findAll(bundle).count())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crashIssuePrefillTargetsAndroidAndContainsOnlyRedactedDetail() {
|
||||
val report = report("rpt-prefill", "2026-08-04T00:00:00Z").copy(
|
||||
summary = "Crash token=private-value",
|
||||
technicalDetail = "at Example https://private.example.test/path",
|
||||
)
|
||||
|
||||
val url = CrashReporter.buildGithubIssueUrl(report)
|
||||
val decoded = URLDecoder.decode(url, "UTF-8")
|
||||
|
||||
assertTrue(decoded.contains("labels=bug,area:android"))
|
||||
assertTrue(decoded.contains("### Affected area\nAndroid app"))
|
||||
assertFalse(decoded.contains("private-value"))
|
||||
assertFalse(decoded.contains("private.example.test"))
|
||||
}
|
||||
|
||||
private fun report(id: String, time: String): ReliabilityReport = ReliabilityReport(
|
||||
reportId = id,
|
||||
appSessionId = "app-test",
|
||||
timeIso = time,
|
||||
kind = ReliabilityKind.FatalCrash,
|
||||
owner = ReliabilityOwner.Android,
|
||||
severity = ReliabilitySeverity.Fatal,
|
||||
summary = "Unexpected problem",
|
||||
recovery = "The app restarted",
|
||||
reportRecommended = true,
|
||||
technicalDetail = "java.lang.IllegalStateException",
|
||||
environment = environment,
|
||||
pendingReview = true,
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,11 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FloatingPetCompanionTest {
|
||||
@Test
|
||||
fun `supported floating pet rails add no visible vertical clearance`() {
|
||||
assertEquals(0f, FLOATING_PET_SUPPORTED_RAIL_CLEARANCE_DP, 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terrain lookahead plans multiple levels before movement starts`() {
|
||||
val composer = PetRoamingRail(
|
||||
@@ -109,6 +114,35 @@ class FloatingPetCompanionTest {
|
||||
assertFalse(floatingPetAcceptsPointerInput(positioned = false, surfaceScrolling = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pet waits for a measured viewport before publishing its position`() {
|
||||
assertFalse(shouldInitializeFloatingPet(false, viewportWidth = 0, viewportHeight = 0))
|
||||
assertFalse(shouldInitializeFloatingPet(false, viewportWidth = 400, viewportHeight = 0))
|
||||
assertFalse(shouldInitializeFloatingPet(false, viewportWidth = 0, viewportHeight = 800))
|
||||
assertTrue(shouldInitializeFloatingPet(false, viewportWidth = 400, viewportHeight = 800))
|
||||
assertFalse(shouldInitializeFloatingPet(true, viewportWidth = 400, viewportHeight = 800))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chat pet waits for owner terrain before publishing its first position`() {
|
||||
assertFalse(
|
||||
shouldInitializeFloatingPet(
|
||||
positioned = false,
|
||||
viewportWidth = 400,
|
||||
viewportHeight = 800,
|
||||
terrainReady = false,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
shouldInitializeFloatingPet(
|
||||
positioned = false,
|
||||
viewportWidth = 400,
|
||||
viewportHeight = 800,
|
||||
terrainReady = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `roaming starts immediately then uses the normal repeat delay`() {
|
||||
assertEquals(0L, floatingPetRoamDelayMs(hasMoved = false))
|
||||
@@ -429,6 +463,16 @@ class FloatingPetCompanionTest {
|
||||
assertEquals(60f, compactMaximum.visualSizeDp, 0.001f)
|
||||
assertEquals(72f, compactMaximum.targetSizeDp, 0.001f)
|
||||
assertEquals(FloatingPetDimensions(60f, 70f), floatingPetDimensions(false, Float.NaN))
|
||||
|
||||
listOf(
|
||||
70f to 60f,
|
||||
48f to 72f,
|
||||
84f to 84f,
|
||||
).forEach { (target, visual) ->
|
||||
val collision = floatingPetCollisionSizePx(target, visual)
|
||||
assertTrue(collision >= target)
|
||||
assertTrue(collision >= visual)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w360dp-h720dp-xhdpi")
|
||||
class MessageBubbleCompletionLayoutTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `completion does not resize a retained live tail`() {
|
||||
val streaming = mutableStateOf(true)
|
||||
val message = ChatMessage(
|
||||
id = "assistant-live",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "A reply whose final text keeps the live renderer.",
|
||||
timestamp = 1_700_000_000_000L,
|
||||
)
|
||||
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
MessageBubble(
|
||||
message = message.copy(isStreaming = streaming.value),
|
||||
retainStreamingLayout = true,
|
||||
modifier = Modifier.testTag("retained-live-tail"),
|
||||
)
|
||||
}
|
||||
}
|
||||
compose.waitForIdle()
|
||||
val streamingHeight = compose.onNodeWithTag("retained-live-tail")
|
||||
.fetchSemanticsNode().boundsInRoot.height
|
||||
|
||||
compose.runOnIdle { streaming.value = false }
|
||||
compose.mainClock.advanceTimeBy(1_000L)
|
||||
compose.waitForIdle()
|
||||
val completedHeight = compose.onNodeWithTag("retained-live-tail")
|
||||
.fetchSemanticsNode().boundsInRoot.height
|
||||
|
||||
assertEquals(streamingHeight, completedHeight, 0.01f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w360dp-h720dp-xhdpi")
|
||||
class SessionDrawerTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `reordered leading session remains visible after drawer refresh`() {
|
||||
var sessions by mutableStateOf(
|
||||
List(15) { index ->
|
||||
ChatSession(
|
||||
sessionId = "session-$index",
|
||||
title = "Session $index",
|
||||
model = null,
|
||||
lastActivityAt = 100L - index,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
SessionDrawerContent(
|
||||
sessions = sessions,
|
||||
currentSessionId = null,
|
||||
isOpen = true,
|
||||
onNewChat = {},
|
||||
onSelectSession = {},
|
||||
onDeleteSession = {},
|
||||
onRenameSession = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(SESSION_DRAWER_LIST_TAG)
|
||||
.performScrollToNode(hasText("Session 14"))
|
||||
compose.onNodeWithText("Session 14").assertIsDisplayed()
|
||||
|
||||
compose.runOnIdle {
|
||||
sessions = sessions.map { session ->
|
||||
if (session.sessionId == "session-10") {
|
||||
session.copy(title = "Now latest", lastActivityAt = 1_000L)
|
||||
} else {
|
||||
session
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Now latest").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.reliability.ReliabilityEnvironment
|
||||
import com.hermesandroid.relay.reliability.ReliabilityKind
|
||||
import com.hermesandroid.relay.reliability.ReliabilityOwner
|
||||
import com.hermesandroid.relay.reliability.ReliabilityReport
|
||||
import com.hermesandroid.relay.reliability.ReliabilitySeverity
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupportBundleDialogTest {
|
||||
@Test
|
||||
fun emptyStateCannotShare() {
|
||||
val state = buildSupportReviewState(emptyList())
|
||||
assertEquals(0, state.reportCount)
|
||||
assertFalse(state.shareEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun populatedStateShowsExactlyTheBundleThatCanBeShared() {
|
||||
val report = ReliabilityReport(
|
||||
reportId = "rpt-visible",
|
||||
appSessionId = "app-visible",
|
||||
timeIso = "2026-08-04T12:00:00Z",
|
||||
kind = ReliabilityKind.RecoverableProductError,
|
||||
owner = ReliabilityOwner.Voice,
|
||||
severity = ReliabilitySeverity.Error,
|
||||
summary = "Focus controls stopped responding",
|
||||
recovery = "Voice closed and chat remained available",
|
||||
reportRecommended = true,
|
||||
environment = ReliabilityEnvironment("1.6.1", 38, "googlePlay", "Example", "Phone", "13", 33),
|
||||
)
|
||||
|
||||
val state = buildSupportReviewState(listOf(report))
|
||||
|
||||
assertEquals(1, state.reportCount)
|
||||
assertTrue(state.shareEnabled)
|
||||
assertTrue(state.text.contains("Focus controls stopped responding"))
|
||||
assertTrue(state.text.contains("Owner: Voice"))
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.click
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w360dp-h720dp-xxhdpi")
|
||||
class VoiceModeOverlayInteractionTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun focusMode_childControlsReceiveRealPointerClicks() {
|
||||
var micTaps = 0
|
||||
var dismissals = 0
|
||||
val modeChanges = mutableListOf<InteractionMode>()
|
||||
compose.mainClock.autoAdvance = false
|
||||
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
VoiceModeOverlay(
|
||||
uiState = idleVoiceState(),
|
||||
onMicTap = { micTaps += 1 },
|
||||
onMicRelease = {},
|
||||
onInterrupt = {},
|
||||
onDismiss = { dismissals += 1 },
|
||||
onModeChange = { modeChanges += it },
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithContentDescription("Voice mic")
|
||||
.performTouchInput { click() }
|
||||
compose.onNodeWithContentDescription("Expand voice controls")
|
||||
.performTouchInput { click() }
|
||||
compose.mainClock.advanceTimeBy(500)
|
||||
compose.onNodeWithText("Hold")
|
||||
.performTouchInput { click() }
|
||||
compose.onNodeWithContentDescription("Collapse voice controls")
|
||||
.performTouchInput { click() }
|
||||
compose.onNodeWithContentDescription("Exit voice mode")
|
||||
.performTouchInput { click() }
|
||||
|
||||
compose.runOnIdle {
|
||||
assertEquals(1, micTaps)
|
||||
assertEquals(1, dismissals)
|
||||
assertEquals(listOf(InteractionMode.HoldToTalk), modeChanges)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun focusMode_emptySpaceDoesNotClickThroughToChat() {
|
||||
var backgroundTaps = 0
|
||||
compose.mainClock.autoAdvance = false
|
||||
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.testTag("backgroundChat")
|
||||
.clickable { backgroundTaps += 1 },
|
||||
)
|
||||
VoiceModeOverlay(
|
||||
uiState = idleVoiceState(),
|
||||
onMicTap = {},
|
||||
onMicRelease = {},
|
||||
onInterrupt = {},
|
||||
onDismiss = {},
|
||||
onModeChange = {},
|
||||
onClearError = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(FOCUS_INPUT_SCRIM_TAG)
|
||||
.performTouchInput { click(Offset(1f, center.y)) }
|
||||
|
||||
compose.runOnIdle { assertEquals(0, backgroundTaps) }
|
||||
}
|
||||
|
||||
private fun idleVoiceState() = VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Idle,
|
||||
interactionMode = InteractionMode.TapToTalk,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val FOCUS_INPUT_SCRIM_TAG = "voiceFocusInputScrim"
|
||||
}
|
||||
}
|
||||
+11
-20
@@ -16,6 +16,7 @@ import com.hermesandroid.relay.viewmodel.realtimeTranscriptState
|
||||
import com.hermesandroid.relay.viewmodel.realtimeTurnActiveAfterPromotion
|
||||
import com.hermesandroid.relay.viewmodel.voiceSessionExitState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
@@ -63,29 +64,19 @@ class VoiceModeOverlayStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transcriptKeys_remainDistinctWhenRowsShareReconciledServerId() {
|
||||
fun transcriptKey_staysStableAcrossServerIdAdoption() {
|
||||
val serverId = "7c4af8b7-1bb2-4830-a4e5-0332d5ddcd1f"
|
||||
val messages = listOf(
|
||||
ChatMessage(
|
||||
id = serverId,
|
||||
uiKey = "persisted-assistant-row",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Earlier snapshot",
|
||||
timestamp = 1L,
|
||||
),
|
||||
ChatMessage(
|
||||
id = serverId,
|
||||
uiKey = "live-assistant-row",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Reconciled live snapshot",
|
||||
timestamp = 2L,
|
||||
),
|
||||
val live = ChatMessage(
|
||||
id = "client-assistant-id",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Earlier snapshot",
|
||||
timestamp = 1L,
|
||||
)
|
||||
val reconciled = live.copy(id = serverId, content = "Reconciled snapshot")
|
||||
|
||||
assertEquals(
|
||||
listOf("persisted-assistant-row", "live-assistant-row"),
|
||||
messages.map(::voiceTranscriptItemKey),
|
||||
)
|
||||
assertEquals(voiceTranscriptItemKey(live), voiceTranscriptItemKey(reconciled))
|
||||
assertEquals("message:client-assistant-id", voiceTranscriptItemKey(reconciled))
|
||||
assertNotEquals("aux:pending-voice-transcript", voiceTranscriptItemKey(reconciled))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+48
@@ -1,5 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components.avatar
|
||||
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -7,6 +8,53 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PetAvatarRuntimeTest {
|
||||
@Test
|
||||
fun `floating alignment removes transparent bottom padding while preserving horizontal stabilization`() {
|
||||
assertEquals(
|
||||
IntOffset(4, 11),
|
||||
petContentAlignmentOffset(
|
||||
width = 64,
|
||||
height = 64,
|
||||
minX = 13,
|
||||
minY = 9,
|
||||
maxX = 44,
|
||||
maxY = 52,
|
||||
stabilize = true,
|
||||
groundOpaqueBottom = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-floating alignment retains centered preview behavior`() {
|
||||
assertEquals(
|
||||
IntOffset(4, 2),
|
||||
petContentAlignmentOffset(
|
||||
width = 64,
|
||||
height = 64,
|
||||
minX = 13,
|
||||
minY = 9,
|
||||
maxX = 44,
|
||||
maxY = 52,
|
||||
stabilize = true,
|
||||
groundOpaqueBottom = false,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
IntOffset.Zero,
|
||||
petContentAlignmentOffset(
|
||||
width = 64,
|
||||
height = 64,
|
||||
minX = 13,
|
||||
minY = 9,
|
||||
maxX = 44,
|
||||
maxY = 52,
|
||||
stabilize = false,
|
||||
groundOpaqueBottom = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clip transition retains the previous complete visual until decode finishes`() {
|
||||
assertEquals(
|
||||
|
||||
+30
@@ -674,6 +674,36 @@ class PetRoamingGeometryTest {
|
||||
assertEquals(180f, segments[1].top, 0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scroll control envelope is excluded from every composer rail segment`() {
|
||||
val outer = PetSafeBounds(0f, 0f, 400f, 400f)
|
||||
val footprint = PetFootprint(width = 70f, height = 70f, clearance = 6f)
|
||||
val composer = PetMeasuredPerch(
|
||||
key = "chat-composer",
|
||||
bounds = PetObstacle(0f, 260f, 400f, 340f),
|
||||
)
|
||||
val controlEnvelope = PetMeasuredObstacle(
|
||||
key = "chat-scroll-to-bottom-obstacle",
|
||||
bounds = PetObstacle(292f, 200f, 364f, 248f),
|
||||
)
|
||||
|
||||
val segments = petPerchSegments(
|
||||
perch = composer,
|
||||
obstacles = listOf(controlEnvelope),
|
||||
footprint = footprint,
|
||||
outer = outer,
|
||||
)
|
||||
val expandedControl = controlEnvelope.bounds.expanded(
|
||||
footprint.horizontalRadius,
|
||||
footprint.verticalRadius,
|
||||
)
|
||||
|
||||
assertTrue(segments.isNotEmpty())
|
||||
segments.forEach { segment ->
|
||||
assertFalse(segment.left < expandedControl.right && segment.right > expandedControl.left)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sibling perch segments choose an above-control hop`() {
|
||||
val safe = PetSafeBounds(20f, 20f, 280f, 220f)
|
||||
|
||||
@@ -1,103 +1,413 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatScrollSnapshotTest {
|
||||
@Test
|
||||
fun `completion releases only the retained live tail`() {
|
||||
assertNull(releaseRetainedLiveTail("assistant-live", "assistant-live"))
|
||||
fun `ordinary viewport resize follows an owned restored conversation`() {
|
||||
assertEquals(
|
||||
true,
|
||||
shouldFollowConversationViewportResize(
|
||||
userScrolledAway = false,
|
||||
userDragging = false,
|
||||
imeBottomPx = 0,
|
||||
followImeResize = false,
|
||||
voiceDockAnchorTransitionActive = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `voice dock transition preserves its leading row anchor`() {
|
||||
assertEquals(
|
||||
false,
|
||||
shouldFollowConversationViewportResize(
|
||||
userScrolledAway = false,
|
||||
userDragging = false,
|
||||
imeBottomPx = 0,
|
||||
followImeResize = false,
|
||||
voiceDockAnchorTransitionActive = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ime resize follows only after bottom ownership was captured`() {
|
||||
assertEquals(
|
||||
false,
|
||||
shouldFollowConversationViewportResize(
|
||||
userScrolledAway = false,
|
||||
userDragging = false,
|
||||
imeBottomPx = 320,
|
||||
followImeResize = false,
|
||||
voiceDockAnchorTransitionActive = false,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
true,
|
||||
shouldFollowConversationViewportResize(
|
||||
userScrolledAway = false,
|
||||
userDragging = false,
|
||||
imeBottomPx = 320,
|
||||
followImeResize = true,
|
||||
voiceDockAnchorTransitionActive = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same transcript late viewport or tail layout is corrected`() {
|
||||
val previous = viewportSnapshot(
|
||||
tailSizePx = 400,
|
||||
viewportHeightPx = 1_000,
|
||||
visibleBottomDistancePx = 0,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
true,
|
||||
shouldCorrectConversationBottomAfterLayout(
|
||||
previous = previous,
|
||||
current = previous.copy(viewportHeightPx = 960, visibleBottomDistancePx = 40),
|
||||
atExactBottom = false,
|
||||
userScrolledAway = false,
|
||||
userDragging = false,
|
||||
isStreaming = false,
|
||||
smoothAutoScroll = false,
|
||||
viewportFollowAllowed = true,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
true,
|
||||
shouldCorrectConversationBottomAfterLayout(
|
||||
previous = previous,
|
||||
current = previous.copy(tailSizePx = 460, visibleBottomDistancePx = null),
|
||||
atExactBottom = false,
|
||||
userScrolledAway = false,
|
||||
userDragging = false,
|
||||
isStreaming = false,
|
||||
smoothAutoScroll = false,
|
||||
viewportFollowAllowed = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `late layout correction preserves reader and new message ownership`() {
|
||||
val previous = viewportSnapshot(visibleBottomDistancePx = 0)
|
||||
val remeasured = previous.copy(viewportHeightPx = 960, visibleBottomDistancePx = 40)
|
||||
fun correction(
|
||||
current: ChatViewportFollowSnapshot = remeasured,
|
||||
exact: Boolean = false,
|
||||
away: Boolean = false,
|
||||
dragging: Boolean = false,
|
||||
) = shouldCorrectConversationBottomAfterLayout(
|
||||
previous = previous,
|
||||
current = current,
|
||||
atExactBottom = exact,
|
||||
userScrolledAway = away,
|
||||
userDragging = dragging,
|
||||
isStreaming = false,
|
||||
smoothAutoScroll = true,
|
||||
viewportFollowAllowed = true,
|
||||
)
|
||||
|
||||
assertEquals(false, correction(exact = true))
|
||||
assertEquals(false, correction(away = true))
|
||||
assertEquals(false, correction(dragging = true))
|
||||
assertEquals(
|
||||
false,
|
||||
correction(
|
||||
current = remeasured.copy(
|
||||
totalItemsCount = remeasured.totalItemsCount + 1,
|
||||
tailUiKey = "new-tail",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible footer distance is the exact follow amount`() {
|
||||
val previous = viewportSnapshot(tailSizePx = 400, visibleBottomDistancePx = 0)
|
||||
val current = viewportSnapshot(tailSizePx = 432, visibleBottomDistancePx = 7)
|
||||
|
||||
assertEquals(7, requiredBottomFollowScroll(previous, current))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `viewport loss follows keyboard when footer leaves the viewport`() {
|
||||
val previous = viewportSnapshot(viewportHeightPx = 1_000)
|
||||
val current = viewportSnapshot(
|
||||
viewportHeightPx = 680,
|
||||
visibleBottomDistancePx = null,
|
||||
followTailGrowth = false,
|
||||
followViewportResize = true,
|
||||
)
|
||||
|
||||
assertEquals(320, requiredBottomFollowScroll(previous, current))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `history reading never follows viewport or tail changes`() {
|
||||
val previous = viewportSnapshot(tailSizePx = 400, viewportHeightPx = 1_000)
|
||||
val current = viewportSnapshot(
|
||||
tailSizePx = 520,
|
||||
viewportHeightPx = 680,
|
||||
visibleBottomDistancePx = 120,
|
||||
followTailGrowth = false,
|
||||
followViewportResize = false,
|
||||
)
|
||||
|
||||
assertEquals(0, requiredBottomFollowScroll(previous, current))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ordinary viewport ownership does not follow a new tail`() {
|
||||
val previous = viewportSnapshot(visibleBottomDistancePx = 0)
|
||||
val newTail = previous.copy(
|
||||
totalItemsCount = previous.totalItemsCount + 1,
|
||||
tailUiKey = "assistant-new",
|
||||
visibleBottomDistancePx = 48,
|
||||
followTailGrowth = false,
|
||||
followViewportResize = true,
|
||||
)
|
||||
|
||||
assertEquals(0, ownedBottomFollowScroll(previous, newTail))
|
||||
assertEquals(
|
||||
48,
|
||||
ownedBottomFollowScroll(
|
||||
previous,
|
||||
newTail.copy(followTailGrowth = true),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `voice anchor transition suppresses retained tail following`() {
|
||||
val previous = viewportSnapshot(visibleBottomDistancePx = 0)
|
||||
val duringTransition = previous.copy(
|
||||
visibleBottomDistancePx = 52,
|
||||
followTailGrowth = false,
|
||||
followViewportResize = false,
|
||||
)
|
||||
|
||||
assertEquals(0, ownedBottomFollowScroll(previous, duringTransition))
|
||||
assertEquals(
|
||||
false,
|
||||
shouldCorrectConversationBottomAfterLayout(
|
||||
previous = previous,
|
||||
current = duringTransition,
|
||||
atExactBottom = false,
|
||||
userScrolledAway = false,
|
||||
userDragging = false,
|
||||
isStreaming = false,
|
||||
smoothAutoScroll = true,
|
||||
viewportFollowAllowed = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `voice transcript can correct late layout after anchor transition`() {
|
||||
assertEquals(
|
||||
true,
|
||||
shouldFollowConversationViewportResize(
|
||||
userScrolledAway = false,
|
||||
userDragging = false,
|
||||
imeBottomPx = 0,
|
||||
followImeResize = false,
|
||||
voiceDockAnchorTransitionActive = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keyboard follow arms only from the conversation bottom`() {
|
||||
assertEquals(
|
||||
true,
|
||||
shouldFollowImeAfterInsetChange(
|
||||
wasFollowing = false,
|
||||
previousImeBottomPx = 0,
|
||||
currentImeBottomPx = 1,
|
||||
wasAtBottom = true,
|
||||
userDragging = false,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
false,
|
||||
shouldFollowImeAfterInsetChange(
|
||||
wasFollowing = false,
|
||||
previousImeBottomPx = 0,
|
||||
currentImeBottomPx = 1,
|
||||
wasAtBottom = false,
|
||||
userDragging = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keyboard follow survives animation and clears on close or drag`() {
|
||||
assertEquals(
|
||||
true,
|
||||
shouldFollowImeAfterInsetChange(
|
||||
wasFollowing = true,
|
||||
previousImeBottomPx = 120,
|
||||
currentImeBottomPx = 480,
|
||||
wasAtBottom = false,
|
||||
userDragging = false,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
false,
|
||||
shouldFollowImeAfterInsetChange(
|
||||
wasFollowing = true,
|
||||
previousImeBottomPx = 480,
|
||||
currentImeBottomPx = 0,
|
||||
wasAtBottom = true,
|
||||
userDragging = false,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
false,
|
||||
shouldFollowImeAfterInsetChange(
|
||||
wasFollowing = true,
|
||||
previousImeBottomPx = 120,
|
||||
currentImeBottomPx = 480,
|
||||
wasAtBottom = true,
|
||||
userDragging = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `completion and keyboard settle exactly only while bottom follow is owned`() {
|
||||
assertEquals(
|
||||
true,
|
||||
shouldExactlySettleConversation(
|
||||
autoFollowEnabled = true,
|
||||
userScrolledAway = false,
|
||||
userDragging = false,
|
||||
hasMessages = true,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
false,
|
||||
shouldExactlySettleConversation(
|
||||
autoFollowEnabled = true,
|
||||
userScrolledAway = true,
|
||||
userDragging = false,
|
||||
hasMessages = true,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
false,
|
||||
shouldExactlySettleConversation(
|
||||
autoFollowEnabled = true,
|
||||
userScrolledAway = false,
|
||||
userDragging = true,
|
||||
hasMessages = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled auto follow and empty conversations do not request exact settlement`() {
|
||||
assertEquals(
|
||||
false,
|
||||
shouldExactlySettleConversation(
|
||||
autoFollowEnabled = false,
|
||||
userScrolledAway = false,
|
||||
userDragging = false,
|
||||
hasMessages = true,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
false,
|
||||
shouldExactlySettleConversation(
|
||||
autoFollowEnabled = true,
|
||||
userScrolledAway = false,
|
||||
userDragging = false,
|
||||
hasMessages = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stream start captures the live tail renderer`() {
|
||||
assertEquals(
|
||||
"assistant-live",
|
||||
releaseRetainedLiveTail("assistant-live", "different-message"),
|
||||
)
|
||||
assertNull(releaseRetainedLiveTail(null, "assistant-live"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tall markdown tail is positioned by its trailing edge`() {
|
||||
assertEquals(
|
||||
1_208,
|
||||
tailEndScrollOffset(
|
||||
tailSizePx = 2_400,
|
||||
footerSizePx = 8,
|
||||
viewportSizePx = 1_200,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
tailEndScrollOffset(
|
||||
tailSizePx = 600,
|
||||
footerSizePx = 8,
|
||||
viewportSizePx = 1_200,
|
||||
retainedLiveTailAfterTransition(
|
||||
retainedUiKey = null,
|
||||
streamStarted = true,
|
||||
lastMessageUiKey = "assistant-live",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same-tail stream completion requests an atomic bottom anchor`() {
|
||||
val streaming = snapshot(isStreaming = true)
|
||||
val complete = snapshot(isStreaming = false)
|
||||
|
||||
assertTrue(complete.isCompletionAfter(streaming))
|
||||
fun `same-tail completion keeps the stable live renderer`() {
|
||||
assertEquals(
|
||||
"assistant-live",
|
||||
retainedLiveTailAfterTransition(
|
||||
retainedUiKey = "assistant-live",
|
||||
streamStarted = false,
|
||||
lastMessageUiKey = "assistant-live",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tail replacement is not mistaken for stream completion`() {
|
||||
val streaming = snapshot(isStreaming = true)
|
||||
val replaced = snapshot(isStreaming = false, lastMessageUiKey = "replacement-tail")
|
||||
|
||||
assertFalse(replaced.isCompletionAfter(streaming))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message list rebuild is not mistaken for stream completion`() {
|
||||
val streaming = snapshot(isStreaming = true)
|
||||
val rebuilt = snapshot(isStreaming = false, messageCount = 10)
|
||||
|
||||
assertFalse(rebuilt.isCompletionAfter(streaming))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ordinary streaming growth is not a completion`() {
|
||||
val before = snapshot(contentLength = 4_000, isStreaming = true)
|
||||
val after = snapshot(contentLength = 4_500, isStreaming = true)
|
||||
|
||||
assertFalse(after.isCompletionAfter(before))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `starting a stream is not a completion`() {
|
||||
val idle = snapshot(isStreaming = false)
|
||||
val streaming = snapshot(isStreaming = true)
|
||||
|
||||
assertFalse(streaming.isCompletionAfter(idle))
|
||||
fun `new tail releases the retained renderer`() {
|
||||
assertNull(
|
||||
retainedLiveTailAfterTransition(
|
||||
retainedUiKey = "assistant-live",
|
||||
streamStarted = false,
|
||||
lastMessageUiKey = "next-row",
|
||||
),
|
||||
)
|
||||
assertNull(
|
||||
retainedLiveTailAfterTransition(
|
||||
retainedUiKey = null,
|
||||
streamStarted = false,
|
||||
lastMessageUiKey = "next-row",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `server id adoption remains an observable tail change`() {
|
||||
val local = snapshot(isStreaming = false)
|
||||
val local = snapshot()
|
||||
val reconciled = local.copy(lastMessageId = "assistant-server-id")
|
||||
|
||||
assertNotEquals(local, reconciled)
|
||||
}
|
||||
|
||||
private fun snapshot(
|
||||
contentLength: Int = 12_000,
|
||||
isStreaming: Boolean,
|
||||
messageCount: Int = 8,
|
||||
lastMessageUiKey: String = "assistant-ui-key",
|
||||
) = ChatScrollSnapshot(
|
||||
messageCount = messageCount,
|
||||
private fun snapshot() = ChatScrollSnapshot(
|
||||
messageCount = 8,
|
||||
lastMessageId = "assistant-live-id",
|
||||
lastMessageUiKey = lastMessageUiKey,
|
||||
lastContentLength = contentLength,
|
||||
lastMessageUiKey = "assistant-ui-key",
|
||||
lastContentLength = 12_000,
|
||||
lastThinkingLength = 1_200,
|
||||
lastToolCallCount = 2,
|
||||
isStreaming = isStreaming,
|
||||
isStreaming = false,
|
||||
)
|
||||
|
||||
private fun viewportSnapshot(
|
||||
totalItemsCount: Int = 10,
|
||||
tailSizePx: Int? = 400,
|
||||
viewportHeightPx: Int = 1_000,
|
||||
visibleBottomDistancePx: Int? = null,
|
||||
followTailGrowth: Boolean = true,
|
||||
followViewportResize: Boolean = false,
|
||||
) = ChatViewportFollowSnapshot(
|
||||
totalItemsCount = totalItemsCount,
|
||||
tailUiKey = "assistant-live",
|
||||
tailSizePx = tailSizePx,
|
||||
viewportHeightPx = viewportHeightPx,
|
||||
visibleBottomDistancePx = visibleBottomDistancePx,
|
||||
followTailGrowth = followTailGrowth,
|
||||
followViewportResize = followViewportResize,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.auth.AuthState
|
||||
import com.hermesandroid.relay.diagnostics.CheckStatus
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.network.shared.ConnectivityObserver
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
import com.hermesandroid.relay.network.upstream.ServerCapabilities
|
||||
@@ -68,4 +71,43 @@ class DiagnosticsScreenTest {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedStatusCheckLeadsWithActionableSuggestion() {
|
||||
val suggestion = "Verify Relay is running and listening on the configured host and port."
|
||||
val entry = DiagnosticLogEntry(
|
||||
timestampMs = 123L,
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay connection refused",
|
||||
detail = "Failed to connect",
|
||||
suggestion = suggestion,
|
||||
)
|
||||
|
||||
val checks = buildStatusChecks(
|
||||
network = ConnectivityObserver.Status.Available,
|
||||
dashboardUrl = "https://hermes.example.com",
|
||||
gatewayAvailability = GatewayAvailability.Ready,
|
||||
apiConfigured = false,
|
||||
apiHealth = ConnectionViewModel.HealthStatus.Unknown,
|
||||
apiUrl = "",
|
||||
capabilities = ServerCapabilities.DISCONNECTED,
|
||||
authState = AuthState.Unpaired,
|
||||
relayConfigured = true,
|
||||
relayHealth = ConnectionViewModel.HealthStatus.Unreachable,
|
||||
relayReady = false,
|
||||
relayUpdateInfo = null,
|
||||
voiceReady = true,
|
||||
relayVoiceReady = false,
|
||||
recentEntries = listOf(entry),
|
||||
context = context,
|
||||
)
|
||||
|
||||
val relay = checks.single {
|
||||
it.name == context.getString(R.string.active_section_optional_relay)
|
||||
}
|
||||
assertEquals(CheckStatus.Fail, relay.status)
|
||||
assertEquals(suggestion, relay.reason)
|
||||
assertEquals(123L, relay.timestampMs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class SessionActivityStateTest {
|
||||
@Test
|
||||
fun `multiple background turns remain visible beside current working turn`() {
|
||||
val resolved = resolveSessionActivityStates(
|
||||
background = mapOf(
|
||||
"session-a" to SessionActivityState.Working,
|
||||
"session-b" to SessionActivityState.NeedsInput,
|
||||
),
|
||||
currentSessionId = "session-c",
|
||||
isStreaming = true,
|
||||
needsInput = false,
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.Working, resolved["session-a"])
|
||||
assertEquals(SessionActivityState.NeedsInput, resolved["session-b"])
|
||||
assertEquals(SessionActivityState.Working, resolved["session-c"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `needs input takes precedence over current working state`() {
|
||||
val resolved = resolveSessionActivityStates(
|
||||
background = emptyMap(),
|
||||
currentSessionId = "session-a",
|
||||
isStreaming = true,
|
||||
needsInput = true,
|
||||
)
|
||||
|
||||
assertEquals(SessionActivityState.NeedsInput, resolved["session-a"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selected idle session does not retain a stale background state`() {
|
||||
val resolved = resolveSessionActivityStates(
|
||||
background = mapOf("session-a" to SessionActivityState.Working),
|
||||
currentSessionId = "session-a",
|
||||
isStreaming = false,
|
||||
needsInput = false,
|
||||
)
|
||||
|
||||
assertFalse(resolved.containsKey("session-a"))
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import java.net.URLDecoder
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -101,6 +102,10 @@ class IssueReportAndDiagnosticsTest {
|
||||
endpointRole: String? = null,
|
||||
url: String? = null,
|
||||
detail: String? = null,
|
||||
operation: String? = null,
|
||||
configuredUrl: String? = null,
|
||||
requestUrl: String? = null,
|
||||
suggestion: String? = null,
|
||||
) = DiagnosticLogEntry(
|
||||
timestampMs = 0L,
|
||||
category = DiagnosticCategory.Api,
|
||||
@@ -109,13 +114,17 @@ class IssueReportAndDiagnosticsTest {
|
||||
detail = detail,
|
||||
endpointRole = endpointRole,
|
||||
url = url,
|
||||
operation = operation,
|
||||
configuredUrl = configuredUrl,
|
||||
requestUrl = requestUrl,
|
||||
suggestion = suggestion,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun errorEntriesKeepBugTitleAndLabel() {
|
||||
val entry = sampleEntry(DiagnosticSeverity.Error, title = "API key rejected")
|
||||
assertEquals("[Bug]: API key rejected", DiagnosticIssuePrefill.issueTitle(entry))
|
||||
assertEquals("bug", DiagnosticIssuePrefill.issueLabels(entry))
|
||||
assertEquals("bug,area:android", DiagnosticIssuePrefill.issueLabels(entry))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -124,7 +133,7 @@ class IssueReportAndDiagnosticsTest {
|
||||
val entry = sampleEntry(severity)
|
||||
assertEquals("[Diagnostic]: Testing API connection", DiagnosticIssuePrefill.issueTitle(entry))
|
||||
// "question" already exists on the repo — the prefill must not invent labels.
|
||||
assertEquals("question", DiagnosticIssuePrefill.issueLabels(entry))
|
||||
assertEquals("question,area:android", DiagnosticIssuePrefill.issueLabels(entry))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +170,7 @@ class IssueReportAndDiagnosticsTest {
|
||||
)
|
||||
assertTrue(body.contains("- Connection mode: tailscale"))
|
||||
assertFalse(body.contains("LAN / Tailscale / public TLS / other"))
|
||||
assertFalse(body.contains("10.0.0.5"))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -182,6 +192,65 @@ class IssueReportAndDiagnosticsTest {
|
||||
assertTrue(body.contains("- Connection mode: unknown"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bodyExplainsConfiguredRouteActualRequestAndNextStep() {
|
||||
val body = DiagnosticIssuePrefill.issueBody(
|
||||
sampleEntry(
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay connection refused",
|
||||
operation = "Relay health probe before WebSocket connection",
|
||||
configuredUrl = "ws://10.3.0.5:8767",
|
||||
requestUrl = "http://10.3.0.5:8767/health",
|
||||
suggestion = "Verify Relay is running and listening on the configured host and port.",
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(body.contains("- Operation: Relay health probe before WebSocket connection"))
|
||||
assertTrue(body.contains("- Configured URL: ws://[host]"))
|
||||
assertTrue(body.contains("- Request: http://[host]/health"))
|
||||
assertTrue(
|
||||
body.contains(
|
||||
"- Suggested next step: Verify Relay is running and listening on the configured host and port.",
|
||||
),
|
||||
)
|
||||
assertFalse(body.contains("10.3.0.5"))
|
||||
assertFalse(body.lineSequence().any { it.startsWith("- URL:") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionModePrefersConfiguredRouteOverConvertedProbeRequest() {
|
||||
val entry = sampleEntry(
|
||||
severity = DiagnosticSeverity.Error,
|
||||
configuredUrl = "wss://100.80.1.2:8767",
|
||||
requestUrl = "https://relay.example.com:8767/health",
|
||||
)
|
||||
|
||||
assertEquals("tailscale", DiagnosticIssuePrefill.connectionMode(entry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recordSanitizesStructuredDiagnosticContext() {
|
||||
DiagnosticsLog.clear()
|
||||
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Relay failed",
|
||||
operation = " Relay health probe ",
|
||||
configuredUrl = "ws://token=secret@relay.example.com:8767?token=secret",
|
||||
requestUrl = "http://relay.example.com:8767/health?api_key=secret",
|
||||
suggestion = " Retry with token=secret after checking the service. ",
|
||||
url = "http://legacy.example.com:8767",
|
||||
)
|
||||
|
||||
val entry = DiagnosticsLog.recent(limit = 1).single()
|
||||
assertEquals("Relay health probe", entry.operation)
|
||||
assertEquals("ws://[host]", entry.configuredUrl)
|
||||
assertEquals("http://[host]/health", entry.requestUrl)
|
||||
assertEquals("Retry with token=[hidden] after checking the service.", entry.suggestion)
|
||||
assertNull(entry.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recordErrorRedactsSecretsInTheStacktrace() {
|
||||
DiagnosticsLog.clear()
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.ApiModelRoutingErrorCode
|
||||
import com.hermesandroid.relay.network.upstream.ApiModelRoutingException
|
||||
import java.net.SocketTimeoutException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
class ModelInventoryFailurePolicyTest {
|
||||
@Test
|
||||
fun `background inventory failure stays out of global notices`() {
|
||||
val failure = ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||
"Model inventory could not be loaded.",
|
||||
)
|
||||
|
||||
assertNull(modelInventoryFailureNotice(failure, userInitiated = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `user initiated inventory failure remains actionable`() {
|
||||
val failure = ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||
"Model inventory unavailable (HTTP 503).",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"Couldn't refresh API model inventory: Model inventory unavailable (HTTP 503).",
|
||||
modelInventoryFailureNotice(failure, userInitiated = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `routing failure retains the network cause used by diagnostics`() {
|
||||
val cause = SocketTimeoutException("timeout")
|
||||
val failure = ApiModelRoutingException(
|
||||
ApiModelRoutingErrorCode.INVENTORY_UNAVAILABLE,
|
||||
"Model inventory could not be loaded.",
|
||||
cause,
|
||||
)
|
||||
|
||||
assertSame(cause, failure.cause)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
# Android reliability and support audit
|
||||
|
||||
**Date:** 2026-08-04
|
||||
**Scope:** Android fatal crashes, handled failures, diagnostics, symbolication,
|
||||
privacy, recovery, and user-initiated support submission.
|
||||
|
||||
## Executive summary
|
||||
|
||||
Hermes-Relay already has the right product posture: crash capture is local,
|
||||
reports are never uploaded automatically, the platform crash handler still runs,
|
||||
and users can copy, share, or open GitHub themselves. The implementation is not
|
||||
yet one reliability system, however. Fatal crashes use a private one-file JSON
|
||||
format, handled errors use an in-memory diagnostics ring, issue builders duplicate
|
||||
environment and truncation rules, and release mappings are not retained outside
|
||||
the transient build workspace. The UI therefore asks users to review developer
|
||||
traces without enough interaction context while maintainers receive obfuscated or
|
||||
truncated reports that cannot always be retraced.
|
||||
|
||||
The foundational change should align those seams without adding telemetry:
|
||||
|
||||
1. use one allowlisted, versioned reliability event contract for fatal and handled
|
||||
failures;
|
||||
2. redact locally before persistence, display, copy, share, or issue prefill;
|
||||
3. retain a small deterministic local history and build a support export entirely
|
||||
on device;
|
||||
4. make the recovery UI lead with outcome and next steps, with technical detail
|
||||
behind an explicit review action;
|
||||
5. retain release mapping files outside public release assets by immutable
|
||||
version/SHA and make Android's own issue prefills request the Android area.
|
||||
|
||||
No automatic upload, analytics SDK, remote crash service, prompt/message capture,
|
||||
or background logcat collection is justified.
|
||||
|
||||
## Current end-to-end architecture
|
||||
|
||||
### Fatal crashes
|
||||
|
||||
- `HermesRelayApp.onCreate()` installs `CrashReporter` before other app setup.
|
||||
- The process-wide uncaught-exception handler synchronously writes
|
||||
`files/crash/last-crash.json`, then delegates to Android's previous handler.
|
||||
This preserves the platform crash path and Google Play Android vitals.
|
||||
- The next main-app launch displays `CrashReportGate`. The report remains until
|
||||
the user dismisses, shares, or opens GitHub; copy alone does not acknowledge it.
|
||||
- The persisted record contains time, version/code/flavor, manufacturer/model,
|
||||
Android release/API, thread, exception summary, and the full Java trace.
|
||||
- GitHub prefill truncates the trace to 3,000 characters because it is encoded in
|
||||
a browser URL. Copy/share use the full persisted trace.
|
||||
|
||||
Failure modes:
|
||||
|
||||
- the raw exception message and trace are written before privacy redaction;
|
||||
- a single non-atomic file means a second crash replaces the first and an
|
||||
interrupted write can leave no readable report;
|
||||
- the format has no explicit schema version, retention period, size bound, report
|
||||
identifier, or migration contract;
|
||||
- the primary recovery experience is a large stack trace rather than what
|
||||
happened, what recovered, and what the user can do;
|
||||
- a crash in a secondary process can compete for the same file;
|
||||
- fatal OOM may leave too little memory for serialization or file I/O.
|
||||
|
||||
### Handled errors and coroutine failures
|
||||
|
||||
- `classifyError()` maps common network, auth, HTTP, SSL, permission, and voice
|
||||
failures to `HumanError`, then records every non-null throwable in
|
||||
`DiagnosticsLog`.
|
||||
- `DiagnosticsLog` is an in-memory 200-entry ring. List fields are capped at 180
|
||||
characters and error traces at 8,000 characters. It is cleared on process death.
|
||||
- Network ownership is partially represented by `DiagnosticCategory` (`API`,
|
||||
`Relay`, `Session`, `Voice`, `Route`, `Auth`), but `send_message` defaults to
|
||||
API even when Gateway owns the turn. Dashboard and upstream Gateway do not have
|
||||
first-class categories.
|
||||
- Multiple long-lived `SupervisorJob` scopes exist in services, clients, and the
|
||||
process runtime. There is no process-wide `CoroutineExceptionHandler`; failures
|
||||
are visible only where a caller catches/classifies them or where they become
|
||||
uncaught exceptions.
|
||||
- Expected cancellation and user denial are not a first-class taxonomy. A caught
|
||||
cancellation passed to `classifyError()` can appear as an error and become
|
||||
reportable noise.
|
||||
|
||||
### Connectivity, auth, HTTP, WebSocket, and voice
|
||||
|
||||
- Typed DNS, connection-refused, timeout, TLS, permission, and generic I/O
|
||||
failures receive humane copy and retry/repair hints.
|
||||
- HTTP status handling is message-based. 401/403/404/413/500/503 have distinct
|
||||
outcomes, but rate limiting is not first-class and ownership depends on a small
|
||||
caller-provided context string.
|
||||
- Gateway, Dashboard, API fallback, optional Relay, and Voice have separate live
|
||||
checks in Diagnostics, but the captured error data contract cannot represent
|
||||
all five owners precisely.
|
||||
- `isConnectivityError()` lets startup UI avoid duplicate scary snackbars, but
|
||||
those failures are still recorded as generic errors.
|
||||
|
||||
### OOM and ANR boundaries
|
||||
|
||||
- OOM is only captured if the uncaught handler has enough memory and storage to
|
||||
finish. The current reporter allocates a `StringWriter` containing the complete
|
||||
trace, which is specifically fragile during OOM.
|
||||
- ANRs do not throw through the uncaught handler. Google Play can observe Play
|
||||
builds, while sideload builds currently have no local ANR signal.
|
||||
- Adding a main-thread watchdog immediately would introduce false positives during
|
||||
debugger pauses, device sleep, startup, and legitimate long frames. It belongs
|
||||
in a later opt-in/bounded phase after lifecycle-aware design and device testing.
|
||||
|
||||
### Diagnostics, breadcrumbs, and correlation
|
||||
|
||||
- Diagnostics has read-only subsystem checks plus the in-memory activity ring.
|
||||
- Relay envelopes and some bridge/TUI paths have request IDs, and chat has durable
|
||||
session/run/message IDs. These identifiers are not joined to crash reports.
|
||||
- Raw session, profile, and connection identifiers may themselves be sensitive.
|
||||
The safe foundation is a new random per-launch app-session ID and per-event
|
||||
report ID. Product/session IDs should only be added later as short local hashes
|
||||
after a demonstrated diagnostic need.
|
||||
- There is no bounded breadcrumb contract. Capturing arbitrary diagnostic detail
|
||||
would risk prompts, message text, media paths, profile names, and host data.
|
||||
|
||||
### R8, ProGuard, and symbolication
|
||||
|
||||
- Release builds enable R8 and preserve `SourceFile`/`LineNumberTable`; source file
|
||||
names are normalized to `SourceFile`.
|
||||
- AGP writes `mapping.txt` under `app/build/outputs/mapping/<variant>/`. The file
|
||||
is overwritten by subsequent builds.
|
||||
- The Play AAB contains its mapping, so Play reports can be deobfuscated in Play
|
||||
Console. Sideload reports need the exact locally retained variant mapping.
|
||||
- The release workflow publishes the sideload APK and Play AAB but does not retain
|
||||
either release mapping as a versioned workflow artifact. A GitHub issue containing
|
||||
a sideload trace therefore may be practically irretrievable after the runner is
|
||||
gone.
|
||||
- Maintainer procedure should be deterministic:
|
||||
`retrace <mapping-for-exact-version-and-flavor> <trace-file>`.
|
||||
|
||||
### Recent-report evidence
|
||||
|
||||
- #289 (`1.6.0`, Play) contains a `NoSuchElementException` trace with application
|
||||
frames reduced to names such as `gn5.g(SourceFile:2)` and is truncated mid-frame.
|
||||
The interaction that preceded the crash is absent.
|
||||
- #292 (`1.6.0-sideload`) contains a duplicate Compose lazy-list key but only
|
||||
obfuscated application frames and a trace truncated by URL limits.
|
||||
- #298 (`1.6.1`, Play) shows the same duplicate-key class on another device, again
|
||||
with obfuscated application frames. There is no route/session/interaction
|
||||
context to distinguish the owning list.
|
||||
- #299 (`1.6.1`, Play) provides the missing human context—Focus-mode controls
|
||||
animate but taps usually do not complete—but has no diagnostic event, app-session
|
||||
correlation, or technical trail.
|
||||
- All four Android issues received `area:plugin`. The former issue-triage workflow
|
||||
tested broad `relay|plugin|...` keywords before Android terms, so the
|
||||
repository/app name won before `Android app`, device, Compose, or voice context
|
||||
was considered. Current `dev` has since retired that unreliable keyword
|
||||
labeler in favor of maintainer review; it should not be reintroduced.
|
||||
|
||||
The reports demonstrate both halves of the gap: traces without interaction context
|
||||
and interaction context without a safe technical trail.
|
||||
|
||||
## Privacy threat review
|
||||
|
||||
The following must never be collected by the reliability contract:
|
||||
|
||||
- authentication headers, cookies, API keys, Relay/session/pairing tokens, OAuth
|
||||
codes, or signed URLs;
|
||||
- prompt, response, transcript, reasoning, tool arguments/results, or notification
|
||||
content;
|
||||
- real hostnames, IP addresses, full URLs, SSIDs, proxy routes, or private
|
||||
infrastructure names;
|
||||
- profile/agent/person names, raw connection/session/run/message IDs, contacts, or
|
||||
account identifiers;
|
||||
- local/media/workspace paths, attachment names, clipboard contents, screenshots,
|
||||
audio, or camera data.
|
||||
|
||||
The contract should allow only enumerated owner/kind/status values, version/device
|
||||
metadata, random local correlation IDs, bounded redacted summaries/traces, route
|
||||
roles (for example `lan` or `public TLS`, never the host), and bounded allowlisted
|
||||
breadcrumbs with no arbitrary payload.
|
||||
|
||||
Redaction is defense in depth, not permission to collect prohibited fields. It
|
||||
must run before disk persistence and again when rendering/exporting legacy data.
|
||||
|
||||
## Shared taxonomy and data contract
|
||||
|
||||
### Kinds
|
||||
|
||||
| Kind | Persistence / UI policy |
|
||||
|---|---|
|
||||
| Fatal crash | Persist synchronously; show recovery once; reporting is useful |
|
||||
| ANR/watchdog signal | Contract reserved; later lifecycle-aware implementation |
|
||||
| Recoverable product error | Persist bounded history; show owned recovery action |
|
||||
| Connectivity | Low-noise; retry/offline guidance; do not nag for reports |
|
||||
| Authentication | Name owning surface; repair/sign-in guidance |
|
||||
| Rate limit | Show retry timing when safely known; do not report by default |
|
||||
| Service unavailable | Retry guidance; report only if repeated/unexpected |
|
||||
| Expected cancellation | Do not persist or offer reporting |
|
||||
| User denial | Do not persist or offer reporting; explain how to change permission |
|
||||
|
||||
### Owners
|
||||
|
||||
`Android`, `Dashboard`, `API`, `Relay`, `Upstream Gateway`, `Voice`, and `Unknown`.
|
||||
Standard Dashboard/Gateway/API ownership remains upstream; Relay is optional and
|
||||
must never be presented as required for standard recovery.
|
||||
|
||||
### Versioned record
|
||||
|
||||
Each record contains: schema version, random report ID, random app-session ID,
|
||||
timestamp, kind, owner, severity, humane summary, recovery outcome, whether a
|
||||
report is recommended, bounded redacted technical detail, and allowlisted app /
|
||||
OS / device / flavor metadata. Optional context contains route role and bounded
|
||||
allowlisted breadcrumbs only.
|
||||
|
||||
## UX recommendation
|
||||
|
||||
### Post-crash recovery
|
||||
|
||||
Lead with “Hermes-Relay restarted after an unexpected problem.” Explain that work
|
||||
on the Hermes server may still be running, no report was sent, and the user can
|
||||
continue. Technical information stays collapsed behind “Review report.” Copy,
|
||||
share, and GitHub use the same reviewed redacted payload. Dismiss remains the
|
||||
lowest-friction path and never nags again for that event.
|
||||
|
||||
### Inline handled errors
|
||||
|
||||
Use owner + outcome + action: “Dashboard sign-in expired — chat can use API
|
||||
fallback” or “Relay unavailable — standard Chat and Manage are unaffected.” Do not
|
||||
offer reporting for connectivity, expected cancellation, user denial, or a missing
|
||||
optional Relay feature. Keep report actions in Diagnostics rather than snackbars.
|
||||
|
||||
### Settings / About / Diagnostics
|
||||
|
||||
Diagnostics should expose “Review support information,” showing exactly the
|
||||
bounded text that copy/share will receive. It should work offline and include no
|
||||
new probe. About should continue to show version/flavor; duplicating export entry
|
||||
points there is unnecessary in the foundation.
|
||||
|
||||
### Accessibility and localization
|
||||
|
||||
The crash dialog must support narrow/foldable layouts, scrolling, screen-reader
|
||||
labels, large text, and an explicit technical-detail toggle. All new visible copy
|
||||
must use resources across supported locales. Clipboard/share/browser absence must
|
||||
fall back without losing the local report.
|
||||
|
||||
## Phased plan
|
||||
|
||||
### Immediate coherent foundation
|
||||
|
||||
- Add the versioned taxonomy/record and centralized local redactor.
|
||||
- Persist fatal and centrally classified handled failures in a bounded atomic
|
||||
local store; migrate the legacy one-file crash record.
|
||||
- Suppress persistence/report prompting for expected cancellation and user denial.
|
||||
- Build crash/support text and GitHub prefill from the same redacted contract.
|
||||
- Add explicit review-before-sharing UI and a Diagnostics support export.
|
||||
- Route Android crash and Diagnostics issue prefills to `area:android`; keep the
|
||||
unreliable repository-wide keyword labeler disabled.
|
||||
- Retain Play and sideload `mapping.txt` files as non-release version/SHA workflow
|
||||
artifacts and document retrace.
|
||||
- Add focused privacy, bounds, classification, migration, issue-prefill, and UI
|
||||
state tests.
|
||||
|
||||
### Later, evidence-gated follow-up
|
||||
|
||||
- Lifecycle-aware ANR watchdog with debugger/sleep/startup suppression and device
|
||||
false-positive testing.
|
||||
- Strict allowlisted breadcrumbs at high-value transitions (screen/feature owner,
|
||||
route transition, retry outcome), never user content.
|
||||
- Hashed product correlation IDs only where a concrete diagnosis requires them.
|
||||
- OOM emergency record preallocation / minimal writer.
|
||||
- Broader structured error adoption at WebSocket, coroutine-scope, and service
|
||||
boundaries that currently bypass `classifyError()`.
|
||||
- Maintainer tooling that downloads the exact release mapping and runs retrace from
|
||||
a report's version/code/flavor tuple.
|
||||
|
||||
Automatic telemetry, remote upload, full logcat collection, prompt/transcript
|
||||
capture, and third-party crash SDKs remain out of scope unless separately proposed
|
||||
and approved.
|
||||
@@ -2548,3 +2548,44 @@ top of a response bubble without ever covering its text or jumping through it.
|
||||
provably safe, no visit is preferable to a partially obscured message.
|
||||
- Position, activity truth, accessibility behavior, and Petdex fallback semantics
|
||||
share one source of runtime truth instead of drifting across route-local effects.
|
||||
|
||||
## ADR 45 — Provider/model reasoning levels use an optional capability overlay
|
||||
|
||||
**Context.** Upstream Hermes owns model discovery and selection. Its
|
||||
`model.options` contract supplies provider/model identities and may report a
|
||||
reasoning boolean, but not every provider exposes an authoritative list of
|
||||
selectable effort values. A client-side provider table would drift, while making
|
||||
Relay mandatory would break the Vanilla Hermes path.
|
||||
|
||||
**Decision.**
|
||||
|
||||
- Upstream `model.options` remains the source of provider/model identity. Relay
|
||||
never invents models or gates model selection or chat.
|
||||
- When available, the optional Relay `POST /relay/model-capabilities` overlay
|
||||
resolves capability metadata for the exact, profile-scoped `{provider, model}`
|
||||
pairs supplied by the client. A remote caller needs a paired session with an
|
||||
active `chat` grant; loopback operator calls may omit it.
|
||||
- Resolution uses this precedence: an exact upstream effort list, then an exact
|
||||
Relay effort list, then an explicit upstream `reasoning: false`, then the
|
||||
canonical advisory set `none`, `minimal`, `low`, `medium`, `high`, `xhigh`,
|
||||
`max`, `ultra`.
|
||||
- Only a coherent provider/model identity may produce an exact result. API model
|
||||
aliases may contribute only when their provider resolves uniquely. Missing,
|
||||
malformed, unsupported, unauthenticated, or failed overlay calls keep the
|
||||
advisory set instead of disabling the control.
|
||||
- Exact lists are selectable truth for the next request. Advisory lists are
|
||||
compatibility choices, not a claim that every value is provider-supported.
|
||||
An effort confirmed by the active session may remain visible as current state
|
||||
even when it is no longer selectable for a new request.
|
||||
- Requests are schema-versioned and bounded to 64 exact pairs. Relay may perform
|
||||
bounded provider discovery using host-owned credentials, but returns only
|
||||
capability metadata. Credentials, credential fingerprints, probe details, and
|
||||
internal cache keys never cross the route.
|
||||
- Primary UI copy describes **available levels** for exact results and
|
||||
**standard levels** for advisory results. Provider source and overlay details
|
||||
belong in diagnostics, not in the normal composer.
|
||||
|
||||
**Consequences.** Vanilla Hermes remains complete without Relay. A connected
|
||||
Relay can narrow choices after capability discovery without changing the model
|
||||
inventory. Old Relay versions, a missing `chat` grant, network failures, and
|
||||
unsupported providers fail soft to the same stable advisory behavior.
|
||||
|
||||
+11
-16
@@ -1,7 +1,7 @@
|
||||
# Issue → fix dev-loop
|
||||
|
||||
How an incoming issue becomes a worktree you (or a local agent) can start working
|
||||
in. This documents deterministic issue labeling, subscription-backed Codex review,
|
||||
in. This documents maintainer issue labeling, subscription-backed Codex review,
|
||||
path-aware required CI, and the local bridge `scripts/start-issue.sh`.
|
||||
|
||||
It complements — does not replace — `RELEASE.md` (how a fix ships) and `CLAUDE.md`
|
||||
@@ -11,7 +11,7 @@ public-repo writing hygiene).
|
||||
## The loop at a glance
|
||||
|
||||
```
|
||||
issue opened ──▶ deterministic type + area labels ──▶ maintainer triage
|
||||
issue opened ──▶ maintainer triage + labels
|
||||
│
|
||||
▼
|
||||
scripts/start-issue.sh <N> ── local bridge
|
||||
@@ -25,15 +25,12 @@ issue opened ──▶ deterministic type + area labels ──▶ maintainer tri
|
||||
└─▶ Codex review
|
||||
```
|
||||
|
||||
## Deterministic issue triage
|
||||
## Manual issue triage
|
||||
|
||||
`.github/workflows/issue-triage.yml` runs a no-LLM keyword classifier when an
|
||||
issue opens. It applies a title-derived type label and, when the issue text is
|
||||
clear, one `area:*` label. It never closes an issue, edits its body, diagnoses a
|
||||
root cause, or posts an automated opinion.
|
||||
|
||||
To label an older issue again, use the workflow's `workflow_dispatch` input or
|
||||
run `gh workflow run issue-triage.yml -f issue_number=NNN`.
|
||||
Maintainers review new issues and apply the appropriate type and `area:*` labels.
|
||||
Area labels are deliberately manual: generic words such as “relay”, “plugin”,
|
||||
“chat”, and “voice” cross product boundaries and cannot reliably identify the
|
||||
owning implementation surface from issue text alone.
|
||||
|
||||
## PR review
|
||||
|
||||
@@ -99,9 +96,7 @@ gh label create "area:docs" -c "bfd4f2" -d "docs/ or user-docs/"
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Activation lag.** Issue-triggered workflows run the copy on the default branch
|
||||
(`main`). Changes stay dormant on `dev` until a release merge lands them on main.
|
||||
- **No model cost for issue labeling.** The issue workflow is deterministic
|
||||
`github-script`; Codex review usage is accounted through the connected Codex plan.
|
||||
- **No write-access escalation here.** Issue triage cannot push code or open PRs.
|
||||
Auto-attempting a fix from untrusted issue text remains intentionally out of scope.
|
||||
- **Manual issue labels.** Type and area labels are applied during maintainer
|
||||
triage; no issue-open workflow guesses ownership from keywords.
|
||||
- **No write-access escalation from issues.** Auto-attempting a fix from
|
||||
untrusted issue text remains intentionally out of scope.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
|
||||
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
|
||||
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -72,7 +72,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
|
||||
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -96,7 +96,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
|
||||
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -120,7 +120,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
|
||||
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -135,7 +135,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "25f5a4ef506b35ec0ba2dbde89b87c13908b037f9c4e889b8a4e64a1ee8103cc",
|
||||
"main": "df4323e2a665b1ca05ddaeb1a00698017b004239e4112ae423fc43a6765131ca",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
|
||||
@@ -89,12 +89,13 @@ This app is a community project and is not affiliated with or endorsed by NousRe
|
||||
Paste into Play Console → **What's new** (≤500 characters):
|
||||
|
||||
```
|
||||
v1.6.1 - Clearer recovery, steadier chat
|
||||
v1.7.0 - Smarter controls, steadier sessions
|
||||
|
||||
* Clear optional Relay status and recovery guidance.
|
||||
* Full session history through compatible paging.
|
||||
* Stable streamed text selection and Voice microphone handoff.
|
||||
* New-chat coaching stays clear of Voice controls.
|
||||
* Provider-aware reasoning levels with a standard fallback.
|
||||
* Working and needs-input states in the searchable session drawer.
|
||||
* Restored chats stay bottom-pinned without overriding scrollback.
|
||||
* Private support information and clearer connection diagnostics.
|
||||
* Reliable Focus Voice controls and recovered chat rows.
|
||||
```
|
||||
|
||||
## Category
|
||||
@@ -161,13 +162,44 @@ This app is a client for a Hermes server the user runs themselves, so a fresh in
|
||||
|
||||
### Foreground service permissions
|
||||
|
||||
The Play build declares `**FOREGROUND_SERVICE_SPECIAL_USE**` for `GatewayKeepAliveService`. It protects user-started active chat turns automatically and also backs the opt-in **Persistent connection** feature for idle connectivity. At submission, complete **App content → Foreground service permissions** for `specialUse`:
|
||||
Before promoting any Play release, compare this section with the merged
|
||||
`googlePlayRelease` manifest and complete **App content → Foreground service
|
||||
permissions** for every declared type. Google blocks the Android Publisher API
|
||||
edit at commit time when a declaration is missing, even if the Production draft
|
||||
upload itself succeeded.
|
||||
|
||||
The Play build declares `**FOREGROUND_SERVICE_SPECIAL_USE**` for
|
||||
`GatewayKeepAliveService`. It protects user-started active chat turns
|
||||
automatically and also backs the opt-in **Persistent connection** feature for
|
||||
idle connectivity. Declare `specialUse` with:
|
||||
|
||||
- **Use case:** keeps a user-started Hermes turn connected until it finishes or pauses for user input, including multiple concurrent sessions; optionally maintains the idle connection when the user enables Persistent connection.
|
||||
- **Why a foreground service:** it's a real-time, user-initiated streaming connection that must survive Doze / background execution limits; `dataSync` is force-stopped after a 6-hour/day cap on Android 15, so `specialUse` is the only fit for "stay connected."
|
||||
- **User control:** the service starts when the user sends a chat message and stops after all active turns settle. Continuous idle retention is off by default and enabled only via *Settings → Quick Controls → Persistent connection*. The ongoing notification shows the active/waiting session count; its **Turn off always-on** action disables idle retention without interrupting active work. Swiping the app from recents ends it.
|
||||
- Google usually asks for a short screen recording of a backgrounded active turn, the ongoing notification, and the optional persistent toggle.
|
||||
|
||||
The Play build also declares `**FOREGROUND_SERVICE_MICROPHONE**` for two
|
||||
explicitly user-started voice features. Declare `microphone` and cover both
|
||||
entry points in its description and demonstration:
|
||||
|
||||
- **Local wake word:** when the user explicitly enables *Hey Hermes* in Voice
|
||||
settings, `WakeWordForegroundService` listens on-device for the configured
|
||||
wake phrase. Audio before activation remains on the device, the ongoing
|
||||
microphone notification has a Stop action, and the service is never started
|
||||
at boot.
|
||||
- **Voice overlay:** when the user opens the app-owned voice overlay while
|
||||
Hermes is visible, `VoiceOverlayForegroundService` preserves microphone
|
||||
access for that active voice session while the overlay is shown over another
|
||||
app. Hiding or exiting the overlay, stopping voice, or removing the task ends
|
||||
the service.
|
||||
- **Why a foreground service:** Android requires an active microphone
|
||||
foreground service for user-visible capture that continues while the app is
|
||||
backgrounded. Both paths are opt-in, show Android's persistent microphone
|
||||
indicator and ongoing notification, and expose an immediate Stop action.
|
||||
- Record a short video that enables the wake listener and shows its notification
|
||||
and Stop action, then opens the voice overlay, backgrounds Hermes, and ends
|
||||
the session from the overlay or notification.
|
||||
|
||||
The Play build does **not** declare `FOREGROUND_SERVICE_MEDIA_PROJECTION` or the Device Control accessibility/bridge services — those are sideload-only.
|
||||
|
||||
### Data safety
|
||||
|
||||
+19
-1
@@ -7,7 +7,9 @@ Hermes-Relay is a companion app for the Hermes agent. It connects only to server
|
||||
- The app makes **no connections** to Anthropic, Google, or any third party by default
|
||||
- **No telemetry**, analytics, crash reports, or tracking data are sent externally
|
||||
- **No advertising SDKs** or third-party SDKs that phone home are included
|
||||
- Your Hermes server may connect to AI providers such as OpenAI or Anthropic; that is server-side and outside this app's scope
|
||||
- Your Hermes server may connect to configured AI providers for inference and
|
||||
bounded capability discovery; that traffic is server-side and uses the
|
||||
credentials already owned by the selected Hermes profile
|
||||
|
||||
## Build Tracks
|
||||
|
||||
@@ -31,6 +33,7 @@ All app data is stored on-device in the app's private sandbox:
|
||||
| Relay session token | EncryptedSharedPreferences | Same encryption as API key |
|
||||
| Theme and display preferences | DataStore preferences | Tool display mode, reasoning toggle, voice preferences |
|
||||
| Stats for Nerds counters | DataStore preferences | Response times, token counts, health stats — local only |
|
||||
| Reliability reports | App-private JSON | Up to 20 locally redacted crash/handled-error records, retained for 14 days; no prompts, messages, profile names, hosts, tokens, or media |
|
||||
|
||||
Chat messages are **not cached locally**. They are loaded from the Hermes API server on demand and exist only in memory while the app is running.
|
||||
|
||||
@@ -41,9 +44,21 @@ The app connects only to user-configured endpoints:
|
||||
- **HTTP/SSE** to your Hermes API server for chat streaming
|
||||
- **WSS** to your relay server for terminal/TUI relay, Bridge Core status, media handoff, notification companion, and paired-session management
|
||||
- **HTTP(S)/WSS** to your relay server's `/voice/*` routes for voice settings, speech-to-text uploads, realtime voice websocket sessions, and text-to-speech audio when you use Voice mode
|
||||
- **HTTP(S)** to your optional Relay server's `/relay/model-capabilities` route
|
||||
when Android refines reasoning-effort choices for models already reported by
|
||||
upstream Hermes. The phone sends provider/model identifiers and the selected
|
||||
profile name, not provider credentials.
|
||||
- Cleartext HTTP is permitted for local/private network connections to user-configured servers; the app warns when using insecure remote connections
|
||||
- No DNS prefetching and no background pings to external services
|
||||
|
||||
The Relay capability resolver may make short, bounded requests from the Hermes
|
||||
host to a configured provider endpoint. Results are cached for five minutes by
|
||||
profile, endpoint, model, and a one-way credential fingerprint to limit repeated
|
||||
provider traffic. Credentials and fingerprints remain on the host; Android
|
||||
receives only reasoning support, ordered effort values, whether the values are
|
||||
exact, and diagnostic provenance. A manual refresh requests a new server-side
|
||||
resolution but does not expose or copy the selected profile's secrets.
|
||||
|
||||
## Permissions
|
||||
|
||||
Google Play build:
|
||||
@@ -74,6 +89,9 @@ Notification companion is opt-in. The app only forwards notification metadata af
|
||||
|
||||
From Settings, users can:
|
||||
|
||||
- **Review support information** in Diagnostics, then explicitly copy or share
|
||||
the exact redacted local text. Nothing is uploaded automatically.
|
||||
|
||||
- **Export** a full connection backup. The file includes server URLs,
|
||||
preferences, API keys, relay session tokens, device IDs, and dashboard
|
||||
cookies so restored connections can work without manual re-entry. Keep it
|
||||
|
||||
@@ -325,8 +325,79 @@ See [`docs/spec.md` §3.3](spec.md) for the full auth flow and the QR wire forma
|
||||
| Device Control routes (`/screen`, `/tap`, `/type`, and peers) | GET/POST | Require `Authorization: Bearer <session_token>` and an active `bridge` grant before any request data is forwarded to a connected Android client. Host tools supply the token through `ANDROID_BRIDGE_TOKEN`; loopback callers do not bypass this gate. |
|
||||
| `/media/inspect` | GET | **Loopback only.** Returns `{"media": [ {token, file_name, content_type, size, created_at, expires_at, last_accessed, is_expired}, ... ]}` — `MediaRegistry.list_all()` snapshot, newest first. Absolute file paths are **never** included — only `file_name` (basename). Query param: `?include_expired=true` includes evicted entries (default false, hides them). 403 for non-loopback callers. Consumed by the dashboard plugin's Media Inspector tab. |
|
||||
| `/relay/info` | GET | Aggregate status and capability contract. Loopback dashboard calls may omit auth; remote callers require a paired-device bearer. Returns backward-compatible `version` plus `plugin_version`, `protocol_version`, stable `capabilities`, per-profile `relay_state`, counters, `health`, and an optional sanitized `gateway_heartbeat` assessment. The heartbeat is diagnostic-only and never changes Relay health, fallback, or restart policy. |
|
||||
| `/relay/model-capabilities` | POST | Resolve reasoning-effort metadata for up to 64 exact `{provider, model}` pairs. Loopback callers may omit auth; remote callers require a paired-device bearer with an active `chat` grant. Request schema v1 accepts optional `profile` and `refresh`; response rows include `reasoning`, ordered `reasoning_efforts`, `reasoning_efforts_exact`, and `source`. Provider credentials and internal cache scopes never leave the host. Advertised by `/relay/info` as `model_reasoning_capabilities_v1`. |
|
||||
| `/relay/security` | GET/PATCH | **Loopback only.** Runtime security toggles for local operators, `hermes relay insecure-api-key`, and `hermes-relay insecure-api-key`. `GET` returns `{"allow_insecure_api_bearer": false, "trust_proxy_headers": false, "scope": "runtime"}`. `PATCH {"allow_insecure_api_bearer": true}` enables plain-LAN API-key voice auth immediately for the running relay; `false` disables it. This is not persisted across restarts. |
|
||||
|
||||
### Model capability overlay
|
||||
|
||||
`POST /relay/model-capabilities` is an optional metadata overlay for clients
|
||||
that already obtained coherent provider/model identities from upstream Hermes.
|
||||
It does not list models, select a model, proxy chat, or make Relay a requirement
|
||||
for reasoning controls.
|
||||
|
||||
Request schema v1:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"profile": "default",
|
||||
"refresh": false,
|
||||
"models": [
|
||||
{"provider": "example-provider", "model": "reasoner-v1"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`profile` defaults to `default`, and `refresh` defaults to `false`. `models`
|
||||
must contain 1–64 exact pairs; provider values are limited to 128 characters,
|
||||
model values to 512 characters, and profile values to 128 characters. An
|
||||
unsupported schema, malformed pair, unknown profile, or over-limit request is
|
||||
rejected with a structured 4xx response.
|
||||
|
||||
Response contract 1.0:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"contract_version": "1.0",
|
||||
"capabilities": [
|
||||
{
|
||||
"provider": "example-provider",
|
||||
"model": "reasoner-v1",
|
||||
"reasoning": true,
|
||||
"reasoning_efforts": ["low", "medium", "high"],
|
||||
"reasoning_efforts_exact": true,
|
||||
"source": "provider-adapter"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`reasoning_efforts_exact: true` means the ordered list is verified for that
|
||||
specific provider/model pair. `false` means the row is advisory; clients should
|
||||
label it as standard compatibility choices rather than guaranteed provider
|
||||
support. `source` is diagnostic provenance, not UI copy or a precedence signal.
|
||||
Android merges the overlay after an exact upstream effort list and before an
|
||||
explicit upstream `reasoning: false`; otherwise it retains the canonical
|
||||
advisory set `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`,
|
||||
`ultra`.
|
||||
|
||||
Loopback callers may omit authentication. Remote callers must send a valid
|
||||
Relay session bearer with an active `chat` grant. The route is profile-isolated:
|
||||
it reads only the selected profile's configuration and credential scope. Some
|
||||
providers can be answered from static or upstream metadata; others require
|
||||
short, bounded provider requests. The resolver caps provider concurrency,
|
||||
applies per-request timeouts, caches results for five minutes by profile,
|
||||
endpoint, model, and credential fingerprint, and fences an explicit refresh so
|
||||
stale in-flight work cannot repopulate the refreshed profile cache.
|
||||
|
||||
Provider credentials stay on the Hermes host. Responses never include secrets,
|
||||
credential fingerprints, endpoint probe details, or internal cache keys. A
|
||||
provider timeout or unsupported provider produces a safe non-exact row; an old
|
||||
Relay `404`, absent pairing, expired grant, unsupported response schema, or
|
||||
network failure is handled client-side by keeping the advisory choices. These
|
||||
fail-soft cases never block model selection or chat.
|
||||
|
||||
### Dashboard plugin proxy routes
|
||||
|
||||
The hermes-agent dashboard plugin at `plugin/dashboard/` exposes a FastAPI router mounted at `/api/plugins/hermes-relay/*` on the gateway's web server. Each route is a loopback-only pass-through to the corresponding relay HTTP route above, so the relay remains the source of truth. Implementation lives in `plugin/dashboard/plugin_api.py`.
|
||||
|
||||
@@ -80,6 +80,31 @@ python scripts/screenshots.py validate
|
||||
- The README sources are currently `1080x2244`. The Play export crops them to
|
||||
`1080x2160` because Google Play requires screenshot dimensions between
|
||||
320 and 3840 pixels and the long side cannot exceed twice the short side.
|
||||
|
||||
## Foreground-service policy video
|
||||
|
||||
`scripts/android-fgs-demo.py` drives a real connected phone through the Google
|
||||
Play foreground-service evidence sequence and records it with Android's native
|
||||
`screenrecord`. It verifies each expected control and notification through the
|
||||
UI hierarchy, and aborts rather than producing misleading evidence when a step
|
||||
is unavailable.
|
||||
|
||||
Use an explicit device serial so a connected emulator cannot receive the run by
|
||||
accident:
|
||||
|
||||
```bash
|
||||
python scripts/android-fgs-demo.py --serial <adb-serial>
|
||||
```
|
||||
|
||||
The sequence enables Persistent connection, shows its ongoing notification,
|
||||
sends and backgrounds a chat turn, returns through the notification, disables
|
||||
the setting and proves the notification clears, then repeats the evidence flow
|
||||
for the app-owned voice overlay and its **Stop voice** notification action.
|
||||
|
||||
Run `--no-record --pause-scale 0.25` first after material UI changes. Before
|
||||
publishing the resulting MP4, review every notification-shade frame for private
|
||||
network names and unrelated notification content; the driver does not clear or
|
||||
alter the user's other notifications.
|
||||
- Google Play listing upload stays on Gradle Play Publisher, matching the
|
||||
existing Android publishing toolchain. Fastlane is not required.
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ AccessibilityService-backed Device Control (screen reading, taps, typing, screen
|
||||
- Codes use the full `A-Z / 0-9` alphabet (36 chars). The earlier "no ambiguous 0/O/1/I" restriction was dropped when the pairing flow moved from "human retypes code from display" to "code flows phone ↔ server via QR + HTTP" (see `docs/decisions.md` §6a).
|
||||
- `POST /pairing/register` is gated to loopback callers only (`127.0.0.1` / `::1`) — only a process with host shell access on the relay machine can inject pairing codes. A LAN attacker cannot.
|
||||
- Relay session tokens carry per-channel grants. Voice routes require explicit `voice:config`, `voice:stt`, `voice:tts`, or `voice:realtime` grants when called with a Relay session token.
|
||||
- Remote `POST /relay/model-capabilities` calls require a valid Relay session
|
||||
bearer with an active `chat` grant. Loopback host-operator calls may omit the
|
||||
bearer. Hermes API bearer tokens are not accepted on this route.
|
||||
- `/voice/config`, `/voice/transcribe`, `/voice/synthesize`, `/voice/output/*`, and `/voice/realtime/*` may also accept the Hermes API bearer token used by API-server clients. The Android app uses this fallback for chat+voice-only setups when no Relay session is paired. This is a narrow exception for chat/media-adjacent voice features only; the API bearer token is not accepted for sessions, media, clipboard, terminal, TUI, bridge, profile writes, or Android control routes.
|
||||
- Hermes API bearer use on voice routes requires HTTPS for non-loopback callers by default. Loopback plaintext is allowed for local clients; reverse-proxy TLS is accepted only when `RELAY_TRUST_PROXY_HEADERS=1`, and plaintext LAN testing requires an explicit opt-in. Use `hermes relay insecure-api-key on` for a running relay, or `RELAY_ALLOW_INSECURE_API_BEARER=1` at startup.
|
||||
|
||||
@@ -29,6 +32,21 @@ AccessibilityService-backed Device Control (screen reading, taps, typing, screen
|
||||
- The server relay only accepts one phone at a time
|
||||
- All tool commands are proxied through the relay — the phone is never directly exposed
|
||||
|
||||
### Model capability discovery
|
||||
|
||||
The optional model-capability route accepts 1–64 validated provider/model pairs
|
||||
and a bounded profile name. Resolution is isolated to that profile's config and
|
||||
credential scope. Dynamic provider checks use short timeouts and a shared
|
||||
four-request concurrency ceiling; results are cached for five minutes. An
|
||||
explicit refresh advances a profile generation so an older in-flight request
|
||||
cannot restore stale cache entries.
|
||||
|
||||
Provider credentials are loaded and used only on the Hermes host. The response
|
||||
contains capability metadata and diagnostic source labels, never credentials,
|
||||
credential fingerprints, provider response bodies, or internal cache keys.
|
||||
Unsupported providers and probe failures return non-exact advisory metadata
|
||||
instead of expanding authority or preventing chat.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### Plaintext `ws://` legs are possible
|
||||
|
||||
@@ -402,6 +402,7 @@ Bottom navigation bar with 4 tabs:
|
||||
- **Behavior and motion director** — direct interaction wins over agent activity, which wins over a pending response visit, autonomous roaming, and idle reactions. Agent activity therefore cannot be overwritten by locomotion. Horizontal travel uses directional walking clips with distance quantized to complete walk cycles; turns pause briefly. Vertical movement uses distance-scaled timing, anticipation, `jumping` to the apex, `falling` through descent, an altitude-responsive shadow, and a landing squash. Cross-level transfers are length-capped on every route, including Settings, so a screen with no explicitly registered safe intermediate level stays on its current rail rather than jumping through UI. Idle patrols alternate short hops, waves, and rests without turning response bubbles into arbitrary roaming terrain. **Calm**, **Balanced** (default), and **Playful** change response-visit, patrol, and idle-reaction cadence and cap older-bubble exploration at one, two, or three stops; safety, foreground, activity, scrolling, dialog, reduced-motion, and accessibility gates remain authoritative.
|
||||
- **Activity versus locomotion** — idle/thinking/writing/error, tool-burst, and completion choose the agent-state clip. Directional `walking-*`/`running-*` clips describe only physical screen travel and are eligible only while the agent is idle; agent activity always wins. Microphone/TTS presentation remains Sphere/voice rather than a roaming-pet surface on Android.
|
||||
- **Input bar** — text field with 4096 char limit, `/` palette button, send button, stop button during streaming. Inline autocomplete on `/` keystroke + full searchable command palette (bottom sheet). Commands sourced from: 29 gateway built-ins, dynamic personalities from `config.agent.personalities`, and server skills from native `GET /v1/skills`.
|
||||
- **Reasoning effort** — choices follow the selected upstream provider/model identity. When upstream or the optional Relay capability overlay reports an exact model-specific list, Android shows only those available levels. Otherwise it shows the standard advisory set (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`). An explicit upstream `reasoning: false` suppresses the control unless a higher-precedence exact list exists. Missing Relay configuration, pairing, route support, or network access never blocks model selection or chat. A server-confirmed current effort may remain visible as session truth even when it is not selectable for the next request. See ADR 45.
|
||||
- **Empty state** — Logo + "Start a conversation" + suggestion chips that populate input
|
||||
- **Agent sheet — Profile section (v0.6.0, updated 2026-05-18)** — upstream Hermes profiles auto-discovered by the relay at `~/.hermes/profiles/*/`. Selecting one routes chat/session calls to that profile's advertised `api_server_url` when present, giving proper Hermes isolation for sessions, memory, tools, provider auth, and SOUL/default model. If no profile API route is advertised, the app falls back to overlaying `model` + `SOUL.md` (as `system_message`) on the active Connection's API server. Selection is persisted per Connection/profile context. Hidden when the server advertises no profiles. See `docs/decisions.md` §21.
|
||||
- **Agent sheet — Personality section** — personalities fetched from `GET /api/config` (`config.agent.personalities`). Shows server default (from `config.display.personality`) + all configured. Active personality name shown on assistant chat bubbles.
|
||||
@@ -489,6 +490,11 @@ vanilla upstream path with live thinking/reasoning events. When that gateway is
|
||||
unavailable, Android falls back to API-server SSE routes. The native Sessions
|
||||
API fallback looks like:
|
||||
|
||||
Model inventory also stays upstream-owned. Android may call the optional Relay
|
||||
`POST /relay/model-capabilities` route to refine reasoning-effort choices for
|
||||
the exact provider/model pairs returned by upstream, but that metadata call is
|
||||
not a chat proxy and is never a prerequisite for sending a message.
|
||||
|
||||
```
|
||||
1. POST /api/sessions → create session → get session_id
|
||||
2. POST /api/sessions/{session_id}/chat/stream → send message, get SSE stream
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hermes-Relay Surface Matrix
|
||||
|
||||
Updated: 2026-07-18
|
||||
Updated: 2026-08-05
|
||||
|
||||
This matrix records the v1.0.0 route ownership contract. It is meant to keep
|
||||
future app, plugin, and agent work honest about what is vanilla upstream
|
||||
@@ -28,9 +28,11 @@ Verified upstream source snapshot:
|
||||
| `/v1/skills`, `/v1/toolsets` | Upstream API server | No | Discovery | Authenticated read-only API-server skill/toolset inventory; Android Diagnostics summarizes enabled toolsets and Relay tool visibility. |
|
||||
| Dashboard `/api/status`, `/api/auth/me` | Upstream dashboard | No | Manage auth | Dashboard cookie/session path; separate from API bearer. Optional status diagnostics include Nous bootstrap validity and profile/gateway topology; these do not gate transport selection. |
|
||||
| Dashboard `/api/auth/ws-ticket`, `/api/ws` | Upstream dashboard/tui_gateway | No | Preferred chat transport | Vanilla Hermes gateway chat path with live reasoning/thinking events. |
|
||||
| Dashboard `model.options` / `/api/model/*` | Upstream dashboard/tui_gateway | No | Provider/model inventory and selection | Source of truth for coherent provider/model identities. A reasoning boolean or exact effort list is consumed when present; clients do not infer provider identity from a model string alone. |
|
||||
| Dashboard `/api/audio/transcribe`, `/api/audio/speak-stream`, `/api/audio/speak` | Upstream dashboard | No | Vanilla Hermes voice | Manage sign-in unlocks Vanilla Hermes voice. Assistant text streams into upstream speech when available; older hosts fall back to whole-request speech before audio starts. API server has no `/v1/audio/*` route today. |
|
||||
| Dashboard `/api/config`, `/api/profiles/*`, `/api/env`, `/api/model/*`, `/api/mcp/*`, `/api/providers/custom-endpoints*` | Upstream dashboard | No | Manage | Do not proxy through Relay. MCP list/actions/OAuth carry Android's effective profile explicitly; Android detects hosted-OAuth support with a read-only missing-flow status GET and caches that capability per dashboard/profile. Hosted OAuth itself stays server-owned, opens only the returned HTTPS URL, and persists only the opaque flow id/server/profile plus a normalized non-secret dashboard/connection identity; polling is held whenever the active connection does not own that flow. Custom-endpoint routes are process-scoped in the current public contract, so Android does not claim or append profile scoping; credentials are write-only and blank edits preserve an existing key. |
|
||||
| `/pairing/*`, `/sessions`, `/voice/*`, `/desktop/*`, `/media/*`, `/notifications/*` on Relay | Hermes-Relay plugin/server | Yes | Relay pairing, terminal, bridge, relay voice, desktop tools | Owned by `plugin/relay/server.py`; Android must gate behind Relay readiness/session grants. |
|
||||
| `POST /relay/model-capabilities` | Hermes-Relay plugin/server | Optional | Refine reasoning-effort choices for exact upstream provider/model pairs | Never supplies model inventory or gates chat. Missing, old, unpaired, malformed, or unreachable Relay falls back to standard advisory choices. Remote calls require a paired bearer with an active `chat` grant. |
|
||||
| Dashboard `/api/plugins/hermes-relay/*` | Hermes-Relay dashboard plugin | Yes for live data | Relay dashboard tab | FastAPI plugin backend proxies loopback requests to the Relay server. |
|
||||
| `hermes relay doctor` | Hermes-Relay plugin CLI | No for diagnostics | Operator/agent diagnostics | Reports vanilla upstream Hermes route reachability (including `/v1/toolsets`), dashboard Nous/topology state, sanitized gateway event-loop heartbeat state, plugin layout, Relay loopback state, and legacy bootstrap presence. |
|
||||
| `hermes_relay_bootstrap` routes | Legacy compatibility monkeypatch | No, but non-upstream | Fallback only | Installed via `.pth` by legacy installer. Injects only compatibility-only gaps: session search, memory, legacy skill detail/toggle, config, available-models, slash middleware. Sessions CRUD and skill/toolset lists are native upstream and retired from the bootstrap. Retained session-database work is offloaded (`AsyncSessionDB` when available, `asyncio.to_thread` fallback), and memory mutations reset newer upstream's request-local consolidation-failure budget. |
|
||||
|
||||
@@ -83,8 +83,15 @@ git worktree remove <path> # delete a worktree (must be clean, or pass --force)
|
||||
(`../hermes-feat-x`), not under the repo root, or it gets swept into globs and
|
||||
IDE indexing.
|
||||
- **Build outputs are per-folder.** That's the point (warm caches), but it also
|
||||
means three worktrees ≈ three `build/` trees on disk. Prune merged worktrees so
|
||||
they don't accumulate.
|
||||
means three worktrees ≈ three `build/` trees on disk. Gradle's local build
|
||||
cache is shared through the Gradle user home and can reuse compatible task
|
||||
outputs across worktrees. Prune merged worktrees so build trees don't
|
||||
accumulate.
|
||||
- **Serialize heavy Gradle invocations on one host.** Worktrees isolate source
|
||||
and output directories, but concurrent Android compiles, lint, tests, and APK
|
||||
packaging still compete for the same CPU, memory, daemon pool, and local
|
||||
cache. Keep one heavy Gradle lane active at a time; parallelism inside that
|
||||
invocation remains enabled.
|
||||
|
||||
## How this maps to releasing
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
|
||||
org.gradle.daemon=true
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
org.gradle.parallel=true
|
||||
android.useAndroidX=true
|
||||
android.suppressUnsupportedCompileSdk=37
|
||||
kotlin.code.style=official
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[versions]
|
||||
appVersionName = "1.6.1"
|
||||
appVersionCode = "38"
|
||||
appVersionName = "1.7.0"
|
||||
appVersionCode = "39"
|
||||
agp = "9.3.1"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.06.01"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"label": "Relay",
|
||||
"description": "Paired devices, bridge activity, media inspection, and remote access for hermes-relay",
|
||||
"icon": "Activity",
|
||||
"version": "1.5.1",
|
||||
"version": "1.6.0",
|
||||
"tab": {
|
||||
"path": "/relay",
|
||||
"position": "after:skills"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.5.1",
|
||||
"version": "1.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.5.1",
|
||||
"version": "1.6.0",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.12",
|
||||
"qrcode": "^1.5.4"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.5.1",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
|
||||
"scripts": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
name: hermes-relay
|
||||
manifest_version: 1
|
||||
version: 1.5.1
|
||||
version: 1.6.0
|
||||
description: "Hermes-Relay plugin for QR pairing, relay sessions, dashboard management, remote desktop/phone tooling, and optional legacy compatibility diagnostics. Standard chat, Manage, and dashboard voice remain vanilla upstream Hermes surfaces."
|
||||
author: Axiom Labs
|
||||
# All three are OPTIONAL — only needed if you use the relay's extra /
|
||||
|
||||
@@ -19,7 +19,7 @@ See ``plugin/relay/server.py`` for the aiohttp server,
|
||||
# Desktop releases use desktop/package.json and desktop-v* tags. The /health endpoint
|
||||
# reports this plugin version, and stale values make live diagnosis harder than
|
||||
# it should be.
|
||||
__version__ = "1.5.1"
|
||||
__version__ = "1.6.0"
|
||||
|
||||
from .server import create_app, main # noqa: E402 — must come after __version__
|
||||
|
||||
|
||||
@@ -0,0 +1,840 @@
|
||||
"""Plugin-owned provider/model reasoning capability resolution.
|
||||
|
||||
The Relay extension cannot require changes to upstream Hermes. This module
|
||||
therefore reads only the selected profile's existing files and probes provider
|
||||
endpoints directly with bounded aiohttp calls. Cache keys include endpoint and
|
||||
credential fingerprints; secrets are never retained in capability results or
|
||||
returned on the wire.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
import aiohttp
|
||||
|
||||
from .config import (
|
||||
RelayConfig,
|
||||
_effective_default_profile_home,
|
||||
_load_yaml_mapping,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("hermes_relay.model_capabilities")
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
CONTRACT_VERSION = "1.0"
|
||||
MAX_MODEL_PAIRS = 64
|
||||
_MAX_OLLAMA_PROBES = 16
|
||||
_MAX_PROBE_CONCURRENCY = 4
|
||||
_CACHE_TTL_SECONDS = 300.0
|
||||
_PROFILE_CONTEXT_TIMEOUT_SECONDS = 1.0
|
||||
_REQUEST_TIMEOUT_SECONDS = 2.5
|
||||
_PROBE_TIMEOUT_SECONDS = 1.25
|
||||
_CANONICAL_EFFORTS = (
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"ultra",
|
||||
)
|
||||
_EFFORT_RANK = {value: index for index, value in enumerate(_CANONICAL_EFFORTS)}
|
||||
|
||||
_PROVIDER_ALIASES = {
|
||||
"github": "copilot",
|
||||
"github-copilot": "copilot",
|
||||
"github-models": "copilot",
|
||||
"github-model": "copilot",
|
||||
"lm-studio": "lmstudio",
|
||||
"ollama": "ollama-cloud",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReasoningCapability:
|
||||
efforts: tuple[str, ...]
|
||||
exact: bool
|
||||
source: str
|
||||
|
||||
def wire(self, provider: str, model: str) -> dict[str, Any]:
|
||||
return {
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"reasoning": bool(self.efforts),
|
||||
"reasoning_efforts": list(self.efforts),
|
||||
"reasoning_efforts_exact": self.exact,
|
||||
"source": self.source,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ProfileContext:
|
||||
name: str
|
||||
home: Path
|
||||
config: dict[str, Any]
|
||||
env: dict[str, str]
|
||||
credential_pool: dict[str, list[dict[str, Any]]]
|
||||
|
||||
|
||||
def _provider_key(provider: str) -> str:
|
||||
value = str(provider or "").strip().lower()
|
||||
return _PROVIDER_ALIASES.get(value, value)
|
||||
|
||||
|
||||
def _model_key(model: str) -> str:
|
||||
return str(model or "").strip().lower().rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
def _ordered_efforts(values: Iterable[Any]) -> tuple[str, ...]:
|
||||
aliases = {"off": "none", "on": "medium"}
|
||||
selected = {
|
||||
aliases.get(str(value).strip().lower(), str(value).strip().lower())
|
||||
for value in values
|
||||
}
|
||||
return tuple(value for value in _CANONICAL_EFFORTS if value in selected)
|
||||
|
||||
|
||||
def _static_capability(provider: str, model: str) -> ReasoningCapability:
|
||||
normalized_provider = _provider_key(provider)
|
||||
normalized_model = _model_key(model)
|
||||
|
||||
if normalized_provider == "zai" and any(
|
||||
token in normalized_model for token in ("glm-5.2", "glm-5-2", "glm-5p2")
|
||||
):
|
||||
return ReasoningCapability(("none", "high", "max"), True, "provider-adapter")
|
||||
|
||||
deepseek_thinking = (
|
||||
normalized_model.startswith("deepseek-v")
|
||||
and not normalized_model.startswith("deepseek-v3")
|
||||
) or normalized_model == "deepseek-reasoner"
|
||||
if normalized_provider in {"deepseek", "opencode-go"} and deepseek_thinking:
|
||||
return ReasoningCapability(
|
||||
("none", "low", "medium", "high", "max"),
|
||||
True,
|
||||
"provider-adapter",
|
||||
)
|
||||
|
||||
if normalized_provider == "opencode-go" and any(
|
||||
token in normalized_model for token in ("glm-5.2", "glm-5-2", "glm-5p2")
|
||||
):
|
||||
return ReasoningCapability(("high", "max"), True, "provider-adapter")
|
||||
|
||||
if normalized_provider == "kimi-for-coding" or (
|
||||
normalized_provider == "opencode-go" and normalized_model.startswith("kimi-k2")
|
||||
):
|
||||
return ReasoningCapability(
|
||||
("none", "low", "medium", "high"), True, "provider-adapter"
|
||||
)
|
||||
|
||||
if normalized_provider == "upstage":
|
||||
if any(marker in normalized_model for marker in ("solar-mini", "syn-pro")):
|
||||
return ReasoningCapability((), True, "provider-adapter")
|
||||
return ReasoningCapability(
|
||||
("none", "low", "medium", "high"), True, "provider-adapter"
|
||||
)
|
||||
|
||||
if normalized_provider == "actual":
|
||||
return ReasoningCapability(
|
||||
("none", "low", "medium", "high", "max"),
|
||||
True,
|
||||
"provider-adapter",
|
||||
)
|
||||
|
||||
return ReasoningCapability(_CANONICAL_EFFORTS, False, "canonical-fallback")
|
||||
|
||||
|
||||
def _static_capabilities(
|
||||
pairs: list[tuple[str, str]],
|
||||
) -> list[ReasoningCapability]:
|
||||
"""Apply plugin adapters plus upstream's stable no-reasoning signal."""
|
||||
results = [_static_capability(provider, model) for provider, model in pairs]
|
||||
try:
|
||||
from agent.models_dev import get_model_capabilities
|
||||
except Exception:
|
||||
return results
|
||||
models_dev_provider = {"copilot": "github-copilot"}
|
||||
for index, (provider, model) in enumerate(pairs):
|
||||
if results[index].exact:
|
||||
continue
|
||||
normalized = _provider_key(provider)
|
||||
try:
|
||||
metadata = get_model_capabilities(
|
||||
models_dev_provider.get(normalized, normalized), model
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if metadata is not None and metadata.supports_reasoning is False:
|
||||
results[index] = ReasoningCapability((), True, "models.dev")
|
||||
return results
|
||||
|
||||
|
||||
def _fingerprint(secret: str) -> str:
|
||||
if not secret:
|
||||
return "anonymous"
|
||||
return hashlib.blake2b(secret.encode("utf-8"), digest_size=12).hexdigest()
|
||||
|
||||
|
||||
def _chatgpt_account_id(access_token: str) -> str:
|
||||
"""Extract the non-secret account routing claim from a Codex OAuth JWT."""
|
||||
try:
|
||||
payload = access_token.split(".")[1]
|
||||
payload += "=" * (-len(payload) % 4)
|
||||
claims = json.loads(base64.urlsafe_b64decode(payload))
|
||||
account_id = _mapping(claims.get("https://api.openai.com/auth")).get(
|
||||
"chatgpt_account_id"
|
||||
)
|
||||
return account_id if isinstance(account_id, str) else ""
|
||||
except (IndexError, ValueError, TypeError, json.JSONDecodeError):
|
||||
return ""
|
||||
|
||||
|
||||
def _mapping(value: object) -> dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _profile_model_config(context: _ProfileContext) -> dict[str, Any]:
|
||||
return _mapping(context.config.get("model"))
|
||||
|
||||
|
||||
def _provider_config(context: _ProfileContext, provider: str) -> dict[str, Any]:
|
||||
providers = _mapping(context.config.get("providers"))
|
||||
return _mapping(providers.get(provider))
|
||||
|
||||
|
||||
def _first_string(*values: object) -> str:
|
||||
for value in values:
|
||||
if value is not None:
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def _credential_pool_entries(path: Path, provider: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
if path.stat().st_size > 1_048_576:
|
||||
return []
|
||||
parsed = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
pool = _mapping(_mapping(parsed).get("credential_pool"))
|
||||
entries = pool.get(provider)
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
return [entry for entry in entries if isinstance(entry, dict)]
|
||||
|
||||
|
||||
def _profile_secret(context: _ProfileContext, provider: str) -> str:
|
||||
model = _profile_model_config(context)
|
||||
configured = _provider_config(context, provider)
|
||||
if provider == "lmstudio":
|
||||
env_names = ("LM_API_KEY",)
|
||||
elif provider == "ollama-cloud":
|
||||
env_names = ("OLLAMA_API_KEY",)
|
||||
elif provider == "copilot":
|
||||
env_names = ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
|
||||
else:
|
||||
env_names = ()
|
||||
for name in env_names:
|
||||
value = context.env.get(name)
|
||||
if value:
|
||||
return value
|
||||
value = _first_string(configured.get("api_key"), configured.get("key"))
|
||||
if value:
|
||||
return value
|
||||
if _provider_key(str(model.get("provider") or "")) == provider:
|
||||
value = _first_string(model.get("api_key"), model.get("key"))
|
||||
if value:
|
||||
return value
|
||||
|
||||
entries = context.credential_pool.get(provider, [])
|
||||
for entry in entries:
|
||||
# Capability discovery must not revive a credential that upstream has
|
||||
# marked unavailable. Conservatively fail to non-exact metadata; the
|
||||
# normal Hermes runtime owns cooldown expiry and token refresh.
|
||||
if str(entry.get("last_status") or "").strip().lower() in {"dead", "exhausted"}:
|
||||
continue
|
||||
value = _first_string(
|
||||
entry.get("access_token"),
|
||||
entry.get("api_key"),
|
||||
entry.get("token"),
|
||||
entry.get("key"),
|
||||
)
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _provider_base_url(context: _ProfileContext, provider: str) -> str:
|
||||
model = _profile_model_config(context)
|
||||
configured = _provider_config(context, provider)
|
||||
if provider == "openai-codex":
|
||||
return _first_string(
|
||||
context.env.get("OPENAI_CODEX_BASE_URL"),
|
||||
configured.get("base_url"),
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
).rstrip("/")
|
||||
if provider == "lmstudio":
|
||||
env_name, default = "LM_BASE_URL", "http://127.0.0.1:1234/v1"
|
||||
elif provider == "ollama-cloud":
|
||||
env_name, default = "OLLAMA_BASE_URL", "https://ollama.com/v1"
|
||||
else:
|
||||
return _first_string(
|
||||
context.env.get("COPILOT_BASE_URL"),
|
||||
context.env.get("GITHUB_COPILOT_BASE_URL"),
|
||||
configured.get("base_url"),
|
||||
"https://api.githubcopilot.com",
|
||||
).rstrip("/")
|
||||
model_url = (
|
||||
model.get("base_url")
|
||||
if _provider_key(str(model.get("provider") or "")) == provider
|
||||
else None
|
||||
)
|
||||
return _first_string(
|
||||
context.env.get(env_name), configured.get("base_url"), model_url, default
|
||||
).rstrip("/")
|
||||
|
||||
|
||||
class ModelCapabilityResolver:
|
||||
"""Resolve exact capabilities with bounded, profile-isolated probes."""
|
||||
|
||||
def __init__(self, config: RelayConfig) -> None:
|
||||
self._config = config
|
||||
self._cache: dict[
|
||||
tuple[str, str, str, str, str], tuple[ReasoningCapability, float]
|
||||
] = {}
|
||||
self._cache_lock = asyncio.Lock()
|
||||
self._profile_generations: dict[str, int] = {}
|
||||
# One limiter for every dynamic provider and every HTTP request. A
|
||||
# burst of concurrent inventory refreshes cannot multiply LM Studio,
|
||||
# Ollama, and Copilot sessions beyond this process-wide resolver cap.
|
||||
self._probe_semaphore = asyncio.Semaphore(_MAX_PROBE_CONCURRENCY)
|
||||
|
||||
def _profile_context(self, profile: str) -> _ProfileContext | None:
|
||||
root_config = Path(self._config.hermes_config_path).expanduser()
|
||||
root_home = root_config.parent
|
||||
if profile == "default":
|
||||
home = _effective_default_profile_home(root_home)
|
||||
else:
|
||||
if not profile or profile in {".", ".."} or Path(profile).name != profile:
|
||||
return None
|
||||
home = root_home / "profiles" / profile
|
||||
if not home.is_dir():
|
||||
return None
|
||||
config_path = home / "config.yaml"
|
||||
if not config_path.is_file():
|
||||
return None
|
||||
config = _load_yaml_mapping(config_path)
|
||||
try:
|
||||
from agent.secret_scope import build_profile_secret_scope
|
||||
|
||||
env = build_profile_secret_scope(home)
|
||||
except Exception:
|
||||
# Older supported upstream builds predate secret_scope. The plugin
|
||||
# parser is read-only and never mutates process-global os.environ.
|
||||
from .config import _profile_dotenv_values
|
||||
|
||||
env = _profile_dotenv_values(home)
|
||||
|
||||
credential_pool: dict[str, list[dict[str, Any]]] = {}
|
||||
try:
|
||||
from agent.secret_scope import (
|
||||
reset_secret_scope,
|
||||
set_secret_scope,
|
||||
)
|
||||
from hermes_constants import (
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
from hermes_cli.auth import read_credential_pool
|
||||
|
||||
home_token = set_hermes_home_override(home)
|
||||
secret_token = set_secret_scope(env)
|
||||
try:
|
||||
loaded_pool = read_credential_pool(None)
|
||||
finally:
|
||||
reset_secret_scope(secret_token)
|
||||
reset_hermes_home_override(home_token)
|
||||
if isinstance(loaded_pool, dict):
|
||||
credential_pool = {
|
||||
str(key): [entry for entry in value if isinstance(entry, dict)]
|
||||
for key, value in loaded_pool.items()
|
||||
if isinstance(value, list)
|
||||
}
|
||||
except Exception:
|
||||
# Compatibility fallback mirrors upstream's per-provider root
|
||||
# fallback without executing subprocesses or changing env state.
|
||||
for provider in (
|
||||
"copilot",
|
||||
"lmstudio",
|
||||
"ollama-cloud",
|
||||
"openai-codex",
|
||||
):
|
||||
entries = _credential_pool_entries(home / "auth.json", provider)
|
||||
if not entries and root_home != home:
|
||||
entries = _credential_pool_entries(
|
||||
root_home / "auth.json", provider
|
||||
)
|
||||
if entries:
|
||||
credential_pool[provider] = entries
|
||||
return _ProfileContext(profile, home, config, env, credential_pool)
|
||||
|
||||
async def _cached(
|
||||
self, key: tuple[str, str, str, str, str]
|
||||
) -> ReasoningCapability | None:
|
||||
now = time.monotonic()
|
||||
async with self._cache_lock:
|
||||
expired = [
|
||||
item for item, (_value, expiry) in self._cache.items() if expiry <= now
|
||||
]
|
||||
for item in expired:
|
||||
self._cache.pop(item, None)
|
||||
entry = self._cache.get(key)
|
||||
return entry[0] if entry else None
|
||||
|
||||
async def _store(
|
||||
self,
|
||||
key: tuple[str, str, str, str, str],
|
||||
capability: ReasoningCapability,
|
||||
generation: int,
|
||||
) -> bool:
|
||||
async with self._cache_lock:
|
||||
if self._profile_generations.get(key[0], 0) != generation:
|
||||
return False
|
||||
self._cache[key] = (
|
||||
capability,
|
||||
time.monotonic() + _CACHE_TTL_SECONDS,
|
||||
)
|
||||
return True
|
||||
|
||||
async def _generation(self, profile: str) -> int:
|
||||
async with self._cache_lock:
|
||||
return self._profile_generations.get(profile, 0)
|
||||
|
||||
async def _clear(self, profile: str) -> int:
|
||||
async with self._cache_lock:
|
||||
generation = self._profile_generations.get(profile, 0) + 1
|
||||
self._profile_generations[profile] = generation
|
||||
for key in [item for item in self._cache if item[0] == profile]:
|
||||
self._cache.pop(key, None)
|
||||
return generation
|
||||
|
||||
async def resolve_many(
|
||||
self,
|
||||
pairs: list[tuple[str, str]],
|
||||
*,
|
||||
profile: str = "default",
|
||||
refresh: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
try:
|
||||
context = await asyncio.wait_for(
|
||||
asyncio.to_thread(self._profile_context, profile),
|
||||
timeout=_PROFILE_CONTEXT_TIMEOUT_SECONDS,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.info("Profile credential resolution timed out for %s", profile)
|
||||
return [
|
||||
_static_capability(provider, model).wire(provider, model)
|
||||
for provider, model in pairs
|
||||
]
|
||||
if context is None:
|
||||
raise KeyError(profile)
|
||||
if refresh:
|
||||
generation = await self._clear(context.name)
|
||||
else:
|
||||
generation = await self._generation(context.name)
|
||||
|
||||
async def _run() -> list[ReasoningCapability]:
|
||||
results = await asyncio.to_thread(_static_capabilities, pairs)
|
||||
lm_indexes = [
|
||||
i
|
||||
for i, pair in enumerate(pairs)
|
||||
if _provider_key(pair[0]) == "lmstudio"
|
||||
]
|
||||
ollama_indexes = [
|
||||
i
|
||||
for i, pair in enumerate(pairs)
|
||||
if _provider_key(pair[0]) == "ollama-cloud"
|
||||
]
|
||||
copilot_indexes = [
|
||||
i for i, pair in enumerate(pairs) if _provider_key(pair[0]) == "copilot"
|
||||
]
|
||||
codex_indexes = [
|
||||
i
|
||||
for i, pair in enumerate(pairs)
|
||||
if _provider_key(pair[0]) == "openai-codex"
|
||||
]
|
||||
|
||||
await asyncio.gather(
|
||||
self._resolve_lmstudio(context, pairs, lm_indexes, results, generation),
|
||||
self._resolve_ollama(
|
||||
context, pairs, ollama_indexes, results, generation
|
||||
),
|
||||
self._resolve_copilot(
|
||||
context, pairs, copilot_indexes, results, generation
|
||||
),
|
||||
self._resolve_codex(
|
||||
context, pairs, codex_indexes, results, generation
|
||||
),
|
||||
)
|
||||
return results
|
||||
|
||||
try:
|
||||
resolved = await asyncio.wait_for(_run(), timeout=_REQUEST_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
logger.info("Model capability resolution timed out for profile %s", profile)
|
||||
resolved = await asyncio.to_thread(_static_capabilities, pairs)
|
||||
return [
|
||||
capability.wire(provider, model)
|
||||
for (provider, model), capability in zip(pairs, resolved)
|
||||
]
|
||||
|
||||
async def _resolve_lmstudio(
|
||||
self,
|
||||
context: _ProfileContext,
|
||||
pairs: list[tuple[str, str]],
|
||||
indexes: list[int],
|
||||
results: list[ReasoningCapability],
|
||||
generation: int,
|
||||
) -> None:
|
||||
if not indexes:
|
||||
return
|
||||
endpoint = _provider_base_url(context, "lmstudio")
|
||||
secret = _profile_secret(context, "lmstudio")
|
||||
pending: list[int] = []
|
||||
for index in indexes:
|
||||
key = (
|
||||
context.name,
|
||||
"lmstudio",
|
||||
pairs[index][1].lower(),
|
||||
endpoint.lower(),
|
||||
_fingerprint(secret),
|
||||
)
|
||||
cached = await self._cached(key)
|
||||
if cached is not None:
|
||||
results[index] = cached
|
||||
else:
|
||||
pending.append(index)
|
||||
if not pending:
|
||||
return
|
||||
server = endpoint[:-3] if endpoint.endswith("/v1") else endpoint
|
||||
headers = {"Authorization": f"Bearer {secret}"} if secret else {}
|
||||
try:
|
||||
async with self._probe_semaphore:
|
||||
timeout = aiohttp.ClientTimeout(total=_PROBE_TIMEOUT_SECONDS)
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=timeout, headers=headers
|
||||
) as session:
|
||||
async with session.get(f"{server}/api/v1/models") as response:
|
||||
if response.status != 200:
|
||||
return
|
||||
payload = await response.json(content_type=None)
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError):
|
||||
return
|
||||
items = payload.get("models") if isinstance(payload, dict) else None
|
||||
if not isinstance(items, list):
|
||||
items = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(items, list):
|
||||
return
|
||||
options: dict[str, tuple[str, ...]] = {}
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
reasoning = _mapping(_mapping(item.get("capabilities")).get("reasoning"))
|
||||
allowed = reasoning.get("allowed_options")
|
||||
if not isinstance(allowed, list):
|
||||
continue
|
||||
efforts = _ordered_efforts(allowed)
|
||||
for field in ("id", "key"):
|
||||
identifier = str(item.get(field) or "").strip()
|
||||
if identifier:
|
||||
options[identifier.lower()] = efforts
|
||||
for index in pending:
|
||||
model = pairs[index][1]
|
||||
if model.lower() not in options:
|
||||
continue
|
||||
capability = ReasoningCapability(
|
||||
options[model.lower()], True, "provider-catalog"
|
||||
)
|
||||
results[index] = capability
|
||||
await self._store(
|
||||
(
|
||||
context.name,
|
||||
"lmstudio",
|
||||
model.lower(),
|
||||
endpoint.lower(),
|
||||
_fingerprint(secret),
|
||||
),
|
||||
capability,
|
||||
generation,
|
||||
)
|
||||
|
||||
async def _resolve_ollama(
|
||||
self,
|
||||
context: _ProfileContext,
|
||||
pairs: list[tuple[str, str]],
|
||||
indexes: list[int],
|
||||
results: list[ReasoningCapability],
|
||||
generation: int,
|
||||
) -> None:
|
||||
endpoint = _provider_base_url(context, "ollama-cloud")
|
||||
secret = _profile_secret(context, "ollama-cloud")
|
||||
server = endpoint[:-3] if endpoint.endswith("/v1") else endpoint
|
||||
|
||||
async def _probe(index: int) -> None:
|
||||
provider, model = pairs[index]
|
||||
key = (
|
||||
context.name,
|
||||
"ollama-cloud",
|
||||
model.lower(),
|
||||
endpoint.lower(),
|
||||
_fingerprint(secret),
|
||||
)
|
||||
cached = await self._cached(key)
|
||||
if cached is not None:
|
||||
results[index] = cached
|
||||
return
|
||||
async with self._probe_semaphore:
|
||||
headers = {"Authorization": f"Bearer {secret}"} if secret else {}
|
||||
try:
|
||||
timeout = aiohttp.ClientTimeout(total=_PROBE_TIMEOUT_SECONDS)
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=timeout, headers=headers
|
||||
) as session:
|
||||
async with session.post(
|
||||
f"{server}/api/show",
|
||||
json={"name": model.split(":cloud", 1)[0]},
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
return
|
||||
payload = await response.json(content_type=None)
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError):
|
||||
return
|
||||
capabilities = (
|
||||
payload.get("capabilities") if isinstance(payload, dict) else None
|
||||
)
|
||||
if not isinstance(capabilities, list):
|
||||
return
|
||||
efforts = (
|
||||
("none", "low", "medium", "high", "max")
|
||||
if "thinking" in capabilities
|
||||
else ()
|
||||
)
|
||||
capability = ReasoningCapability(efforts, True, "provider-catalog")
|
||||
results[index] = capability
|
||||
await self._store(key, capability, generation)
|
||||
|
||||
await asyncio.gather(*(_probe(index) for index in indexes[:_MAX_OLLAMA_PROBES]))
|
||||
|
||||
async def _resolve_copilot(
|
||||
self,
|
||||
context: _ProfileContext,
|
||||
pairs: list[tuple[str, str]],
|
||||
indexes: list[int],
|
||||
results: list[ReasoningCapability],
|
||||
generation: int,
|
||||
) -> None:
|
||||
if not indexes:
|
||||
return
|
||||
raw_token = _profile_secret(context, "copilot")
|
||||
if not raw_token:
|
||||
return
|
||||
account_scope = _fingerprint(raw_token)
|
||||
endpoint = _provider_base_url(context, "copilot")
|
||||
cache_endpoint = endpoint
|
||||
pending: list[int] = []
|
||||
for index in indexes:
|
||||
key = (
|
||||
context.name,
|
||||
"copilot",
|
||||
pairs[index][1].lower(),
|
||||
endpoint.lower(),
|
||||
account_scope,
|
||||
)
|
||||
cached = await self._cached(key)
|
||||
if cached is not None:
|
||||
results[index] = cached
|
||||
else:
|
||||
pending.append(index)
|
||||
if not pending:
|
||||
return
|
||||
try:
|
||||
async with self._probe_semaphore:
|
||||
api_token, endpoint = await self._copilot_api_token(raw_token, endpoint)
|
||||
timeout = aiohttp.ClientTimeout(total=_PROBE_TIMEOUT_SECONDS)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_token}",
|
||||
"Copilot-Integration-Id": "vscode-chat",
|
||||
"Editor-Version": "vscode/1.104.1",
|
||||
"User-Agent": "HermesRelay/1.0",
|
||||
}
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=timeout, headers=headers
|
||||
) as session:
|
||||
async with session.get(
|
||||
f"{endpoint.rstrip('/')}/models"
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
return
|
||||
payload = await response.json(content_type=None)
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError, KeyError):
|
||||
return
|
||||
items = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(items, list):
|
||||
return
|
||||
by_id = {
|
||||
str(item.get("id") or "").strip().lower(): item
|
||||
for item in items
|
||||
if isinstance(item, dict) and str(item.get("id") or "").strip()
|
||||
}
|
||||
for index in pending:
|
||||
model = pairs[index][1]
|
||||
item = by_id.get(model.lower())
|
||||
# A catalog is authoritative only for rows it actually returned.
|
||||
# Missing IDs may be aliases, stale picker entries, or rollout
|
||||
# differences; keep their static non-exact fallback untouched.
|
||||
if item is None:
|
||||
continue
|
||||
efforts: tuple[str, ...] = ()
|
||||
supports = _mapping(_mapping(item.get("capabilities")).get("supports"))
|
||||
raw_efforts = supports.get("reasoning_effort")
|
||||
if isinstance(raw_efforts, list):
|
||||
efforts = _ordered_efforts(raw_efforts)
|
||||
capability = ReasoningCapability(efforts, True, "github-catalog")
|
||||
results[index] = capability
|
||||
await self._store(
|
||||
(
|
||||
context.name,
|
||||
"copilot",
|
||||
model.lower(),
|
||||
cache_endpoint.lower(),
|
||||
account_scope,
|
||||
),
|
||||
capability,
|
||||
generation,
|
||||
)
|
||||
|
||||
async def _resolve_codex(
|
||||
self,
|
||||
context: _ProfileContext,
|
||||
pairs: list[tuple[str, str]],
|
||||
indexes: list[int],
|
||||
results: list[ReasoningCapability],
|
||||
generation: int,
|
||||
) -> None:
|
||||
if not indexes:
|
||||
return
|
||||
access_token = _profile_secret(context, "openai-codex")
|
||||
if not access_token:
|
||||
return
|
||||
endpoint = _provider_base_url(context, "openai-codex")
|
||||
account_scope = _fingerprint(access_token)
|
||||
pending: list[int] = []
|
||||
for index in indexes:
|
||||
key = (
|
||||
context.name,
|
||||
"openai-codex",
|
||||
pairs[index][1].lower(),
|
||||
endpoint.lower(),
|
||||
account_scope,
|
||||
)
|
||||
cached = await self._cached(key)
|
||||
if cached is not None:
|
||||
results[index] = cached
|
||||
else:
|
||||
pending.append(index)
|
||||
if not pending:
|
||||
return
|
||||
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
account_id = _chatgpt_account_id(access_token)
|
||||
if account_id:
|
||||
headers["ChatGPT-Account-Id"] = account_id
|
||||
try:
|
||||
async with self._probe_semaphore:
|
||||
timeout = aiohttp.ClientTimeout(total=_PROBE_TIMEOUT_SECONDS)
|
||||
async with aiohttp.ClientSession(
|
||||
timeout=timeout, headers=headers
|
||||
) as session:
|
||||
async with session.get(
|
||||
f"{endpoint}/models?client_version=1.0.0"
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
return
|
||||
payload = await response.json(content_type=None)
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError):
|
||||
return
|
||||
|
||||
items = payload.get("models") if isinstance(payload, dict) else None
|
||||
if not isinstance(items, list):
|
||||
return
|
||||
by_id: dict[str, dict[str, Any]] = {}
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
identifier = _first_string(item.get("slug"), item.get("id")).lower()
|
||||
if identifier:
|
||||
by_id[identifier] = item
|
||||
for index in pending:
|
||||
model = pairs[index][1]
|
||||
item = by_id.get(_model_key(model))
|
||||
if item is None:
|
||||
continue
|
||||
raw_levels = item.get("supported_reasoning_levels")
|
||||
if not isinstance(raw_levels, list):
|
||||
continue
|
||||
efforts = _ordered_efforts(
|
||||
level.get("effort") if isinstance(level, dict) else level
|
||||
for level in raw_levels
|
||||
)
|
||||
capability = ReasoningCapability(efforts, True, "provider-catalog")
|
||||
results[index] = capability
|
||||
await self._store(
|
||||
(
|
||||
context.name,
|
||||
"openai-codex",
|
||||
model.lower(),
|
||||
endpoint.lower(),
|
||||
account_scope,
|
||||
),
|
||||
capability,
|
||||
generation,
|
||||
)
|
||||
|
||||
async def _copilot_api_token(
|
||||
self, raw_token: str, endpoint: str
|
||||
) -> tuple[str, str]:
|
||||
# Exchanged Copilot tokens are semicolon-delimited; raw GitHub tokens
|
||||
# must be exchanged without invoking profile-global upstream state.
|
||||
if ";" in raw_token and "=" in raw_token:
|
||||
return raw_token, endpoint
|
||||
timeout = aiohttp.ClientTimeout(total=_PROBE_TIMEOUT_SECONDS)
|
||||
headers = {
|
||||
"Authorization": f"token {raw_token}",
|
||||
"Accept": "application/json",
|
||||
"Editor-Version": "vscode/1.104.1",
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
}
|
||||
async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session:
|
||||
async with session.get(
|
||||
"https://api.github.com/copilot_internal/v2/token"
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise ValueError("Copilot token exchange rejected")
|
||||
payload = await response.json(content_type=None)
|
||||
api_token = str(payload.get("token") or "").strip()
|
||||
if not api_token:
|
||||
raise ValueError("Copilot token exchange returned no token")
|
||||
endpoints = _mapping(payload.get("endpoints"))
|
||||
api_endpoint = _first_string(endpoints.get("api"), endpoint).rstrip("/")
|
||||
return api_token, api_endpoint
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user