Compare commits
142
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c52340ecde | ||
|
|
7570f93dbf | ||
|
|
6d32ccf024 | ||
|
|
4233817e9f | ||
|
|
577732069f | ||
|
|
a738a0e151 | ||
|
|
42d6c77cfb | ||
|
|
bd9f53e8db | ||
|
|
2017d60f4c | ||
|
|
1e133ee15c | ||
|
|
9a40ed9afc | ||
|
|
9ce07b45f7 | ||
|
|
2fd90a6e81 | ||
|
|
7dd1125686 | ||
|
|
522c4fe82a | ||
|
|
c9b30dabae | ||
|
|
e9e92d03f2 | ||
|
|
da8e23068a | ||
|
|
aaee75e7fc | ||
|
|
8ebb21b16d | ||
|
|
0700ac81c6 | ||
|
|
015298f90a | ||
|
|
3c0e51f664 | ||
|
|
92f96831c4 | ||
|
|
033dcc37ba | ||
|
|
a144962e66 | ||
|
|
c683ad290c | ||
|
|
2968a173b1 | ||
|
|
5ff78da8e4 | ||
|
|
ffe534454a | ||
|
|
49c183ef9a | ||
|
|
906ce79a82 | ||
|
|
74f84d9492 | ||
|
|
c6bd418e30 | ||
|
|
8b79c06922 | ||
|
|
7a63a0c6ea | ||
|
|
83580cbf06 | ||
|
|
e636ca4715 | ||
|
|
c1926f6434 | ||
|
|
b78fe0244c | ||
|
|
d15f7860fc | ||
|
|
1660750b67 | ||
|
|
a1818579d9 | ||
|
|
f893330cfa | ||
|
|
16b16fd5ad | ||
|
|
227748912d | ||
|
|
9a1edf7d5a | ||
|
|
ab65ea7705 | ||
|
|
fde5030797 | ||
|
|
0173519183 | ||
|
|
c5d61bc427 | ||
|
|
f116d41295 | ||
|
|
4aedb9b839 | ||
|
|
ba24b3e959 | ||
|
|
5896d4c672 | ||
|
|
3c24e82e5e | ||
|
|
381c62d6b6 | ||
|
|
ad0139ba94 | ||
|
|
2abf9b000f | ||
|
|
bc957ef641 | ||
|
|
e3097682c1 | ||
|
|
63a7a79d2d | ||
|
|
789f32cd25 | ||
|
|
6f0357c2e8 | ||
|
|
7569144cc4 | ||
|
|
5b25b9b154 | ||
|
|
79dd6b44fe | ||
|
|
d96b68c794 | ||
|
|
e3ba358331 | ||
|
|
45c7ef49e2 | ||
|
|
6003258c5d | ||
|
|
a660b3825d | ||
|
|
5c214a2e6a | ||
|
|
c079a632ba | ||
|
|
c7de0da22d | ||
|
|
16133ff081 | ||
|
|
3a12221185 | ||
|
|
699653bf87 | ||
|
|
3d354080dd | ||
|
|
427145ccce | ||
|
|
654663bc3e | ||
|
|
75d965c32e | ||
|
|
37355974b4 | ||
|
|
380ad0b4bf | ||
|
|
13e747c6d9 | ||
|
|
500387d3fa | ||
|
|
55904a600a | ||
|
|
393a485f53 | ||
|
|
4fc1978df7 | ||
|
|
141a7560e7 | ||
|
|
f965c205d7 | ||
|
|
a138165408 | ||
|
|
467e6722a2 | ||
|
|
b5c1d392fb | ||
|
|
5df941a179 | ||
|
|
66728686b9 | ||
|
|
c05ee40db4 | ||
|
|
66166b2c2a | ||
|
|
c8a6534bec | ||
|
|
8afbb6120d | ||
|
|
9c080f522c | ||
|
|
d2a2d01072 | ||
|
|
fe297e2db9 | ||
|
|
f5bb41e46d | ||
|
|
0b0e323b20 | ||
|
|
d8cc3d7082 | ||
|
|
a8db3a2ed8 | ||
|
|
8dc874cbbf | ||
|
|
ea8d09e7f0 | ||
|
|
5c6211e63f | ||
|
|
8c8e24c4d0 | ||
|
|
d16f477d81 | ||
|
|
71f3331f54 | ||
|
|
e9effeeb87 | ||
|
|
e9b00757d5 | ||
|
|
891085ed61 | ||
|
|
b53cfbc906 | ||
|
|
045f42386d | ||
|
|
44e6e9b6dd | ||
|
|
c46560aeeb | ||
|
|
9593787482 | ||
|
|
44ba8ebb41 | ||
|
|
d25f805fa7 | ||
|
|
3cef7c39e3 | ||
|
|
c76e906b30 | ||
|
|
b56dec859d | ||
|
|
91763a286f | ||
|
|
bc6f288b0b | ||
|
|
96a6c963dc | ||
|
|
373b98f316 | ||
|
|
83609a4608 | ||
|
|
2c1d7df764 | ||
|
|
df536b308f | ||
|
|
65f89d4c0b | ||
|
|
e78602c9d8 | ||
|
|
fd2a8b2546 | ||
|
|
37ff218f33 | ||
|
|
45e7911d3d | ||
|
|
1ae0627d92 | ||
|
|
de44059c8e | ||
|
|
0327012666 | ||
|
|
4160cb1f85 |
@@ -138,14 +138,26 @@ jobs:
|
||||
|
||||
# The broad Gradle `test` aggregate currently hangs in deferred JVM test
|
||||
# suites tracked by issue #32. Keep CI release-relevant until that suite is
|
||||
# split: pairing URL derivation plus connection switching are the stable
|
||||
# Android regression slice for the active release work.
|
||||
# split: run the stable connection slice plus focused Chat/Voice state,
|
||||
# parser, layout, and accessibility regressions for the active release.
|
||||
- name: Run focused Android unit tests
|
||||
run: |
|
||||
./gradlew :app:testSideloadDebugUnitTest \
|
||||
--tests com.hermesandroid.relay.network.ArchitectureBoundaryTest \
|
||||
--tests com.hermesandroid.relay.network.relay.RelayUrlDeriverTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ConnectionSwitchTest \
|
||||
--tests com.hermesandroid.relay.util.ServerAddressTest \
|
||||
--tests com.hermesandroid.relay.util.IssueReportAndDiagnosticsTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ChatStreamRecoveryTest \
|
||||
--tests com.hermesandroid.relay.viewmodel.ChatViewModelRealtimeTurnTest \
|
||||
--tests com.hermesandroid.relay.network.relay.RealtimeVoiceEventParsingTest \
|
||||
--tests com.hermesandroid.relay.voice.VoiceCommandInterpreterTest \
|
||||
--tests com.hermesandroid.relay.data.VoiceModePresetTest \
|
||||
--tests com.hermesandroid.relay.ui.components.BackgroundTaskCardTest \
|
||||
--tests com.hermesandroid.relay.ui.components.DotMatrixIndicatorTest \
|
||||
--tests com.hermesandroid.relay.ui.components.AttachmentGalleryLayoutTest \
|
||||
--tests com.hermesandroid.relay.ui.components.MarkdownStreamingParserTest \
|
||||
--tests com.hermesandroid.relay.ui.screens.ChatUnreadStateTest \
|
||||
--console=plain
|
||||
|
||||
# Upload reports only for failures. Successful PR report uploads add
|
||||
|
||||
@@ -12,10 +12,7 @@ on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/__init__.py"
|
||||
- "plugin/android_tool.py"
|
||||
- "plugin/cli.py"
|
||||
- "plugin/pair.py"
|
||||
- "plugin/*.py"
|
||||
- "plugin/plugin.yaml"
|
||||
- "plugin/relay/**"
|
||||
- "plugin/tools/**"
|
||||
@@ -31,10 +28,7 @@ on:
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- "plugin/__init__.py"
|
||||
- "plugin/android_tool.py"
|
||||
- "plugin/cli.py"
|
||||
- "plugin/pair.py"
|
||||
- "plugin/*.py"
|
||||
- "plugin/plugin.yaml"
|
||||
- "plugin/relay/**"
|
||||
- "plugin/tools/**"
|
||||
|
||||
@@ -127,10 +127,12 @@ jobs:
|
||||
# Flavor dimension adds an extra path segment to the AGP output layout.
|
||||
# APKs live under `apk/<flavor>/release/`, AABs under `bundle/<flavor>Release/`
|
||||
# (note the concatenated camelCase — AGP path quirk, documented but
|
||||
# different between APK and AAB). The globs below match both flavors.
|
||||
# different between APK and AAB). Checksums cover EXACTLY the files
|
||||
# attached to the GitHub Release (see the 2-asset policy on the
|
||||
# release step below) so SHA256SUMS.txt matches the assets 1:1.
|
||||
run: |
|
||||
cd app/build/outputs
|
||||
sha256sum apk/*/release/*.apk bundle/*Release/*.aab > SHA256SUMS.txt
|
||||
sha256sum apk/sideload/release/*.apk bundle/googlePlayRelease/*.aab > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Create GitHub Release
|
||||
@@ -140,16 +142,22 @@ jobs:
|
||||
tag_name: android-v${{ needs.validate.outputs.version }}
|
||||
body_path: RELEASE_NOTES.md
|
||||
prerelease: ${{ contains(needs.validate.outputs.version, '-') }}
|
||||
# Attach all four flavored artifacts — users sideload the
|
||||
# `hermes-relay-<version>-sideload-release.apk` for the full
|
||||
# Phase 3 / Tier 3/4/6 feature set; the
|
||||
# `hermes-relay-<version>-googlePlay-release.aab` is what gets
|
||||
# uploaded to Play Console. APK twin of the googlePlay flavor
|
||||
# and AAB twin of the sideload flavor are included for parity
|
||||
# (useful for diff tooling, not primary downloads).
|
||||
# Deliberate 2-asset policy (#144): attach ONLY
|
||||
# `hermes-relay-<version>-sideload-release.apk` (the file users
|
||||
# install by tapping — full Device Control feature set) and
|
||||
# `hermes-relay-<version>-googlePlay-release.aab` (the Play Console
|
||||
# upload bundle — NOT tap-installable on a phone), plus the
|
||||
# SHA256SUMS.txt covering exactly those two files. GitHub sorts
|
||||
# assets alphabetically, so extra files made the non-installable
|
||||
# .aab list first and confused new users. The parity twins
|
||||
# (googlePlay APK, sideload AAB) are still BUILT by the step above
|
||||
# and reproducible from the tag via CI, just not attached.
|
||||
# NEVER rename the sideload APK: the in-app update checker
|
||||
# (update/UpdateChecker.kt) matches assets by ".apk" + "sideload"
|
||||
# in the name, and user-docs verify steps cite the filename.
|
||||
files: |
|
||||
app/build/outputs/apk/*/release/*.apk
|
||||
app/build/outputs/bundle/*Release/*.aab
|
||||
app/build/outputs/apk/sideload/release/*.apk
|
||||
app/build/outputs/bundle/googlePlayRelease/*.aab
|
||||
app/build/outputs/SHA256SUMS.txt
|
||||
|
||||
- name: Upload to Play Console (production draft)
|
||||
|
||||
+81
-1
@@ -6,6 +6,78 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.4.1] - 2026-07-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Background work is visible in Standard Chat.** A live process strip opens a mobile process sheet with running or recent state, output, elapsed time, Stop, and Dismiss controls. It remains compatible with older Hermes servers that do not expose process details.
|
||||
- **Background work has a clearer Chat home.** Realtime work appears as a titled task card with working, waiting, delivery, and completion states, queued work, and an expandable tool timeline.
|
||||
- **Multi-image messages open as galleries.** Adjacent images render in a compact grid and open at the selected image in a swipeable viewer while preserving sensitive-media reveal and original-file actions.
|
||||
- **Voice gains commands and presets.** Spoken commands can stop speech, cancel background work, pause or resume listening, repeat a result, or start Standard voice chat. Hands-free, Low latency, Careful tools, and Quiet presets tune existing interaction settings.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Streaming Chat content stays steadier and more readable.** Settled prose and headings adopt final Markdown styling during generation, wide tables scroll with readable columns, the thinking indicator respects system motion and TalkBack settings, and the jump-to-bottom control counts unread messages.
|
||||
- **Offline Demo mode no longer starts Voice.** The mic action now explains locally that a Hermes connection is required.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **An in-flight Chat turn survives reopening the app.** Session-backed replies restore partial text, live reasoning, lifecycle status, tool/subagent cards, background-task state, and unanswered approval or clarification cards. Current Hermes gateways reattach to the same running turn; older or finished sessions reconcile from history without duplicating the prompt or losing the final answer.
|
||||
- **Realtime Agent delivery is protected.** Hermes results use exact provider speech where supported, delivery validation, generation-safe confirmation, and a single relay-TTS fallback if the provider closes or rejects delivery. Voice commands no longer leave synthetic cancellation turns or mute a later background answer.
|
||||
- **Standard Chat receives background-process completions automatically.** When Hermes completes detached work and starts a follow-up turn on the originating Gateway session, Android shows the unsolicited assistant stream in the open conversation and reconciles history after a cold reconnect. The synthetic process prompt is rendered as a compact process notice rather than a user-authored message.
|
||||
|
||||
## [1.4.0] - 2026-07-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Android model pickers can refresh the server catalog.** Chat's model sheet and Manage's main/profile model dialogs now expose upstream's explicit **Refresh Models** action, so dynamic/custom provider model lists can be reloaded on demand without making every picker open probe providers.
|
||||
- **Server-backed session cleanup plumbing.** The dashboard client now supports single-session export, the upstream `/api/sessions/prune` route with a mandatory dry-run preview before destructive apply, plus soft archive/restore helpers and an `archived` session-list filter for the Manage surface.
|
||||
- **Notification triggers MVP.** Settings → Notifications now has explicit opt-in proactive rules for the Notification companion: match by app package plus optional title/text filters, post a safe local "Ask Hermes?" prompt, show the latest trigger activity, and pause everything instantly with a kill switch.
|
||||
- **Android bridge: multi-device targeting.** The relay can keep multiple Android bridge clients connected at once, route commands by `device` selector (`phone`, `pixel`, `fold`, `boox`, `note`, `notemax`, `tablet`, or device ID), expose `/bridge/devices` and `/bridge/select-active`, and advertise an optional `device` argument on the `android_*` tool schemas.
|
||||
- **Voice: a second long request gets queued, not refused.** Ask for another long task while one is already running in the background and it's now queued (up to three) and starts automatically when the current one finishes — with a short spoken transition. The task card shows "+N queued", and cancelling the current task clears the queue.
|
||||
- **Voice: background answers start speaking sooner and can never be silently lost.** The spoken summary now streams as it's generated (it used to be held until fully complete — a noticeable dead gap, then the whole answer at once). Delivery is verified two ways: the summary must actually reflect the answer's content (not just avoid known filler phrases), and if no spoken delivery lands within 30 seconds the answer is posted as text instead of vanishing.
|
||||
- **Voice: tap the finished-task card to hear the answer again.** After a background task's card settles to "finished," tapping it replays the delivered answer. The card also now shows in the compact voice view (it previously existed only in the full-screen layout), a "Drafting the answer…" status appears as the reply is being composed, and leaving voice mode with a task still running leaves a note in chat so the work stays visible.
|
||||
- **Voice: quick questions answered while a background task runs.** Realtime voice used to refuse *any* second request while a long task ran in the background — even a two-second lookup. A quick second ask is now answered inline on a side session (within the same few-second window that decides backgrounding); anything that turns out to be long still gets the "a task is already running" answer, and the running task is never disturbed.
|
||||
- **Voice: the background-task card no longer vanishes mid-answer.** The card used to disappear the instant the spoken answer started (exactly when the waveform returned), reading as the task being lost. It now settles to a "Background task finished." state, lingers for a few seconds while the answer plays, then dismisses itself — and its ✕ during that settled state just dismisses the card instead of sending a cancel.
|
||||
- **Voice: the "Thinking" pill no longer spins forever.** The server streams its drafting text as an internal pseudo-tool that never reports completion, and the app rendered it as a live tool pill — which then ran indefinitely in both chat and the voice overlay. Internal tool events no longer become pills (their text still feeds the thinking trace).
|
||||
- **Voice: background-task answers can't be lost to a stray cancel.** Tapping cancel/stop after a background task had already finished used to mark the finished run "cancelled" — losing the answer that was about to be spoken. Cancel now only cancels a run that's actually still running; stopping the current speech works as before.
|
||||
- **Voice: no more spoken run IDs or phantom queue state.** The realtime voice model no longer reads 32-character run IDs aloud after starting a background task (identifiers stay out of everything it's asked to speak), no longer claims a request was queued unless the relay accepted it, and a completed task's answer is spoken directly — deferral filler like "one moment while I look that up" in place of a finished result now triggers the fallback that speaks the real answer.
|
||||
- **Voice: finished-task answers keep the realtime voice.** A completed background task's answer is now spoken by the same realtime voice you've been talking to — read word for word from the authoritative Hermes answer — instead of switching to the standard TTS voice mid-conversation. The answer always lands: if the realtime model goes off-script or the provider connection drops, standard TTS speaks it, and if you start talking mid-delivery it's posted as text instead of interrupting you. The "When the answer is ready" setting keeps its four modes (Exact / Summary / Notify / Show), now explained behind an info icon in Voice Settings.
|
||||
- **Voice: realtime models refreshed.** OpenAI realtime now defaults to `gpt-realtime-2.1` (with the cheaper `gpt-realtime-2.1-mini` selectable), the versioned `grok-voice-think-fast-1.0` pin is available alongside xAI's `grok-voice-latest` alias, and session logs record which model the provider *actually* served — so provider-side alias moves no longer happen invisibly.
|
||||
- **Voice: session logs clean up after themselves.** Realtime voice session logs are swept after 14 days by default (`realtime_voice.run_retention_days`, 0 disables), and the per-response TTS audio capture is now opt-in debug tooling (`debug_audio_tap`) instead of an always-on multi-MB tap.
|
||||
- **Voice: one-command delivery health report.** `python -m plugin.relay.realtime_agent.report` summarizes recent voice deliveries — how many were spoken by the realtime voice vs fell back to TTS or text, and why — for quick health checks after live testing.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Bootstrap compatibility layer slimmed to true gaps.** The optional compatibility hook no longer injects session CRUD/messages or the legacy skills list — current Hermes serves those natively; it now covers only surfaces with no native replacement yet (session search, memory, legacy skill detail/toggle, config, available-models, and the slash-command middleware). Older pre-session-API Hermes builds degrade to the standard completions/runs chat paths.
|
||||
- **Dependency floor: aiohttp ≥ 3.14.1.** Raised from 3.9 across plugin requirements and package metadata to the patched line covering the 2026 aiohttp security advisories.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Realtime voice recovers after background route loss.** A recorded turn now waits for a relay-confirmed resumed socket, retains unacknowledged follow-up PCM for replay, and reports transport rejection instead of sitting on a dead persistent connection. Resume handshakes are coalesced, and the relay requires a valid resume claim before replacing the active phone socket, so a slower stale connection cannot detach background-result delivery. Long-lived sessions start their bounded retry window when the route actually drops instead of at voice-mode entry, and a bare socket open cannot reset it. Late callbacks from a retired session are ignored. Exiting voice mode clears its detached reconnect and confirmation state before another session opens; rejected or unacknowledged cancels no longer leave an undismissable background-task pill. Provider transcription no longer impersonates active microphone capture, Stop settles the local turn even when the route is gone, and provisional `Listening...` / `Still working...` rows cannot remain stuck in chat.
|
||||
- **xAI exact background answers bypass model deferral.** Non-structured **Exact** deliveries now use xAI's provider-native forced speech event, preserving the selected realtime voice and normal assistant history while speaking the authoritative Hermes answer without asking the model to follow a read-verbatim prompt. Structured results and summary modes still use natural model summarization, and the validator plus standard-TTS fallback remain as safety nets.
|
||||
- **Background voice handoffs no longer repeat themselves.** If the realtime provider already spoke an acknowledgement before calling Hermes, promotion keeps that first line and suppresses the redundant "running in the background" follow-up; silent tool calls still receive the configured spoken handoff. Provider protocols that report both response creation and output-item creation now also produce one client `response.started` event instead of two.
|
||||
- **Realtime voice model and voice picks now apply to the next session.** Voice Settings persists the selected Realtime Agent model and voice per connection/profile and sends both when opening a session, so choosing a pinned model immediately controls the next session instead of requiring **Save realtime agent** to rewrite the relay config. The active voice UI reflects the override, changing it retires any prewarmed session, and the choice survives an app restart.
|
||||
- **Fresh realtime sessions emit one ready event.** Android's required `session.start` acknowledgement no longer causes the relay to send a second `voice.session.ready`, avoiding duplicate event IDs and duplicate session-ready telemetry on every new voice conversation.
|
||||
- **Relay media can no longer serve credential files.** `/media/by-path` now always blocks paths that resolve into credential or system locations (`~/.hermes/.env`, `auth.json`, `config.yaml`, OAuth/MCP token stores, `pairing/`, `~/.ssh`, and similar) even in the default permissive mode — mirroring upstream Hermes' media-delivery hardening — so a prompt-injected `MEDIA:` marker can't deliver live secrets to a paired phone. Symlinks are resolved before the check, and the relay's own QR-signing secret and session-token store are covered too.
|
||||
- **Long agent turns no longer die or duplicate at the transport.** Gateway chat (Android and the desktop CLI) now gives `prompt.submit` up to 30 minutes to acknowledge — matching upstream desktop and the server's own turn ceiling — instead of short generic RPC timeouts that could falsely fall back to SSE (duplicating the turn on Android) or kill a legitimately long deep-reasoning turn. Turn liveness is governed by idle-progress watchdogs (no events at all for a stretch), never a hard cap while output is still streaming.
|
||||
- **Manage → Models keeps providers that still need keys.** Newer Hermes hides unconfigured providers from the model catalog unless a management UI opts in; Android Manage now opts in and keeps rendering greyed provider rows with their key-setup guidance on both old and new servers. In-chat model picking is unchanged (configured providers only).
|
||||
- **Phone-local context actually reaches the server on fallback chat paths.** The sessions/runs streaming payloads carried voice-intent traces, card dispatches, and attachments in fields the server never reads — silently dropping them. That context now rides channels the server actually consumes (a per-turn context digest, real history fields where they exist, inline images on the completions path), and any attachment with no supported channel is reported instead of silently discarded.
|
||||
- **Relay plugin works under the native `hermes plugins install` path.** The plugin's runtime imports assumed the repo's editable layout, so upstream's native installer (which loads plugins under its own package namespace) broke `hermes relay start` and `hermes pair` with `ModuleNotFoundError: No module named 'plugin'`. All runtime imports are now package-relative, the dashboard module boots correctly when the upstream web server loads it standalone, and `hermes relay doctor` now exercises the real import chain so this class of breakage can't pass doctor again. (#165)
|
||||
- **Installer handles modern venv layouts.** `install.sh` now autodetects the classic venv, uv-managed `.venv`, and containerized layouts — and everything it generates (the systemd unit and all four command shims) points at the interpreter it actually detected instead of a hardcoded classic path. On immutable container images it steers to the native install path with a clear message instead of dying mid-run. (#165)
|
||||
- **Doctor catches dashboard URLs pointed at the wrong Hermes surface.** `hermes relay doctor` now distinguishes the dashboard/Manage surface from an API-server/headless backend URL and tells operators to use `hermes dashboard` when a configured dashboard URL is actually pointing at `hermes serve` / the API server.
|
||||
- **Doctor and installer catch stale duplicate plugin copies.** The gateway plugin loader picks a discovered plugin by manifest name, so a second directory declaring `name: hermes-relay` (a leftover backup copy or a stray extra install) could win and make the gateway load stale code — silently ignoring every later deploy. `hermes relay doctor` now warns when more than one directory under the plugins dir declares the same plugin name, and `install.sh` removes any such duplicate so only the canonical plugin symlink remains.
|
||||
- **Crash-safety on Android 14 and earlier.** Built against SDK 35, Kotlin's `removeFirst()`/`removeLast()` resolve to the new Java `List` methods that don't exist below Android 15, crashing older devices. All such calls in the app are now `removeAt(...)`, and Tink (pulled in by encrypted storage) is pinned ahead of the transitive version whose `HybridConfig` tripped the same Google Play pre-launch check.
|
||||
- **No crash when a relay address is malformed.** A corrupt or hand-edited pairing address with an invalid host could crash the app the moment it opened the relay connection (the connection is built on a background thread, so the error escaped uncaught). A bad relay address is now handled as a normal connection failure — shown as disconnected with a "re-pair to refresh" note — instead of crashing. The same guard now also covers the relay's media, session, and voice HTTP calls. (relay half of #131)
|
||||
- **Voice: cleaner error recovery.** A failed or timed-out voice turn no longer shows the same error twice (the top overlay banner and a duplicate bottom banner) and can now be **dismissed**, not just retried — so a stuck error state can't block the screen.
|
||||
- **Voice: fallback-spoken answers no longer play into a frozen overlay.** When an answer is delivered by the standard TTS fallback (or replayed from the finished-task card), the voice screen now shows the waveform and the answer text while it speaks — previously it sat on "Thinking" with no visuals even though audio was playing.
|
||||
- **Voice: a quiet realtime session no longer dies with a raw provider error.** xAI ends a realtime conversation after 900 seconds of inactivity, and no keepalive traffic resets that timer — so a voice session left open through a long background task (or simply left open) died with a raw provider error. That provider timeout is now treated as routine expiry: the session ends cleanly with no error banner, and your next voice turn transparently opens a fresh provider conversation that picks up from the same durable Hermes chat session.
|
||||
- **No crash when a malformed server address reaches a chat send.** The three streaming chat paths built their HTTP request before any error handling, so a corrupt or hand-edited API URL could throw instead of failing the turn gracefully. They now surface "Invalid server address — edit the connection's API URL or re-pair" through the normal in-chat error channel (closes the remaining #131 crash-class gap).
|
||||
- **Demo mode: typing a message now gets an honest reply.** Sending a message in the offline demo used to do nothing (the composer silently ignored it, reading as broken). The demo now echoes your message and answers with a short notice explaining it's an offline sample, pointing at the Connect action to chat for real.
|
||||
- **Voice: realtime conversations reliably reach your chat history.** Turns the realtime voice model answers directly (without calling Hermes) are folded into the chat session on your next message — but on the default gateway connection that hand-off could be deferred indefinitely, so the agent never learned what was said in voice. The turn that carries them now routes so the sync actually lands. Synced voice turns also render cleanly when a chat reloads: a quiet "Realtime Agent" chip instead of a raw provenance footnote, and no more duplicated voice exchange after the sync.
|
||||
|
||||
## [1.3.0] - 2026-07-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Voice settings: edit your server's voice engine.** Voice settings now has a **Server voice config** section that reads and writes the host's text-to-speech and speech-to-text settings — provider, voice, model, language, and per-provider options — over the dashboard, the same config the official desktop app edits. It includes an **ElevenLabs voice picker** that lists the voices available on your server's ElevenLabs key (and tells you when no key is set). Works on the no-plugin (Standard) path; sign in to Manage to use it.
|
||||
@@ -24,6 +96,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Changed
|
||||
|
||||
- **Reporting a diagnostic now files the right kind of issue.** The Report button on a diagnostics entry used to turn routine log lines into "[Bug]" GitHub issues with an empty template. Now informational entries first ask "what were you expecting to happen?" and file as a "[Diagnostic]" question, error entries keep the direct bug flow, and every report carries the connection mode you were actually on instead of a placeholder line. (#155, #154, #146)
|
||||
- **Simpler release downloads.** Each Android release on GitHub now attaches just two files — the tap-to-install sideload APK and the Play Store upload bundle — plus checksums, with the release notes leading with the one file most people want. The extra "parity/testing" artifacts are gone from the release page (still reproducible from the tag via CI). (#144)
|
||||
- **Clearer, snappier voice capture and playback.** Voice now engages the device's echo-cancellation and noise-suppression while recording (matching the desktop's microphone setup), and requests audio focus before the first reply so the opening words aren't clipped on a cold start. Listening timing also matches the official desktop: auto-stop ~1.25s after you stop speaking (was 3s), give up after 12s with no speech, and cap a turn at 60s.
|
||||
- **Refreshed chat look.** Message bubbles are wider and denser, each assistant turn shows a small Hermes avatar to its left (once per group), and code blocks are richer — a language label, a copy button, and a clearer inset so fenced code and inline `code` no longer blend into the bubble.
|
||||
- **Desktop CLI: visual + ergonomics refresh.** A single color theme across the CLI, aligned tables for `devices`/`sessions`, status dots for on/off states, and progress spinners for slow operations (the multi-endpoint pairing probe and the gateway connect) so nothing looks hung. Errors now suggest the fix (e.g. re-pair on auth failure).
|
||||
@@ -43,6 +117,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Realtime voice: you can keep talking while a background task runs.** Progress updates from a background task were flipping the voice UI back into "Thinking" with a Stop button on every tick, so the mic never came back until the task finished. Progress now feeds only the task chip; the conversation stays open the whole time.
|
||||
- **Realtime voice: leaving voice mode no longer cancels a running task.** Exiting (or tapping Stop to interrupt speech) used to kill an in-flight background task and could overwrite its already-delivered answer with "Cancelled." in the chat. Exit now detaches — the task keeps running and the result arrives on your next session or as a notification — and a delivered answer always keeps its text (a Stopped badge marks a genuine cancel). The chip's ✕ remains the one deliberate way to cancel.
|
||||
- **Long answers are no longer lost when the connection drops mid-turn.** On slow local models (or skills that delegate long background work), the phone could drop the stream mid-turn — the server finishes and saves the answer, but the chat sat on "Still working…" forever. The app now detects the dropped stream and quietly re-checks the conversation until the finished answer arrives, then completes the turn normally (with the usual done-notification if you've backgrounded the app). Switching chats or sending something new cancels the wait. (#166)
|
||||
- **Onboarding slides fit every screen.** Intro slide text could run past the bottom of the screen with no way to scroll on short displays or large font sizes. Slides now scroll when needed and compact their artwork on short viewports, so no setup guidance is unreachable. (#145)
|
||||
- **Docs: fixed stale setup labels and broken links.** The setup guide referenced a "Vanilla Hermes" button the app hasn't shown since v1.2.2 (it's labeled "Hermes"), several deep links into the getting-started page were dead, and the README under-counted the available phone tools. (docs site)
|
||||
- **Back button on Manage and Bridge now works.** The back arrow on the Manage ("Hermes management") and Bridge screens did nothing — it tried to jump to Chat in a way that silently no-op'd. Back now reliably returns to the screen you opened it from.
|
||||
- **Dropped relay connections from a status-report race.** The phone's periodic device-status report could occasionally be sent to the relay *before* the connection had finished authenticating, which made the relay reject the whole connection and forced a reconnect. The app now holds every message until the connection is authenticated, so the handshake always completes first.
|
||||
- **Fewer needless connection re-checks when switching apps.** Returning to the app after a quick glance at another app no longer triggers a full connection re-probe (and the brief "checking…" flash) when the connection was already healthy — it only re-checks after a longer absence or if something actually looks off.
|
||||
@@ -1444,7 +1523,8 @@ MVP release — native Android companion app for Hermes agent with direct API ch
|
||||
- **Dev scripts** — build, install, run, test, relay via scripts/dev.bat
|
||||
- **ProGuard rules** — okhttp-sse, markdown renderer, intellij-markdown parser
|
||||
|
||||
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v1.0.0...HEAD
|
||||
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v1.4.0...HEAD
|
||||
[1.4.0]: https://github.com/Codename-11/hermes-relay/compare/android-v1.3.0...android-v1.4.0
|
||||
[1.0.0]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v1.0.0
|
||||
[0.8.1]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v0.8.1
|
||||
[0.8.0]: https://github.com/Codename-11/hermes-relay/compare/v0.7.0...android-v0.8.0
|
||||
|
||||
@@ -46,20 +46,20 @@ The Vanilla Hermes path must stay upstream-only. API-server bearer auth and dash
|
||||
Upstream main now contains the focused session-control API (`#33134`) and read-only skills/toolsets (`#33016`). The original broad PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) was closed as superseded. Keep these distinctions straight:
|
||||
|
||||
1. **Native upstream** — `/api/sessions`, `/api/sessions/{id}/messages`, `/api/sessions/{id}/chat`, `/api/sessions/{id}/chat/stream`, `/v1/capabilities`, `/v1/skills`, and `/v1/toolsets` exist in current `gateway/platforms/api_server.py`.
|
||||
2. **Bootstrap compatibility** (`plugin/hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file for older or partial core builds. It skips native routes per method/path and should be retired per surface, not treated as the preferred path. The repo-root `hermes_relay_bootstrap/` package is a legacy import shim.
|
||||
2. **Bootstrap compatibility** (`plugin/hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file, injecting only compatibility-only surfaces (session search, memory, legacy skill detail/toggle, config, available-models, slash middleware). Sessions CRUD/messages/fork and the legacy skills list are **retired** — native upstream owns them (#33134/#33016) and the bootstrap carries no fallback for old builds. Native routes still win per method/path for the remaining set. The repo-root `hermes_relay_bootstrap/` package is a legacy import shim.
|
||||
3. **Legacy fork branches** — useful as lineage only. Do not cite `feat/session-api` / `#8556` as the current upstream contract.
|
||||
|
||||
|
||||
| Endpoint | Purpose | Provided by |
|
||||
| -------------------------------------- | -------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Native upstream (#33134); bootstrap only for old builds |
|
||||
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Native upstream (#33134); bootstrap injection retired |
|
||||
| `GET /api/sessions/{id}/messages` | Conversation history | Native upstream (#33134); bootstrap injection retired |
|
||||
| `POST /api/sessions/{id}/chat` | Synchronous session chat | Native upstream (#33134) |
|
||||
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Native upstream (#33134); bootstrap does NOT inject |
|
||||
| `GET /v1/skills`, `GET /v1/toolsets` | Read-only skill/toolset discovery | Native upstream (#33016) |
|
||||
| `GET /api/sessions/search` | Full-text message search | Bootstrap/fork legacy; not in current upstream main |
|
||||
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Bootstrap/fork legacy or dashboard web-server surface; not current API-server upstream |
|
||||
| `GET /api/skills`, `/{name}` | Legacy skill discovery/detail | Bootstrap/fork legacy; prefer native `/v1/skills` for lists |
|
||||
| `GET /api/skills/{name}` | Legacy skill detail | Bootstrap compat; list (`GET /api/skills`) retired — use native `/v1/skills` |
|
||||
| `PUT /api/skills/toggle` | Enable/disable installed skill | `hermes_cli/web_server.py` dashboard surface; bootstrap stub returns 501 |
|
||||
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Bootstrap/fork legacy; not current API-server upstream |
|
||||
| `GET /api/available-models` | Provider model list | Bootstrap/fork legacy; not current API-server upstream |
|
||||
@@ -84,7 +84,7 @@ Current upstream supports two auth modes on this surface. Loopback dashboards st
|
||||
- **Vanilla Hermes path = upstream-only.** The default (no-plugin) connection path — gateway/API chat, Manage, and Vanilla Hermes voice via the dashboard surface — must work against **unmodified upstream hermes-agent**: no fork patches, no bespoke server config as a dependency. The app ships on Google Play to users whose servers we don't control. Features that need server-side changes go through upstream PRs (with graceful degradation until merged) or live behind the opt-in relay plugin.
|
||||
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document whether bootstrap injects it or it requires the fork.
|
||||
- If we use a non-standard endpoint, ensure `probeCapabilities()` covers it and the auto-resolver degrades gracefully.
|
||||
- **Bootstrap maintenance:** Retire `plugin/hermes_relay_bootstrap/` per surface. Sessions and read-only skills/toolsets now have native upstream replacements; config, memory, legacy skill detail/toggle, available-models, and slash middleware still need explicit replacement decisions before full removal.
|
||||
- **Bootstrap maintenance:** Retire `plugin/hermes_relay_bootstrap/` per surface. Done: sessions CRUD/messages/fork and the legacy skills list are retired from the bootstrap (native upstream #33134/#33016, no old-build fallback kept). Remaining: config, memory, legacy skill detail/toggle, available-models, session search, and slash middleware still need explicit replacement decisions before full removal.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
@@ -282,7 +282,7 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
| `plugin/pair.py` | QR payload builder + CLI; `build_payload(sign=True)`; `--register-code` fallback |
|
||||
| `plugin/doctor.py` | `hermes relay doctor`; checks standard upstream API/dashboard reachability, Relay loopback state, plugin layout, and compat hook state |
|
||||
| `plugin/compat.py` | `hermes relay compat status/install/remove`; owns the optional `hermes_relay_bootstrap.pth` lifecycle |
|
||||
| `plugin/hermes_relay_bootstrap/` | Plugin-owned runtime compatibility patch; skips native routes per method/path; retire only after remaining config/memory/legacy skill/slash gaps are handled |
|
||||
| `plugin/hermes_relay_bootstrap/` | Plugin-owned runtime compatibility patch — compat-only surfaces (session search, memory, skill detail/toggle, config, available-models, slash middleware); sessions + skills-list injection retired (#33134/#33016) |
|
||||
| `install.sh` | Canonical installer — 6 steps; idempotent; drops `hermes-relay-update` shim |
|
||||
| `uninstall.sh` | Canonical uninstaller; reverses install.sh; never touches `.env` or `state.db` |
|
||||
| `hermes_relay_bootstrap/` | Legacy import shim for old `.pth` files and editable installs |
|
||||
@@ -473,7 +473,7 @@ See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; async run-control path |
|
||||
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | Native upstream session-persisted SSE; preferred when capability probe finds it |
|
||||
| Chat (compat) | `POST /v1/chat/completions` (stream=true) | Inline tool annotations only |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Native upstream (#33134); bootstrap fallback only for old builds |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Native upstream (#33134); bootstrap fallback retired |
|
||||
| Manage | Dashboard `/api/status`, `/api/auth/me`, `/api/config`, `/api/profiles/*`, `/api/env`, `/api/model/*`, `/api/mcp/*` | Vanilla Hermes dashboard surface; do not proxy through Relay |
|
||||
| Vanilla Hermes voice | Dashboard `POST /api/audio/transcribe`, `POST /api/audio/speak` | Vanilla Hermes no-plugin voice; uses dashboard session from Manage |
|
||||
| Pairing (QR) | `POST /pairing/register` (loopback only) | Via `/hermes-relay-pair` or `hermes-pair` shim; accepts optional `endpoints` for multi-endpoint QRs |
|
||||
|
||||
+24
-14
@@ -1,29 +1,39 @@
|
||||
# Hermes-Relay-Plugin v__VERSION__
|
||||
|
||||
**Release Date:** June 22, 2026
|
||||
**Since the previous plugin release:** Reliability fixes for the Realtime Agent voice path — brokered Hermes turns no longer drop with `session_not_found`, and long-running Hermes work no longer times out a live voice session.
|
||||
**Release Date:** July 11, 2026
|
||||
|
||||
This is a focused patch for the relay's Realtime Agent. When a spoken turn reached back into Hermes for context or tool work, a session-namespace mismatch could make the API Server reject the turn, and long background tasks could let the voice session lapse mid-run. Both paths are now resilient. Provider-native voice turns and vanilla upstream (no plugin) are unaffected.
|
||||
**Since v1.4.0:** Realtime Agent result delivery is more dependable when a provider closes, stalls, or overlaps a newer response. Completed Hermes work stays authoritative through provider-native delivery where available and a single relay-TTS fallback otherwise.
|
||||
|
||||
Pairs with Hermes-Relay-Android v1.4.1 for the matching background-task, voice-command, and result-delivery behavior. Standard chat and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Changed
|
||||
|
||||
- **Provider-native delivery carries an explicit mode.** Realtime responses consistently identify forced-summary and fallback delivery so the Android client can present one authoritative result.
|
||||
- **Exact delivery is more direct.** Non-structured verbatim results can use provider-native exact text while natural summaries retain delivery guidance.
|
||||
|
||||
### Fixed
|
||||
- **Brokered Hermes turns no longer fail with `session_not_found`.** When the Realtime Agent reached back to Hermes for context or tool work, it could hand the API Server a session id from a different session namespace (the gateway/client store), which the API Server rejected. The broker now mints a valid API Server session and retries the turn once when that happens, reuses an existing API Server session when the id is already valid, and reads the API Server's current nested `{"session": {"id": …}}` create-session response (previously only the legacy flat shape) so session creation no longer errors with "created a session without an id."
|
||||
- **Realtime voice survives long Hermes runs.** A heartbeat now keeps the realtime voice session alive while a long-running Hermes task is in flight, so the turn no longer times out before the work finishes.
|
||||
|
||||
## Install
|
||||
- **A completed result survives provider failure.** If tool-result submission or a follow-up provider response fails, the relay speaks the authoritative Hermes answer through its fallback path before reporting the provider error.
|
||||
- **Delivery confirmation ignores stale work.** A generation token prevents an older confirmation alarm from emitting a duplicate answer after a newer delivery or preemption.
|
||||
- **Fallback completion is unambiguous.** The fallback path emits one complete result event even when the provider's audio render cannot finish.
|
||||
|
||||
```bash
|
||||
pip install hermes-relay==__VERSION__
|
||||
```
|
||||
## Install / update
|
||||
|
||||
# Native upstream plugin path:
|
||||
hermes plugins install Codename-11/hermes-relay/plugin --enable
|
||||
|
||||
# Classic install / update on a systemd host:
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
# or, if already installed:
|
||||
hermes-relay-update
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
python -m relay_server --help
|
||||
```
|
||||
hermes relay doctor
|
||||
python scripts/check-plugin-version-sync.py --expect __VERSION__
|
||||
|
||||
---
|
||||
|
||||
Tag prefixes: Android releases use `android-v*`, CLI releases use `cli-v*`. Historical
|
||||
relay/plugin releases used `relay-v*` tags.
|
||||
Tag prefixes: Android releases use android-v*, plugin releases use plugin-v*, and CLI releases use cli-v*.
|
||||
|
||||
@@ -311,7 +311,7 @@ docker build -t hermes-relay relay_server/ && docker run -d --network host --nam
|
||||
ln -s "$PWD/plugin" ~/.hermes/plugins/hermes-relay
|
||||
```
|
||||
|
||||
Then restart hermes and run `hermes pair` to verify. The 18 `android_*` and 9 `desktop_*` tools register regardless of hermes-agent version. See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
|
||||
Then restart hermes and run `hermes pair` to verify. The 35 `android_*` and 25 `desktop_*` tools register regardless of hermes-agent version. See [docs/relay-server.md](docs/relay-server.md) for TLS, systemd, and full setup.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+22
-8
@@ -403,12 +403,25 @@ the new app version and a higher `appVersionCode`.
|
||||
- `RELEASE_NOTES.md` — body of the GitHub Release for this version
|
||||
(rewritten each release; the workflow uses this as-is). This is the
|
||||
operator-facing summary, not the CHANGELOG mirror. Keep the
|
||||
**Download** section near the top — it should spell out which file
|
||||
to grab by its `-sideload-release.apk` / `-googlePlay-release.aab`
|
||||
suffix (every artifact is version-tagged as
|
||||
**Download** section near the top, in the required format (#144):
|
||||
1. A lead callout naming the **one file most people want** —
|
||||
"Installing on your phone? Download
|
||||
`hermes-relay-<version>-sideload-release.apk` and tap it"
|
||||
(full feature set), with the Play Store link for the
|
||||
conservative build.
|
||||
2. One explicit line that the `.aab` is a Play Console upload
|
||||
bundle and **cannot** be installed by tapping it on a phone.
|
||||
3. The `SHA256SUMS.txt` verify line + sideload-guide link.
|
||||
No download table, no parity/testing artifacts: releases attach
|
||||
exactly **two** app artifacts — the sideload APK and the googlePlay
|
||||
AAB — plus `SHA256SUMS.txt` covering exactly those two (the 2-asset
|
||||
policy in `.github/workflows/release-android.yml`; the parity twins
|
||||
stay reproducible from the tag via CI but are not attached).
|
||||
Every artifact is version-tagged as
|
||||
`hermes-relay-<version>-<flavor>-<buildType>` via `archivesName`
|
||||
in `app/build.gradle.kts`) and link to the sideload guide.
|
||||
The v0.3.0 body is a good template.
|
||||
in `app/build.gradle.kts`. Never rename the sideload APK — the
|
||||
in-app update checker matches assets by `.apk` + `sideload` in the
|
||||
name, and user-docs verify steps cite the filename.
|
||||
- `app/src/main/assets/whats_new.txt` — in-app "What's New" content
|
||||
shown in the settings/about screen. Update with the version number
|
||||
and a brief feature summary. Gets stale silently if forgotten
|
||||
@@ -651,9 +664,10 @@ On every push of a tag matching `android-v*`, `.github/workflows/release-android
|
||||
regression slice with explicit timeouts.
|
||||
3. Decodes `HERMES_KEYSTORE_BASE64` into `$RUNNER_TEMP/release.keystore`
|
||||
and exports `HERMES_KEYSTORE_PATH` (skipped if the secret is unset).
|
||||
4. Builds both Android release artifacts:
|
||||
`./gradlew bundleRelease assembleRelease`.
|
||||
5. Generates `SHA256SUMS.txt` covering both.
|
||||
4. Builds all four flavored release artifacts
|
||||
(`./gradlew bundleRelease assembleRelease`); only the sideload APK and
|
||||
googlePlay AAB are attached (see §Release assets).
|
||||
5. Generates `SHA256SUMS.txt` covering the two attached files.
|
||||
6. Creates a GitHub Release named `Hermes-Relay-Android v<version>` with `RELEASE_NOTES.md` as
|
||||
the body. Attaches the APK, AAB, and `SHA256SUMS.txt`. Tags any version
|
||||
containing a dash (e.g. `android-v0.2.0-beta.1`) as a prerelease automatically.
|
||||
|
||||
+27
-19
@@ -1,38 +1,46 @@
|
||||
# Hermes-Relay-Android v1.2.6
|
||||
# Hermes-Relay-Android v1.4.1
|
||||
|
||||
**Release Date:** June 27, 2026
|
||||
**Since v1.2.5:** A fix for chats stuck showing "Untitled", and a calmer way to surface connection status. Chats now keep your first message as a stand-in title until the server names them (and titles reconcile after a turn / via a new refresh button in the session drawer), renaming sticks on non-default agent profiles, and the connection-status card no longer floats over your chat — transient states slide the screen down as a thin top banner, with the floating alert reserved for persistent errors.
|
||||
**Release Date:** July 11, 2026
|
||||
|
||||
v1.2.6 is recommended for everyone.
|
||||
**Since v1.4.0:** Chat now keeps durable work visible and recoverable. Follow background terminal work from the conversation, receive its completion automatically, and reopen the app into the same in-flight answer with its visible progress intact. Voice adds practical spoken controls and mode presets, while streaming chat gets smoother Markdown, table, and image handling.
|
||||
|
||||
v1.4.1 is recommended for everyone. Realtime Agent delivery hardening and voice presets pair with relay plugin v1.4.1; Standard chat and Vanilla Hermes voice remain compatible with unmodified upstream Hermes.
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
v1.2.6 ships in two Android build flavors. APK and AAB filenames are version-tagged:
|
||||
**Installing on your phone?** Download hermes-relay-1.4.1-sideload-release.apk and tap it — that's the direct-install build with the full feature set (installs as com.axiomlabs.hermesrelay.sideload). Prefer the conservative build (no Device Control surface)? Get it from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
| Flavor | File | Who it's for |
|
||||
|---|---|---|
|
||||
| Google Play | `hermes-relay-1.2.6-googlePlay-release.aab` | Upload this Android App Bundle to Play Console. It has no AccessibilityService, screen reading, screenshots, gestures, SMS/calls, contacts/location, overlays, or unattended phone control. |
|
||||
| sideload | `hermes-relay-1.2.6-sideload-release.apk` | Direct-install APK for full Device Control. Installs as `com.axiomlabs.hermesrelay.sideload`. |
|
||||
| googlePlay APK | `hermes-relay-1.2.6-googlePlay-release.apk` | Parity/testing artifact. |
|
||||
| sideload AAB | `hermes-relay-1.2.6-sideload-release.aab` | Parity/testing artifact. |
|
||||
The other file, hermes-relay-1.4.1-googlePlay-release.aab, is an Android App Bundle for uploading to Play Console — it **cannot** be installed by tapping it on a phone.
|
||||
|
||||
Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
|
||||
Verify integrity with SHA256SUMS.txt from the same release. See the [Sideload guide](https://codename-11.github.io/hermes-relay/guide/getting-started.html#sideload-apk) for APK install steps.
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
### Fixed
|
||||
- **Chats stuck showing "Untitled".** The session drawer treated the server's session list as fully authoritative for the title, so a re-list that arrived before (or without) the server auto-naming a chat overwrote the optimistic first-message preview with a blank title. The drawer now keeps a known local title when the server returns a blank one, re-pulls shortly after a turn settles, and offers a manual refresh button — so chats stop reading "Untitled". The api_server SSE path never auto-titles, which is why the preview is now the durable fallback there. (#133)
|
||||
- **Rename on a non-default agent profile.** A non-default profile's chats live in that profile's own store, but rename went through the shared path — so the new title never landed. Renaming is now profile-scoped (the write twin of the earlier session-delete and list fixes).
|
||||
### Chat that keeps up
|
||||
|
||||
### Changed
|
||||
- **Calmer connection status.** Transient/active/warning connection status — reconnecting, checking, LAN↔Tailscale handoffs — now renders as a thin banner at the top that takes its own space (content slides down) instead of a card floating over the chat. A persistent **error** keeps the floating alert so it still demands attention. Frequent confirmations (copied, profiles updated, profile/personality switches) moved to the same top banner instead of a bottom pop-up.
|
||||
- **See background work where it belongs.** Standard Chat surfaces active and recent background processes in a compact strip and expandable sheet with elapsed time, output, a targeted Stop action, and local Dismiss.
|
||||
- **Get the completion without asking again.** When Hermes finishes detached work, its follow-up answer appears in the originating conversation automatically. The server's internal completion marker stays in history but is shown as a compact process notice.
|
||||
- **Come back to the same answer.** Closing and reopening the app restores the partial reply, live reasoning, lifecycle status, tool and subagent states, background-task state, and any pending approval or clarification. The app reattaches when the server still has a live turn, otherwise it reconciles the finished transcript without repeating your prompt.
|
||||
|
||||
### Voice you can direct
|
||||
|
||||
- **Use natural spoken controls.** Pause or resume listening, stop speech, cancel background work, repeat a settled result, or start a new Standard voice chat.
|
||||
- **Choose an interaction preset.** Hands-free, Low latency, Careful tools, and Quiet presets adjust existing voice and long-task behavior without changing your voice identity or routing.
|
||||
- **Keep delivered answers authoritative.** Realtime delivery is generation-safe and uses one relay-TTS fallback if the provider cannot deliver a completed Hermes result.
|
||||
|
||||
### Clearer conversations
|
||||
|
||||
- **Browse images together.** Adjacent images form a compact gallery that opens at the image you selected.
|
||||
- **Read while the reply streams.** Markdown settles into its final styling as text arrives, wide tables stay usable, and motion-sensitive indicators respect system accessibility settings.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade notes
|
||||
- This is an app-side release on **both** flavors — no Device Control or server changes needed.
|
||||
- `appVersionCode` is **20**.
|
||||
|
||||
- App version: **1.4.1** (versionCode **23**).
|
||||
- Realtime Agent improvements pair with relay plugin **1.4.1**.
|
||||
- Standard Chat and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
|
||||
@@ -6,6 +6,427 @@ For shipped work, see `DEVLOG.md`. For architectural decisions, see `docs/decisi
|
||||
|
||||
---
|
||||
|
||||
## Active — 1.4.1 release verification (2026-07-10)
|
||||
|
||||
Implementation plan: `docs/plans/2026-07-09-1.4.1-chat-voice-enhancements.md`.
|
||||
Android 1.4.0 / versionCode 22 and plugin 1.4.0 were published on 2026-07-09.
|
||||
The 1.4.1 Chat and Voice waves are code-complete and merged into local `dev` for
|
||||
device validation. Version bumps, public release artifacts, push, tags, production
|
||||
deployment, and store upload remain separate owner-controlled steps.
|
||||
|
||||
Before release preparation, keep these owner/device gates explicit:
|
||||
|
||||
- Repeat the exact record → background/route loss → foreground reproduction on the
|
||||
newly installed debug APK; no `Listening...` / `Still working...` row may strand.
|
||||
- Recheck long-run tool ordering, the screen wake lock, output waveform timing,
|
||||
final-syllable tail, and the reported PCM tap/static between sentences.
|
||||
- Exercise the 1.4.1 Chat surfaces: streaming reflow, wide-table overflow, gallery
|
||||
paging/zoom/sensitive actions, unread tracking, Demo mic gate, and task-card lifecycle.
|
||||
- Re-run an ordinary Chat background process through the Gateway: the current-chat
|
||||
process strip/sheet must show running state, live or snapshot output, exact Stop,
|
||||
recent completion and Dismiss; the synthetic completion must render as a process
|
||||
notice, its unsolicited assistant follow-up must appear without another prompt,
|
||||
and both must survive a socket-close/foreground history refresh without crossing
|
||||
into a different session or profile. Backgrounding with keep-alive disabled must
|
||||
also let the Gateway socket close normally instead of polling it back open.
|
||||
- Start a long Standard Chat turn, wait for visible reasoning plus at least one
|
||||
running tool card, then background/force-stop/reopen the app. The same session
|
||||
must restore its partial answer, thinking/status line, tool state, and any live
|
||||
approval card; new deltas must continue without a duplicate prompt, and a turn
|
||||
that finished while offline must settle from history instead of staying busy.
|
||||
- Exercise commands and presets on Standard and Realtime Voice, including ordinary
|
||||
prompts that resemble commands, explicit stop-vs-cancel behavior, Custom detection,
|
||||
and preservation of route/provider/model/voice/concurrency/barge-in choices.
|
||||
- Repeat the Tink encrypted-session smoke: pair → force-stop → relaunch; the session
|
||||
must persist without an encrypted-preferences startup crash.
|
||||
- Run release preparation separately: 1.4.1 versioning and public release artifacts,
|
||||
then owner-controlled `dev` → `main` merge, tag, production deployment, and upload.
|
||||
- Complete the owner/Mizu GitHub triage batch, including closing #64 as superseded.
|
||||
|
||||
---
|
||||
|
||||
## Voice background-tasks — live findings + UX vision (2026-07-09 e2e realtime test)
|
||||
|
||||
Live on-device e2e (relay through `8ebb21b`, app `1.4.0-sideload` build 22, provider
|
||||
`xai_realtime`). The delivery-report tooling from `5ff78da` was confirmed working
|
||||
against live data during this test.
|
||||
|
||||
### Findings
|
||||
- **Background route loss could strand a recorded turn — FIXED IN CODE; EXTENDED LIVE STRESS TEST DEFERRED (2026-07-09).** Initial logs showed valid PCM accepted by the persistent turn channel after foregrounding, but the socket had failed during background route retries and no new relay event arrived. The first fixed APK restored submission and let the background Hermes run finish, then exposed the delivery race: a slower overlapping resume handshake connected 250 ms after the valid resume, claimed relay ownership before the Android generation check, and detached the session just before the forced answer. The second installed reproduction completed the run and delivered its notification fallback, but voice stayed on `Waiting for route`: the periodic retry deadline had been created when the session was prewarmed, so its coroutine had already expired after five minutes of healthy uptime. Exiting voice mode also retained the session-owned `RECONNECTING` run; reopening rendered that orphaned pill and its close action targeted the new session instead of the detached task. Android now coalesces pending handshakes, waits for a relay-confirmed resumed socket, retains unacknowledged chunks for atomic replay, and returns a per-turn delivery result. Its retry worker lives for the session, starts a fresh bounded budget only when a route is lost, and clears that budget only after `voice.session.resumed`; bare WebSocket opens cannot reset it. Voice sessions carry a generation fence so late handoff, run, playback, and completion callbacks cannot repopulate or act on a newer session. Voice exit atomically drops detached handoff/run/confirmation UI before another session can prewarm; offline cancel rejection dismisses immediately, and a queued cancel without acknowledgement dismisses after a bounded wait. The relay requires a valid resume claim before changing ownership and isolates invalid/stale candidate failures from the active phone socket. Provider STT stays in `Transcribing` unless `VoiceRecorder.isRecording()` is true; Stop/failure settles local placeholders, late terminal deltas are ignored, and stale capture state is reconciled on resume. Route, promotion, ownership, UI-state, and chat-terminal regressions are green. The current APK is installed; repeated long-idle, background/foreground, route-churn, and terminal-exhaustion coverage remains a post-release follow-up and may drive further hardening.
|
||||
- **Model-generated exact delivery is inconsistent and deferral is model-agnostic; xAI now has a deterministic path.**
|
||||
A background turn ("what do you
|
||||
think about our notes so far?") delivered `forced_summary_streaming`
|
||||
(provider-voiced, early-commit) — grok read the answer in its own voice and
|
||||
passed validation. BUT the same session's earlier turn ("check Hermes for what
|
||||
we know about Minnesota") fell back to relay TTS (`acknowledgement_not_summary`).
|
||||
A later forced-summary round on `grok-voice-think-fast-1.0` also spoke a genuine
|
||||
deferral ("one moment ... I'll let you know") rather than the completed answer.
|
||||
Validator fallback is therefore correct; model choice alone does not solve the
|
||||
delivery-voice problem. xAI's provider-native `force_message` now handles
|
||||
non-structured Exact deliveries without model inference. Its raw live event
|
||||
stream and the full Android background path are verified; a recall follow-up
|
||||
also answered from that provider history without re-running Hermes.
|
||||
- **think-fast selection bug fixed + live-verified.** The app's session POST omitted
|
||||
model/voice, so the settings dropdown was only a transient server-config editor
|
||||
until **Save realtime agent** was tapped. Model/voice now persist per
|
||||
connection/profile and ride every new session. On-device verification selected
|
||||
think-fast without Save, saw the relay request it and the provider's final
|
||||
resolution report it, then force-stop/relaunch restored the selection.
|
||||
- **Duplicate "background task is running" — FIXED + LIVE-VERIFIED (2026-07-09).** The signoff trace captured both lines and disproved the suspected TTS mismatch: the provider first said it would check Hermes, then the broker requested a second provider response after promotion. Promotion now suppresses that second handoff when the original tool-calling response already emitted audio; silent calls still get one handoff. A deployed on-device round recorded `provider_acknowledged: true` and `spoken_handoff: false`, with only the original acknowledgement spoken. The same round confirmed the forced delivery emits one client response-start event after deduplicating xAI's `response.created` + `response.output_item.added` pair.
|
||||
- **Status-speech logging gap — CLOSED / premise disproved (2026-07-09).** The raw signoff log contains both provider utterances as `voice.response.delta` text, plus the progress events; relay TTS did not speak either line. The flight recorder can reconstruct what the user heard. The real defect was redundant provider response generation, fixed above.
|
||||
|
||||
### Background-tasks-as-first-class-chat vision (owner ask 2026-07-09)
|
||||
Theme: stop treating a background run as an ephemeral voice-only side effect —
|
||||
surface it in chat like any other turn and keep its result. Overlaps the "Voice
|
||||
background-run v2" chip roadmap below (items 3/4/7) but reframed around
|
||||
chat/history rather than the voice chip; unify rather than build twice.
|
||||
- **First-class Chat task turn — CODE-COMPLETE for 1.4.1; device verification
|
||||
remains.** Promotion attaches a short objective title and running state to
|
||||
the existing assistant row; progress, queued count, waiting/delivery, completion,
|
||||
failure, cancellation, answer text, and expandable tool detail settle that same
|
||||
identity. The authoritative answer persists in normal session history. The new
|
||||
in-flight Chat checkpoint preserves client-only task-card metadata while a turn is
|
||||
still running across a cold app restart. Metadata for an already-completed task is
|
||||
still absent from the server history schema after the checkpoint is cleared; keep
|
||||
that terminal-history case as a separate durability decision.
|
||||
- **Realtime agent retains background-result context in-session — FALLBACK PATH
|
||||
DONE + SEEDING LIVE-VERIFIED (2026-07-09); NO-RERUN VERIFY PENDING.** On a FALLBACK delivery the broker now
|
||||
seeds the delivered answer into the provider's history as an assistant turn
|
||||
(`append_context_item` → silent `conversation.item.create`, no `response.create`),
|
||||
so a follow-up ("what did that say?", "expand on that") finds it durably — fixing
|
||||
the live "can't you see we ran the task?" failure; live follow-up confirmed the
|
||||
provider knew the delivered context. Provider-VOICED success already
|
||||
had its own turn in history, so it's untouched (no double-record). **Remaining:**
|
||||
(a) live on-device verify that a pure-recall post-fallback follow-up is answered
|
||||
without a re-run after the `92f9683` instruction fix; (b) the detached/promoted delivery (`_deliver_pending_background_result`)
|
||||
and the DONE-chip respeak weren't in scope — confirm whether they leave the same
|
||||
gap; (c) decide if the one-shot `native_pending_delivery_note` is now redundant
|
||||
with durable seeding or still earns its keep as an explicit correction.
|
||||
- **Proper concurrent multi-task.** True N-way parallel background runs — see v2
|
||||
item 7 (deferred: needs session-per-run topology, run-id-targeted cancel,
|
||||
multi-run chip/list). Owner is now explicitly asking for it; re-rank against the
|
||||
queue rather than leaving deferred.
|
||||
|
||||
---
|
||||
|
||||
## Voice background-run A–E enhancement batch — SHIPPED in code (2026-07-08 PM)
|
||||
|
||||
Owner-approved full batch from the gap review; relay 93/93 realtime tests
|
||||
green. Needs relay deploy + APK install + live verify.
|
||||
|
||||
- **A1 — positive summary validation + early-flush streaming.** The forced
|
||||
summary must content-overlap the Hermes answer (`_summary_overlaps_answer`;
|
||||
vacuous for bare confirmations) — blocklists chase phrasings, overlap
|
||||
doesn't. And the summary response now STREAMS: buffered only until the
|
||||
prefix (≥40 chars) clears the blocklist + shows answer overlap
|
||||
(`_maybe_commit_forced_summary_early`), then flushes and streams live —
|
||||
kills the observed "silence, then the whole answer in one burst" delay.
|
||||
Uncommitted responses still get full end-of-response validation.
|
||||
- **A2 — delivered-or-alarm.** `_confirm_background_delivery`: within 30s of
|
||||
injection the summary must be done or committed-streaming, else
|
||||
`delivery_unconfirmed` is logged and the answer is force-emitted as text.
|
||||
A background answer can no longer be silently lost.
|
||||
- **A3 — respeak.** `hermes.result.respeak` client message → relay respeaks
|
||||
`last_background_result` via relay TTS. Client: tapping the settled (DONE)
|
||||
chip requests it; chip stays up while it plays.
|
||||
- **B — task queue (+N queued).** A long second ask is queued (FIFO, cap 3)
|
||||
instead of refused (`status: "queued"`); starts automatically when the
|
||||
current run's delivery settles (`_start_next_queued_run`, waits for the
|
||||
summary, runs as durable, spoken transition via `_queued_start_prompt`).
|
||||
Cancel clears the queue. `hermes.run.queued` event + `queued_count` on
|
||||
promoted/background_completed/get_status; chip shows "+N queued". Queue
|
||||
full → the old busy answer.
|
||||
- **C1 — chip in compact mode.** The chip previously rendered ONLY in the
|
||||
focus layout; compact mode now shows it above the bottom controls
|
||||
(`bottom = 120.dp` — eyeball on device).
|
||||
- **C2 — exit breadcrumb.** Exiting voice mode with a live background run
|
||||
posts a chat system notice ("Background voice task still running (+N
|
||||
queued) — Hermes will report back") via `VoiceViewModel.chatNoticeSink`
|
||||
(wired in RelayApp to the shared ChatHandler).
|
||||
- **D — `_thinking` drafting signal + answer redundancy.** Relay: the
|
||||
drafted `_thinking` text is the answer of last resort when the
|
||||
response-delta path yields empty (`answer_from_thinking` log). Client:
|
||||
`_thinking` deltas drive a "Drafting the answer…" chip status line.
|
||||
- **E — hygiene.** Fast lane reuses ONE side-session per voice session
|
||||
(`fast_lane_session_id`); the idle probe now injects the relay xAI OAuth
|
||||
token (`_probe_provider_options`) so it actually runs on the relay host;
|
||||
new e2e test where the provider answers the summary request with filler →
|
||||
fallback must carry the real answer
|
||||
(`test_filler_summary_triggers_fallback_delivery`).
|
||||
- **Live verify list:** summary starts speaking promptly (streaming, no
|
||||
burst); filler → fallback speaks the answer; queue: two long asks →
|
||||
"queued" spoken + "+1 queued" on chip → auto-starts with spoken
|
||||
transition; DONE-chip tap respeaks; compact-mode chip visible; exit
|
||||
leaves the chat breadcrumb; probe run completes (repro + keepalive).
|
||||
- **VERIFIED LIVE (rounds 3–4, 2026-07-08 PM):** queue flow end-to-end
|
||||
(queued ack → auto-start → both answers), chip +1-queued/finished states,
|
||||
fallback delivery + audibility (user's own follow-up confirmed), and two
|
||||
new gaps found + fixed same-day (see DEVLOG: whole-word/2-hit validation,
|
||||
next-turn delivery note).
|
||||
- **KEEPALIVE FINAL VERDICT — no protocol message resets xAI's 900s timer
|
||||
(empirical 2026-07-08, 4 probe runs).** Repro died at 900.0s; silent-PCM
|
||||
pings died at 900.0s; server-ACKNOWLEDGED `session.update` pings
|
||||
(240/480/720s) died at 900.0s. The timer counts only real conversation
|
||||
items. **SHIPPED IN CODE (2026-07-08 PM):** picked design (b): treat
|
||||
idle-close as routine, close the Android websocket cleanly while idle,
|
||||
and let the next user turn open a fresh provider conversation seeded
|
||||
from the synced Hermes session. `_provider_keepalive_loop` is retired.
|
||||
**Remaining:** relay deploy + live >15 min idle probe to verify silent
|
||||
next-turn recovery on device.
|
||||
- **Delivery input-quiet gate — SHIPPED (2026-07-08 PM, round-5 finding).**
|
||||
A background task finishing while the user was mid-utterance delivered
|
||||
over them and ended their recording. The relay now knows the user is
|
||||
speaking (live `input_audio.append` chunks stamp
|
||||
`native_last_input_audio_at`) and `_await_floor_idle_for_result` holds
|
||||
delivery until they've been quiet ≥1.5s (bounded by the existing floor
|
||||
timeout). Covers summary/fallback/queued-transition. **Client half shipped:**
|
||||
`VoiceViewModel` suppresses realtime response/audio/done only while
|
||||
`VoiceRecorder.isRecording()` is actually true. Provider STT uses
|
||||
`Transcribing`, not the capture-owned `Listening` state, so a partial
|
||||
transcript cannot wedge the mic controls or suppress its own response.
|
||||
- **Audio tail cut at end of response (round-5 repro) — MITIGATED IN CODE.**
|
||||
Final word ("you?") cut hard instead of finishing smoothly. The client
|
||||
output resume tail guard is raised from 350ms to 650ms so the final
|
||||
buffered PCM has more time to drain before capture resumes. **Remaining:**
|
||||
verify on device; if the final syllable still snaps, inspect
|
||||
`RealtimePcmPlayer` drain/fade-out behavior.
|
||||
- **Fallback speech says file paths (round-5 polish) — FIXED IN CODE.**
|
||||
The fallback spoke "Source: 1. Personal/Household/Househol…"; TTS-safe
|
||||
answer extraction now strips `Source:` / `Sources:` / citation lines and
|
||||
source-list path lines before relay TTS.
|
||||
- **grok-voice fails the delivery instruction ~always (4/4 live rounds) —
|
||||
DEFAULT CHANGED, then REWORKED same-day.** Every observed forced summary
|
||||
was deferral filler; the validator+fallback carried every delivery.
|
||||
`speak_verbatim` was first made a direct relay-TTS default, then reworked
|
||||
to provider-voiced exact delivery (below) to keep voice continuity.
|
||||
- **Provider-voiced exact delivery — xAI direct path live-verified.**
|
||||
Model-generated word-for-word instructions were not reliable. Non-structured
|
||||
`speak_verbatim` now supplies the authoritative answer to xAI's `force_message`,
|
||||
which synthesizes it in the selected realtime voice without inference and
|
||||
records a normal assistant turn. A raw live probe confirmed the full transcript,
|
||||
audio, history, and completion lifecycle. Structured results and summary modes
|
||||
remain model-generated; relay TTS remains the validator fallback. The on-device
|
||||
background path produced a clean `forced_summary_streaming` event and recall
|
||||
reused the resulting provider history without another Hermes run.
|
||||
**1.4.1 post-audit hardening is code-complete:** foreground Hermes results now
|
||||
enter the same validation/confirmation lifecycle, non-structured Exact delivery
|
||||
passes authoritative text to provider-native forced speech where supported,
|
||||
structured answers keep instruction-driven routing, an answer equal to a short
|
||||
acknowledgement is not falsely blocked, and provider tool-result/response-request
|
||||
failure emits exactly one authoritative fallback before its terminal error. Live
|
||||
verify foreground delivery and provider-failure fallback. Barge-in preemption as
|
||||
durable visible text remains open.
|
||||
- **Audit leftovers (deliberate, small).** (1) DONE-chip respeak always
|
||||
renders via relay TTS — intentional determinism, but it voice-mismatches
|
||||
the exact mode's promise; candidate: provider-voiced respeak with TTS
|
||||
fallback. (2) Exact-mode answers >1400 chars are truncated with an
|
||||
appended "…" (and machine-looking text gets "…" even under the cap) —
|
||||
silent for a mode promising completeness; consider a visual "full answer
|
||||
in chat" cue on truncation.
|
||||
|
||||
## Voice observability (2026-07-08 assessment) — pre-RC hardening
|
||||
|
||||
The realtime flight recorder (per-session JSONL under
|
||||
`realtime-agent-runs/`, decision-point events with reasons, task-failure
|
||||
wrappers, Android `DiagnosticsLog` Voice category) is in good shape — it
|
||||
carried every live-round forensics session. Three gaps before the release
|
||||
candidate:
|
||||
|
||||
- **Buffered flight-recorder writes (minor).** `_log` open/appends per
|
||||
event on the event loop, including one line per audio chunk. Fine so
|
||||
far; switch to a buffered writer if voice sessions ever stutter under
|
||||
load — measure before optimizing.
|
||||
|
||||
## OpenAI realtime provider — next-RC roadmap (2026-07-08 research)
|
||||
|
||||
Full findings with sources in
|
||||
`docs/plans/2026-07-08-openai-realtime-notes.md`. Headline: the OpenAI
|
||||
provider already exists and is broker-wired
|
||||
(`plugin/relay/realtime_agent/providers/openai.py`) but has never had a
|
||||
recorded live round. The default is already updated to `gpt-realtime-2.1`.
|
||||
Key provider contrasts vs
|
||||
xAI: hard 60-min wall-clock session cap (not an inactivity timer),
|
||||
out-of-band responses (`conversation:"none"` + explicit `input`), async
|
||||
function calls, per-token pricing (2.1 audio $32/$64 per 1M; mini $10/$20)
|
||||
vs grok's flat $0.05/min.
|
||||
|
||||
- **Live-verify the OpenAI provider end-to-end.** Code-complete but no
|
||||
recorded live round (all forensics are grok-voice). Run the xAI
|
||||
on-device battery (pair → voice turn → `hermes_run_task` →
|
||||
exact-delivery → queue → respeak) on 2.1. Success bar: a
|
||||
`realtime-agent-runs/` log shows a clean OpenAI session reproducing the
|
||||
flows with provider-voiced Hermes delivery.
|
||||
- **Handle OpenAI's 60-min hard cap.** Distinct failure mode from xAI's
|
||||
900s inactivity close — it can cut an ACTIVE session. First confirm how
|
||||
a cap-close currently surfaces (idle-close handling is xAI-shaped, e.g.
|
||||
`_PROVIDER_IDLE_CLOSE_WS_REASON`), then add wall-clock-aware proactive
|
||||
reconnect/reseed. Success bar: a >60-min OpenAI session survives the
|
||||
cap with a proactive reseed, no user-visible break.
|
||||
- **Spike out-of-band exact delivery on OpenAI
|
||||
(`conversation:"none"` + answer as `input`).** Supply the Hermes answer
|
||||
as explicit input context instead of an instructions injection the
|
||||
model may ignore. Success bar: measurably lower deferral/filler rate
|
||||
than grok forced-summary in repeated live deliveries, demoting the
|
||||
validator to a safety net.
|
||||
- **Async function-call delivery on OpenAI.** OpenAI GA allows the
|
||||
session to continue while a function call is pending — a promoted
|
||||
`hermes_run_task` could complete with a real late
|
||||
`function_call_output` instead of interim-ack + synthetic
|
||||
instructions, retiring `native_pending_delivery_note`. Success bar:
|
||||
provider history reads "done" (never "still running") after a promoted
|
||||
run, verified live.
|
||||
- **(Defer/eval-only) provider `semantic_vad` vs relay-owned floor.**
|
||||
Better turn-taking naturalness but moves barge-in ownership off
|
||||
`RealtimeFloor` — re-architecture, not RC scope.
|
||||
|
||||
## xAI voice platform moved (2026-07) — re-baseline items
|
||||
|
||||
xAI shipped `grok-voice-think-fast-1.0` (reasoning voice model, built for
|
||||
tool-calling precision) as the new flagship; `grok-voice-fast-1.0` is
|
||||
deprecated and the `grok-voice-latest` ALIAS NOW RESOLVES TO THINK-FAST.
|
||||
We default to the alias everywhere (`config.py:106`,
|
||||
`providers/xai.py:31`), so the live model may have changed under us —
|
||||
xAI's docs explicitly say to pin versioned models in production. The current
|
||||
platform documents five built-in expressive voices, 20+ spoken languages,
|
||||
speech tags, custom voice IDs, session resumption, and a
|
||||
`turn_detection.idle_timeout_ms` re-engagement knob.
|
||||
|
||||
- **Decide pin-vs-alias, then re-baseline the live delivery rounds.** The
|
||||
4/4 deferral-filler verdicts may predate the alias flip — a reasoning
|
||||
voice model may comply with the exact-reading instruction where fast-1.0
|
||||
didn't. Resolved-model logging is DONE (2026-07-08):
|
||||
`provider_model_resolved` records the session.created echo, the delivery
|
||||
report prefers it, and `grok-voice-think-fast-1.0` is a selectable pin.
|
||||
Remaining: run the live rounds, read the resolved ids, and decide
|
||||
pin-vs-alias for production. Success bar: we know which model each live
|
||||
round actually ran on, and the default is a deliberate choice.
|
||||
- **Re-probe session lifecycle on think-fast.** The 900s
|
||||
conversation-inactivity close and the keepalive-negative verdict were
|
||||
measured pre-think-fast; xAI now documents session resumption and
|
||||
`idle_timeout_ms`. Re-run `scripts/realtime-provider-idle-probe.py`;
|
||||
if resumption is real, the idle-close-and-reseed handling can become
|
||||
reconnect-and-resume. Success bar: fresh empirical timeout/resume
|
||||
verdicts recorded in the POC doc.
|
||||
- **xAI voice catalog + speech-tag UX are code-current; live verify only.** Dynamic
|
||||
discovery uses xAI's paginated `/tts/voices` surface when auth is available; the
|
||||
unauthenticated fallback matches the documented built-ins (`eve`, `ara`, `rex`,
|
||||
`sal`, `leo`; verified 2026-07-09). Voice Settings and Voice Output already expose
|
||||
the enhanced contract's expressive speech-tag toggle. Exercise both surfaces with
|
||||
a live xAI relay before release.
|
||||
|
||||
## Voice — on-device findings (2026-07-08 e2e realtime test)
|
||||
|
||||
Live e2e test (phone on 1.4.0 dev APK, relay at `789f32c`) surfaced a chained
|
||||
failure — full forensics from the session event log
|
||||
(`realtime-agent-20260708-122613`). **All five fixes below are in code
|
||||
(2026-07-08 PM); need relay redeploy + app rebuild + a repeat of the same
|
||||
test.**
|
||||
|
||||
- **Stuck "Thinking" pill (root of the chain) — FIXED.** The gateway streams
|
||||
drafting text as a `_thinking` pseudo-tool (`hermes.tool.delta` only, never
|
||||
`tool.completed`), and `ChatViewModel.applyRealtimeAgentEvent` created a
|
||||
ToolCall pill from the first delta of ANY tool name → a pill that spins
|
||||
"running" forever (chat + voice overlay transcript). Fix: `_`-prefixed tool
|
||||
names are internal (upstream's own hidden-tool convention) — never become
|
||||
pills; their text still feeds the detailed thinking trace. Defensive same
|
||||
guard on `hermes.tool.started`.
|
||||
- **Cancel on an already-finished run killed the delivered answer — FIXED
|
||||
(relay).** `response.cancel` unconditionally flipped `hermes_run_status` to
|
||||
"cancelled" and emitted `hermes.run.cancelled` even with no run in flight
|
||||
(observed: user cancelled 10s after completion — invited by the stuck pill —
|
||||
and the Tokyo answer was never spoken). Now the Hermes-run half of cancel
|
||||
only fires when a run is actually active; speech-stop always happens.
|
||||
- **Model read the 32-char run ID aloud — FIXED (relay).** The interim ack
|
||||
and the forced-summary prompt both handed the model `run_id`
|
||||
(payload/metadata). Removed everywhere model-visible (get_status/cancel
|
||||
default to the active run; the client gets ids via events) + explicit
|
||||
"never say run/session IDs aloud" in all three instruction sites.
|
||||
- **Delivery spoke deferral filler instead of the answer — FIXED (relay).**
|
||||
The forced-summary validator caught run-id speech (that saved the Minnesota
|
||||
answer via fallback) but not "One moment while I look that up. I'll report
|
||||
back as soon as I have the info." — Tokyo's answer was lost behind that
|
||||
filler. Added deferral phrases (one moment / report back / looking into /
|
||||
i'll look / as soon as i have) to `_bad_forced_summary_reason`; summary
|
||||
prompt reworded to "speak the answer NOW". Tests:
|
||||
`plugin/tests/test_realtime_summary_validation.py` (5) + updated cancel
|
||||
route test; realtime batch 69/69 green.
|
||||
- **Stale pre-lead — FIXED (relay).** A new run's "I'll check Hermes"
|
||||
progress event carried the PREVIOUS run's run_id + completed_tool_count
|
||||
(fires before the per-run reset). Now sends null/zero identity when no run
|
||||
is in flight; keeps the active run's identity during a fast-lane attempt.
|
||||
- **Background-run chip vanished the instant the waveform came back — FIXED
|
||||
(client, second finding same day).** The chip was nulled at the first
|
||||
summary-audio byte ("the DELIVERING chip has done its job"), so it
|
||||
disappeared exactly when speech started — reading as the task being lost.
|
||||
New `BackgroundRunPhase.DONE`: on first summary audio (or the 20s
|
||||
no-audio watchdog) the chip settles to "Background task finished." — solid
|
||||
dot, frozen ticker — lingers 10s (`DONE_CHIP_LINGER_MS`), then
|
||||
auto-dismisses; ✕ on a DONE chip is a local dismiss (never a cancel); a
|
||||
new promoted run replaces a lingering DONE chip and cancels its timer;
|
||||
progress/tool/reconnect handlers can't reanimate a settled chip. Verify:
|
||||
chip visibly settles + lingers while the answer is being spoken, ✕ during
|
||||
DONE doesn't emit a relay cancel.
|
||||
|
||||
## Voice — on-device findings (2026-07-07 realtime test)
|
||||
|
||||
Surfaced during a live realtime-voice test with a long, many-tool-call background run. (The duplicate-error-toast + no-dismiss issue from the same test shipped this session — see DEVLOG 2026-07-07.)
|
||||
|
||||
- **Tool-call status pills stuck / ordering wrong — FIXED, needs on-device re-verify (2026-07-07).** After the recent background-run-chip work (`8dc874c`/`9554c7c`), the owner found on-device that the "Thinking" indicator can get stuck and that the relative order of tool-call pills vs. the agent's reply doesn't cleanly track what actually happened. Root cause was narrower than first suspected — `VoiceUiState.responseText` is write-only for the realtime path (nothing renders it), so the actual stuck surface was the `BackgroundRunChip`: no `hermes.tool.completed`/`hermes.tool.failed` branch in `VoiceViewModel`'s event handler meant a finished tool's `statusLine` stayed pinned at `phase=RUNNING` until the next unrelated event overwrote it. Fixed (`VoiceViewModel.kt:2619`): clears the finished tool's status line, advances `completedToolCount`, leaves `DELIVERING` alone. The ordering half was `CompactTranscriptRow` (`VoiceModeOverlay.kt`) rendering reply text above the tool rows that produced it — reordered to tool-rows-first (chronological). The per-message `ToolCall` transcript rows were already correct (untouched). `:app:compileSideloadDebugKotlin` green. **Needs on-device re-verify** (long multi-tool background run: chip never shows a stale finished-tool name; reply reads below its tool calls, not above) before the release resumes.
|
||||
- **Tap/static click between sentences (realtime PCM playback) — NEEDS on-device audio investigation.** Suspected discontinuity at TTS chunk/sentence boundaries in `RealtimePcmPlayer` (a buffer underrun between segments, or a pop when a new segment's `AudioTrack` write starts). Capture head-position / underrun logs during a multi-sentence reply to confirm before touching the buffer sizing or adding a boundary crossfade/fade. Related to the existing "Realtime-PCM waveform output gating" note.
|
||||
- **Screen-wake-lock for chat/voice — SHIPPED (2026-07-07).** The app previously relied entirely on the OS screen-timeout during both chat and voice mode. Added `KeepScreenOnWhile(enabled)` (`ui/components/OrientationOverride.kt`, `Window.FLAG_KEEP_SCREEN_ON` via `DisposableEffect` — the same Android-recommended visible-surface mechanism `power/WakeLockManager.kt`'s doc comment already pointed at for a background/no-window case), wired at the `ChatScreen` root as a single call site: `enabled = voiceUiState.voiceMode || isStreaming`. Rationale (matches other apps): voice mode is a call-like continuous session (Assistant/phone-call convention) so it holds the flag for the whole time the overlay is open, regardless of Idle/Listening/Thinking/Speaking sub-state; chat only holds it while a reply is actively streaming (video-playback convention) — idle reading/scrolling falls back to the OS default, matching WhatsApp/Telegram/Signal norms rather than pinning the screen on for a static transcript. Deliberately a single owner of the window flag (not ref-counted) — see the function's doc comment before adding a second caller. **Needs on-device confirmation**: screen stays on for the whole voice session incl. silent gaps, screen stays on only during active streaming in chat (not while idle), and the flag is correctly released on exiting voice mode / when a stream ends.
|
||||
|
||||
## Voice background-run v2 (2026-07-06 roadmap — post plugin-v1.3.0)
|
||||
|
||||
The v1 shape shipped in plugin-v1.3.0 (single durable run, free floor during
|
||||
background work, busy answer, deliver-on-reattach, exit-detaches / chip-✕-
|
||||
cancels). Ranked next increments, in value-per-complexity order:
|
||||
|
||||
1. **Fast lane — SHIPPED in code (2026-07-08; needs relay deploy + live voice
|
||||
verify).** `_run_fast_lane_task` in `broker.py`: while a detached
|
||||
(promoted/durable) run holds the background slot, a second
|
||||
`hermes_run_task` first runs INLINE on a separate ephemeral Hermes session
|
||||
(`session_id=None`) within the normal grace window; grace-elapse, a
|
||||
known-long tool start (`_long_tool_hints`), explicit `mode=background`, or
|
||||
promotion-off all abandon it and fall through to the (reworded) busy
|
||||
answer. Touches NONE of the session's `hermes_*` run state — run_id/
|
||||
status/progress/chip stay owned by the in-flight run — and emits no client
|
||||
events of its own (bounded by grace; a chip would fight the detached
|
||||
run's). Events: `voice.hermes_fast_lane.completed/abandoned/error` in the
|
||||
session log. Tests: `plugin/tests/test_realtime_fast_lane.py` (7) +
|
||||
updated `test_second_run_task_answers_busy_without_orphaning_first`
|
||||
(per-stream cancellation tracking). **Residuals:** (a) context injection —
|
||||
the ephemeral session gets only the task text + interface context, not
|
||||
rolling conversation context (broker keeps no per-turn transcript; the
|
||||
model is instructed to pass self-contained task text); (b) an abandoned
|
||||
attempt may still finish server-side into the ephemeral session
|
||||
(at-least-once, unread) — same property as promotion; (c) live verify:
|
||||
during a long background run, ask a quick second question → answered
|
||||
inline; ask a second long thing → busy answer unchanged.
|
||||
2. **Task queue — SHIPPED + LIVE-VERIFIED (2026-07-08).** FIFO cap 3,
|
||||
start-next-on-completion, spoken transition, cancel-clears-queue, and the
|
||||
`+N queued` chip all landed in the A-E batch above.
|
||||
3. **Chip tap-through to the transcript** — the run executes on a real
|
||||
gateway session, so full tool calls/outputs already live in that session's
|
||||
history; make the chip (or the finished turn) open it. Cheapest "see tool
|
||||
output" step.
|
||||
4. **Live tool-output sheet** — chip expands to a run timeline (tool name,
|
||||
status, capped ~500-char output snippet). Relay adds a truncated output
|
||||
field to `hermes.tool.*` events; client renders a lane (reuse the
|
||||
`SubagentLane` pattern).
|
||||
5. **Injection framing (recorded earlier, still open)** — on providers with
|
||||
native async function calling, leave the tool call pending and deliver the
|
||||
real `function_call_output` late instead of interim-ack + synthetic
|
||||
instruction text. Needs a live xAI parity check first.
|
||||
6. **Pending-result FIFO** — `pending_background_result` is a single slot and
|
||||
remains correct for the shipped serial queue. Generalize it only with N-way
|
||||
concurrent background runs so multiple completions can race while detached.
|
||||
7. **Full N-way concurrent background runs — deliberately deferred.** Needs
|
||||
session-per-run topology (a gateway session serializes turns), which
|
||||
fragments conversation context, multiplies delivery/floor/failure modes,
|
||||
and needs run-id-targeted cancel + a multi-run chip. Only worth it when
|
||||
two *long* tasks genuinely need parallel wall-clock; revisit if the queue
|
||||
feels slow in practice.
|
||||
|
||||
## Open-issue resolution batch (2026-07-06) — owner GitHub actions + deferrals
|
||||
|
||||
Plan: `docs/plans/2026-07-06-open-issue-resolution.md` (13 open issues triaged;
|
||||
@@ -69,6 +490,37 @@ Deferred from the batch (coordination / decisions):
|
||||
create a dedicated relay venv under a writable path so the full installer
|
||||
works in-container.
|
||||
|
||||
Implementation-batch follow-ups (from the per-branch reviews):
|
||||
|
||||
- **#166 recovery: empty-session fail-fast.** `HermesApiClient.getMessages()`
|
||||
maps fetch failures to `emptyList()`, so the recovery poller can't distinguish
|
||||
"server unreachable" from "session genuinely empty" — a `Result`-returning
|
||||
history read would let the never-landed-send fail-fast also cover a dropped
|
||||
FIRST message of a fresh session (today that case polls to the cap).
|
||||
- **#166 recovery cap.** Recovery gives up after 30 minutes; longer turns still
|
||||
land in session history but only surface after a manual reload. Consider a
|
||||
"keep waiting" affordance if real turns exceed the cap.
|
||||
- **CI android slice.** `ServerAddressTest` + `IssueReportAndDiagnosticsTest`
|
||||
added to the focused `--tests` slice; the Robolectric/MockWebServer recovery
|
||||
tests and the compact-onboarding Roborazzi test stay local-only (same
|
||||
precedent as `StoreScreenshotTest`) until the broad-suite hang (#32) is fixed.
|
||||
- **Skills docs still cite editable-only fixes.** `skills/devops/hermes-relay-pair/SKILL.md`
|
||||
and `skills/android/SKILL.md` document `python -m plugin.pair` + `pip install -e`
|
||||
as the ModuleNotFoundError fix — add the native-layout equivalent when the
|
||||
#165 branch ships.
|
||||
- **Dashboard API tests not CI-visible.** `plugin/dashboard/test_plugin_api.py`
|
||||
isn't discovered by `unittest discover -s plugin/tests` and needs
|
||||
fastapi/httpx — wire into a CI runner or move under plugin/tests with skips.
|
||||
- **Desktop tool-count drift.** `user-docs/desktop/index.md` counts client-side
|
||||
handlers (clipboard/screenshot/open_in_editor) that have no server-side
|
||||
`desktop_*` registration in `plugin/tools/desktop_tool.py` — reconcile the
|
||||
advertised set; also `user-docs/desktop/pairing.md` wrongly says Android uses
|
||||
`~/.hermes/remote-sessions.json` (it's Keystore/EncryptedSharedPrefs; the file
|
||||
is shared with the Ink TUI). CLAUDE.md Key Files also still says 18/24 tools.
|
||||
- **Info-report button label.** The diagnostics Report button reads "Report"
|
||||
even when the first tap only reveals the expectation field — a "Continue"
|
||||
label would make the two-step flow clearer.
|
||||
|
||||
## Connections UI / status banner (2026-06-30 restructure follow-ups)
|
||||
|
||||
The Connections screen was split into a scannable list + a tabbed detail screen
|
||||
@@ -136,13 +588,13 @@ every bubble) + grouping breaks on a >5min gap (`GROUP_GAP_MS`) so a resumed
|
||||
conversation gets its own beat; long-press haptic on the action menu; streaming dots
|
||||
gated to pre-first-token. Deferred:
|
||||
|
||||
- **Streaming↔final render parity (kill the reflow).** `StreamingMarkdownContent`
|
||||
renders raw markdown source (`## `, `**bold**`, `- item`) as plain 14sp text for the
|
||||
whole turn, then swaps to the full renderer at completion — headings still pop
|
||||
14sp→20sp on finalize (much reduced now that settled headings are small and lists no
|
||||
longer resize, but not zero). Run the real renderer on the settled prefix and keep
|
||||
only the trailing unterminated block raw. Riskier (partial-fence flicker) — needs
|
||||
on-device testing. Highest-effort audit item.
|
||||
- **Streaming↔final render parity — conservative 1.4.1 slice implemented; live
|
||||
reflow check remains.** Blank-terminated, unambiguous top-level prose/headings use
|
||||
the final Markdown renderer during generation while the active tail stays raw.
|
||||
Lists, quotes, tables, HTML, and fences intentionally remain lightweight until the
|
||||
final parse because partial CommonMark containers can re-parent earlier blocks.
|
||||
Verify that the chosen boundary removes the common heading/prose pop without
|
||||
introducing partial-fence or list flicker.
|
||||
- **Bubble body 14sp → 15sp/21.** 14sp is the smallest body of the five reference
|
||||
apps. Bump markdown paragraph/text/list + the two plain `Text` sites
|
||||
(`MessageBubble.kt` user/system) together; keep ~1.4 leading so the ~272dp measure
|
||||
@@ -152,9 +604,6 @@ gated to pre-first-token. Deferred:
|
||||
(every bubble tails). Switching to iMessage-style "tail on the last bubble only"
|
||||
changes the look — get design intent before flipping. `isLastInGroup` is now
|
||||
meaningful (grouping breaks on gaps) so it's ready if wanted.
|
||||
- **Wide tables.** GFM tables use the default renderer on ~272dp (columns crush);
|
||||
code fences already horizontal-scroll. Add a custom `table` component in
|
||||
`markdownComponents` with `horizontalScroll` + ~110dp min column + right-edge fade.
|
||||
- **Assistant bubble width decoupled from user.** Both cap at 300dp though only the
|
||||
assistant carries markdown/code; let the assistant run wider (~92% of available /
|
||||
340–360dp cap) so fences wrap/scroll later. Keep user ~300dp.
|
||||
@@ -165,8 +614,8 @@ gated to pre-first-token. Deferred:
|
||||
text selection instead of opening Copy/Quote. Pick one owner (drop
|
||||
`SelectionContainer`, expose Copy via the menu — chat-app norm — or move actions to a
|
||||
kebab). Needs on-device confirmation of the current conflict first.
|
||||
- **Jump-to-bottom FAB unread badge** + drop the no-op tap ripple on bubbles
|
||||
(`combinedClickable onClick={}` still ripples). Telegram pattern.
|
||||
- **Drop the no-op tap ripple on bubbles.** The 1.4.1 jump-to-bottom unread badge is
|
||||
code-complete; `combinedClickable(onClick={})` still ripples on a normal bubble tap.
|
||||
- **Sessions-transport `animateItem` flash.** Stream-complete rebuilds the list with
|
||||
new ids → every visible bubble replays its enter animation (gateway transport,
|
||||
stable id, is unaffected). Reuse the streaming bubble's id for the final message.
|
||||
@@ -185,17 +634,83 @@ gated to pre-first-token. Deferred:
|
||||
The deliver-on-reattach / adaptive-promotion / milestone-speech / resume-retry /
|
||||
prewarm batch shipped (see DEVLOG 2026-07-01). Deferred:
|
||||
|
||||
- **Result injection framing (needs xAI parity check).** The completed background
|
||||
summary is injected as a synthetic *user* message (`send_text` →
|
||||
`conversation.item.create` role=user). Cleaner per current realtime-API practice:
|
||||
inject as a function-call output / out-of-band response so the model can't mistake
|
||||
it for the human speaking. OpenAI realtime supports this; xAI support unverified —
|
||||
requires a live parity test before switching. Keep the user-message path as the
|
||||
fallback.
|
||||
- **Result injection framing — FIXED in code, deployed, needs live e2e voice verify (2026-07-07).** The completed background summary, the background-handoff acknowledgement, and the forced-Hermes preamble were all injected as a synthetic *user* message (`send_text` → `conversation.item.create` role=user) — the model saw a fake turn where "the user" said things like "Hermes has already handled the user's previous voice request..." Research turned up a cleaner mechanism than the one originally guessed at: `response.create` supports a per-response `instructions` field that overrides the session system prompt for one response only, **without creating any conversation item at all** — confirmed supported by both providers (OpenAI's own docs; xAI's Voice Agent API docs explicitly show the same `response.create.response.instructions` shape). `conversation: "none"` (true out-of-band, not in history) is OpenAI-only and was deliberately NOT used — we want the spoken summary to land in real conversation history so follow-ups like "what was that again" still work; only the injection *transport* changed, not where the turn ends up. Implementation: `RealtimeAgentConnection.request_response()` (`providers/base.py`) gained an optional `instructions: str | None` kwarg; both `providers/openai.py` and `providers/xai.py` implement it identically (`{"type": "response.create", "response": {"instructions": ...}}` only when instructions are given, else the original bare `response.create`); all 4 broker-authored injection call sites (`broker.py:1244, 2113, 2352, 2560`) switched from `send_text(prompt)` to `request_response(instructions=prompt)`. The one genuine passthrough site (`broker.py:699`, real client-supplied text) is untouched. `python -m unittest discover -s plugin/tests` — 1073/1074 green (the one failure is the pre-existing, already-documented `test_reads_hermes_xai_oauth_credential_pool` fixture gap, unrelated). **Deployed to the relay (2026-07-07) — still needs a real on-device voice session** confirming the model still speaks a natural summary when driven by `instructions` alone (no preceding fake user turn); watch for a background-task delivery in particular since that's the highest-traffic call site. **Confirmed live-verified (2026-07-08)** via the raw event log on the relay: a background run (~4min, terminal tool ×9-10) delivered its spoken summary correctly through the new `request_response(instructions=...)` path (`voice.response.started` → `voice.output_audio.delta` ×N → `voice.response.done`, clean).
|
||||
- **xAI closes the realtime session after 900s of true silence — SETTLED (2026-07-08).** Live logs showed the provider closing after ~900s of zero conversation activity. Four probe runs proved no keepalive works: the repro, silent-PCM appends, and acknowledged `session.update` pings all died at exactly 900.0s. **Current code path:** idle-close is routine provider-session expiry; the broker closes Android cleanly with no `voice.error`, the old keepalive loop is gone, and the next user turn opens a fresh provider conversation seeded from the durable Hermes session. **Remaining:** relay deploy + on-device >15 min idle recovery verify.
|
||||
- **Realtime voice: provider-answered turn durability — gateway drain + provenance badge SHIPPED (2026-07-08); app-restart persistence still open.** Shipped in code (needs on-device verify with the rest of the voice batch): (a) **gateway trace drain** — a gateway-configured turn with unsynced synthetic sync messages (voice intents / card dispatches / provider-answered realtime turns) now forces itself onto the sessions SSE route so the traces actually reach the server (previously "leave them for the next SSE turn" meant *never* on a gateway-primary phone). Deliberately narrow: only with an existing session id + the sessions fallback route (a stateless completions/runs detour would drop the turn itself from the transcript) and only on the default profile (a non-default profile's gateway session lives in its own state.db — the shared api_server POST would 404 and fail the user's turn; that residual defer case is accepted). The synced-mark guard now checks the route the turn actually *dispatched* on (`effectiveEndpoint`), also fixing a latent duplicate-resend for forced-SSE voice turns. (b) **provenance badge on reload** — `RealtimeTurnSyncBuilder.stripProvenanceMarker()` recognizes the synced `[Realtime Agent provider-native voice turn: …]` marker in loaded history, strips the bracket noise, restores the quiet "Realtime Agent" badge (same chip live turns get), and drops the superseded local clientOnly bubble so the exchange doesn't render twice. **Still open — app-restart loss:** unsynced traces are in-memory only; a restart before the next Hermes turn loses them. A fix needs a client-side pending-trace store (DataStore) plus answers to: which session should late traces sync into (voice binds per-session; the next turn may be a different session/profile), and restore-as-bubbles vs builder-side-only. A true flush-on-voice-exit is NOT implementable without an upstream append-messages API (every chat POST runs the agent); the drain above narrows the exposure window to "restart before the very next turn." Deliberately NOT a separate relay transcript store (forks the conversation).
|
||||
- ~~**Realtime voice: subtle "Voice" provenance chip (2026-07-08).**~~ **Done via the durability item above** — turned out message-level "Realtime Agent"/"Voice" badges already rendered for live turns (`MessageBubble.kt` VolumeUp chips); the actual gap was reloaded history showing raw bracket provenance instead of the badge, now fixed by the marker → badge restore.
|
||||
- **Pre-existing test failure:** `test_realtime_voice_routes.py::
|
||||
test_reads_hermes_xai_oauth_credential_pool` fails at HEAD too (`token is None`) —
|
||||
looks like an environment/fixture dependency on a local xai oauth pool, not a code
|
||||
regression. Diagnose or gate on the fixture.
|
||||
- **Standard voice `delegate_task(background=true)` nudge — SHIPPED then
|
||||
REVERTED same-day (2026-07-08); premise disproven by the VERIFY-FIRST
|
||||
check.** The nudge (a `STABLE_VOICE_INTERFACE_CONTEXT` line telling the
|
||||
model to background long voice asks) was implemented, then the companion
|
||||
verify-first item below was actually checked against upstream source and
|
||||
killed it: **`delegate_task(background=true)` never dispatches async on the
|
||||
api_server surface at all.** Upstream downgrades it to synchronous
|
||||
execution (issue #10760): every api_server route binds
|
||||
`async_delivery=False` (`gateway/platforms/api_server.py` ~4000), and
|
||||
`tools/delegate_tool.py` (~2775) checks
|
||||
`gateway.session_context.async_delivery_supported()` and runs the batch
|
||||
inline with a "ran SYNCHRONOUSLY" note — "the adapter's send() is a no-op,
|
||||
so a background dispatch would silently never re-enter the conversation."
|
||||
Since ALL standard voice turns are forced onto SSE (ephemeral prompt slot),
|
||||
the nudge would have made the model block just as long (plus subagent
|
||||
overhead) while claiming it backgrounded. Reverted in `45c7ef4`. If a
|
||||
"don't hold the voice floor" behavior is ever wanted on the standard path,
|
||||
it needs the upstream async-delivery gap fixed first (a poll/webhook
|
||||
delivery channel for stateless sessions — upstream contribution), or the
|
||||
Relay realtime engine, which already has real background runs (ADR 33).
|
||||
- ~~**Standard voice: speak a delegated result if the overlay is still open when
|
||||
it lands.**~~ **CLOSED 2026-07-08 — premise gone.** There is no delayed
|
||||
`delegate_task` completion turn on the standard voice path: the api_server
|
||||
surface downgrades `background=true` to synchronous execution (see the
|
||||
reverted-nudge entry above), so the "delegated result landing later" case
|
||||
this wanted to speak cannot occur on SSE. On the gateway transport a
|
||||
background completion does re-enter as a new turn — whether the phone's
|
||||
gateway client renders an unsolicited idle-time turn is a separate
|
||||
(text-chat) question, tracked nowhere yet; add it if gateway background
|
||||
delegation becomes a used flow on phone text chat.
|
||||
- **VERIFIED 2026-07-08 — a `delegate_task` completion turn can NEVER reach an
|
||||
api_server-sourced session, because upstream never dispatches one there.**
|
||||
Answered by reading current upstream source (clone @ `5057f03bf`): the
|
||||
question is moot one layer earlier than expected. Every api_server route
|
||||
binds the session context with `async_delivery=False`
|
||||
(`gateway/platforms/api_server.py` ~4000, "the stateless HTTP path");
|
||||
`tools/delegate_tool.py` (~2775) consults
|
||||
`gateway.session_context.async_delivery_supported()` and, when false, runs
|
||||
the whole batch SYNCHRONOUSLY with an explanatory note (issue #10760) —
|
||||
there is no detached child, no completion event, no forged turn. The
|
||||
`_async_delegation_watcher` → `_inject_watch_notification` →
|
||||
`adapter.handle_message()` path only ever fires for sessions whose origin
|
||||
routes to a real push-capable platform adapter (gateway chats, Discord,
|
||||
etc.). Consequences applied same-day: the voice delegate nudge was reverted
|
||||
and the speak-on-overlay item closed (entries above).
|
||||
|
||||
## Relay-enhanced standard voice for background tasks — research (2026-07-08)
|
||||
|
||||
**Verdict: NO — don't build it.** Full owner ask + Fable 5 agent research (cross-
|
||||
checked against hermes-desktop's actual source, found in the local upstream
|
||||
monorepo clone). Three lanes already cover "a long voice request survives and
|
||||
reports back": (1) standard voice isn't a blocking call — a long turn just keeps
|
||||
streaming, and the #166 SSE-recovery poller + `TurnCompleteNotifier` already
|
||||
recover + notify on a dropped socket, zero relay involvement; (2) upstream's own
|
||||
`delegate_task(background=true)` is the standard-path equivalent of the realtime
|
||||
broker's `hermes_run_task` promotion — the model can detach a long task itself;
|
||||
(3) hermes-desktop's own voice hook (`apps/desktop/src/app/chat/composer/hooks/
|
||||
use-voice-conversation.ts` in the upstream monorepo — verified, zero mentions of
|
||||
background/promotion) is the same thin synchronous record→transcribe→submit→speak
|
||||
loop with NO background awareness; their background-task UX lives entirely in the
|
||||
chat/composer surface (a status stack + native OS notification, never spoken) —
|
||||
convergent with Android's existing background-run chip / `SubagentLane` /
|
||||
`TurnCompleteNotifier`, not a gap to fill. Building a relay-side background layer
|
||||
for standard voice would mean proxying an upstream-only surface through the relay
|
||||
or monkey-patching deeper than the accepted `plugin/enhancements/` seam — against
|
||||
the standard-path rule — to duplicate machinery ADR 33 itself calls the most
|
||||
fragile code in `broker.py`, for an audience realtime already serves better.
|
||||
Action items from this research are above (prompt nudge, speak-on-overlay-open
|
||||
polish, the api_server-routing verify-first gate).
|
||||
- **Prewarm cost watch.** Voice-mode entry now opens the provider session before the
|
||||
first utterance. If users habitually open+close voice mode without speaking, idle
|
||||
provider sessions cost connect/teardown churn — consider a short "no utterance in
|
||||
@@ -229,7 +744,7 @@ Phase 1 (end-to-end spine) shipped on `Codename-11/phone-platform` — `send_mes
|
||||
- End-to-end: with the app paired + "Let Hermes message me" on, run `send_message target=phone text=...` (and a cron `deliver=phone`) and confirm a notification on the device. Verify 503 (no phone) and the off-by-default gates.
|
||||
- **Phase 2c reply round-trip — ✅ DONE (verified on-device 2026-06-29).** Confirmed: agent → phone notification → inline reply → drained through the relay's loopback `GET /phone/replies` (different process) → `handle_message` (`role_authorized=True`, no `PHONE_ALLOW_ALL_USERS`) → agent answer back in the *same* thread. Both fixes required (see DEVLOG / the Phase 2c bullet above).
|
||||
- **FIX: cron `deliver=phone` / standalone send is broken.** Live testing: `hermes send --to phone` returns `{"error": "Unknown platform: phone"}`. The standalone (non-gateway) send path doesn't run a `kind=standalone` plugin's programmatic `ctx.register_platform`, so it never learns `phone` — only the running gateway (which loads `register()` at startup) does. The agent path (`send_message target=phone` in the gateway) works and was verified end-to-end on-device; the standalone/cron path needs the platform discoverable there too (declare it so the standalone loader picks it up, or route cron through the gateway). Until then `cron deliver=phone` won't work.
|
||||
- **FIX: installer leaves stale plugin backup copies in the plugins dir (root cause of the 2026-06-29 round-trip failure).** `install.sh`'s plugin-clone rebuild backs the old copy up *inside* `~/.hermes/plugins/` (e.g. `hermes-relay.copy-backup-…`). Because the loader dedups discovered plugins by manifest `name` and both copies declare `name: hermes-relay`, the backup can win the dedup and the gateway loads stale code — so every later deploy is silently ignored. Fix: back up *outside* the plugins dir (or delete the old copy), and have `hermes relay doctor` warn when more than one directory under `~/.hermes/plugins/` resolves to the same plugin `name`.
|
||||
- **FIX SHIPPED (2026-07-07) — installer + doctor guard against stale duplicate plugin copies; live-host verify pending.** Root cause of the 2026-06-29 round-trip failure: the gateway loader dedups discovered plugins by manifest `name`, so a second directory declaring `name: hermes-relay` (an old-installer backup copy, or a stray native install) could win the dedup and make the gateway load stale code — silently ignoring every later deploy. `plugin/doctor.py` now emits a `plugin-name-unique` warning when more than one directory under `~/.hermes/plugins/` declares the same plugin name (distinct real targets only — two links to the same target are deduped), and `install.sh` sweeps any such duplicate so only the canonical `hermes-relay` symlink survives. (Current `install.sh` already `rm -rf`s the old link rather than backing it up inside the plugins dir, so the original "back up outside the plugins dir" half is moot.) **Verify on the live host:** `hermes relay doctor` reports the `plugin-name-unique` check, and a reinstall leaves exactly one `hermes-relay` entry under `~/.hermes/plugins/`.
|
||||
|
||||
## Phone platform — usability roadmap (post device-verification, 2026-06-29)
|
||||
|
||||
@@ -249,8 +764,12 @@ Phase 1 (end-to-end spine) shipped on `Codename-11/phone-platform` — `send_mes
|
||||
- **LOOK INTO (own item, owner-requested 2026-06-29): live `/api/ws` transport for a foregrounded Thread.** Goal: when a Thread is open in the app foreground, give it the *same* live experience as Chat (live `reasoning.delta` + tool-progress) by running the turn over the `/api/ws` dashboard-gateway transport into that `source=phone` session, instead of the notification-grade `proactive.reply` path. Spec the experiment: (1) does `session.resume` + `prompt.submit` on a `source=phone` session over `/api/ws` keep `source=phone` (not silently re-tag `tui`)? (2) does it bypass `PhoneAdapter` / the role_authorized reply loop, and does that matter when the user is the one typing? (3) reconcile the two send paths (foreground→`/api/ws`, background/notification→`proactive.reply`) without double-sends. If it holds, a Thread becomes "background-delivered like a DM, but live like Chat when you open it" — the best of both. Until verified, `proactive.reply` stays the only send path.
|
||||
- **Docs/user-docs for Threads (lockstep — author with the user-facing slices 4–5).** Dev refs are done (ADR 12 carries the unified-session decision + the two-"gateway" split). Still to write when the surface ships: a plain-language `user-docs/features/threads.md` — what a Thread *is*, **Chat vs Threads** (live foreground work vs. persistent, agent-reachable conversations), the two opt-in gates, that it's relay-only — plus a **brief in-app explainer** (e.g. a one-line hint on the Threads filter empty state or a small info affordance, not a wall of text), and `docs/relay-protocol.md` + relay-server route docs for the wire. Replace the stale user-docs "Coming Soon → Push Notifications" row; keep it distinct from the clipboard inbox and the inbound Notification Companion.
|
||||
- **More Threads fold-ins (capture now, build with the relevant slice).** (a) **Read-state back to the agent** — tell the gateway you saw a proactive message (Discord-style read receipt) so the agent knows; fold into the `proactive.reply.ack` design (#7). (b) **Cross-surface reply** — because a Thread is just a gateway session, a reply could come from the desktop CLI / dashboard too, not only the phone; near-free once unified, verify the reply routing. (c) **Priority/importance on a proactive message** — let the agent mark urgent vs FYI → notification importance / quiet-hours bypass; small payload field + maps to the notifier channel.
|
||||
- **Per-thread `chat_id`.** Everything is hardcoded `chat_id="phone"` (one thread) today; the adapter already plumbs `chat_id`, so varying it yields multiple threads (per topic, or the agent opening distinct conversations). Ties into the threaded surface.
|
||||
- **Message status + delivery state.** Surface sent / delivered / queued / failed per message in the thread (depends on outbound buffering's queued state) so the user knows whether the agent actually reached them.
|
||||
- **Agent-created per-thread `chat_id`.** User-created named Threads and arbitrary
|
||||
`chat_id` routing are shipped. Remaining: expose a `send_message`-adjacent
|
||||
agent affordance that can deliberately open/name a project Thread.
|
||||
- **Queued message state.** Sending/Delivered/Failed bubbles and relay reply ACKs
|
||||
are shipped. Add an honest Queued state plus Cancel when the offline outbox
|
||||
exists; do not infer delivery from socket enqueue alone.
|
||||
- **Auto-title the phone thread** like other sessions (first confirm whether the gateway already auto-titles platform sessions; wire it through if so).
|
||||
### Discord/Telegram replacement — capability gaps (to fully retire reaching for them)
|
||||
|
||||
@@ -258,15 +777,27 @@ The gateway-platform model is the *correct + sufficient architecture* (the phone
|
||||
|
||||
- **Guaranteed background delivery (the biggest gap; no push today).** Delivery is **live-WSS-only** + a 24 h relay buffer; there is **no FCM/UnifiedPush** wake-up. If the app process is dead AND not holding a socket, a message waits for the next reconnect, and the relay buffer is ephemeral (lost on relay restart). Discord/Telegram feel instant because they wake the device via push even when the app is dead. Decide a **push transport**: **UnifiedPush/ntfy** (recommended — self-hostable, no Google dependency, upstream *already* ships an `ntfy` platform, on-brand for self-hosted) vs **FCM** (simplest UX but adds Play Services + a push relay; clashes with self-hosted ethos — at most the `googlePlay` flavor) vs **persistent foreground keep-alive service** holding the relay WSS (zero new infra, like `GatewayKeepAliveService`, but battery cost + Doze-fragile). Likely: UnifiedPush primary + foreground-keepalive fallback.
|
||||
- **Cron / background-job delivery is BROKEN** (already tracked above): `deliver=phone` standalone path → `Unknown platform: phone`. This is load-bearing for "receiver of crons/background jobs" — fix is required, not optional, for the replacement goal.
|
||||
- **Multi-thread is wired-for but never varied** (already tracked: per-thread `chat_id`). For real DM/channel parity the agent must *open distinct threads* (vary `chat_id` per topic/job), the app must render a **thread list** (N conversations, not one), and replies route back by `chat_id`+`reply_to` (already plumbed).
|
||||
- **Durable history / scrollback.** The relay buffer is ephemeral; a real messaging surface needs persisted scrollback. Read the gateway **session store** for the `phone` platform's history (relay-exposed read path) so reopening a thread shows the full conversation, not just buffered-while-away.
|
||||
- **Agent-initiated multi-thread creation remains.** The app already renders N
|
||||
`source=phone` sessions, user-created Threads vary `chat_id`, and replies route
|
||||
by `chat_id` + `reply_to`. The missing parity is letting the agent open/name a
|
||||
distinct Thread for a topic or job.
|
||||
- **Durable history / scrollback — SHIPPED.** Threads reopen through the gateway
|
||||
session store; the relay buffer is only the live/offline-delivery layer, not a
|
||||
parallel history database.
|
||||
- **Profile = contact mapping (new idea, fold in).** Multiple Hermes **profiles** (distinct agent personas/configs) could each be a distinct thread *source*/"contact" — DMing different agents. Maps cleanly onto the per-thread `chat_id` + source-attribution work; lets the app feel like a contact list of agents.
|
||||
- **Per-thread notification controls + deep-link (Discord-parity affordances).** Per-thread notification channels, mute/DND/quiet-hours (Phase 3 partially), and a notification that **deep-links into the exact thread** (tap → land in that conversation) so dipping in/out while multitasking is frictionless.
|
||||
- **Agent-initiated rich content.** Agent → phone thread with **images/cards** (relay media infra + `InboundAttachmentCard`/`HermesCardBubble` already exist on the chat side — reuse). Inbound (phone → agent) reply media stays deferred (text-first), but outbound rich content is low-cost parity.
|
||||
- **In-thread "agent is working" indicator.** A typing/working state in the thread while the agent thinks/runs tools (Discord typing-dots parity) — the chat surface already has thinking indicators to reuse.
|
||||
|
||||
- **Source/platform attribution + filtering in the drawer (NOW READY — owner-requested 2026-06-29; the gateway/Threads surface has shipped).** `/api/sessions` DOES expose `source` (confirmed live: `tui`, `cli`, `api_server`, `web`, `discord`, `telegram`, `cron`, `webhook`, `phone`). Build: **(a)** a clean **source badge** per session in the drawer — phone → the thread-spool (done); discord / telegram / cron / webhook / web → a small per-platform chip/icon (match hermes-desktop's convention); the app's own `tui`/`api_server` chats get no badge (or a subtle one). **(b)** a **filter** (drawer dropdown) to show/hide sources. **(c)** a **setting** (Chat settings) for the default — **hide the agent's other-gateway/automation sessions (cron / webhook / discord / telegram) by default** so the drawer shows just your chats + Threads, with a toggle to reveal them (the live default `state.db` is full of cron/discord/webhook noise). Persist the visibility prefs. Can't see the official desktop (no clone) — infer its chip styling; match exactly if specifics surface. Standard-path: read-only display of the upstream `source` field. Fold cross-restart **Thread-name persistence** (currently in-memory) into this drawer pass.
|
||||
- **Beta-gate the Threads featureset (owner direction 2026-06-29).** Mark Threads **Beta** with a clean badge in the UI (the Threads filter chip + the best-path "Threads" capability row) until the enhancements land. Full (non-beta) release is gated on: **live `/api/ws` transport for a foregrounded Thread** (an open Thread streams like Chat — the headline), per-session **unread**, the **`chat_id`-on-`/api/sessions` upstream fix** (so threads route after restart / cross-device), and **outbox/retry**.
|
||||
- **Source/platform attribution, filtering, and Thread-name persistence — SHIPPED.**
|
||||
The drawer and Chat settings show source badges and persisted visibility filters;
|
||||
`ThreadNameStore` persists user Thread names across restart and reapplies them to
|
||||
session rows. Remaining Threads work is the explicit residual list above
|
||||
(unread, outbox/retry, exact deep-link, agent-created named Threads, and live
|
||||
foreground `/api/ws`).
|
||||
- **Threads Beta badges — SHIPPED.** The Threads filter and best-path capability
|
||||
row render the shared `BetaChip`. Removing Beta remains gated on live foreground
|
||||
`/api/ws`, per-session unread, upstream `chat_id` exposure, and outbox/retry.
|
||||
|
||||
## Voice — Standard-path parity follow-ups
|
||||
|
||||
@@ -283,9 +814,13 @@ The gateway-platform model is the *correct + sufficient architecture* (the phone
|
||||
|
||||
## Crash-class follow-ups
|
||||
|
||||
- **Audit remaining throwing URL-build sites for the "Invalid URL host" class (#131).** The #131 fix guarded the two clients that take a user-entered base URL on the Manage/voice path (`DashboardApiClient`, `StandardHermesVoiceClient`) and validates input at entry, but two lower-risk site groups still call okhttp's throwing `url(String)` / `.toHttpUrl()`:
|
||||
- `HermesApiClient` streaming methods (`sendChatStream` / `sendCompletionsStream` / `sendRunStream`) build `authRequest("$baseUrl/…")` *outside* the surrounding `try`. Latent only — the non-streaming methods (incl. `checkHealth`) already `try/catch`, so a bad `apiServerUrl` is caught and marks the connection unreachable before streaming is reached. Consider a non-throwing `authRequestOrNull()` chokepoint → `onError`.
|
||||
- Relay clients (`RelayHttpClient`, `RelayProfileInspectorClient`, `RelayVoiceClient`, `ConnectionManager`) use `.toHttpUrl()` on `$httpBase/…`. These ride post-pairing relay URLs (from a signed QR / pairing payload), not free-text fields, so the input-validation layer doesn't cover them — route them through `ServerAddress`/`toHttpUrlOrNull` for defense-in-depth.
|
||||
- **Verify the Tink pin didn't break EncryptedSharedPreferences (owner, on-device).** The Android-15 `removeFirst`/`removeLast` crash lint flagged `com.google.crypto.tink.hybrid.HybridConfig.<clinit>` in the Tink dependency. Our app pulls Tink transitively via `androidx.security:security-crypto` for `SessionTokenStore`'s `EncryptedSharedPreferences`, which uses the AEAD path (not Hybrid), so the flagged `<clinit>` is very likely never reached at runtime — but we pinned `com.google.crypto.tink:tink-android:1.16.0` (ahead of security-crypto's transitive Tink) to clear the Play warning. **This is untestable without a build:** a too-new Tink can break `EncryptedSharedPreferences` at *runtime* (a `NoSuchMethodError`, not a compile error, so `./gradlew build` won't catch it). On-device smoke: launch the app, pair/sign in, force-stop + relaunch, and confirm the stored session survives (no re-pair prompt) and no startup crash. If it breaks, the blast radius is one line — revert the `tink-android` pin (catalog + `app/build.gradle.kts`) and the token store falls back to security-crypto's transitive Tink; then either try a lower Tink (1.15.0) or leave the (unreached) warning.
|
||||
- **Bridge screenshots: regrant UX.** Multi-device live smoke found that a device can report `screen_capture_granted=false` because the MediaProjection grant was revoked and needs an in-app/user-consent regrant. The e-ink timeout path has been hardened with a longer configurable wait and one capture-pipeline rebuild retry; remaining polish is to surface the regrant action more prominently in Bridge status.
|
||||
|
||||
- **Audit remaining throwing URL-build sites for the "Invalid URL host" class (#131).** The #131 fix guarded the two clients that take a user-entered base URL on the Manage/voice path (`DashboardApiClient`, `StandardHermesVoiceClient`) and validates input at entry. Remaining site groups:
|
||||
- **`HermesApiClient` streaming methods — DONE 2026-07-08.** `sendChatStream` / `sendCompletionsStream` / `sendRunStream` now build via the non-throwing `authRequestOrNull()` chokepoint (backed by top-level `buildApiRequestOrNull`, unit-tested like `buildRelayRequestOrNull`); a malformed base URL fails the turn through the normal `onError` channel ("Invalid server address …") and returns an inert EventSource instead of throwing out of the ViewModel. The whole #131 audit list is now closed.
|
||||
- **`ConnectionManager` WSS connect — FIXED 2026-07-07** (this was the confirmed crasher: Play 1.2.6 on a Galaxy S25 Ultra / Android 16, `IllegalArgumentException` from `HttpUrl$Builder.parse` via `doConnectInternal` → `Request.Builder.url()` on the IO coroutine). Now routed through `buildRelayRequestOrNull()` → graceful Disconnected + diagnostic instead of a throw. `ConnectionManagerUrlGuardTest` covers it.
|
||||
- **Remaining relay HTTP clients — DONE 2026-07-07 (defense-in-depth).** `RelayVoiceClient` now validates its base in `resolveHttpBase()` (returns null on a malformed URL → the existing `Result.failure` guards fire), and `RelayHttpClient`'s two string-URL sites (`fetchMedia`, `listSessions`) use `toHttpUrlOrNull()` → `Result.failure`. `RelayProfileInspectorClient` was already fully guarded (every `.toHttpUrl()` wrapped in `catch (IllegalArgumentException)`). The whole #131 relay class is now covered; `HermesApiClient` streaming (the other lower-risk group above) remains the only open item.
|
||||
|
||||
## Session titles (#133) — follow-ups beyond the client fixes
|
||||
|
||||
@@ -310,33 +845,23 @@ The client-side mitigations shipped (see DEVLOG 2026-06-27): the `updateSessions
|
||||
|
||||
## User-Added:
|
||||
|
||||
- [x] **Clean-chat: taller scrollable text viewport** *(impl 2026-06-22, orchestration batch — unbuilt; verify in Studio.)* Replaced the fragile `screenHeightDp*0.34f` cap with a weight split (sphere `weight(1f)` / flow `weight(1.1f)` ≈ 52% of the vertical slack); kept the internal scroll + top-fade + `min=96.dp` floor. `AgentTextFlow.kt` (`1dca285`).
|
||||
- [ ] Verify profile selection retains voice config selections in all voice modes/configuration combinations - enhance UI/configurability/management for this.
|
||||
- [x] **Session delete on a non-default profile now persists** *(impl 2026-06-22, orchestration batch — unbuilt; verify in Studio.)* Root cause: a non-default profile's sessions live in that profile's own `state.db`, but the delete went through the unscoped api_server `DELETE /api/sessions/{id}` (shared DB) so the row survived and the next profile-scoped list resurrected it. Fix routes gateway deletes through the dashboard profile-scoped surface (write twin of the list path) + `refreshSessions()` after success. `DashboardApiClient`/`ConnectionViewModel`/`ChatViewModel`/`RelayApp` (`6552566`).
|
||||
- [x] **Voice-settings profile override in 'auto' mode** *(impl 2026-06-21, orchestration batch — unbuilt; verify in Studio. See DEVLOG + "Orchestration batch (2026-06-21)" below.)* Root cause: `VoiceViewModel.shouldPreferRealtimeVoice()` gated on `.route` (configured) not `.effectiveRoute` (resolved), so 'auto'+relay never engaged the override-capable relay path and fell back to host-global Standard `/api/audio/speak` (no override slot). Fixed + wired `connectionId` for per-profile voice-prefs namespacing. Original note: *Look into the voice-settings profile specific capabilities - in 'auto' mode the user-override voice wasn't applied (system default used) despite being displayed; only 'Relay' applied it.*
|
||||
|
||||
- [x] **Analytics + Diagnostics overhaul** *(impl 2026-06-22, orchestration batch — unbuilt; verify in Studio.)* Diagnostics is now a full-screen `DiagnosticsScreen` (new `Screen.Diagnostics` route, replacing the modal sheet) led by a vertical status-check timeline — Network, API server, capabilities, chat transport, pairing/auth, relay, voice — each a green/amber/red/gray dot on a connecting rail with an inline failure reason; checks backed by a logged error are tappable into `DiagnosticDetailDialog`. Derived read-only from existing `ConnectionViewModel` flows + recent `DiagnosticsLog` via a pure `buildStatusChecks()`; recent-activity log kept below. Analytics hierarchy tidied. `c3098a9`. See follow-ups below.
|
||||
- [x] **Realtime voice stall + over-chatty status** *(client half impl 2026-06-21, orchestration batch — unbuilt; server half deferred, see below.)* Client now relaxes the 90s idle watchdog on promoted/long runs (5-min backstop kept) and throttles spoken status (≥22s gap, ≤3/turn); realtime waveform now gates on real playback-start. Original note: *Realtime voice mode stalls/times-out when calling a background Hermes task and repeatedly reports status vocally when not necessary.*
|
||||
- [x] **Connections reframe: "Vanilla/Standard Hermes" → "Hermes"** *(impl 2026-06-22, orchestration batch — unbuilt; verify in Studio.)* 28 user-facing display strings across 10 connection/voice/permissions files; "Hermes-Relay plugin" → "Relay plugin" where it reads naturally. Display text only — no enum names, sealed types, when-branches, or stored route values touched. `c9fa8f7`.
|
||||
- [x] **Lock app to a specific profile** *(impl 2026-06-21, orchestration batch — unbuilt; verify in Studio.)* Per-connection lock: new `ProfileLockStore`, `ProfileController` lock flows + enforcement, `ConnectionInfoSheet` collapses the picker to a static "Locked to <name>" row, `SettingsScreen` adds the lock card + dialog (the one surface still listing all profiles). Original note: *Allow locking app to a specific profile, hiding all other profiles except from this setting - cleanly hide profile specific UI elements based on this gate.*
|
||||
- [x] **Profile icon in the floating voice overlay** *(impl 2026-06-21, orchestration batch — unbuilt.)* `VoiceModeOverlay` header pill now shows the per-profile icon (`LocalAgentIconPath`); sphere/pet stays the fallback.
|
||||
- [x] **Voice dropdown state mixes + label overflow** *(impl 2026-06-21, orchestration batch — unbuilt.)* Invalid engine/route combos made unreachable (RealtimeAgent disabled without relay, unavailable routes disabled, `coerceAudioRoute` auto-corrects); long dropdown/provider labels get `maxLines=1`+ellipsis. Original note: *Fix the voice dropdown mode toggles to not allow weird state mixes - labels need overflow control to prevent 2 lines or crunching.*
|
||||
### Thinking indicator — post-v1.3.0 follow-ups
|
||||
|
||||
- [x] **Per-profile agent icon + static-image avatar (shipped 2026-06-20 —** `d827e46`**, see DEVLOG).** Per-profile icon: client-side `ProfileIconStore` (per `(connection, profile)`, never sent to Hermes; stores a copied-file path) → small Coil image beside the agent name in `MessageBubble` via `LocalAgentIconPath`; picker is `AgentIconRow` under the local-name row in `ConnectionInfoSheet`. Static image: "Add a pet" accepts a single image (magic-byte detect → one-frame static pet). Scope shipped: small name-adjacent icon only; big avatar stays global. Follow-ups: on-device smoke (import an image as a pet; set a profile icon, confirm it shows by the name + persists across restart); optionally also show the icon in the profile picker.
|
||||
The animated dot-matrix "thinking" indicator shipped in **android-v1.3.0**
|
||||
(Wave/Pulse/Bounce/Sparkle motions + Auto/accent colors, live preview in Chat
|
||||
settings). The 1.4.1 path also honors app animation settings, OS animator scale,
|
||||
and TalkBack touch exploration. Remaining:
|
||||
|
||||
- [ ] **Dot-matrix "thinking" indicator** *(prototype impl 2026-06-28 — unbuilt; verify in Studio.)* New `DotMatrixIndicator` (`ui/components/DotMatrixIndicator.kt`): a Compose-`Canvas` dot grid with a brightness wave sweeping left→right — the dot-anime-react concept reimplemented natively (not a port). Swaps the in-bubble `StreamingDots` working indicator via `LocalThinkingIndicator` (provided in `ChatScreen` around the message `LazyColumn`), behind a new **Chat settings → "Thinking indicator" (Dots / Matrix)** selector with a live preview (`thinkingIndicatorStyle` pref on `ConnectionViewModel`, default "matrix"). Brand-themed (uses the bubble `textColor`), frame-throttled via `rememberAmbientPhase` (not `rememberInfiniteTransition`), and renders a static frame when `animationEnabled` is off. Follow-ups once the base motion is approved:
|
||||
- [x] **Preset frame patterns** *(impl 2026-06-28)* — `ThinkingMatrixPattern` (Wave/Pulse/Bounce/Sparkle): Wave stays procedural, the rest are authored `List<Set<Int>>` frame sequences (built generatively in `buildMatrixFrames`, addressed `row*cols+col`), crossfaded between frames. New `thinkingMatrixPattern` pref + a Matrix-only "Pattern" selector in Chat settings. Width widened twice on request (column pitch now 9dp).
|
||||
- [x] **Per-indicator color** *(impl 2026-06-28)* — `ThinkingMatrixColor` (Auto + brand accents relay/cyan/green/amber/purple/pink) resolved against `LocalBrand` via `toColor()`, so accents re-theme per app theme. New `thinkingMatrixColor` pref + a Matrix-only swatch row in Chat settings; Auto follows the bubble text color. Possible later add-on: a freeform custom-color picker.
|
||||
- **OS-level reduce-motion / TalkBack** — currently gates only on the app's `animationEnabled` pref. Also honor OS reduce-motion + touch-exploration like `CleanChatMode` does (`rememberCleanMotionState().osAnimations`).
|
||||
- **Optional: promote to a full avatar style** — the alternative scope (a `DotMatrixAvatar` `AgentAvatar` shown everywhere via `LocalAvailableAvatars`, selected in Appearance). Deferred in favor of the narrower in-bubble indicator.
|
||||
- **Optional: promote to a full avatar style** — the alternative scope (a `DotMatrixAvatar` `AgentAvatar` shown everywhere via `LocalAvailableAvatars`, selected in Appearance). Deferred in favor of the narrower in-bubble indicator.
|
||||
|
||||
## Demo mode (2026-06-27) — deferred polish
|
||||
|
||||
Shipped offline Demo / Explore mode (see DEVLOG 2026-06-27). Core is in; these are non-blocking polish items, none required for the Play "App access" fix:
|
||||
|
||||
- **On-device verify (Studio).** Confirm: "Try the demo" on the onboarding Connect page and the standalone Connect screen lands on Chat showing the canned transcript (Markdown, tool-progress card, weather card, code block); the persistent banner shows and its Connect exits demo into the real wizard; demo runs in airplane mode with no network; Manage/Voice show the demo empty state; Bridge/Terminal show their pair-gate; backing out of demo Chat clears the flag so a real connection still works.
|
||||
- **Demo composer is a silent no-op.** `ChatViewModel.sendMessage()` early-returns with no API client, so typing + Send in demo does nothing. Polish: intercept sends while `isDemoMode` to append a canned "This is a demo — connect your Hermes server to chat for real" assistant bubble (or disable the composer with a hint), so it doesn't read as broken.
|
||||
- **Live voice mode in demo.** The voice-mode overlay (mic) launched from Chat isn't demo-gated — a tap would attempt a transcribe (fails gracefully, no crash). Add a demo notice / disable the mic in demo. (Voice settings screen already shows the demo empty state.)
|
||||
- **On-device verify (Studio).** Confirm: "Try the demo" on the onboarding Connect page and the standalone Connect screen lands on Chat showing the canned transcript (Markdown, tool-progress card, weather card, code block); the persistent banner shows and its Connect exits demo into the real wizard; demo runs in airplane mode with no network; the Chat mic explains locally that Voice needs a connection and never attempts transcription; Manage/Voice show the demo empty state; Bridge/Terminal show their pair-gate; backing out of demo Chat clears the flag so a real connection still works.
|
||||
- **Demo composer is a silent no-op — DONE 2026-07-08.** `sendMessage` now intercepts while `isDemoMode`: echoes the user bubble and appends `DemoContent.composerReply` ("offline demo, can't answer for real — tap Connect in the banner"), both clientOnly so demo-exit's `clearMessages()` wipes them. Wired via `setDemoModeWiring` (unconditional in RelayApp — the client-gated chat init never runs in demo, so ChatViewModel's own handler is null there). On-device check rides the existing demo verify item above.
|
||||
- **Light typewriter/stream simulation.** The transcript is statically populated; an optional per-token reveal on first entry would better convey the "streaming" feel. Acceptable as static for v1.
|
||||
- **Optional richer demo.** Could add a second tool type or an image attachment to the transcript to showcase more surfaces; kept minimal/one-file for now.
|
||||
|
||||
@@ -359,7 +884,6 @@ Client-side profile-lock + voice fixes (the items marked above) landed via a pla
|
||||
- **Per-profile voice on Standard (upstream).** `/api/audio/*` is host-global/text-only; the Standard surface still can't carry a per-request voice. Needs the upstream profile-voice / `/v1/audio/*` PR. Until then the client prefers the relay path; consider surfacing an honest "override needs Relay" state when Standard is the effective surface.
|
||||
- **Profile lock: ChatScreen glyph + export.** The optional lock glyph on the chat-header avatar was skipped (`ChatScreen.kt` is owned by a concurrent session). Decide whether the per-connection lock belongs in settings export/import (it rides the `profile_selections` DataStore).
|
||||
- **Unit tests — DONE 2026-06-21 (36/36 pass via `:app:testSideloadDebugUnitTest`).** `ProfileLockStoreTest` (9 — uses an in-memory `DataStore` harness; the file-backed factory hits a Windows write-rename/instance race), `ProfileControllerLockTest` (8, Robolectric), `CoerceAudioRouteTest` (7), `VoiceStatusGatesTest` (12).
|
||||
- **CHANGELOG.** Add `[Unreleased]` entries (Profile lock → Added; voice override + realtime → Fixed) at build-verify/PR time.
|
||||
- **On-device verification.** Override applies in 'auto'+relay; realtime survives a >90s background task without stalling and stops over-narrating; Speaking waveform unfolds at first audible frame; profile lock hides pickers + holds on a missing profile; overlay shows the profile icon.
|
||||
|
||||
## Hands-free agentic voice backlog
|
||||
@@ -368,33 +892,25 @@ Goal: make Hermes usable for hands-free work without leaving the operator blind
|
||||
|
||||
to tool state, safety prompts, or the current task.
|
||||
|
||||
- **Waveform output-start sync** — current input waveform timing feels good, but
|
||||
- **Waveform output-start sync — SHIPPED; on-device confirmation remains.**
|
||||
Realtime output now gates on `RealtimePcmPlayer` playback-head movement or
|
||||
playback-synchronized amplitude through `shouldMarkRealtimeOutputActive`,
|
||||
matching the basic-TTS path. Confirm visually on-device with the 1.4.1 batch.
|
||||
|
||||
the agent-output waveform can unfold and begin movement before audible speech
|
||||
- **Voice command layer — initial 1.4.1 subset code-complete; live verify and
|
||||
navigation residuals remain.** Exact final transcripts can stop speech,
|
||||
explicitly cancel the active background task, pause/resume Continuous mode,
|
||||
repeat a settled background answer, and start a new Standard chat. Bare `stop`
|
||||
and `cancel`, partial transcripts, and command-like ordinary prompts stay on the
|
||||
normal Hermes route. Realtime `new chat` remains gated on a clean websocket
|
||||
session-rebind boundary; `open overlay` and `return to Hermes` remain future
|
||||
navigation commands. Verify barge-in Stop, pause during a background run, local
|
||||
command Chat cleanup, and Continuous rearm on device.
|
||||
|
||||
starts. Split "preparing audio" from "speaking audio" in the visual layer, or
|
||||
|
||||
gate the unfolded Speaking waveform on the first real playback frame/audio
|
||||
|
||||
amplitude. Processing can stay as the folded circular spinner until output is
|
||||
|
||||
actually audible.
|
||||
|
||||
- **Voice command layer** — reserve local commands that bypass normal agent
|
||||
|
||||
routing: "pause", "resume", "stop talking", "cancel", "repeat that", "open
|
||||
|
||||
overlay", "return to Hermes", and "new chat". These should work while the
|
||||
|
||||
agent is thinking, speaking, or using tools.
|
||||
|
||||
- **Spoken tool progress** — when Hermes uses tools, voice mode should speak
|
||||
|
||||
short status updates such as "I'm checking the relay logs" or "I found an
|
||||
|
||||
error" without waiting for final assistant text. Long tool calls should emit
|
||||
|
||||
periodic, low-noise progress updates.
|
||||
- **Spoken tool progress — baseline shipped; broader hands-free policy remains.**
|
||||
Realtime background runs already emit milestone speech plus coarse, low-noise
|
||||
progress with repeat suppression. The 1.4.1 residual is a unified policy across
|
||||
Voice engines and presets, not another parallel heartbeat implementation.
|
||||
|
||||
- **Realtime tool timeline parity** — the voice overlay should render the same
|
||||
|
||||
@@ -414,11 +930,12 @@ the current voice task: active objective, last tool result, pending next step,
|
||||
|
||||
and whether the agent is waiting on the user.
|
||||
|
||||
- **Mode presets** — add presets such as Hands-free, Low latency, Careful tool
|
||||
|
||||
mode, and Quiet/visual-only. Hands-free should favor Continuous listening,
|
||||
|
||||
spoken tool progress, confirmations, and overlay availability.
|
||||
- **Mode presets — CODE-COMPLETE for 1.4.1; live apply/Custom-state verification
|
||||
remains.** Hands-free, Low latency, Careful tools, and Quiet/visual-only compose
|
||||
existing interaction and relay-promotion controls. They preserve engine, route,
|
||||
provider, model, voice, credentials, concurrency, and Hands-free's existing
|
||||
experimental barge-in choice. Relay update is server-first; local Voice/barge-in
|
||||
values share one DataStore transaction, with relay rollback on local failure.
|
||||
|
||||
- **Barge-in hardening** — keep barge-in experimental until echo/self-recording
|
||||
|
||||
@@ -485,10 +1002,10 @@ Things to look into:
|
||||
- **Update discovery (shipped 2026-06-30 — CLI + dashboard + app).** `hermes relay update-check`, a dashboard "Plugin version" card, and an app **About → "Relay"** row all compare the installed plugin against the latest `plugin-v*` release and surface the right update command (`hermes plugins update hermes-relay` vs `hermes-relay-update`). The app polls the relay's `GET /relay/update-check` (`:8767`, bearer) on each `auth.ok`; the relay is the single source of truth (the app never hits GitHub). Possible polish (deferred): a more prominent dismissible "relay is behind" banner outside About (today it's capability-first + the About row), and showing the app's own version alongside the relay's in the same readout (the app-Version row already exists separately just above it).
|
||||
- **Per-profile enablement (shipped 2026-06-30).** `hermes relay profiles list|enable [--all|NAME]` + `plugin/profiles.py` resolve the install-once/enable-per-profile papercut; docs now cover the pair-once/one-relay model. Possible follow-up: an `install.sh` / `hermes plugins install` prompt offering "enable for all existing profiles" so new installs don't need the manual `profiles enable --all`.
|
||||
- `**hermes-relay-self-setup` SKILL.md as a precedent** — we just shipped a self-installing skill that an LLM can fetch from a raw GitHub URL and execute. Does this pattern generalize? Could it become a recommended way for any third-party Hermes project to ship setup automation?
|
||||
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla upstream. This is intentional but feels like a hack. Upstream PR #8556 (`feat/session-api`) will eventually let us delete it — verified 2026-04-15 that its scope covers the full bootstrap surface (sessions, memory, skills, config, available-models). Track that PR's status periodically.
|
||||
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Sibling follow-up to #8556. Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
|
||||
- **Bootstrap injection** — `hermes_relay_bootstrap/` monkey-patches `aiohttp.web.Application` to inject endpoints into vanilla/partial upstream. This is intentional but feels like a hack. The original broad PR #8556 was **closed as superseded**; native upstream now covers sessions/chat/fork via [#33134](https://github.com/NousResearch/hermes-agent/pull/33134) and skill/toolset discovery via `/v1/skills` + `/v1/toolsets` (#33016). **Done (2026-07-08, HRUI-002):** the bootstrap's sessions CRUD/messages/fork handlers and the legacy `GET /api/skills` list were retired outright — no pre-#33134 fallback remains; old core builds degrade via the client capability probe. **Still gapped (bootstrap remains for these):** config, memory, legacy `/api/skills/{name}` detail + `PUT /api/skills/toggle` (501 stub), available-models, `/api/sessions/search`, and the slash-command middleware — each retires individually when a native replacement lands or the dependent UX is removed. Track upstream per surface.
|
||||
- **Gateway slash-command preprocessor — upstream Stage 1 PR.** Sibling follow-up to the native session-control baseline (#33134). Intercepts known gateway commands on `/v1/runs` + `/v1/chat/completions`, dispatches the stateless ones (`/help`, `/commands`) via `gateway_help_lines()`, returns a deterministic "use a channel with session state" notice for the stateful majority. Currently being prepared in `C:/Users/Bailey/Desktop/Open-Projects/hermes-agent-pr-prep/` on branch `feat/api-server-gateway-commands`; awaiting subagent's code + draft PR body before pushing. See `docs/upstream-contributions.md` §5.
|
||||
- **Gateway slash-command preprocessor — bootstrap middleware (Stage 1 equivalent).** Sibling shim in `hermes_relay_bootstrap/_command_middleware.py` that mirrors the upstream Stage 1 PR as an aiohttp middleware injected at bootstrap time. Ships the hallucination fix to vanilla-upstream installs before the upstream PR lands. Planned for v0.4.1, after the current bridge feature branch wraps. See `ROADMAP.md` v0.4.1 entry.
|
||||
- **Stage 2 — stateful slash-command dispatch on `/api/sessions/{id}/chat/stream`.** Blocked on PR #8556 merging. Once session primitives ship upstream, add a preprocessor scoped to the session chat stream endpoint only, using `session_id` as the persistence handle. Separate upstream PR + matching bootstrap middleware. See `docs/upstream-contributions.md` §5 ("Stage 2").
|
||||
- **Stage 2 — stateful slash-command dispatch on `/api/sessions/{id}/chat/stream`.** Unblocked now that session primitives shipped upstream (#33134 / `f7527b0`). Add a preprocessor scoped to the session chat stream endpoint only, using `session_id` as the persistence handle. Separate upstream PR + matching bootstrap middleware. See `docs/upstream-contributions.md` §5 ("Stage 2").
|
||||
|
||||
When the answer becomes clearer, this section becomes either an ADR in `docs/decisions.md` or a Plan under `Plans/`.
|
||||
|
||||
@@ -537,7 +1054,6 @@ Follow-ups:
|
||||
## Attachments (shipped 2026-06-18 — `docs/plans/2026-06-18-attachment-experience.md`)
|
||||
|
||||
- **B3 — download progress + cancel.** Inbound fetch is un-cancelable; the previews work scaffolded an indeterminate bar + nullable `onCancel`. Live wiring needs the fetch-path owner (`ChatViewModel`/`Attachment`) to expose determinate progress (Content-Length) + a cancel hook.
|
||||
- **A6 — multi-image gallery.** N images in one message → grid + swipe-across viewer (Telegram media-group parity).
|
||||
- **C5 — agent-side sensitivity config gate.** `RELAY_MEDIA_SENSITIVITY_HINTS` (env or per-profile) instructing the agent to annotate sensitive media via the prompt-builder. Transport (relay `X-Media-Sensitive` header + client blur) already ships; the agent isn't asked to set the bit yet.
|
||||
- **Relay thumbnails (D6).** Server-side thumbnail generation to avoid full-size download for cards/galleries. Needs an image lib (Pillow not currently a dep) — evaluate before adding.
|
||||
- **D5 — outbound upload progress.** No per-attachment progress during the 60s gateway PDF-render window.
|
||||
@@ -545,12 +1061,13 @@ Follow-ups:
|
||||
## Voice overhaul (shipped 2026-06-18 — `docs/plans/2026-06-18-voice-overhaul.md`)
|
||||
|
||||
- **Per-profile voice on Standard (upstream PR).** Upstream `/api/profiles/*` has no voice field and `/api/audio/*` is host-global. Long-term: PR a voice section to the profile config + make `/api/audio/*` honor the active/`?profile=` profile. The relay path already carries per-profile voice; ship that first.
|
||||
- **Wire connectionId for per-profile voice namespacing.** `VoicePreferencesRepository` is scope-aware (`base_connId_profile`), but `RelayApp` passes only the profile *name* to `onProfileChanged`, so `connectionId` is null and keys namespace by profile-only. Wire `setVoicePrefsConnection` to `ConnectionViewModel.activeConnectionId` (in `RelayApp`) so two connections with same-named profiles don't share voice settings.
|
||||
- **Realtime-PCM waveform output gating.** The basic-TTS output waveform is now Visualizer-accurate (gated on real playback amplitude), but the realtime path gates `outputAudioActive` on `audioSeen` (first decoded PCM bytes) in `VoiceViewModel.handleRealtimeVoiceEvent`, which can still lead audible output by the `RealtimePcmPlayer` start prebuffer. Gate realtime on actual playback-start (head moved) to match the basic-TTS path.
|
||||
|
||||
## Chat clean-mode + pets (shipped 2026-06-18 — `docs/plans/2026-06-18-chat-clean-mode-and-pets.md`)
|
||||
|
||||
- **Part-A chat polish (optional bundle).** Per-code-block copy + horizontal scroll, visible copy affordance, mid-stream stall feedback, profile/skill-aware empty-state chips, the ~40-flow recomposition hotspot at the top of `ChatScreen`. (Sphere `contentDescription`/reduced-motion was handled by the clean-mode a11y work.)
|
||||
- **Part-A chat polish residuals.** Per-code-block copy, horizontal scroll, the
|
||||
visible copy affordance, and mid-stream stall feedback are shipped. Remaining:
|
||||
profile/skill-aware empty-state chips and the ~40-flow recomposition hotspot at
|
||||
the top of `ChatScreen`.
|
||||
- **Pet hot-load + in-app add/remove (shipped 2026-06-20).** Pets now live-refresh: an `avatarsRefreshTick` keys the avatar `produceState` in `RelayApp`, and Appearance re-scans `pets/` on open and after in-app import/delete — no app restart. Appearance gained "Add a pet" (SAF `.zip` import via `PetImporter`, zip-slip/zip-bomb guarded + validated through `toAvatar`) and an "Installed pets" list with per-pet remove (`PetLoader.deletePet`, confirm dialog, Sphere fallback). Remaining:
|
||||
- **Sphere-skin parity.** Skins are still process-scoped + `adb push` only — the live tick and the importer cover pets, not skins. Extend the tick to `loadUserSkins` and add a `.json` skin import if hot-loading/adding skins in-app is wanted.
|
||||
- `**adb push` into `Android/data` hangs on Samsung scoped storage.** Confirmed: pushing a pet pack to `/sdcard/Android/data/<pkg>/files/pets/` stalls (no bytes written) although `adb shell ls` of the dir works. In-app `.zip` import is the supported path; `/sdcard/Download` pushes fine. Consider softening `docs/pet-spec.md` + user-docs to lead with in-app import over adb.
|
||||
|
||||
+12
-3
@@ -182,7 +182,13 @@ android {
|
||||
// [POC] Roborazzi runs without its Gradle plugin (the plugin needs AGP's
|
||||
// removed TestedExtension). Force record mode via the test-JVM system
|
||||
// property the plugin would otherwise inject, so captureRoboImage writes.
|
||||
unitTests.all { it.systemProperty("roborazzi.test.record", "true") }
|
||||
// Heap: the Roborazzi store renders (1080×2160 native graphics) share a
|
||||
// worker JVM with the Robolectric suites; Gradle's 512m default OOMs
|
||||
// once both are in the same run.
|
||||
unitTests.all {
|
||||
it.systemProperty("roborazzi.test.record", "true")
|
||||
it.maxHeapSize = "2g"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +296,9 @@ dependencies {
|
||||
|
||||
// Security
|
||||
implementation(libs.security.crypto)
|
||||
// Force a Tink newer than security-crypto's transitive one — older Tink's
|
||||
// HybridConfig removeFirst()/removeLast() trips the Android-15 crash lint.
|
||||
implementation(libs.tink.android)
|
||||
|
||||
// DataStore
|
||||
implementation(libs.datastore.preferences)
|
||||
@@ -316,8 +325,8 @@ dependencies {
|
||||
// [POC] Roborazzi host-side screenshot rendering (src/test, Robolectric).
|
||||
// Renders real composables on the JVM at an exact canvas — no device, no
|
||||
// status bar, no clipping. See StoreScreenshotTest.
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.43.1")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.43.1")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.66.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.66.0")
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
testImplementation(libs.compose.ui.test.manifest)
|
||||
testImplementation("androidx.test.ext:junit:1.3.0")
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
v1.2.6 — Tidier chats & calmer status.
|
||||
v1.4.1 - Chat that keeps up
|
||||
|
||||
• Chats no longer get stuck on "Untitled" — your first message stands in as the title until the chat is named, plus a new refresh button in the session list. Renaming a chat now sticks on non-default profiles.
|
||||
• Connection status now slides in as a thin banner at the top instead of a card floating over your chat; the floating alert is kept for persistent errors.
|
||||
Chat
|
||||
* Follow background work from a live process strip; its result appears automatically in the same conversation.
|
||||
* Reopen while an answer runs: partial text, thinking, tool progress, and approvals return.
|
||||
|
||||
Voice
|
||||
* Speak commands to pause, resume, cancel, repeat a result, or start Standard voice chat.
|
||||
* Pick Hands-free, Low latency, Careful tools, or Quiet presets.
|
||||
|
||||
Polish
|
||||
* Multi-image galleries plus smoother streaming Markdown and long tables.
|
||||
|
||||
+332
-230
@@ -1,238 +1,340 @@
|
||||
{
|
||||
"versions": [
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.4.1",
|
||||
"title": "Chat that keeps up",
|
||||
"date": "2026-07-11",
|
||||
"sections": [
|
||||
{
|
||||
"version": "1.2.6",
|
||||
"title": "Tidier chats & calmer status",
|
||||
"date": "2026-06-27",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Tidier chats",
|
||||
"bullets": [
|
||||
"Chats no longer get stuck showing \"Untitled\" — your first message stands in as the title until the chat is named, titles refresh once a turn settles, and a new refresh button in the session drawer pulls the latest on demand. Renaming a chat now sticks when you're on a non-default agent profile."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Calmer status",
|
||||
"bullets": [
|
||||
"Connection status — reconnecting, checking, network handoffs — now shows as a thin banner at the top that gently slides the screen down, instead of a card floating over your chat; the floating alert is kept for persistent errors. Quick confirmations (copied, profiles updated, profile/personality switches) land in the same calm banner instead of a pop-up at the bottom."
|
||||
]
|
||||
}
|
||||
]
|
||||
"header": "Chat that stays with you",
|
||||
"bullets": [
|
||||
"Follow background terminal work from a compact process strip and expandable sheet. Its completed answer appears in the same conversation automatically.",
|
||||
"Close and reopen while a reply runs: partial text, thinking, tool progress, background-task state, and pending approvals return in the same chat without repeating your prompt."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.5",
|
||||
"title": "Stability + Try the demo",
|
||||
"date": "2026-06-27",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app when a non-URL value — a UI label, or a line copied from the docs — was entered in the API server or Dashboard URL field. The setup fields now reject anything that isn't a valid host or http(s) URL with an inline error, and the dashboard and voice request paths treat a bad address as unreachable instead of crashing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Try the demo",
|
||||
"bullets": [
|
||||
"A new \"Try the demo\" option on the setup screen — and on the empty chat screen if you skip setup — opens an offline preview of the real chat experience: a sample conversation with Markdown, a tool-progress card, and a rich card, with no server, account, or network. A banner shows it's a demo, with a one-tap Connect to set up for real."
|
||||
]
|
||||
}
|
||||
]
|
||||
"header": "Voice you can direct",
|
||||
"bullets": [
|
||||
"Use spoken commands to pause or resume listening, stop speech, cancel background work, repeat a finished result, or start Standard voice chat.",
|
||||
"Hands-free, Low latency, Careful tools, and Quiet presets tune existing voice behavior without changing your selected voice or route."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.4",
|
||||
"title": "Stability + connection security",
|
||||
"date": "2026-06-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app when the dashboard connection check hit a transient network failure — a pooled connection aborting or timing out over Tailscale. The check now reports the failure cleanly and the connection probe degrades gracefully instead of force-closing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "See if you're secure",
|
||||
"bullets": [
|
||||
"The chat status chip, connection card, and route picker now show at a glance whether your connection is encrypted — Encrypted · TLS, Encrypted · Tailscale (both secure), Mixed routes, or Not encrypted — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale or WireGuard route is now correctly shown as encrypted rather than implied insecure."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"title": "Connection crash fix",
|
||||
"date": "2026-06-23",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS) — a live secure connection was being torn down on the main thread as it came up. Securing your connection no longer force-closes the app; plain-LAN connections were never affected."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.2",
|
||||
"title": "Multi-profile polish",
|
||||
"date": "2026-06-22",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Profiles that behave",
|
||||
"bullets": [
|
||||
"Deleting a session while a non-default agent profile is active now sticks — it no longer reappears after the list refreshes.",
|
||||
"On a cold start with a non-default profile selected, the session drawer opens on that profile's chats directly instead of briefly showing the default profile's."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Clearer diagnostics",
|
||||
"bullets": [
|
||||
"Diagnostics is now a full screen led by a top-to-bottom list of subsystem health checks — network, API server, chat transport, pairing, relay, and voice — each with a pass / warning / fail state and the reason when something's wrong; tap a failing check for full detail. The recent-activity log stays below."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Small touches",
|
||||
"bullets": [
|
||||
"The default connection is now simply \"Hermes\" (and the optional power features are labelled \"Relay\"), across setup, the switcher, voice, and permissions.",
|
||||
"Distraction-free chat mode gives its text a taller, scrollable area."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.1",
|
||||
"title": "Polish & control",
|
||||
"date": "2026-06-21",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Yours to control",
|
||||
"bullets": [
|
||||
"Lock the app to a single agent profile (Settings → Profile lock) and hide the rest from the pickers."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Find your way back",
|
||||
"bullets": [
|
||||
"A new \"What's New\" entry in Settings shows current and past release notes any time — not just after an update."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "When something breaks",
|
||||
"bullets": [
|
||||
"Diagnostics show clean error titles — tap any entry for a detail view with Copy, Share, and a one-tap GitHub issue.",
|
||||
"A tasteful in-app banner tells you when a newer version is live (Play or sideload) — dismissable, and it never nags."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice fixes",
|
||||
"bullets": [
|
||||
"Stop now halts realtime speech instantly, hold-to-talk is steadier, the voice overlay is easier to read, and a chosen voice applies in Auto mode.",
|
||||
"Realtime turns that reach back to Hermes no longer drop with a session error."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"title": "Make it yours",
|
||||
"date": "2026-06-20",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Personalize",
|
||||
"bullets": [
|
||||
"Eight app themes in Settings → Appearance — the Hermes Relay brand plus ports of the Nous Hermes looks (Teal, Nous Blue, Midnight, Ember, Mono, Cyberpunk, Rosé), with light/dark.",
|
||||
"Swap the agent orb for an animated pet that reacts to what the agent is doing — add, preview, and tune pets right in the app, or generate one from sprite art with the AI authoring kit.",
|
||||
"Reskin the sphere, and give each agent profile its own icon."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "See what's happening",
|
||||
"bullets": [
|
||||
"The chat status strip names the actual streaming path (Gateway, Sessions, Completions, Runs), with a basic→best tier ladder in Chat Settings.",
|
||||
"Tap the context meter for a \"What the agent sees\" sheet — the exact extra context prepended to your next turn.",
|
||||
"Voice and Realtime turns are badged in the scrollback."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Privacy",
|
||||
"bullets": [
|
||||
"When paired to the relay, the agent can mark private media and the phone blurs it per your setting — sensitivity stays model-emitted."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Faster & more reliable",
|
||||
"bullets": [
|
||||
"Cold start is about 3× faster, and model/personality/approvals load honestly instead of showing a maybe-wrong value.",
|
||||
"In-app crash reporting offers a one-tap, pre-filled bug report.",
|
||||
"QR pairing no longer force-closes on unusual cameras (foldables); fixed crashes opening server images and PDFs; in-chat model picks now apply."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice & terminal",
|
||||
"bullets": [
|
||||
"Enhanced voice control for Gemini and xAI providers.",
|
||||
"Leaner terminal with TUI-correct input and an isolated, tuned tmux."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.1.0",
|
||||
"title": "Release plumbing & polish",
|
||||
"date": "2026-06-16",
|
||||
"sections": [
|
||||
{
|
||||
"header": "New",
|
||||
"bullets": [
|
||||
"Automated Play Console upload when a release tag ships (a human still starts the rollout).",
|
||||
"/relay slash commands — status, devices, and pair from any platform — plus a relay-status badge in the dashboard header.",
|
||||
"The relay plugin prompts for its optional voice-provider keys on install, and a tools-only native install path."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Improved",
|
||||
"bullets": [
|
||||
"Settings overhaul: status pills are now exception-only, Power tools shows a single Plugin active/required/offline badge, and Connections moved to the top.",
|
||||
"Release names and notes are now split per surface (Android, plugin, CLI)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Fixed",
|
||||
"bullets": [
|
||||
"No more force-close on connect when the stored credential keyset was corrupt — it now heals in place.",
|
||||
"The installer works on uv-managed Hermes hosts, and the dashboard relay panel buttons are readable again."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"title": "Stable launch",
|
||||
"date": "2026-06-14",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Gateway chat with live thinking",
|
||||
"bullets": [
|
||||
"Chat can ride the upstream dashboard gateway — the only vanilla-upstream path that streams reasoning live, so the Thinking block and sphere light up during generation. \"Auto\" prefers it and falls back to the SSE endpoints per turn.",
|
||||
"Desktop parity: native image/PDF/file attachments, mid-turn steering, edit & resend, approval/clarify/sudo/secret cards, live subagent lanes, a context-window meter, server slash commands, and turn-complete notifications.",
|
||||
"Warm-start and an opt-in Keep connected in background toggle so long-backgrounded conversations resume instantly."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Agents, Manage & media",
|
||||
"bullets": [
|
||||
"Switch agent profiles per conversation — model, SOUL, personality, and skills — with the selection bound to the session, never changing the server default for other clients.",
|
||||
"Manage parity with the desktop dashboard: change models, manage provider keys, edit profiles and SOUL.md, and browse/install skills.",
|
||||
"Open and save chat images and attachments — full-screen viewer with pinch-zoom, plus an Open/Share/Save menu."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Standard path is first-class",
|
||||
"bullets": [
|
||||
"Chat, Manage, and voice all work against an unmodified upstream Hermes agent; the relay plugin is now purely additive.",
|
||||
"Seamless connection UX — LAN↔Tailscale handoffs and reconnects no longer reload the chat, and status shows as in-theme slide-down toasts.",
|
||||
"Persistent Realtime Agent voice that keeps one session across turns, with long runs promoted to tracked background tasks."
|
||||
]
|
||||
}
|
||||
]
|
||||
"header": "Clearer conversations",
|
||||
"bullets": [
|
||||
"Browse adjacent images as a gallery, read smoother streaming Markdown and wide tables, and see background-process completion as a compact process notice."
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.4.0",
|
||||
"title": "Realtime voice that finishes the job",
|
||||
"date": "2026-07-09",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Voice that keeps going",
|
||||
"bullets": [
|
||||
"Quick follow-ups can be answered while a long Hermes task runs, another long request can wait in a bounded queue, and the finished answer can stay in the selected realtime voice.",
|
||||
"Voice route recovery now waits for relay confirmation, replays unacknowledged input without starting a second Hermes run, and rejects stale sockets or sessions before they can overwrite a healthy connection.",
|
||||
"Listening, thinking, reconnecting, and cancellation states now settle cleanly after Stop, exit, route loss, or terminal retry failure."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Models and phone automation",
|
||||
"bullets": [
|
||||
"Realtime Agent model and voice choices apply to the next session, persist per connection/profile, and survive restart.",
|
||||
"Chat and Manage can refresh dynamic provider model catalogs on demand.",
|
||||
"Opt-in notification rules can offer a local Ask Hermes action, and Bridge tools can target a specific paired Android device."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Reliability and safety",
|
||||
"bullets": [
|
||||
"Long chat turns avoid premature transport fallback, and supported voice, card, and attachment context now reaches upstream Hermes through channels it consumes.",
|
||||
"Malformed server addresses fail through normal connection errors, older Android versions avoid newer collection APIs, and relay media blocks credential and token paths.",
|
||||
"Model management keeps unconfigured providers visible with key-setup guidance, and session cleanup gains export, prune preview/apply, archive, and restore plumbing."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.3.0",
|
||||
"title": "Voice that multitasks & sturdier chats",
|
||||
"date": "2026-07-06",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Voice, hands-free",
|
||||
"bullets": [
|
||||
"Ask for something big and keep talking — long tasks hand off to the background with a live chip showing the current step, steps done, and a running timer, with a tap-to-cancel. The answer is spoken when it's ready, even after a brief disconnect — and if the voice session is gone, it arrives as a notification (the full answer is always in the chat).",
|
||||
"Leaving voice mode (or tapping stop to interrupt speech) no longer cancels a running background task — the chip's ✕ is the one deliberate kill switch, and a delivered answer keeps its text instead of flipping to \"Cancelled.\"",
|
||||
"Quieter and quicker: the agent speaks at milestones instead of narrating every step, clearly long tasks hand off to the background right away, and the first turn starts faster — the session warms up when you open voice mode."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Chats that keep their answers",
|
||||
"bullets": [
|
||||
"An answer is no longer lost when the connection drops mid-reply on a long turn (slow local models, delegating skills) — the app quietly re-checks the conversation and completes the turn when the server finishes, with the usual done-notification if you've switched away.",
|
||||
"Markdown reads like chat: headings are proportionate instead of billboard-sized, lists and paragraphs share one size, links are clearly styled, and timestamps show once per message group."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Your agent can reach out",
|
||||
"bullets": [
|
||||
"Proactive messages: your Hermes agent can message your phone first (off by default, opt-in on both server and phone), and you can reply straight from the notification or the new Hermes inbox — the conversation continues like any other chat."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Make it yours",
|
||||
"bullets": [
|
||||
"Pick your app font — Inter (new default), Nunito, or your system font — applied instantly, everywhere.",
|
||||
"The in-bubble working indicator can be a small animated dot-matrix (Wave, Pulse, Bounce, Sparkle) with a color of your choice.",
|
||||
"Quick Controls at the top of Settings puts Persistent connection and Turn-complete alerts one tap away."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Setup & housekeeping",
|
||||
"bullets": [
|
||||
"Onboarding slides now scroll on small screens and large font sizes, so no setup guidance is cut off.",
|
||||
"Reporting a diagnostic files the right kind of issue: informational entries ask what you expected and file as a question, and every report carries your actual connection mode.",
|
||||
"Connections is a scannable list with a tabbed detail screen (Overview, Routes, Advanced, Security), and voice settings can now read and edit your server's voice engine (provider, voice, model) over the dashboard."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.6",
|
||||
"title": "Tidier chats & calmer status",
|
||||
"date": "2026-06-27",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Tidier chats",
|
||||
"bullets": [
|
||||
"Chats no longer get stuck showing \"Untitled\" — your first message stands in as the title until the chat is named, titles refresh once a turn settles, and a new refresh button in the session drawer pulls the latest on demand. Renaming a chat now sticks when you're on a non-default agent profile."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Calmer status",
|
||||
"bullets": [
|
||||
"Connection status — reconnecting, checking, network handoffs — now shows as a thin banner at the top that gently slides the screen down, instead of a card floating over your chat; the floating alert is kept for persistent errors. Quick confirmations (copied, profiles updated, profile/personality switches) land in the same calm banner instead of a pop-up at the bottom."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.5",
|
||||
"title": "Stability + Try the demo",
|
||||
"date": "2026-06-27",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app when a non-URL value — a UI label, or a line copied from the docs — was entered in the API server or Dashboard URL field. The setup fields now reject anything that isn't a valid host or http(s) URL with an inline error, and the dashboard and voice request paths treat a bad address as unreachable instead of crashing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Try the demo",
|
||||
"bullets": [
|
||||
"A new \"Try the demo\" option on the setup screen — and on the empty chat screen if you skip setup — opens an offline preview of the real chat experience: a sample conversation with Markdown, a tool-progress card, and a rich card, with no server, account, or network. A banner shows it's a demo, with a one-tap Connect to set up for real."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.4",
|
||||
"title": "Stability + connection security",
|
||||
"date": "2026-06-25",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app when the dashboard connection check hit a transient network failure — a pooled connection aborting or timing out over Tailscale. The check now reports the failure cleanly and the connection probe degrades gracefully instead of force-closing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "See if you're secure",
|
||||
"bullets": [
|
||||
"The chat status chip, connection card, and route picker now show at a glance whether your connection is encrypted — Encrypted · TLS, Encrypted · Tailscale (both secure), Mixed routes, or Not encrypted — and tapping it opens a per-transport breakdown (chat, API, relay tools). A Tailscale or WireGuard route is now correctly shown as encrypted rather than implied insecure."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"title": "Connection crash fix",
|
||||
"date": "2026-06-23",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Stability",
|
||||
"bullets": [
|
||||
"Fixed a crash that could close the app right after connecting over an encrypted link (Tailscale or HTTPS) — a live secure connection was being torn down on the main thread as it came up. Securing your connection no longer force-closes the app; plain-LAN connections were never affected."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.2",
|
||||
"title": "Multi-profile polish",
|
||||
"date": "2026-06-22",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Profiles that behave",
|
||||
"bullets": [
|
||||
"Deleting a session while a non-default agent profile is active now sticks — it no longer reappears after the list refreshes.",
|
||||
"On a cold start with a non-default profile selected, the session drawer opens on that profile's chats directly instead of briefly showing the default profile's."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Clearer diagnostics",
|
||||
"bullets": [
|
||||
"Diagnostics is now a full screen led by a top-to-bottom list of subsystem health checks — network, API server, chat transport, pairing, relay, and voice — each with a pass / warning / fail state and the reason when something's wrong; tap a failing check for full detail. The recent-activity log stays below."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Small touches",
|
||||
"bullets": [
|
||||
"The default connection is now simply \"Hermes\" (and the optional power features are labelled \"Relay\"), across setup, the switcher, voice, and permissions.",
|
||||
"Distraction-free chat mode gives its text a taller, scrollable area."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.1",
|
||||
"title": "Polish & control",
|
||||
"date": "2026-06-21",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Yours to control",
|
||||
"bullets": [
|
||||
"Lock the app to a single agent profile (Settings → Profile lock) and hide the rest from the pickers."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Find your way back",
|
||||
"bullets": [
|
||||
"A new \"What's New\" entry in Settings shows current and past release notes any time — not just after an update."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "When something breaks",
|
||||
"bullets": [
|
||||
"Diagnostics show clean error titles — tap any entry for a detail view with Copy, Share, and a one-tap GitHub issue.",
|
||||
"A tasteful in-app banner tells you when a newer version is live (Play or sideload) — dismissable, and it never nags."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice fixes",
|
||||
"bullets": [
|
||||
"Stop now halts realtime speech instantly, hold-to-talk is steadier, the voice overlay is easier to read, and a chosen voice applies in Auto mode.",
|
||||
"Realtime turns that reach back to Hermes no longer drop with a session error."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"title": "Make it yours",
|
||||
"date": "2026-06-20",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Personalize",
|
||||
"bullets": [
|
||||
"Eight app themes in Settings → Appearance — the Hermes Relay brand plus ports of the Nous Hermes looks (Teal, Nous Blue, Midnight, Ember, Mono, Cyberpunk, Rosé), with light/dark.",
|
||||
"Swap the agent orb for an animated pet that reacts to what the agent is doing — add, preview, and tune pets right in the app, or generate one from sprite art with the AI authoring kit.",
|
||||
"Reskin the sphere, and give each agent profile its own icon."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "See what's happening",
|
||||
"bullets": [
|
||||
"The chat status strip names the actual streaming path (Gateway, Sessions, Completions, Runs), with a basic→best tier ladder in Chat Settings.",
|
||||
"Tap the context meter for a \"What the agent sees\" sheet — the exact extra context prepended to your next turn.",
|
||||
"Voice and Realtime turns are badged in the scrollback."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Privacy",
|
||||
"bullets": [
|
||||
"When paired to the relay, the agent can mark private media and the phone blurs it per your setting — sensitivity stays model-emitted."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Faster & more reliable",
|
||||
"bullets": [
|
||||
"Cold start is about 3× faster, and model/personality/approvals load honestly instead of showing a maybe-wrong value.",
|
||||
"In-app crash reporting offers a one-tap, pre-filled bug report.",
|
||||
"QR pairing no longer force-closes on unusual cameras (foldables); fixed crashes opening server images and PDFs; in-chat model picks now apply."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Voice & terminal",
|
||||
"bullets": [
|
||||
"Enhanced voice control for Gemini and xAI providers.",
|
||||
"Leaner terminal with TUI-correct input and an isolated, tuned tmux."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.1.0",
|
||||
"title": "Release plumbing & polish",
|
||||
"date": "2026-06-16",
|
||||
"sections": [
|
||||
{
|
||||
"header": "New",
|
||||
"bullets": [
|
||||
"Automated Play Console upload when a release tag ships (a human still starts the rollout).",
|
||||
"/relay slash commands — status, devices, and pair from any platform — plus a relay-status badge in the dashboard header.",
|
||||
"The relay plugin prompts for its optional voice-provider keys on install, and a tools-only native install path."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Improved",
|
||||
"bullets": [
|
||||
"Settings overhaul: status pills are now exception-only, Power tools shows a single Plugin active/required/offline badge, and Connections moved to the top.",
|
||||
"Release names and notes are now split per surface (Android, plugin, CLI)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Fixed",
|
||||
"bullets": [
|
||||
"No more force-close on connect when the stored credential keyset was corrupt — it now heals in place.",
|
||||
"The installer works on uv-managed Hermes hosts, and the dashboard relay panel buttons are readable again."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"title": "Stable launch",
|
||||
"date": "2026-06-14",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Gateway chat with live thinking",
|
||||
"bullets": [
|
||||
"Chat can ride the upstream dashboard gateway — the only vanilla-upstream path that streams reasoning live, so the Thinking block and sphere light up during generation. \"Auto\" prefers it and falls back to the SSE endpoints per turn.",
|
||||
"Desktop parity: native image/PDF/file attachments, mid-turn steering, edit & resend, approval/clarify/sudo/secret cards, live subagent lanes, a context-window meter, server slash commands, and turn-complete notifications.",
|
||||
"Warm-start and an opt-in Keep connected in background toggle so long-backgrounded conversations resume instantly."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Agents, Manage & media",
|
||||
"bullets": [
|
||||
"Switch agent profiles per conversation — model, SOUL, personality, and skills — with the selection bound to the session, never changing the server default for other clients.",
|
||||
"Manage parity with the desktop dashboard: change models, manage provider keys, edit profiles and SOUL.md, and browse/install skills.",
|
||||
"Open and save chat images and attachments — full-screen viewer with pinch-zoom, plus an Open/Share/Save menu."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Standard path is first-class",
|
||||
"bullets": [
|
||||
"Chat, Manage, and voice all work against an unmodified upstream Hermes agent; the relay plugin is now purely additive.",
|
||||
"Seamless connection UX — LAN↔Tailscale handoffs and reconnects no longer reload the chat, and status shows as in-theme slide-down toasts.",
|
||||
"Persistent Realtime Agent voice that keeps one session across turns, with long runs promoted to tracked background tasks."
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
v1.2.6 - Tidier chats & calmer status
|
||||
v1.4.1 - Chat that keeps up
|
||||
|
||||
Chats
|
||||
* Chats no longer get stuck on "Untitled" — your first message stands
|
||||
in as the title until the chat is named, titles refresh once a turn
|
||||
settles, and a new refresh button in the session drawer pulls the
|
||||
latest. Renaming a chat now sticks on non-default agent profiles.
|
||||
Chat
|
||||
* Follow background work from a live process strip; its result appears automatically in the same conversation.
|
||||
* Reopen while an answer runs: partial text, thinking, tool progress, and approvals return.
|
||||
|
||||
Calmer status
|
||||
* Connection status (reconnecting, checking, network handoffs) now
|
||||
shows as a thin banner at the top that gently slides the screen
|
||||
down, instead of a card floating over your chat — the floating alert
|
||||
is kept for persistent errors. Quick confirmations land there too.
|
||||
Voice
|
||||
* Speak commands to pause, resume, cancel, repeat a result, or start Standard voice chat.
|
||||
* Pick Hands-free, Low latency, Careful tools, or Quiet presets.
|
||||
|
||||
Polish
|
||||
* Multi-image galleries plus smoother streaming Markdown and long tables.
|
||||
|
||||
@@ -15,6 +15,7 @@ import android.os.HandlerThread
|
||||
import android.util.DisplayMetrics
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -114,8 +115,27 @@ class ScreenCapture(
|
||||
*/
|
||||
private const val MAX_IMAGES = 2
|
||||
|
||||
/** Capture timeout — if no frame arrives in this window, fail loudly. */
|
||||
private const val CAPTURE_TIMEOUT_MS = 2_500L
|
||||
/**
|
||||
* Capture timeout — if no frame arrives in this window, fail loudly.
|
||||
*
|
||||
* BOOX / e-ink devices can take several seconds before a
|
||||
* VirtualDisplay-backed ImageReader emits its first frame, especially
|
||||
* after a fresh MediaProjection grant or when the display is idle. Keep
|
||||
* the default generous enough for those devices while still bounded so
|
||||
* a dead capture pipeline reports a clear error.
|
||||
*/
|
||||
private const val DEFAULT_CAPTURE_TIMEOUT_MS = 10_000L
|
||||
|
||||
/** Optional JVM/system-property override for local QA and OEM tuning. */
|
||||
private const val CAPTURE_TIMEOUT_PROPERTY =
|
||||
"hermes.relay.screen_capture_timeout_ms"
|
||||
|
||||
private const val MIN_CAPTURE_TIMEOUT_MS = 2_500L
|
||||
private const val MAX_CAPTURE_TIMEOUT_MS = 30_000L
|
||||
|
||||
/** One retry covers stale VirtualDisplay/ImageReader pipelines. */
|
||||
private const val MAX_CAPTURE_ATTEMPTS = 2
|
||||
private const val CAPTURE_RETRY_DELAY_MS = 350L
|
||||
}
|
||||
|
||||
// === PHASE3-bridge-ui-followup: MediaProjection reuse fix ===
|
||||
@@ -211,7 +231,27 @@ class ScreenCapture(
|
||||
// mutex keeps us honest if anything ever parallelizes.
|
||||
val pngBytes = try {
|
||||
captureMutex.withLock {
|
||||
captureFrame(projection)
|
||||
var lastTimeout: CaptureTimeoutException? = null
|
||||
for (attempt in 1..MAX_CAPTURE_ATTEMPTS) {
|
||||
try {
|
||||
return@withLock captureFrame(projection)
|
||||
} catch (e: CaptureTimeoutException) {
|
||||
lastTimeout = e
|
||||
Log.w(
|
||||
TAG,
|
||||
"screen capture timed out on attempt " +
|
||||
"$attempt/$MAX_CAPTURE_ATTEMPTS: ${e.message}"
|
||||
)
|
||||
if (attempt < MAX_CAPTURE_ATTEMPTS) {
|
||||
// A timeout can leave an OEM VirtualDisplay path
|
||||
// wedged without invalidating the MediaProjection
|
||||
// grant. Rebuild our pipeline once before giving up.
|
||||
releaseCache()
|
||||
delay(CAPTURE_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastTimeout ?: IOException("screen capture timed out")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "captureFrame failed: ${e.message}")
|
||||
@@ -286,16 +326,28 @@ class ScreenCapture(
|
||||
}
|
||||
|
||||
return try {
|
||||
kotlinx.coroutines.withTimeout(CAPTURE_TIMEOUT_MS) { deferred.await() }
|
||||
val timeoutMs = captureTimeoutMs()
|
||||
kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
|
||||
} catch (e: kotlinx.coroutines.TimeoutCancellationException) {
|
||||
pendingCaptureRef.compareAndSet(deferred, null)
|
||||
throw IOException("screen capture timed out")
|
||||
throw CaptureTimeoutException(
|
||||
"screen capture timed out after ${captureTimeoutMs()}ms"
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
pendingCaptureRef.compareAndSet(deferred, null)
|
||||
throw t
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureTimeoutMs(): Long {
|
||||
val configured = System.getProperty(CAPTURE_TIMEOUT_PROPERTY)
|
||||
?.toLongOrNull()
|
||||
?.coerceIn(MIN_CAPTURE_TIMEOUT_MS, MAX_CAPTURE_TIMEOUT_MS)
|
||||
return configured ?: DEFAULT_CAPTURE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
private class CaptureTimeoutException(message: String) : IOException(message)
|
||||
|
||||
/**
|
||||
* Build (or reuse) the cached VirtualDisplay + ImageReader + HandlerThread
|
||||
* for this projection. Rebuilds when:
|
||||
@@ -489,7 +541,7 @@ class ScreenCapture(
|
||||
fastClient.newCall(request).execute().use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val raw = response.body?.string().orEmpty()
|
||||
val raw = response.body.string()
|
||||
val token = extractToken(raw)
|
||||
if (token.isNullOrBlank()) {
|
||||
Result.failure(
|
||||
|
||||
@@ -225,7 +225,7 @@ class RealtimePcmPlayer(context: Context? = null) {
|
||||
// is the chunk's end frame. The cursor reaches this amplitude once
|
||||
// playbackHeadPosition passes the previous end frame.
|
||||
playbackAmpQueue.addLast(FrameAmp(endFrame = totalFramesWritten, rms = rms))
|
||||
while (playbackAmpQueue.size > MAX_AMP_QUEUE) playbackAmpQueue.removeFirst()
|
||||
while (playbackAmpQueue.size > MAX_AMP_QUEUE) playbackAmpQueue.removeAt(0)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,7 +240,7 @@ class RealtimePcmPlayer(context: Context? = null) {
|
||||
val head = readHeadFrames(track).toLong()
|
||||
// Drop fully-played chunks so the head of the queue is the one playing now.
|
||||
while (playbackAmpQueue.size > 1 && playbackAmpQueue.first().endFrame <= head) {
|
||||
playbackAmpQueue.removeFirst()
|
||||
playbackAmpQueue.removeAt(0)
|
||||
}
|
||||
amplitudeAtHead(playbackAmpQueue, head)
|
||||
}
|
||||
|
||||
@@ -141,6 +141,9 @@ class VoiceRecorder(
|
||||
fun stopRecording(): File {
|
||||
val file = currentOutputFile
|
||||
?: throw IllegalStateException("stopRecording called with no active recording")
|
||||
// Claim the capture exactly once. A stale UI stop must not repackage
|
||||
// the previous PCM as a second voice turn.
|
||||
currentOutputFile = null
|
||||
|
||||
val record = audioRecord
|
||||
stopRequested.set(true)
|
||||
@@ -207,8 +210,15 @@ class VoiceRecorder(
|
||||
}
|
||||
}
|
||||
updateAmplitude(buffer, read)
|
||||
} else if (read < 0) {
|
||||
Log.w(TAG, "AudioRecord.read ended with error code $read")
|
||||
break
|
||||
}
|
||||
}
|
||||
// Android can terminate capture while the app is backgrounded without
|
||||
// stopRecording() running. Reflect that loss in isRecording() so the
|
||||
// foreground UI can recover instead of remaining stuck on Listening.
|
||||
stopRequested.set(true)
|
||||
}
|
||||
|
||||
private fun updateAmplitude(buffer: ByteArray, read: Int) {
|
||||
|
||||
@@ -82,9 +82,9 @@ class BargeInPreferencesRepository(
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
companion object {
|
||||
private val KEY_ENABLED = booleanPreferencesKey("barge_in_enabled")
|
||||
private val KEY_SENSITIVITY = stringPreferencesKey("barge_in_sensitivity")
|
||||
private val KEY_RESUME_AFTER_INTERRUPTION =
|
||||
internal val KEY_ENABLED = booleanPreferencesKey("barge_in_enabled")
|
||||
internal val KEY_SENSITIVITY = stringPreferencesKey("barge_in_sensitivity")
|
||||
internal val KEY_RESUME_AFTER_INTERRUPTION =
|
||||
booleanPreferencesKey("barge_in_resume_after_interruption")
|
||||
}
|
||||
|
||||
|
||||
@@ -120,8 +120,42 @@ data class ChatMessage(
|
||||
* no status affix.
|
||||
*/
|
||||
val deliveryStatus: MessageDeliveryStatus? = null,
|
||||
/**
|
||||
* Client-side lifecycle for a promoted/durable Hermes run that belongs to
|
||||
* this assistant turn. The same message owns the state from promotion
|
||||
* through delivery so Chat never needs a separate system notice and final
|
||||
* reply for one task. On the normal post-turn history reconcile this field
|
||||
* is carried forward with the rest of the client-only enrichment whenever
|
||||
* the live message can be matched to its server row.
|
||||
*/
|
||||
val backgroundTask: BackgroundTaskState? = null,
|
||||
)
|
||||
|
||||
/** One Chat-visible identity for a promoted/durable realtime Hermes run. */
|
||||
data class BackgroundTaskState(
|
||||
/** Relay run id when supplied; otherwise a stable id derived from the message. */
|
||||
val id: String,
|
||||
/** Short objective derived from the associated user turn. */
|
||||
val title: String,
|
||||
/** ADR 33 tier: `promoted` or `durable`. */
|
||||
val tier: String = "promoted",
|
||||
val phase: BackgroundTaskPhase = BackgroundTaskPhase.RUNNING,
|
||||
/** Latest meaningful progress line, deliberately not a raw event trace. */
|
||||
val statusLine: String? = null,
|
||||
val completedToolCount: Int = 0,
|
||||
val queuedCount: Int = 0,
|
||||
val startedAt: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
enum class BackgroundTaskPhase {
|
||||
RUNNING,
|
||||
WAITING,
|
||||
DELIVERING,
|
||||
COMPLETE,
|
||||
FAILED,
|
||||
CANCELLED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured details about a phone-local voice intent that was dispatched
|
||||
* in-process via [com.hermesandroid.relay.network.relay.BridgeCommandHandler.handleLocalCommand].
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Durable, client-owned snapshot of one in-flight chat turn.
|
||||
*
|
||||
* Hermes history is authoritative once a turn finishes, but it cannot recreate
|
||||
* transient UI that existed before persistence (live reasoning, a running tool,
|
||||
* an interactive ask, or the latest lifecycle line). This checkpoint bridges
|
||||
* that gap across Activity recreation and process death. It deliberately stores
|
||||
* no entered secret/approval response; only the server-issued ask is retained.
|
||||
*/
|
||||
@Serializable
|
||||
data class ChatTurnCheckpoint(
|
||||
val schemaVersion: Int = CURRENT_SCHEMA,
|
||||
val contextKey: String,
|
||||
val sessionId: String,
|
||||
val liveSessionId: String? = null,
|
||||
val transport: String,
|
||||
val user: ChatTurnUserCheckpoint,
|
||||
val assistant: ChatTurnAssistantCheckpoint,
|
||||
val turnStatus: String? = null,
|
||||
val priorUserMessageCount: Int,
|
||||
val baselineAssistantCount: Int,
|
||||
val pendingAsk: ChatTurnAskCheckpoint? = null,
|
||||
val startedAt: Long,
|
||||
val updatedAt: Long,
|
||||
) {
|
||||
companion object {
|
||||
const val CURRENT_SCHEMA = 1
|
||||
const val MAX_AGE_MS = 24L * 60L * 60L * 1_000L
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ChatTurnUserCheckpoint(
|
||||
val id: String,
|
||||
val content: String,
|
||||
val timestamp: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatTurnAssistantCheckpoint(
|
||||
val id: String,
|
||||
val content: String = "",
|
||||
val timestamp: Long,
|
||||
val isStreaming: Boolean = true,
|
||||
val thinkingContent: String = "",
|
||||
val isThinkingStreaming: Boolean = false,
|
||||
val inputTokens: Int? = null,
|
||||
val outputTokens: Int? = null,
|
||||
val totalTokens: Int? = null,
|
||||
val estimatedCost: Double? = null,
|
||||
val agentName: String? = null,
|
||||
val badges: List<String> = emptyList(),
|
||||
val cards: List<HermesCard> = emptyList(),
|
||||
val cardDispatches: List<HermesCardDispatch> = emptyList(),
|
||||
val toolCalls: List<ChatTurnToolCheckpoint> = emptyList(),
|
||||
val backgroundTask: ChatTurnBackgroundTaskCheckpoint? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatTurnToolCheckpoint(
|
||||
val id: String? = null,
|
||||
val name: String,
|
||||
val result: String? = null,
|
||||
val success: Boolean? = null,
|
||||
val isComplete: Boolean = false,
|
||||
val error: String? = null,
|
||||
val runId: String? = null,
|
||||
val provenance: String? = null,
|
||||
val startedAt: Long,
|
||||
val completedAt: Long? = null,
|
||||
val isGenerating: Boolean = false,
|
||||
val taskIndex: Int? = null,
|
||||
val taskLabel: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatTurnBackgroundTaskCheckpoint(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val tier: String,
|
||||
val phase: String,
|
||||
val statusLine: String? = null,
|
||||
val completedToolCount: Int = 0,
|
||||
val queuedCount: Int = 0,
|
||||
val startedAt: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChatTurnAskCheckpoint(
|
||||
val kind: String,
|
||||
val requestId: String? = null,
|
||||
val text: String,
|
||||
val choices: List<String>? = null,
|
||||
val envVar: String? = null,
|
||||
val timeoutSeconds: Int,
|
||||
val messageId: String,
|
||||
val cardKey: String,
|
||||
/** Original receive time, used to preserve an ask's expiry after reopen. */
|
||||
val receivedAt: Long,
|
||||
)
|
||||
|
||||
interface ChatTurnCheckpointStore {
|
||||
suspend fun read(): ChatTurnCheckpoint?
|
||||
suspend fun write(checkpoint: ChatTurnCheckpoint)
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
class DataStoreChatTurnCheckpointStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
) : ChatTurnCheckpointStore {
|
||||
constructor(context: Context) : this(context.applicationContext.relayDataStore)
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
override suspend fun read(): ChatTurnCheckpoint? {
|
||||
val raw = runCatching { dataStore.data.first()[KEY_CHECKPOINT] }.getOrNull()
|
||||
?: return null
|
||||
val checkpoint = runCatching { json.decodeFromString<ChatTurnCheckpoint>(raw) }.getOrNull()
|
||||
if (checkpoint == null ||
|
||||
checkpoint.schemaVersion != ChatTurnCheckpoint.CURRENT_SCHEMA ||
|
||||
now() - checkpoint.updatedAt > ChatTurnCheckpoint.MAX_AGE_MS
|
||||
) {
|
||||
// Cleanup is best-effort. In particular, Windows can briefly keep
|
||||
// the just-read preferences file open and reject DataStore's atomic
|
||||
// temp-file rename; an invalid checkpoint must still read as null.
|
||||
runCatching { clear() }
|
||||
return null
|
||||
}
|
||||
return checkpoint
|
||||
}
|
||||
|
||||
override suspend fun write(checkpoint: ChatTurnCheckpoint) {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[KEY_CHECKPOINT] = json.encodeToString(checkpoint)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
dataStore.edit { preferences -> preferences.remove(KEY_CHECKPOINT) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val KEY_CHECKPOINT = stringPreferencesKey("chat_inflight_turn_checkpoint_v1")
|
||||
}
|
||||
}
|
||||
@@ -108,9 +108,36 @@ object DemoContent {
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Assistant reply appended when the user sends a message INSIDE demo
|
||||
* mode. The composer must not be a silent no-op (it reads as broken —
|
||||
* see the demo-polish TODO), but there is no server to answer, so the
|
||||
* "reply" is an honest notice pointing at the exit path. Same content
|
||||
* contract as the transcript: clientOnly, terminal, zero network.
|
||||
*
|
||||
* @param id unique message id supplied by the caller (UUID-based; two
|
||||
* rapid sends must not collide on LazyColumn keys).
|
||||
* @param nowMs wall-clock timestamp for the bubble.
|
||||
*/
|
||||
fun composerReply(id: String, nowMs: Long): ChatMessage = ChatMessage(
|
||||
id = id,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = COMPOSER_REPLY,
|
||||
timestamp = nowMs,
|
||||
agentName = DEMO_AGENT_NAME,
|
||||
badges = listOf("Demo"),
|
||||
clientOnly = true,
|
||||
)
|
||||
|
||||
// --- Message bodies (Markdown). Kept as constants so the content is easy
|
||||
// to scan and the [transcript] builder stays readable. ---
|
||||
|
||||
private val COMPOSER_REPLY: String = """
|
||||
This is the offline demo, so I can't answer for real — nothing here talks to a server.
|
||||
|
||||
Connect your own Hermes server to chat live: tap **Connect** in the demo banner above.
|
||||
""".trimIndent()
|
||||
|
||||
private val ASSISTANT_TOUR: String = """
|
||||
I'm **Hermes**, the agent running on *your* server. Here's a quick tour of what this app surfaces:
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* A process event that upstream Hermes injected into transcript history as a
|
||||
* synthetic user message.
|
||||
*
|
||||
* Hermes intentionally persists these events with role=user so the agent can
|
||||
* react to them without breaking message-role alternation. UI code should use
|
||||
* [ChatMessage.hermesProcessNotificationOrNull] to present them as process
|
||||
* notices without changing their canonical role or content.
|
||||
*/
|
||||
data class HermesProcessNotification(
|
||||
val processId: String,
|
||||
val headline: String,
|
||||
val detail: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Recognizes the exact envelope emitted by upstream
|
||||
* `tools.process_registry.format_process_notification` for background-process
|
||||
* completion and watch events.
|
||||
*
|
||||
* The parser deliberately excludes other `[IMPORTANT: ...]` messages. Those
|
||||
* can carry unrelated agent instructions and must continue through the normal
|
||||
* transcript renderer.
|
||||
*/
|
||||
object HermesProcessNotificationParser {
|
||||
private const val ENVELOPE_PREFIX = "[IMPORTANT: Background process "
|
||||
private const val HEADLINE_PREFIX = "Background process "
|
||||
|
||||
fun parse(content: String): HermesProcessNotification? {
|
||||
val normalized = content.trim()
|
||||
if (!normalized.startsWith(ENVELOPE_PREFIX) || !normalized.endsWith(']')) {
|
||||
return null
|
||||
}
|
||||
|
||||
val body = normalized
|
||||
.removePrefix("[IMPORTANT: ")
|
||||
.dropLast(1)
|
||||
val headline = body.substringBefore('\n').trim()
|
||||
if (!headline.startsWith(HEADLINE_PREFIX)) return null
|
||||
|
||||
val identityAndStatus = headline.removePrefix(HEADLINE_PREFIX)
|
||||
val processId = identityAndStatus.substringBefore(' ')
|
||||
val status = identityAndStatus.substringAfter(' ', missingDelimiterValue = "")
|
||||
if (processId.isBlank() || status.isBlank()) return null
|
||||
|
||||
val detail = body
|
||||
.substringAfter('\n', missingDelimiterValue = "")
|
||||
.trim()
|
||||
.ifBlank { null }
|
||||
|
||||
return HermesProcessNotification(
|
||||
processId = processId,
|
||||
headline = headline,
|
||||
detail = detail,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the upstream process-notification presentation model only for the
|
||||
* canonical synthetic user-row shape. The original [ChatMessage.role] remains
|
||||
* [MessageRole.USER].
|
||||
*/
|
||||
fun ChatMessage.hermesProcessNotificationOrNull(): HermesProcessNotification? =
|
||||
takeIf { it.role == MessageRole.USER }
|
||||
?.content
|
||||
?.let(HermesProcessNotificationParser::parse)
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
/**
|
||||
* One-tap bundles over voice settings that already exist in the app and relay.
|
||||
*
|
||||
* Presets intentionally do not own voice identity or routing: engine, audio
|
||||
* route, provider, model, voice, enhanced-voice overrides, and background-run
|
||||
* concurrency all remain exactly as the user configured them. A preset only
|
||||
* coordinates interaction ergonomics, barge-in, Realtime trace/session
|
||||
* behavior, and the existing ADR 33 background-delivery controls.
|
||||
*/
|
||||
enum class VoiceModePreset(
|
||||
val displayName: String,
|
||||
val shortLabel: String,
|
||||
val description: String,
|
||||
internal val localSettings: VoicePresetLocalSettings,
|
||||
internal val bargeInUpdate: VoicePresetBargeInUpdate,
|
||||
val promotionUpdate: VoicePresetPromotionUpdate,
|
||||
) {
|
||||
HandsFree(
|
||||
displayName = "Hands-free",
|
||||
shortLabel = "Hands-free",
|
||||
description =
|
||||
"Continuous listening, exact answers, detailed trace, and low-noise " +
|
||||
"spoken progress after 15 seconds. Your barge-in choice is preserved.",
|
||||
localSettings = VoicePresetLocalSettings(
|
||||
interactionMode = "continuous",
|
||||
silenceThresholdMs = 1250L,
|
||||
realtimeTraceDetails = true,
|
||||
realtimePersistentSession = true,
|
||||
),
|
||||
// Barge-in remains an explicit experimental opt-in until echo and
|
||||
// self-recording hardening is complete. Never enable it via a preset.
|
||||
bargeInUpdate = VoicePresetBargeInUpdate(),
|
||||
promotionUpdate = VoicePresetPromotionUpdate(
|
||||
enabled = true,
|
||||
promoteAfterMs = 6000,
|
||||
backgroundDefaultMode = "promote",
|
||||
spokenHandoff = true,
|
||||
progressSpokenAfterMs = 15000,
|
||||
progressRepeatMs = 90000,
|
||||
resultDelivery = "speak_verbatim",
|
||||
),
|
||||
),
|
||||
LowLatency(
|
||||
displayName = "Low latency",
|
||||
shortLabel = "Fast",
|
||||
description =
|
||||
"Tap capture, the shortest supported silence window, a persistent " +
|
||||
"session, and a fast visual handoff for long work.",
|
||||
localSettings = VoicePresetLocalSettings(
|
||||
interactionMode = "tap",
|
||||
silenceThresholdMs = 750L,
|
||||
realtimeTraceDetails = false,
|
||||
realtimePersistentSession = true,
|
||||
),
|
||||
bargeInUpdate = VoicePresetBargeInUpdate(enabled = false),
|
||||
promotionUpdate = VoicePresetPromotionUpdate(
|
||||
enabled = true,
|
||||
promoteAfterMs = 2500,
|
||||
backgroundDefaultMode = "promote",
|
||||
spokenHandoff = false,
|
||||
progressSpokenAfterMs = 0,
|
||||
resultDelivery = "speak_when_idle",
|
||||
),
|
||||
),
|
||||
CarefulTools(
|
||||
displayName = "Careful tools",
|
||||
shortLabel = "Careful",
|
||||
description =
|
||||
"Hold-to-talk, uninterrupted foreground tool runs, a detailed trace, and exact result delivery.",
|
||||
localSettings = VoicePresetLocalSettings(
|
||||
interactionMode = "hold",
|
||||
silenceThresholdMs = 1750L,
|
||||
realtimeTraceDetails = true,
|
||||
realtimePersistentSession = true,
|
||||
),
|
||||
bargeInUpdate = VoicePresetBargeInUpdate(enabled = false),
|
||||
promotionUpdate = VoicePresetPromotionUpdate(
|
||||
enabled = false,
|
||||
backgroundDefaultMode = "foreground",
|
||||
spokenHandoff = false,
|
||||
progressSpokenAfterMs = 0,
|
||||
resultDelivery = "speak_verbatim",
|
||||
),
|
||||
),
|
||||
QuietVisualOnly(
|
||||
displayName = "Quiet / visual-only",
|
||||
shortLabel = "Quiet",
|
||||
description =
|
||||
"Manual capture with visual long-task handoffs and results. Normal short voice replies still speak.",
|
||||
localSettings = VoicePresetLocalSettings(
|
||||
interactionMode = "tap",
|
||||
silenceThresholdMs = 1250L,
|
||||
realtimeTraceDetails = true,
|
||||
realtimePersistentSession = true,
|
||||
),
|
||||
bargeInUpdate = VoicePresetBargeInUpdate(enabled = false),
|
||||
promotionUpdate = VoicePresetPromotionUpdate(
|
||||
enabled = true,
|
||||
promoteAfterMs = 6000,
|
||||
backgroundDefaultMode = "promote",
|
||||
spokenHandoff = false,
|
||||
progressSpokenAfterMs = 0,
|
||||
resultDelivery = "visual_only",
|
||||
),
|
||||
);
|
||||
|
||||
/** Apply only fields owned by this preset; every other value is preserved. */
|
||||
fun applyTo(current: VoiceModePresetState): VoiceModePresetState =
|
||||
current.copy(
|
||||
voiceSettings = current.voiceSettings.copy(
|
||||
interactionMode = localSettings.interactionMode,
|
||||
silenceThresholdMs = localSettings.silenceThresholdMs,
|
||||
realtimeTraceDetails = localSettings.realtimeTraceDetails,
|
||||
realtimePersistentSession = localSettings.realtimePersistentSession,
|
||||
),
|
||||
bargeInPreferences = current.bargeInPreferences.copy(
|
||||
enabled = bargeInUpdate.enabled ?: current.bargeInPreferences.enabled,
|
||||
sensitivity =
|
||||
bargeInUpdate.sensitivity ?: current.bargeInPreferences.sensitivity,
|
||||
resumeAfterInterruption = bargeInUpdate.resumeAfterInterruption
|
||||
?: current.bargeInPreferences.resumeAfterInterruption,
|
||||
),
|
||||
promotion = current.promotion?.let(promotionUpdate::applyTo),
|
||||
)
|
||||
|
||||
/** A preset is active only when every field it owns still matches. */
|
||||
fun matches(current: VoiceModePresetState): Boolean =
|
||||
current.promotion != null && applyTo(current) == current
|
||||
}
|
||||
|
||||
/** Snapshot used by the pure preset reducer and active-preset detector. */
|
||||
data class VoiceModePresetState(
|
||||
val voiceSettings: VoiceSettings,
|
||||
val bargeInPreferences: BargeInPreferences,
|
||||
val promotion: VoicePresetPromotionSettings?,
|
||||
)
|
||||
|
||||
/** Relay promotion values mirrored without introducing a data -> network dependency. */
|
||||
data class VoicePresetPromotionSettings(
|
||||
val enabled: Boolean = true,
|
||||
val promoteAfterMs: Int = 6000,
|
||||
val backgroundDefaultMode: String = "promote",
|
||||
val spokenHandoff: Boolean = true,
|
||||
val progressSpokenAfterMs: Int = 0,
|
||||
val progressRepeatMs: Int = 90000,
|
||||
val resultDelivery: String = "speak_verbatim",
|
||||
val maxBackgroundRuns: Int = 1,
|
||||
)
|
||||
|
||||
/** Nullable fields map directly to RelayVoiceClient's partial PATCH contract. */
|
||||
data class VoicePresetPromotionUpdate(
|
||||
val enabled: Boolean? = null,
|
||||
val promoteAfterMs: Int? = null,
|
||||
val backgroundDefaultMode: String? = null,
|
||||
val spokenHandoff: Boolean? = null,
|
||||
val progressSpokenAfterMs: Int? = null,
|
||||
val progressRepeatMs: Int? = null,
|
||||
val resultDelivery: String? = null,
|
||||
val maxBackgroundRuns: Int? = null,
|
||||
) {
|
||||
internal fun applyTo(current: VoicePresetPromotionSettings): VoicePresetPromotionSettings =
|
||||
current.copy(
|
||||
enabled = enabled ?: current.enabled,
|
||||
promoteAfterMs = promoteAfterMs ?: current.promoteAfterMs,
|
||||
backgroundDefaultMode = backgroundDefaultMode ?: current.backgroundDefaultMode,
|
||||
spokenHandoff = spokenHandoff ?: current.spokenHandoff,
|
||||
progressSpokenAfterMs = progressSpokenAfterMs ?: current.progressSpokenAfterMs,
|
||||
progressRepeatMs = progressRepeatMs ?: current.progressRepeatMs,
|
||||
resultDelivery = resultDelivery ?: current.resultDelivery,
|
||||
maxBackgroundRuns = maxBackgroundRuns ?: current.maxBackgroundRuns,
|
||||
)
|
||||
}
|
||||
|
||||
internal data class VoicePresetLocalSettings(
|
||||
val interactionMode: String,
|
||||
val silenceThresholdMs: Long,
|
||||
val realtimeTraceDetails: Boolean,
|
||||
val realtimePersistentSession: Boolean,
|
||||
)
|
||||
|
||||
internal data class VoicePresetBargeInUpdate(
|
||||
val enabled: Boolean? = null,
|
||||
val sensitivity: BargeInSensitivity? = null,
|
||||
val resumeAfterInterruption: Boolean? = null,
|
||||
)
|
||||
|
||||
/** Null means the current manual values are Custom. */
|
||||
fun detectVoiceModePreset(current: VoiceModePresetState): VoiceModePreset? =
|
||||
VoiceModePreset.entries.firstOrNull { it.matches(current) }
|
||||
@@ -44,6 +44,9 @@ data class VoiceSettings(
|
||||
* docs/plans/2026-05-24-realtime-persistent-session.md.
|
||||
*/
|
||||
val realtimePersistentSession: Boolean = true,
|
||||
/** Per-profile Realtime Agent session overrides; blank uses relay config. */
|
||||
val realtimeModel: String = "",
|
||||
val realtimeVoice: String = "",
|
||||
/**
|
||||
* Enhanced-voice overrides for the relay TTS path, mapped onto the active
|
||||
* provider (Gemini / xAI). Empty string / false means "use the server's
|
||||
@@ -154,9 +157,9 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
// over the hard default — see [scopedName] / [resolveString].
|
||||
//
|
||||
// Why these are per-profile: engine mode, audio route, and the
|
||||
// enhanced-voice overrides describe *which voice the agent speaks
|
||||
// with*, which is a property of the profile (the relay already
|
||||
// persists `voice_output:`/`realtime_voice:` per profile and
|
||||
// enhanced-voice and realtime-session overrides describe *which voice
|
||||
// the agent speaks with*, which is a property of the profile (the relay
|
||||
// already persists `voice_output:`/`realtime_voice:` per profile and
|
||||
// `RelayVoiceClient` already sends `?profile=`). Keeping them global
|
||||
// leaked one profile's voice onto every other profile.
|
||||
private const val KEY_ENGINE_MODE = "voice_engine_mode"
|
||||
@@ -166,6 +169,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
private const val KEY_ENH_AUDIO_TAGS = "voice_enh_audio_tags"
|
||||
private const val KEY_ENH_PERSONA = "voice_enh_persona"
|
||||
private const val KEY_ENH_LANGUAGE = "voice_enh_language"
|
||||
private const val KEY_REALTIME_MODEL = "voice_realtime_model"
|
||||
private const val KEY_REALTIME_VOICE = "voice_realtime_voice"
|
||||
|
||||
// --- Global keys (shared across profiles; never namespaced) ----------
|
||||
// Why these stay global: interaction-mode and silence-threshold are
|
||||
@@ -214,8 +219,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
|
||||
/**
|
||||
* Point the repository at a (connection, profile) scope. Per-profile reads
|
||||
* and writes (engine/route/enhanced) re-target the namespaced keys for that
|
||||
* profile; global prefs are unaffected. Passing a null/blank profile name
|
||||
* and writes (engine/route/enhanced/realtime) re-target the namespaced keys
|
||||
* for that profile; global prefs are unaffected. Passing a null/blank profile name
|
||||
* reverts per-profile reads/writes to the global base layer (the default
|
||||
* profile). Idempotent — a no-op when the normalized scope is unchanged.
|
||||
*/
|
||||
@@ -248,6 +253,8 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
enhancedAudioTags = resolveBoolean(prefs, KEY_ENH_AUDIO_TAGS, scope, false),
|
||||
enhancedPersona = resolveString(prefs, KEY_ENH_PERSONA, scope, ""),
|
||||
enhancedLanguage = resolveString(prefs, KEY_ENH_LANGUAGE, scope, ""),
|
||||
realtimeModel = resolveString(prefs, KEY_REALTIME_MODEL, scope, ""),
|
||||
realtimeVoice = resolveString(prefs, KEY_REALTIME_VOICE, scope, ""),
|
||||
// --- global (shared across profiles) ---
|
||||
interactionMode = prefs[KEY_INTERACTION_MODE] ?: DEFAULT_INTERACTION_MODE,
|
||||
silenceThresholdMs = prefs[KEY_SILENCE_THRESHOLD_MS] ?: DEFAULT_SILENCE_THRESHOLD_MS,
|
||||
@@ -327,6 +334,29 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
dataStore.edit { it[key] = language.trim() }
|
||||
}
|
||||
|
||||
/** "" clears the override so new sessions use the relay's saved model. */
|
||||
suspend fun setRealtimeModel(model: String) {
|
||||
val key = stringPreferencesKey(scopedName(KEY_REALTIME_MODEL, _scope.value))
|
||||
dataStore.edit { it[key] = model.trim() }
|
||||
}
|
||||
|
||||
/** "" clears the override so new sessions use the relay's saved voice. */
|
||||
suspend fun setRealtimeVoice(voice: String) {
|
||||
val key = stringPreferencesKey(scopedName(KEY_REALTIME_VOICE, _scope.value))
|
||||
dataStore.edit { it[key] = voice.trim() }
|
||||
}
|
||||
|
||||
/** Persist a compatible model/voice pair without exposing a half-updated snapshot. */
|
||||
suspend fun setRealtimeSelection(model: String, voice: String) {
|
||||
val scope = _scope.value
|
||||
val modelKey = stringPreferencesKey(scopedName(KEY_REALTIME_MODEL, scope))
|
||||
val voiceKey = stringPreferencesKey(scopedName(KEY_REALTIME_VOICE, scope))
|
||||
dataStore.edit {
|
||||
it[modelKey] = model.trim()
|
||||
it[voiceKey] = voice.trim()
|
||||
}
|
||||
}
|
||||
|
||||
// --- global setters (always the un-namespaced key) -----------------------
|
||||
|
||||
suspend fun setInteractionMode(mode: String) {
|
||||
@@ -344,4 +374,31 @@ class VoicePreferencesRepository(private val dataStore: DataStore<Preferences>)
|
||||
suspend fun setRealtimePersistentSession(enabled: Boolean) {
|
||||
dataStore.edit { it[KEY_REALTIME_PERSISTENT_SESSION] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically apply the phone-side portion of [preset]. Only fields owned by
|
||||
* the preset are written, so route/provider/model/voice overrides and other
|
||||
* preferences remain untouched. Barge-in shares this DataStore and is
|
||||
* updated in the same transaction so observers never see a half-applied
|
||||
* local preset.
|
||||
*/
|
||||
suspend fun applyModePreset(preset: VoiceModePreset) {
|
||||
val local = preset.localSettings
|
||||
val bargeIn = preset.bargeInUpdate
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY_INTERACTION_MODE] = local.interactionMode
|
||||
prefs[KEY_SILENCE_THRESHOLD_MS] = local.silenceThresholdMs.coerceAtLeast(500L)
|
||||
prefs[KEY_REALTIME_TRACE_DETAILS] = local.realtimeTraceDetails
|
||||
prefs[KEY_REALTIME_PERSISTENT_SESSION] = local.realtimePersistentSession
|
||||
bargeIn.enabled?.let {
|
||||
prefs[BargeInPreferencesRepository.KEY_ENABLED] = it
|
||||
}
|
||||
bargeIn.sensitivity?.let {
|
||||
prefs[BargeInPreferencesRepository.KEY_SENSITIVITY] = it.name
|
||||
}
|
||||
bargeIn.resumeAfterInterruption?.let {
|
||||
prefs[BargeInPreferencesRepository.KEY_RESUME_AFTER_INTERRUPTION] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,14 @@ object DiagnosticsLog {
|
||||
return noUserInfo.take(MAX_TEXT_LENGTH)
|
||||
}
|
||||
|
||||
/**
|
||||
* Public secret redaction for user-composed report text (e.g. the "what
|
||||
* were you expecting?" answer embedded in a GitHub issue body). Same
|
||||
* redaction + cap as the stored stacktraces — entry fields are already
|
||||
* sanitized at record time; this covers text added after the fact.
|
||||
*/
|
||||
fun redactReportText(value: String?): String? = redactTrace(value)
|
||||
|
||||
private fun clean(value: String?): String? {
|
||||
val trimmed = value?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return redact(trimmed).take(MAX_TEXT_LENGTH)
|
||||
|
||||
@@ -169,7 +169,7 @@ object EventStore {
|
||||
)
|
||||
|
||||
if (buffer.size >= MAX_ENTRIES) {
|
||||
buffer.removeFirst()
|
||||
buffer.removeAt(0)
|
||||
}
|
||||
buffer.addLast(entry)
|
||||
}
|
||||
|
||||
@@ -42,6 +42,20 @@ enum class ConnectionState {
|
||||
Reconnecting
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an OkHttp request for a relay socket URL, or `null` if the URL is
|
||||
* malformed. OkHttp's [Request.Builder.url] throws [IllegalArgumentException]
|
||||
* on an invalid host; the relay connect runs on a background coroutine, so an
|
||||
* uncaught throw crashes the app (the #131 "Invalid URL host" class). Callers
|
||||
* treat `null` as a connection failure instead of letting it propagate.
|
||||
*/
|
||||
internal fun buildRelayRequestOrNull(url: String): Request? =
|
||||
try {
|
||||
Request.Builder().url(url).build()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
|
||||
class ConnectionManager(
|
||||
private val multiplexer: ChannelMultiplexer,
|
||||
/**
|
||||
@@ -828,9 +842,30 @@ class ConnectionManager(
|
||||
authenticated = false
|
||||
client = buildClient()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.build()
|
||||
val request = buildRelayRequestOrNull(url)
|
||||
if (request == null) {
|
||||
// A malformed relay URL (an invalid/empty host from a corrupt or
|
||||
// hand-edited pairing payload) can't be built into a request. This
|
||||
// runs on a background coroutine, so letting OkHttp's url() throw
|
||||
// would crash the app — the #131 "Invalid URL host" class, relay-
|
||||
// socket half. Route it through the same path onFailure uses.
|
||||
Log.e(TAG, "doConnect: malformed relay URL '$url' — not connecting")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Relay,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
title = "Invalid relay URL",
|
||||
detail = "The relay address could not be parsed; re-pair to refresh it.",
|
||||
url = url,
|
||||
)
|
||||
authenticated = false
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
previousSocketToClose?.let { stale ->
|
||||
runCatching { stale.close(1000, replaceReason) }
|
||||
stale.cancel()
|
||||
}
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(TAG, "doConnect: opening WSS to $url")
|
||||
val newSocket = client.newWebSocket(request, object : WebSocketListener() {
|
||||
|
||||
@@ -13,6 +13,7 @@ import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -153,7 +154,10 @@ class RelayHttpClient(
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
|
||||
val url = "$httpBase/media/$token"
|
||||
val url = "$httpBase/media/$token".toHttpUrlOrNull()
|
||||
?: return@withContext Result.failure(
|
||||
IllegalArgumentException("Invalid relay URL: $httpBase")
|
||||
)
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
@@ -597,7 +601,10 @@ class RelayHttpClient(
|
||||
.replace(Regex("^ws://", RegexOption.IGNORE_CASE), "http://")
|
||||
.trimEnd('/')
|
||||
|
||||
val url = "$httpBase/sessions"
|
||||
val url = "$httpBase/sessions".toHttpUrlOrNull()
|
||||
?: return@withContext Result.failure(
|
||||
IllegalArgumentException("Invalid relay URL: $httpBase")
|
||||
)
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,11 @@ package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.BackgroundTaskPhase
|
||||
import com.hermesandroid.relay.data.BackgroundTaskState
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.ChatTurnCheckpoint
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
import com.hermesandroid.relay.data.MessageDeliveryStatus
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
@@ -15,6 +18,7 @@ import com.hermesandroid.relay.network.upstream.GatewaySubagentEvent
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
import com.hermesandroid.relay.voice.RealtimeTurnSyncBuilder
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -220,14 +224,29 @@ class ChatHandler {
|
||||
private val _isStreaming = MutableStateFlow(false)
|
||||
val isStreaming: StateFlow<Boolean> = _isStreaming.asStateFlow()
|
||||
|
||||
/**
|
||||
* Silently drop the global streaming flag + turn-status caption without
|
||||
* touching the message list, error, or per-message `isStreaming` flags.
|
||||
* For abandoning an in-flight answer recovery (issue #166) on a path that
|
||||
* clears or reloads the transcript itself: there is no placeholder to
|
||||
* finalize and nothing went wrong, so [onStreamComplete] (which reconciles
|
||||
* a specific message) and [onStreamError] (which raises an error banner)
|
||||
* are both the wrong tool. Leaves per-message streaming flags intact so a
|
||||
* caller that still needs to find the placeholder afterwards (e.g.
|
||||
* cancelStream's Stopped-badge pass) can.
|
||||
*/
|
||||
fun clearStreamingStatus() {
|
||||
_isStreaming.value = false
|
||||
_turnStatus.value = null
|
||||
}
|
||||
|
||||
private val _sessions = MutableStateFlow<List<ChatSession>>(emptyList())
|
||||
val sessions: StateFlow<List<ChatSession>> = _sessions.asStateFlow()
|
||||
|
||||
// User-chosen Thread names (sessionId → name), authoritative over the
|
||||
// server's auto-title — applied in [updateSessions] so the gateway's async
|
||||
// auto-titler can't clobber the name. Fed by ChatViewModel. In-memory for
|
||||
// now (survives list refreshes within a session); cross-restart persistence
|
||||
// is a follow-up (see TODO).
|
||||
// auto-titler can't clobber the name. ChatViewModel hydrates this map from
|
||||
// ThreadNameStore, so names survive both list refreshes and app restarts.
|
||||
private val userThreadNames = mutableMapOf<String, String>()
|
||||
|
||||
/** Record a user-chosen name for one Thread session + re-apply it now. */
|
||||
@@ -349,6 +368,31 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/** Attach the first Chat-visible state for a promoted/durable Hermes run. */
|
||||
fun setBackgroundTask(messageId: String, task: BackgroundTaskState) {
|
||||
_messages.update { list ->
|
||||
list.map { message ->
|
||||
if (message.id == messageId) message.copy(backgroundTask = task) else message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Update an existing task in place; no-op when the message/task is absent. */
|
||||
fun updateBackgroundTask(
|
||||
messageId: String,
|
||||
transform: (BackgroundTaskState) -> BackgroundTaskState,
|
||||
) {
|
||||
_messages.update { list ->
|
||||
list.map { message ->
|
||||
if (message.id == messageId && message.backgroundTask != null) {
|
||||
message.copy(backgroundTask = transform(message.backgroundTask))
|
||||
} else {
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a SYSTEM-role notice bubble (e.g. a gateway interactive ask the
|
||||
* phone can't answer). SYSTEM role keeps it out of the voice TTS observer
|
||||
@@ -460,6 +504,11 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a provisional client-side message that never became a real turn. */
|
||||
fun removeMessage(messageId: String) {
|
||||
_messages.update { messages -> messages.filterNot { it.id == messageId } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a local-only voice-intent trace to the chat scroll. Used by
|
||||
* the sideload voice intent flow (`RealVoiceBridgeIntentHandler`) so
|
||||
@@ -841,6 +890,136 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate the last client-owned state of an unfinished turn.
|
||||
*
|
||||
* The caller loads server history first. That means the user row may already
|
||||
* be present while the assistant row is not yet durable; positional matching
|
||||
* avoids duplicating short repeated prompts. Rich assistant-only state is
|
||||
* then restored so thinking and tool cards do not reset to an empty spinner.
|
||||
*/
|
||||
fun restoreInFlightTurn(
|
||||
checkpoint: ChatTurnCheckpoint,
|
||||
upstreamAssistantText: String? = null,
|
||||
) {
|
||||
val user = checkpoint.user
|
||||
val assistant = checkpoint.assistant
|
||||
val upstreamText = upstreamAssistantText.orEmpty()
|
||||
val currentAssistant = _messages.value.lastOrNull { it.id == assistant.id }
|
||||
val restoredContent = listOf(
|
||||
assistant.content,
|
||||
upstreamText,
|
||||
currentAssistant?.content.orEmpty(),
|
||||
).maxByOrNull { it.length }.orEmpty()
|
||||
val checkpointTools = assistant.toolCalls.map { tool ->
|
||||
ToolCall(
|
||||
id = tool.id,
|
||||
name = tool.name,
|
||||
args = null,
|
||||
result = tool.result,
|
||||
success = tool.success,
|
||||
isComplete = tool.isComplete,
|
||||
error = tool.error,
|
||||
runId = tool.runId,
|
||||
provenance = tool.provenance,
|
||||
startedAt = tool.startedAt,
|
||||
completedAt = tool.completedAt,
|
||||
isGenerating = tool.isGenerating,
|
||||
taskIndex = tool.taskIndex,
|
||||
taskLabel = tool.taskLabel,
|
||||
)
|
||||
}
|
||||
val currentTools = currentAssistant?.toolCalls.orEmpty()
|
||||
val restoredTools = buildList {
|
||||
checkpointTools.forEach { checkpointTool ->
|
||||
val live = currentTools.firstOrNull {
|
||||
(it.id != null && it.id == checkpointTool.id) ||
|
||||
(it.id == null && checkpointTool.id == null &&
|
||||
it.name == checkpointTool.name &&
|
||||
it.taskIndex == checkpointTool.taskIndex)
|
||||
}
|
||||
add(live ?: checkpointTool)
|
||||
}
|
||||
currentTools.filterTo(this) { live ->
|
||||
checkpointTools.none { checkpointTool ->
|
||||
(live.id != null && live.id == checkpointTool.id) ||
|
||||
(live.id == null && checkpointTool.id == null &&
|
||||
live.name == checkpointTool.name &&
|
||||
live.taskIndex == checkpointTool.taskIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
val restoredBackgroundTask = assistant.backgroundTask?.let { task ->
|
||||
BackgroundTaskState(
|
||||
id = task.id,
|
||||
title = task.title,
|
||||
tier = task.tier,
|
||||
phase = runCatching { BackgroundTaskPhase.valueOf(task.phase) }
|
||||
.getOrDefault(BackgroundTaskPhase.RUNNING),
|
||||
statusLine = task.statusLine,
|
||||
completedToolCount = task.completedToolCount,
|
||||
queuedCount = task.queuedCount,
|
||||
startedAt = task.startedAt,
|
||||
)
|
||||
}
|
||||
val restoredAssistant = ChatMessage(
|
||||
id = assistant.id,
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = restoredContent,
|
||||
timestamp = assistant.timestamp,
|
||||
isStreaming = true,
|
||||
toolCalls = restoredTools,
|
||||
thinkingContent = listOf(
|
||||
assistant.thinkingContent,
|
||||
currentAssistant?.thinkingContent.orEmpty(),
|
||||
).maxByOrNull { it.length }.orEmpty(),
|
||||
isThinkingStreaming = currentAssistant?.isThinkingStreaming
|
||||
?: assistant.isThinkingStreaming,
|
||||
inputTokens = currentAssistant?.inputTokens ?: assistant.inputTokens,
|
||||
outputTokens = currentAssistant?.outputTokens ?: assistant.outputTokens,
|
||||
totalTokens = currentAssistant?.totalTokens ?: assistant.totalTokens,
|
||||
estimatedCost = currentAssistant?.estimatedCost ?: assistant.estimatedCost,
|
||||
agentName = currentAssistant?.agentName ?: assistant.agentName ?: activeAgentName,
|
||||
badges = (assistant.badges + currentAssistant?.badges.orEmpty()).distinct(),
|
||||
cards = currentAssistant?.cards?.takeIf { it.isNotEmpty() } ?: assistant.cards,
|
||||
cardDispatches = currentAssistant?.cardDispatches?.takeIf { it.isNotEmpty() }
|
||||
?: assistant.cardDispatches,
|
||||
backgroundTask = currentAssistant?.backgroundTask ?: restoredBackgroundTask,
|
||||
)
|
||||
|
||||
activeAgentName = restoredAssistant.agentName ?: activeAgentName
|
||||
_messages.update { current ->
|
||||
val withoutOldAssistant = current.filterNot { it.id == assistant.id }
|
||||
val users = withoutOldAssistant.filter { it.role == MessageRole.USER }
|
||||
val positionalUser = users.getOrNull(checkpoint.priorUserMessageCount)
|
||||
val hasUser = withoutOldAssistant.any { it.id == user.id } ||
|
||||
positionalUser?.content?.trim() == user.content.trim()
|
||||
val withUser = if (hasUser) {
|
||||
withoutOldAssistant
|
||||
} else {
|
||||
withoutOldAssistant + ChatMessage(
|
||||
id = user.id,
|
||||
role = MessageRole.USER,
|
||||
content = user.content,
|
||||
timestamp = user.timestamp,
|
||||
)
|
||||
}
|
||||
val insertBeforeAsk = withUser.indexOfFirst {
|
||||
it.clientOnly && it.id.startsWith("ask-")
|
||||
}
|
||||
val restored = if (insertBeforeAsk >= 0) {
|
||||
withUser.toMutableList().apply { add(insertBeforeAsk, restoredAssistant) }
|
||||
} else {
|
||||
withUser + restoredAssistant
|
||||
}
|
||||
restored.let { list ->
|
||||
if (list.size > MAX_MESSAGES) list.drop(list.size - MAX_MESSAGES) else list
|
||||
}
|
||||
}
|
||||
_isStreaming.value = true
|
||||
_turnStatus.value = checkpoint.turnStatus ?: "Reconnecting to the active turn…"
|
||||
}
|
||||
|
||||
fun clearMessages() {
|
||||
_messages.value = emptyList()
|
||||
// Drop any pending line buffers / dedupe state so a fresh session
|
||||
@@ -1011,6 +1190,11 @@ class ChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Trimmed assistant texts of synced provider-answered realtime turns
|
||||
// found in this reload — used below to drop their superseded local
|
||||
// clientOnly bubbles (same exchange, pre-sync copy).
|
||||
val syncedRealtimeTurnContents = mutableSetOf<String>()
|
||||
|
||||
val loaded = items.mapNotNull { item ->
|
||||
val role = when (item.role) {
|
||||
"user" -> MessageRole.USER
|
||||
@@ -1060,7 +1244,7 @@ class ChatHandler {
|
||||
// straight onto the reconstructed ChatMessage and strip their
|
||||
// lines from the displayed content in the same pass. No
|
||||
// post-assignment dispatch needed.
|
||||
val (cleanedContent, extractedCards) = if (
|
||||
val (cardCleanedContent, extractedCards) = if (
|
||||
role == MessageRole.ASSISTANT && afterMedia.isNotEmpty()
|
||||
) {
|
||||
extractCardsFromContent(afterMedia)
|
||||
@@ -1068,6 +1252,23 @@ class ChatHandler {
|
||||
afterMedia to emptyList()
|
||||
}
|
||||
|
||||
// A provider-answered realtime voice turn synced into the session
|
||||
// (RealtimeTurnSyncBuilder) carries a trailing provenance marker —
|
||||
// "[Realtime Agent provider-native voice turn: provider=…]" — in
|
||||
// its assistant text. Render it as the quiet "Realtime Agent"
|
||||
// badge (same chip live turns get) instead of raw bracket noise,
|
||||
// and remember the stripped text so the superseded local
|
||||
// clientOnly bubble can be dropped below instead of duplicating
|
||||
// the exchange.
|
||||
val strippedRealtimeContent = if (role == MessageRole.ASSISTANT) {
|
||||
RealtimeTurnSyncBuilder.stripProvenanceMarker(cardCleanedContent)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val isSyncedRealtimeTurn = strippedRealtimeContent != null
|
||||
val cleanedContent = strippedRealtimeContent ?: cardCleanedContent
|
||||
if (isSyncedRealtimeTurn) syncedRealtimeTurnContents.add(cleanedContent.trim())
|
||||
|
||||
val prior = priorById[messageId]
|
||||
// Outbound attachments: prefer an id-match (covers any future
|
||||
// user-message id reconciliation), else fall back to the
|
||||
@@ -1119,6 +1320,11 @@ class ChatHandler {
|
||||
} else {
|
||||
""
|
||||
},
|
||||
badges = if (isSyncedRealtimeTurn && "Realtime Agent" !in prior.badges) {
|
||||
prior.badges + "Realtime Agent"
|
||||
} else {
|
||||
prior.badges
|
||||
},
|
||||
)
|
||||
} else {
|
||||
// INSERT — a server message with no local row yet. Built from
|
||||
@@ -1137,6 +1343,7 @@ class ChatHandler {
|
||||
// Server persists per-message reasoning — restore it so the
|
||||
// Thought-process block survives returning to the chat.
|
||||
thinkingContent = if (role == MessageRole.ASSISTANT) serverThinking ?: "" else "",
|
||||
badges = if (isSyncedRealtimeTurn) listOf("Realtime Agent") else emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1164,7 +1371,19 @@ class ChatHandler {
|
||||
// but IS in the transcript, so it reconciles normally; only clientOnly +
|
||||
// absent-from-transcript marks a preservable orphan.
|
||||
val loadedIds = loaded.mapTo(HashSet()) { it.id }
|
||||
val preservedLocal = _messages.value.filter { it.clientOnly && it.id !in loadedIds }
|
||||
val preservedLocal = _messages.value.filter { msg ->
|
||||
if (!msg.clientOnly || msg.id in loadedIds) return@filter false
|
||||
// Drop a provider-answered realtime bubble whose SYNCED copy just
|
||||
// loaded from the server transcript (matched on the synced
|
||||
// assistant text) — keeping both would render the exchange twice.
|
||||
// Unsynced traces are always preserved: they are still the only
|
||||
// record of the turn.
|
||||
val trace = msg.realtimeTurn
|
||||
!(
|
||||
trace != null && trace.syncedToServer &&
|
||||
trace.assistantText.trim() in syncedRealtimeTurnContents
|
||||
)
|
||||
}
|
||||
val merged = if (preservedLocal.isEmpty()) {
|
||||
loaded
|
||||
} else {
|
||||
@@ -2644,6 +2863,9 @@ class ChatHandler {
|
||||
|
||||
fun onStreamError(message: String) {
|
||||
_isStreaming.value = false
|
||||
// The turn is over — a stale lifecycle/recovery caption must not
|
||||
// outlive it (onStreamComplete clears the same way).
|
||||
_turnStatus.value = null
|
||||
_error.value = message
|
||||
// Clear streaming flag on any actively streaming message
|
||||
_messages.update { messages ->
|
||||
@@ -2750,6 +2972,10 @@ class ChatHandler {
|
||||
fun setLastSentMessage(text: String) {
|
||||
_lastSentMessage.value = text
|
||||
}
|
||||
|
||||
fun clearLastSentMessage() {
|
||||
_lastSentMessage.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+117
-8
@@ -7,6 +7,9 @@ import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageListResponse
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionItem
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionListResponse
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionPruneFilters
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionPrunePreview
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionPruneResult
|
||||
import com.hermesandroid.relay.auth.SecureStoreCache
|
||||
import com.hermesandroid.relay.auth.SessionTokenStore
|
||||
import com.hermesandroid.relay.auth.buildRawTokenStore
|
||||
@@ -270,8 +273,27 @@ class DashboardApiClient(
|
||||
getJson("/api/audio/elevenlabs/voices").mapCatching { parseElevenLabsVoices(it) }
|
||||
}
|
||||
|
||||
/** Full provider/model universe — REST twin of the TUI's `model.options` RPC. */
|
||||
suspend fun getModelOptions(): Result<JsonObject> = getJsonObject("/api/model/options")
|
||||
/**
|
||||
* Full provider/model universe — REST twin of the TUI's `model.options` RPC.
|
||||
*
|
||||
* Always opts into `include_unconfigured=1`: newer upstream defaults this
|
||||
* route to configured-providers-only, which would silently drop the
|
||||
* unauthenticated skeleton rows Manage renders as its Keys-setup
|
||||
* affordance. Older upstream returned the full universe by default and
|
||||
* ignores the extra param, so both generations serve the same catalog.
|
||||
*
|
||||
* [refresh] maps to upstream's explicit `refresh=1` path, which refreshes
|
||||
* dynamic/custom-provider catalogs on demand without probing every
|
||||
* provider during normal picker opens.
|
||||
*/
|
||||
suspend fun getModelOptions(refresh: Boolean = false): Result<JsonObject> =
|
||||
getJsonObject(
|
||||
if (refresh) {
|
||||
"/api/model/options?refresh=1&include_unconfigured=1"
|
||||
} else {
|
||||
"/api/model/options?include_unconfigured=1"
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Assign the main model in `~/.hermes/config.yaml` (new sessions only).
|
||||
@@ -508,7 +530,11 @@ class DashboardApiClient(
|
||||
* ordering where the host honors it. Android still sorts by decoded
|
||||
* `last_active` locally because older hosts return started-time order.
|
||||
*/
|
||||
suspend fun listSessions(profile: String? = null, limit: Int = 200): Result<List<SessionItem>> =
|
||||
suspend fun listSessions(
|
||||
profile: String? = null,
|
||||
limit: Int = 200,
|
||||
archived: String? = null,
|
||||
): Result<List<SessionItem>> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val query = buildList {
|
||||
add("limit=${limit.coerceIn(1, 200)}")
|
||||
@@ -516,6 +542,10 @@ class DashboardApiClient(
|
||||
add("min_messages=1")
|
||||
val name = profile?.trim().orEmpty()
|
||||
if (name.isNotBlank()) add("profile=${pathSegment(name)}")
|
||||
// Upstream `archived` filter: exclude (default) | only | include.
|
||||
// Omitted unless requested so older hosts see an unchanged request.
|
||||
val archivedMode = archived?.trim().orEmpty()
|
||||
if (archivedMode.isNotBlank()) add("archived=${pathSegment(archivedMode)}")
|
||||
}.joinToString(prefix = "?", separator = "&")
|
||||
getJson("/api/sessions$query").mapCatching { root ->
|
||||
val parsed = json.decodeFromJsonElement(SessionListResponse.serializer(), root)
|
||||
@@ -555,19 +585,98 @@ class DashboardApiClient(
|
||||
suspend fun deleteSession(sessionId: String, profile: String? = null): Result<JsonObject> =
|
||||
deleteJsonObject("/api/sessions/${pathSegment(sessionId)}${profileQuery(profile)}")
|
||||
|
||||
/**
|
||||
* Export one session as server-owned JSON metadata + messages. This is the
|
||||
* safe "archive a copy before cleanup" primitive for clients that want to
|
||||
* offer download/share before a destructive delete or prune. Profile scoping
|
||||
* matches [deleteSession].
|
||||
*/
|
||||
suspend fun exportSession(sessionId: String, profile: String? = null): Result<JsonObject> =
|
||||
getJsonObject("/api/sessions/${pathSegment(sessionId)}/export${profileQuery(profile)}")
|
||||
|
||||
/**
|
||||
* Rename a session scoped to a profile via the dashboard
|
||||
* `PATCH /api/sessions/{id}?profile=` surface — the write twin of
|
||||
* [deleteSession]. A non-default profile's sessions live in that profile's
|
||||
* own `state.db`, so the unscoped api_server rename would patch the wrong
|
||||
* DB and the new title would never appear in the profile-scoped list.
|
||||
* `PATCH /api/sessions/{id}` surface — the write twin of [deleteSession].
|
||||
* A non-default profile's sessions live in that profile's own `state.db`,
|
||||
* so the unscoped api_server rename would patch the wrong DB and the new
|
||||
* title would never appear in the profile-scoped list. Current upstream
|
||||
* reads `profile` from the PATCH body (`SessionRename`); the query param
|
||||
* rides along for builds that scoped by query.
|
||||
*/
|
||||
suspend fun renameSession(sessionId: String, title: String, profile: String? = null): Result<JsonObject> =
|
||||
patchJsonObject(
|
||||
"/api/sessions/${pathSegment(sessionId)}${profileQuery(profile)}",
|
||||
buildJsonObject { put("title", title) },
|
||||
buildJsonObject {
|
||||
put("title", title)
|
||||
profile?.trim()?.takeIf { it.isNotBlank() }?.let { put("profile", it) }
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Soft-archive or restore a session via the same dashboard
|
||||
* `PATCH /api/sessions/{id}` surface (`{archived: true|false}`). Archived
|
||||
* sessions drop out of the default list and are excluded from a prune
|
||||
* unless [SessionPruneFilters.includeArchived] is set; list them back with
|
||||
* [listSessions] `archived = "only"`. Profile scoping matches
|
||||
* [renameSession]: body for current upstream, query for older builds.
|
||||
*/
|
||||
suspend fun setSessionArchived(
|
||||
sessionId: String,
|
||||
archived: Boolean,
|
||||
profile: String? = null,
|
||||
): Result<JsonObject> =
|
||||
patchJsonObject(
|
||||
"/api/sessions/${pathSegment(sessionId)}${profileQuery(profile)}",
|
||||
buildJsonObject {
|
||||
put("archived", archived)
|
||||
profile?.trim()?.takeIf { it.isNotBlank() }?.let { put("profile", it) }
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Dry-run a server-backed bulk session cleanup via the dashboard
|
||||
* `POST /api/sessions/prune` (`dry_run: true`). Returns what WOULD be
|
||||
* deleted — matched count, started-at span, and the candidate rows —
|
||||
* without deleting anything. This is the required first step of the
|
||||
* prune flow: show the preview, then pass it to [pruneSessions].
|
||||
*/
|
||||
suspend fun previewSessionPrune(filters: SessionPruneFilters): Result<SessionPrunePreview> =
|
||||
postJsonObject("/api/sessions/prune", filters.toPrunePayload(dryRun = true))
|
||||
.mapCatching { root ->
|
||||
json.decodeFromJsonElement(SessionPrunePreview.serializer(), root)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a server-backed bulk session cleanup (`POST /api/sessions/prune`,
|
||||
* `dry_run: false`). Destructive — [confirmedPreview] is required so no
|
||||
* caller can reach this without first running [previewSessionPrune] with
|
||||
* the same [filters] and showing the user its count/span. A preview that
|
||||
* matched nothing short-circuits without touching the server: sessions
|
||||
* that aged into the filter after the preview are not covered by what the
|
||||
* user confirmed.
|
||||
*/
|
||||
suspend fun pruneSessions(
|
||||
filters: SessionPruneFilters,
|
||||
confirmedPreview: SessionPrunePreview,
|
||||
): Result<SessionPruneResult> {
|
||||
if (confirmedPreview.matched <= 0) {
|
||||
return Result.success(SessionPruneResult(ok = true, removed = 0))
|
||||
}
|
||||
return postJsonObject("/api/sessions/prune", filters.toPrunePayload(dryRun = false))
|
||||
.mapCatching { root ->
|
||||
json.decodeFromJsonElement(SessionPruneResult.serializer(), root)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SessionPruneFilters.toPrunePayload(dryRun: Boolean): JsonObject =
|
||||
buildJsonObject {
|
||||
olderThanDays?.let { put("older_than_days", it) }
|
||||
source?.trim()?.takeIf { it.isNotBlank() }?.let { put("source", it) }
|
||||
profile?.trim()?.takeIf { it.isNotBlank() }?.let { put("profile", it) }
|
||||
if (includeArchived) put("include_archived", true)
|
||||
put("dry_run", dryRun)
|
||||
}
|
||||
|
||||
private fun parseProfiles(root: JsonObject): List<Profile> {
|
||||
fun decode(element: JsonElement, nameOverride: String?): Profile? = runCatching {
|
||||
val obj = element as? JsonObject ?: return null
|
||||
|
||||
+694
-47
File diff suppressed because it is too large
Load Diff
@@ -21,13 +21,17 @@ import kotlinx.serialization.json.intOrNull
|
||||
* why dispatch is a manual `when (type)` over [JsonObject] rather than a
|
||||
* sealed polymorphic hierarchy (which throws on unknown discriminators).
|
||||
*/
|
||||
class GatewayEventMapper(private val callbacks: GatewayTurnCallbacks) {
|
||||
class GatewayEventMapper(
|
||||
private val callbacks: GatewayTurnCallbacks,
|
||||
private val dedupeAdjacentMessageStarts: Boolean = false,
|
||||
) {
|
||||
|
||||
/** True once `message.complete` or `error` has been seen — the turn is over. */
|
||||
var turnEnded: Boolean = false
|
||||
private set
|
||||
|
||||
private var sawMessageStart = false
|
||||
private var previousEventType: String? = null
|
||||
private var sawTextDelta = false
|
||||
private var sawThinkingDelta = false
|
||||
private var syntheticToolCounter = 0
|
||||
@@ -77,11 +81,18 @@ class GatewayEventMapper(private val callbacks: GatewayTurnCallbacks) {
|
||||
}
|
||||
|
||||
"message.start" -> {
|
||||
// The upstream background-completion poller currently emits
|
||||
// message.start immediately before _run_prompt_submit(), which
|
||||
// emits the same start again. Treat an adjacent pair as one
|
||||
// boundary; a later start after any other event still closes
|
||||
// the previous assistant message as before.
|
||||
if (dedupeAdjacentMessageStarts && previousEventType == "message.start") return
|
||||
// Gateway has no server-side message id (placeholder UUID
|
||||
// stays). A second start inside one turn means a new
|
||||
// assistant message began — close out the previous one.
|
||||
if (sawMessageStart) callbacks.onTurnComplete()
|
||||
sawMessageStart = true
|
||||
callbacks.onStart()
|
||||
}
|
||||
|
||||
"tool.generating" -> {
|
||||
@@ -228,6 +239,7 @@ class GatewayEventMapper(private val callbacks: GatewayTurnCallbacks) {
|
||||
// alike: ignore.
|
||||
else -> Unit
|
||||
}
|
||||
previousEventType = type
|
||||
}
|
||||
|
||||
private fun syntheticToolId(name: String): String {
|
||||
|
||||
@@ -81,8 +81,33 @@ fun resolveStreamingEndpointPreference(
|
||||
*/
|
||||
fun interface ActiveTurnHandle {
|
||||
fun cancel()
|
||||
|
||||
/**
|
||||
* Release this client's callbacks without interrupting server-side work.
|
||||
* Gateway turns override this for process/UI teardown; transports that
|
||||
* cannot be reattached retain their existing cancel behavior.
|
||||
*/
|
||||
fun detach() = cancel()
|
||||
}
|
||||
|
||||
/** Partial text checkpoint returned by current upstream Hermes on live resume. */
|
||||
data class GatewayInflightTurn(
|
||||
val user: String,
|
||||
val assistant: String,
|
||||
val streaming: Boolean,
|
||||
)
|
||||
|
||||
/** Result of reattaching Android to an existing durable Gateway session. */
|
||||
data class GatewaySessionRecovery(
|
||||
val storedSessionId: String,
|
||||
val liveSessionId: String,
|
||||
val running: Boolean,
|
||||
val status: String?,
|
||||
val inflight: GatewayInflightTurn?,
|
||||
/** Non-null only when subsequent turn events are bound to [GatewayTurnCallbacks]. */
|
||||
val handle: ActiveTurnHandle?,
|
||||
)
|
||||
|
||||
/**
|
||||
* One server-side interactive ask. The agent thread upstream is BLOCKED
|
||||
* until the matching respond RPC arrives, the ask times out (resolves to ""
|
||||
@@ -135,6 +160,67 @@ data class GatewaySubagentEvent(
|
||||
enum class Phase { START, THINKING, TOOL, PROGRESS, COMPLETE }
|
||||
}
|
||||
|
||||
/**
|
||||
* One session-owned background process returned by the upstream gateway's
|
||||
* `process.list` RPC. The registry calls its process id `session_id`; Android
|
||||
* exposes it as [id] so it cannot be confused with either the stored chat id or
|
||||
* the gateway's live, per-connection session id.
|
||||
*
|
||||
* [outputPreview] is the registry's short preview, while [outputTail] is the
|
||||
* gateway's larger (currently 4,000-character) snapshot used to recover output
|
||||
* missed while the WebSocket was unavailable. Unknown/new fields are ignored
|
||||
* by the parser so this remains compatible with older and newer gateways.
|
||||
*/
|
||||
data class GatewayProcess(
|
||||
val id: String,
|
||||
val command: String,
|
||||
val cwd: String? = null,
|
||||
val pid: Long? = null,
|
||||
val startedAt: String? = null,
|
||||
val uptimeSeconds: Long = 0L,
|
||||
val status: String,
|
||||
val outputPreview: String? = null,
|
||||
val outputTail: String? = null,
|
||||
val exitCode: Int? = null,
|
||||
val detached: Boolean = false,
|
||||
val notifyOnComplete: Boolean = false,
|
||||
val sessionScoped: Boolean = false,
|
||||
val watchPatterns: List<String> = emptyList(),
|
||||
val watchHit: Boolean = false,
|
||||
) {
|
||||
val isRunning: Boolean get() = status.equals("running", ignoreCase = true)
|
||||
}
|
||||
|
||||
/** Whether this gateway socket supports the session-scoped process RPCs. */
|
||||
enum class GatewayProcessCapability {
|
||||
/** Not probed on this socket yet (or no socket is currently connected). */
|
||||
Unknown,
|
||||
|
||||
/** A `process.list` / `process.kill` call succeeded. */
|
||||
Supported,
|
||||
|
||||
/** The gateway returned JSON-RPC method-not-found for the process surface. */
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection-level background-process events. These are deliberately separate
|
||||
* from [GatewayTurnCallbacks]: output and completion notifications can arrive
|
||||
* while no app-initiated turn is active.
|
||||
*/
|
||||
sealed interface GatewayProcessEvent {
|
||||
enum class Trigger { TOOL_COMPLETE, STATUS_UPDATE, MESSAGE_COMPLETE }
|
||||
|
||||
/** The process snapshot may have changed and should be refreshed. */
|
||||
data class Invalidated(val trigger: Trigger) : GatewayProcessEvent
|
||||
|
||||
/** Live output from `agent.terminal.output`. */
|
||||
data class Output(val processId: String, val chunk: String) : GatewayProcessEvent
|
||||
|
||||
/** The agent requested that its read-only terminal view be closed. */
|
||||
data class TerminalClosed(val processId: String) : GatewayProcessEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* One provider from the gateway `model.options` RPC — the curated, authenticated
|
||||
* provider/model list the upstream desktop + TUI model picker uses (NOT the
|
||||
@@ -208,6 +294,8 @@ data class GatewayReasoningSettings(
|
||||
class GatewayTurnCallbacks(
|
||||
/** Stored (DB) session id — fired on session create/rotate so the drawer + persistence stay correct. */
|
||||
val onSessionId: (String) -> Unit,
|
||||
/** A gateway `message.start` opened an assistant response for this turn. */
|
||||
val onStart: () -> Unit,
|
||||
val onTextDelta: (String) -> Unit,
|
||||
val onThinkingDelta: (String) -> Unit,
|
||||
val onToolCallStart: (toolCallId: String, toolName: String) -> Unit,
|
||||
@@ -238,3 +326,18 @@ class GatewayTurnCallbacks(
|
||||
*/
|
||||
val onStatusUpdate: (kind: String?, text: String) -> Unit = { _, _ -> },
|
||||
)
|
||||
|
||||
/**
|
||||
* UI registration for one server-initiated gateway turn.
|
||||
*
|
||||
* Background-process completion is converted upstream into a normal assistant
|
||||
* turn on the originating session. It has no matching client [GatewayChatClient.sendTurn]
|
||||
* call, so the client asks the active conversation for callbacks when the first
|
||||
* `message.start` arrives. [onHandle] binds the resulting cancellable turn into
|
||||
* the same Stop/steer lifecycle as a locally submitted turn.
|
||||
*/
|
||||
class GatewayInboundTurnRegistration(
|
||||
val callbacks: GatewayTurnCallbacks,
|
||||
/** Main-thread admission. False leaves the server turn unbound for history recovery. */
|
||||
val onHandle: (ActiveTurnHandle) -> Boolean,
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -178,6 +179,32 @@ class HermesApiClient(
|
||||
companion object {
|
||||
private const val TAG = "HermesApiClient"
|
||||
private val JSON_MEDIA = "application/json".toMediaType()
|
||||
|
||||
/**
|
||||
* Prefix stamped by [streamFailureMessage] on stream failures raised
|
||||
* by the transport layer (the IOException family: socket reset/close,
|
||||
* DNS, TLS, timeouts) as opposed to a server-reported error. The
|
||||
* dropped-stream answer recovery (issue #166) keys on it via
|
||||
* [isTransportStreamError].
|
||||
*/
|
||||
const val TRANSPORT_ERROR_PREFIX = "Connection failed"
|
||||
|
||||
/**
|
||||
* True when a stream `onError` message came from a transport-layer
|
||||
* failure (see [TRANSPORT_ERROR_PREFIX]) — the class of error where
|
||||
* the server may still be running (and persisting) the turn.
|
||||
*/
|
||||
fun isTransportStreamError(errorMsg: String): Boolean =
|
||||
errorMsg.startsWith(TRANSPORT_ERROR_PREFIX)
|
||||
|
||||
/** Shared human-readable message for an SSE [EventSourceListener.onFailure]. */
|
||||
private fun streamFailureMessage(t: Throwable?, response: Response?): String = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
"API error ${response.code}: ${response.message}"
|
||||
t is IOException -> "$TRANSPORT_ERROR_PREFIX: ${t.message}"
|
||||
t != null -> "Stream error: ${t.message}"
|
||||
else -> "Unknown stream error"
|
||||
}
|
||||
}
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
@@ -522,6 +549,9 @@ class HermesApiClient(
|
||||
* blank the `model` field is omitted entirely and the server falls
|
||||
* back to its session default. Used by the agent-profile picker so
|
||||
* an explicit user choice wins over implicit session/server defaults.
|
||||
* Best-effort hint: current native upstream does not parse `model`
|
||||
* on this route (legacy fork builds honor it) — see the contract
|
||||
* notes in `HermesChatPayloads.kt`.
|
||||
*/
|
||||
fun sendChatStream(
|
||||
sessionId: String,
|
||||
@@ -529,23 +559,25 @@ class HermesApiClient(
|
||||
systemMessage: String? = null,
|
||||
attachments: List<com.hermesandroid.relay.data.Attachment>? = null,
|
||||
/**
|
||||
* Pre-built OpenAI-format synthetic messages to splice into the
|
||||
* payload alongside the live `message`. Produced by
|
||||
* Pre-built OpenAI-format synthetic messages carrying phone-local
|
||||
* context (voice intents, card dispatches, realtime voice turns).
|
||||
* Produced by
|
||||
* [com.hermesandroid.relay.voice.VoiceIntentSyncBuilder.buildSyntheticMessages]
|
||||
* for the v0.4.1 voice-intent → server session sync feature.
|
||||
* and its twin builders; the param name is historical — it accepts
|
||||
* any synthetic-message array.
|
||||
*
|
||||
* When non-empty, the request body grows a top-level `messages`
|
||||
* array containing the synthetic `assistant` (with `tool_calls`)
|
||||
* + `tool` (with `tool_call_id`) pairs. The server-side session
|
||||
* absorbs them into its conversation history so the LLM sees
|
||||
* prior phone-local voice actions in its session memory.
|
||||
* Upstream's session-chat handler consumes only `message` and
|
||||
* `system_message` — a top-level `messages` array is NOT parsed
|
||||
* (verified in `gateway/platforms/api_server.py`,
|
||||
* `_handle_session_chat_stream`), so these can't ride the request
|
||||
* as real history entries. Instead [buildSessionChatStreamPayload]
|
||||
* renders them as a plain-text digest folded into this turn's
|
||||
* ephemeral `system_message`. The model sees the context for THIS
|
||||
* turn only; it is not persisted server-side. See the mapping notes
|
||||
* in `HermesChatPayloads.kt`.
|
||||
*
|
||||
* Null / empty on every send that has no unsynced voice intents
|
||||
* to communicate, which is the common case after the first sync.
|
||||
* The Hermes API server treats unrecognised body fields
|
||||
* permissively (matches OpenAI Chat Completions semantics), so
|
||||
* this stays a safe additive change against any conformant
|
||||
* upstream.
|
||||
* Null / empty on every send that has no unsynced traces to
|
||||
* communicate, which is the common case after the first sync.
|
||||
*/
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
onSessionId: (String) -> Unit,
|
||||
@@ -568,7 +600,7 @@ class HermesApiClient(
|
||||
AgentDisplay.profileRequestName(profileName)?.let {
|
||||
Log.d(TAG, "sendChatStream: profile=$it")
|
||||
}
|
||||
val requestPayload = buildSessionChatStreamPayload(
|
||||
val built = buildSessionChatStreamPayload(
|
||||
message = message,
|
||||
systemMessage = systemMessage,
|
||||
attachments = attachments,
|
||||
@@ -576,12 +608,19 @@ class HermesApiClient(
|
||||
modelOverride = modelOverride,
|
||||
profileName = profileName,
|
||||
)
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
|
||||
logDroppedAttachments("sessions chat/stream", built.droppedAttachments)
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), built.payload)
|
||||
|
||||
val request = authRequest("$baseUrl/api/sessions/$sessionId/chat/stream")
|
||||
.header("Accept", "text/event-stream")
|
||||
.post(requestBody.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
val request = authRequestOrNull("$baseUrl/api/sessions/$sessionId/chat/stream")
|
||||
?.header("Accept", "text/event-stream")
|
||||
?.post(requestBody.toRequestBody(JSON_MEDIA))
|
||||
?.build()
|
||||
?: run {
|
||||
// #131: malformed base URL — fail the turn through the normal
|
||||
// error channel instead of throwing out of the ViewModel.
|
||||
mainHandler.post { onError(invalidBaseUrlMessage()) }
|
||||
return failedEventSource()
|
||||
}
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
@@ -762,13 +801,7 @@ class HermesApiClient(
|
||||
) {
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
"API error ${response.code}: ${response.message}"
|
||||
t is IOException -> "Connection failed: ${t.message}"
|
||||
t != null -> "Stream error: ${t.message}"
|
||||
else -> "Unknown stream error"
|
||||
}
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
@@ -819,7 +852,7 @@ class HermesApiClient(
|
||||
AgentDisplay.profileRequestName(profileName)?.let {
|
||||
Log.d(TAG, "sendChatCompletionsStream: profile=$it")
|
||||
}
|
||||
val requestPayload = buildChatCompletionsStreamPayload(
|
||||
val built = buildChatCompletionsStreamPayload(
|
||||
message = message,
|
||||
model = model,
|
||||
systemMessage = systemMessage,
|
||||
@@ -828,12 +861,18 @@ class HermesApiClient(
|
||||
modelOverride = modelOverride,
|
||||
profileName = profileName,
|
||||
)
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
|
||||
logDroppedAttachments("chat completions", built.droppedAttachments)
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), built.payload)
|
||||
|
||||
val request = authRequest("$baseUrl/v1/chat/completions")
|
||||
.header("Accept", "text/event-stream")
|
||||
.post(requestBody.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
val request = authRequestOrNull("$baseUrl/v1/chat/completions")
|
||||
?.header("Accept", "text/event-stream")
|
||||
?.post(requestBody.toRequestBody(JSON_MEDIA))
|
||||
?.build()
|
||||
?: run {
|
||||
// #131: malformed base URL — see sendChatStream.
|
||||
mainHandler.post { onError(invalidBaseUrlMessage()) }
|
||||
return failedEventSource()
|
||||
}
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
val messageStarted = AtomicBoolean(false)
|
||||
@@ -904,13 +943,7 @@ class HermesApiClient(
|
||||
) {
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
"API error ${response.code}: ${response.message}"
|
||||
t is IOException -> "Connection failed: ${t.message}"
|
||||
t != null -> "Stream error: ${t.message}"
|
||||
else -> "Unknown stream error"
|
||||
}
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
@@ -986,7 +1019,13 @@ class HermesApiClient(
|
||||
model: String? = null,
|
||||
systemMessage: String? = null,
|
||||
attachments: List<com.hermesandroid.relay.data.Attachment>? = null,
|
||||
/** See [sendChatStream]'s `voiceIntentMessages` doc — same semantics. */
|
||||
/**
|
||||
* See [sendChatStream]'s `voiceIntentMessages` doc. On the runs
|
||||
* path the mapping differs slightly: plain user/assistant text
|
||||
* turns ride the upstream-parsed `conversation_history` field,
|
||||
* while tool-call pairs fold into the `instructions` digest —
|
||||
* see [buildRunStreamPayload].
|
||||
*/
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
onSessionId: (String) -> Unit,
|
||||
onMessageStarted: (String) -> Unit,
|
||||
@@ -1008,7 +1047,7 @@ class HermesApiClient(
|
||||
AgentDisplay.profileRequestName(profileName)?.let {
|
||||
Log.d(TAG, "sendRunStream: profile=$it")
|
||||
}
|
||||
val requestPayload = buildRunStreamPayload(
|
||||
val built = buildRunStreamPayload(
|
||||
message = message,
|
||||
model = model,
|
||||
systemMessage = systemMessage,
|
||||
@@ -1017,12 +1056,18 @@ class HermesApiClient(
|
||||
modelOverride = modelOverride,
|
||||
profileName = profileName,
|
||||
)
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), requestPayload)
|
||||
logDroppedAttachments("runs", built.droppedAttachments)
|
||||
val requestBody = json.encodeToString(JsonObject.serializer(), built.payload)
|
||||
|
||||
val request = authRequest("$baseUrl/v1/runs")
|
||||
.header("Accept", "text/event-stream")
|
||||
.post(requestBody.toRequestBody(JSON_MEDIA))
|
||||
.build()
|
||||
val request = authRequestOrNull("$baseUrl/v1/runs")
|
||||
?.header("Accept", "text/event-stream")
|
||||
?.post(requestBody.toRequestBody(JSON_MEDIA))
|
||||
?.build()
|
||||
?: run {
|
||||
// #131: malformed base URL — see sendChatStream.
|
||||
mainHandler.post { onError(invalidBaseUrlMessage()) }
|
||||
return failedEventSource()
|
||||
}
|
||||
|
||||
val completeCalled = AtomicBoolean(false)
|
||||
// Comparable to the gateway's turn[gateway] line — see TurnLatencyTracer.
|
||||
@@ -1203,13 +1248,7 @@ class HermesApiClient(
|
||||
) {
|
||||
tracer.done("error")
|
||||
if (completeCalled.compareAndSet(false, true)) {
|
||||
val msg = when {
|
||||
response != null && !response.isSuccessful ->
|
||||
"API error ${response.code}: ${response.message}"
|
||||
t is IOException -> "Connection failed: ${t.message}"
|
||||
t != null -> "Stream error: ${t.message}"
|
||||
else -> "Unknown stream error"
|
||||
}
|
||||
val msg = streamFailureMessage(t, response)
|
||||
mainHandler.post { onError(msg) }
|
||||
}
|
||||
}
|
||||
@@ -1364,6 +1403,65 @@ class HermesApiClient(
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-throwing twin of [authRequest] for the streaming entry points
|
||||
* (#131 crash class). The three send*Stream methods build their Request
|
||||
* BEFORE any try/catch or EventSource listener exists, so a malformed
|
||||
* [baseUrl] (hand-edited connection, corrupt settings import) made
|
||||
* `Request.Builder.url(String)` throw `IllegalArgumentException`
|
||||
* synchronously up through the ViewModel. Returns null on a bad URL so
|
||||
* the caller can route the failure through its normal `onError` channel
|
||||
* instead. Non-streaming methods keep [authRequest] — their existing
|
||||
* try/catch already contains the throw.
|
||||
*/
|
||||
private fun authRequestOrNull(url: String): Request.Builder? {
|
||||
val builder = buildApiRequestOrNull(url) ?: return null
|
||||
if (apiKey.isNotBlank()) {
|
||||
builder.header("Authorization", "Bearer $apiKey")
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
* Inert [EventSource] returned by the streaming methods when the request
|
||||
* couldn't even be built (bad base URL). The turn already failed via
|
||||
* `onError`; this just satisfies the return type so callers' cancel()
|
||||
* handling stays uniform.
|
||||
*/
|
||||
private fun failedEventSource(): EventSource = object : EventSource {
|
||||
// Guaranteed-parseable placeholder; never dispatched.
|
||||
private val placeholder = Request.Builder().url("http://invalid.invalid/").build()
|
||||
override fun request(): Request = placeholder
|
||||
override fun cancel() {}
|
||||
}
|
||||
|
||||
/** Human message for a base URL that fails to parse (#131). */
|
||||
private fun invalidBaseUrlMessage(): String =
|
||||
"Invalid server address ($baseUrl) — edit the connection's API URL or re-pair."
|
||||
|
||||
/**
|
||||
* Make attachment drops on the SSE fallback transports explicit
|
||||
* (HRUI-001): the payload builders return attachments that have no
|
||||
* upstream-supported channel on the target endpoint instead of
|
||||
* silently omitting them. The user-visible notice lives in
|
||||
* ChatViewModel (`warnIfAttachmentsDropped`) — this log line is the
|
||||
* network-layer audit trail that the bytes never left the device.
|
||||
*/
|
||||
private fun logDroppedAttachments(
|
||||
endpoint: String,
|
||||
dropped: List<com.hermesandroid.relay.data.Attachment>,
|
||||
) {
|
||||
if (dropped.isEmpty()) return
|
||||
val names = dropped.joinToString(", ") {
|
||||
it.fileName ?: if (it.isImage) "image" else "file"
|
||||
}
|
||||
Log.w(
|
||||
TAG,
|
||||
"Dropped ${dropped.size} attachment(s) with no supported channel " +
|
||||
"on the $endpoint endpoint (not sent): $names",
|
||||
)
|
||||
}
|
||||
|
||||
private fun apiFailure(response: Response, operation: String): IOException {
|
||||
val detail = response.message.takeIf { it.isNotBlank() }?.let { ": $it" }.orEmpty()
|
||||
val message = when (response.code) {
|
||||
@@ -1380,3 +1478,13 @@ class HermesApiClient(
|
||||
private fun firstNonBlank(vararg values: String?): String =
|
||||
values.firstOrNull { !it.isNullOrBlank() }.orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* #131 guard, api_server half: parse-or-null Request builder for a URL string.
|
||||
* `Request.Builder.url(String)` throws `IllegalArgumentException` on a
|
||||
* malformed host; the streaming send paths must fail through `onError`
|
||||
* instead. Top-level (like `buildRelayRequestOrNull` in ConnectionManager)
|
||||
* so the guard is unit-testable without instantiating the client.
|
||||
*/
|
||||
internal fun buildApiRequestOrNull(url: String): Request.Builder? =
|
||||
url.toHttpUrlOrNull()?.let { Request.Builder().url(it) }
|
||||
|
||||
+284
-56
@@ -4,14 +4,233 @@ import com.hermesandroid.relay.data.AgentDisplay
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
|
||||
/*
|
||||
* === Upstream request contract (HRUI-001) ===
|
||||
*
|
||||
* Verified against hermes-agent `gateway/platforms/api_server.py`. These
|
||||
* builders send ONLY fields the target handler consumes (plus a small,
|
||||
* documented set of legacy hint fields — see below). Fields upstream
|
||||
* ignores are never emitted: a dead field on the wire misrepresents
|
||||
* capability and masks data loss.
|
||||
*
|
||||
* Per-endpoint parsing truth (current upstream main):
|
||||
*
|
||||
* - `POST /api/sessions/{id}/chat/stream` (`_handle_session_chat_stream`)
|
||||
* consumes `message` (or `input`) and `system_message` (or
|
||||
* `instructions`, string only). `message` accepts either a plain string
|
||||
* or OpenAI-style content parts (text + `image_url`) via
|
||||
* `_normalize_multimodal_content`. Top-level `messages`, `attachments`,
|
||||
* `model`, and `profile` are NOT parsed.
|
||||
*
|
||||
* - `POST /v1/runs` (`_handle_runs`) consumes `input` (string or message
|
||||
* array), `instructions`, `conversation_history` (array of
|
||||
* `{role, content}` objects, string-coerced), `previous_response_id`,
|
||||
* `session_id`, and `model`. It does NOT parse `system_message`,
|
||||
* `stream`, `messages`, `attachments`, or `profile` — and always
|
||||
* answers `202 {"run_id": ...}` JSON (no SSE on POST).
|
||||
*
|
||||
* - `POST /v1/chat/completions` (`_handle_chat_completions`) consumes
|
||||
* `messages`, `stream`, and `model`. Within `messages`: `system` roles
|
||||
* fold into the ephemeral system prompt; `user`/`assistant` entries are
|
||||
* kept as history with multimodal content normalization; `tool`-role
|
||||
* entries are silently skipped and `tool_calls` fields are stripped.
|
||||
* Top-level `attachments` and `profile` are NOT parsed.
|
||||
*
|
||||
* Legacy hint fields we deliberately keep sending although current native
|
||||
* upstream ignores them: `model` + `profile` on the sessions path,
|
||||
* `profile` on runs/completions, and `stream` on runs. They are
|
||||
* configuration hints (never user content, so they cannot mask data
|
||||
* loss) honored by legacy fork builds — the runs path in particular only
|
||||
* activates against servers that explicitly advertise SSE-on-POST, which
|
||||
* vanilla upstream never does. See `ServerCapabilities`.
|
||||
*
|
||||
* === Synthetic-history mapping ===
|
||||
*
|
||||
* Phone-local synthetic turns (voice-intent traces, card dispatches,
|
||||
* provider-answered realtime voice turns — see `VoiceIntentSyncBuilder`,
|
||||
* `CardDispatchSyncBuilder`, `RealtimeTurnSyncBuilder`) arrive here as one
|
||||
* OpenAI-format array. Historically they were sent as a top-level
|
||||
* `messages` field on sessions/runs, which upstream never consumed —
|
||||
* silent data loss. They now map onto channels each endpoint actually
|
||||
* supports:
|
||||
*
|
||||
* - Tool-call pairs (`assistant` + `tool` with `tool_call_id`) have no
|
||||
* surviving wire shape on ANY fallback endpoint, so they render as a
|
||||
* plain-text digest ([renderSyntheticHistoryDigest]) folded into the
|
||||
* per-turn ephemeral system prompt: `system_message` on sessions,
|
||||
* `instructions` on runs, the `system` message on completions.
|
||||
* - Plain `user`/`assistant` text turns ride a real history channel
|
||||
* where one exists: spliced into `messages` on completions, sent as
|
||||
* `conversation_history` on runs. The sessions endpoint has no
|
||||
* client-provided history channel, so there they join the digest.
|
||||
*
|
||||
* This mapping is ephemeral where the digest is used: the model sees the
|
||||
* context for THIS turn only; it is not persisted into the server-side
|
||||
* session transcript. That is strictly better than the previous behavior
|
||||
* (context arrived never) and matches the existing voice-turn pattern of
|
||||
* per-turn non-persisted instructions.
|
||||
*
|
||||
* === Attachments ===
|
||||
*
|
||||
* Only the completions endpoint has an upstream-supported attachment
|
||||
* channel on this surface: inline `image_url` content parts (images
|
||||
* only). Sessions/runs payloads carry no attachments at all. Anything
|
||||
* that cannot be delivered is returned in
|
||||
* [ChatPayloadResult.droppedAttachments] so callers can surface the drop
|
||||
* (HermesApiClient logs it; ChatViewModel shows a user-visible notice) —
|
||||
* never a silent discard. Note: current upstream's sessions `message`
|
||||
* field does accept inline `image_url` content parts, so image delivery
|
||||
* on the sessions path is a possible future improvement; it is not wired
|
||||
* yet because the caller's attachment warning and this builder must move
|
||||
* together.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Result of building a fallback-transport chat payload.
|
||||
*
|
||||
* @property payload The JSON request body — contains only fields the
|
||||
* target endpoint consumes (plus documented legacy hint fields).
|
||||
* @property droppedAttachments Attachments that have NO supported channel
|
||||
* on the target endpoint and were therefore not encoded into [payload].
|
||||
* Callers must surface these (log + user notice), never ignore them.
|
||||
*/
|
||||
internal data class ChatPayloadResult(
|
||||
val payload: JsonObject,
|
||||
val droppedAttachments: List<Attachment>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Header line for the synthetic phone-context digest. Tells the model the
|
||||
* listed activity already happened on-device so it treats the lines as
|
||||
* history, not instructions to act on.
|
||||
*/
|
||||
internal const val SYNTHETIC_DIGEST_HEADER =
|
||||
"Phone-side activity since the previous server turn " +
|
||||
"(already completed on-device; context only — do not re-execute):"
|
||||
|
||||
private fun JsonObject.roleOrNull(): String? =
|
||||
(this["role"] as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
private fun JsonObject.contentStringOrNull(): String? =
|
||||
(this["content"] as? JsonPrimitive)?.contentOrNull
|
||||
|
||||
/**
|
||||
* True for a synthetic entry deliverable as a REAL conversation turn on
|
||||
* endpoints with a client-history channel: plain `user`/`assistant` role,
|
||||
* string content, no `tool_calls`. Matches the shape emitted by
|
||||
* `RealtimeTurnSyncBuilder`; tool-call pairs from the voice-intent and
|
||||
* card-dispatch builders fail this check and go through the digest.
|
||||
*/
|
||||
internal fun isPlainSyntheticTurn(entry: JsonObject): Boolean {
|
||||
val role = entry.roleOrNull()
|
||||
if (role != "user" && role != "assistant") return false
|
||||
if (entry.containsKey("tool_calls")) return false
|
||||
return !entry.contentStringOrNull().isNullOrBlank()
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the synthetic sync stream as a compact plain-text digest for the
|
||||
* per-turn ephemeral system prompt.
|
||||
*
|
||||
* Tool-call pairs (`assistant.tool_calls` + matching `tool` result keyed
|
||||
* by `tool_call_id`) always render, one line per call:
|
||||
* `- called <name> with <arguments> -> <result>`. Plain text turns render
|
||||
* as `- user: ...` / `- assistant: ...` lines only when
|
||||
* [includePlainTurns] is true (sessions path — no real history channel);
|
||||
* endpoints that deliver plain turns natively pass false so the same turn
|
||||
* is never delivered twice.
|
||||
*
|
||||
* @return null when nothing renders (no synthetic messages, or only plain
|
||||
* turns while [includePlainTurns] is false).
|
||||
*/
|
||||
internal fun renderSyntheticHistoryDigest(
|
||||
syntheticMessages: JsonArray?,
|
||||
includePlainTurns: Boolean,
|
||||
): String? {
|
||||
if (syntheticMessages.isNullOrEmpty()) return null
|
||||
|
||||
// Pair tool results with their originating call.
|
||||
val resultsByCallId = HashMap<String, String>()
|
||||
for (element in syntheticMessages) {
|
||||
val obj = element as? JsonObject ?: continue
|
||||
if (obj.roleOrNull() != "tool") continue
|
||||
val callId = (obj["tool_call_id"] as? JsonPrimitive)?.contentOrNull ?: continue
|
||||
resultsByCallId[callId] = obj.contentStringOrNull().orEmpty()
|
||||
}
|
||||
|
||||
val lines = mutableListOf<String>()
|
||||
for (element in syntheticMessages) {
|
||||
val obj = element as? JsonObject ?: continue
|
||||
when (obj.roleOrNull()) {
|
||||
"assistant" -> {
|
||||
val toolCalls = obj["tool_calls"] as? JsonArray
|
||||
if (toolCalls != null) {
|
||||
for (call in toolCalls) {
|
||||
val callObj = call as? JsonObject ?: continue
|
||||
val function = callObj["function"] as? JsonObject
|
||||
val name = (function?.get("name") as? JsonPrimitive)
|
||||
?.contentOrNull ?: "unknown_tool"
|
||||
val args = (function?.get("arguments") as? JsonPrimitive)
|
||||
?.contentOrNull ?: "{}"
|
||||
val callId = (callObj["id"] as? JsonPrimitive)?.contentOrNull
|
||||
val result = callId?.let(resultsByCallId::get)
|
||||
lines += if (result.isNullOrBlank()) {
|
||||
"- called $name with $args"
|
||||
} else {
|
||||
"- called $name with $args -> $result"
|
||||
}
|
||||
}
|
||||
} else if (includePlainTurns) {
|
||||
obj.contentStringOrNull()?.takeIf { it.isNotBlank() }
|
||||
?.let { lines += "- assistant: $it" }
|
||||
}
|
||||
}
|
||||
"user" -> if (includePlainTurns) {
|
||||
obj.contentStringOrNull()?.takeIf { it.isNotBlank() }
|
||||
?.let { lines += "- user: $it" }
|
||||
}
|
||||
// "tool" entries fold into their assistant line via resultsByCallId.
|
||||
}
|
||||
}
|
||||
if (lines.isEmpty()) return null
|
||||
return SYNTHETIC_DIGEST_HEADER + "\n" + lines.joinToString("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the caller's per-turn system message with the synthetic-history
|
||||
* digest into one ephemeral prompt string. Either side may be absent.
|
||||
*/
|
||||
internal fun mergeEphemeralContext(systemMessage: String?, digest: String?): String? = when {
|
||||
digest.isNullOrBlank() -> systemMessage?.takeIf { it.isNotBlank() }
|
||||
systemMessage.isNullOrBlank() -> digest
|
||||
else -> systemMessage + "\n\n" + digest
|
||||
}
|
||||
|
||||
/** Synthetic entries deliverable as real history turns (see [isPlainSyntheticTurn]). */
|
||||
private fun plainSyntheticTurns(syntheticMessages: JsonArray?): List<JsonObject> =
|
||||
(syntheticMessages ?: emptyList())
|
||||
.mapNotNull { it as? JsonObject }
|
||||
.filter(::isPlainSyntheticTurn)
|
||||
|
||||
/**
|
||||
* Body for `POST /api/sessions/{id}/chat/stream`.
|
||||
*
|
||||
* Emits `message` + `system_message` (upstream-consumed) and `model` +
|
||||
* `profile` (legacy hints — current native upstream ignores both on this
|
||||
* route; legacy fork builds honor them; see the file header). ALL
|
||||
* synthetic history folds into `system_message` via the digest: the
|
||||
* endpoint has no client-provided history channel. Attachments have no
|
||||
* supported channel here and are returned as dropped.
|
||||
*/
|
||||
internal fun buildSessionChatStreamPayload(
|
||||
message: String,
|
||||
systemMessage: String? = null,
|
||||
@@ -19,30 +238,33 @@ internal fun buildSessionChatStreamPayload(
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): JsonObject = buildJsonObject {
|
||||
put("message", message)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
put("system_message", systemMessage)
|
||||
}
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
put("model", modelOverride)
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let { put("profile", it) }
|
||||
if (!attachments.isNullOrEmpty()) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
): ChatPayloadResult {
|
||||
val digest = renderSyntheticHistoryDigest(voiceIntentMessages, includePlainTurns = true)
|
||||
val effectiveSystem = mergeEphemeralContext(systemMessage, digest)
|
||||
val payload = buildJsonObject {
|
||||
put("message", message)
|
||||
if (!effectiveSystem.isNullOrBlank()) {
|
||||
put("system_message", effectiveSystem)
|
||||
}
|
||||
if (!modelOverride.isNullOrBlank()) {
|
||||
put("model", modelOverride)
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let { put("profile", it) }
|
||||
}
|
||||
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
|
||||
put("messages", voiceIntentMessages)
|
||||
}
|
||||
return ChatPayloadResult(payload, droppedAttachments = attachments.orEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* Body for `POST /v1/runs`.
|
||||
*
|
||||
* Emits `input`, `model`, and `instructions` (upstream-consumed; note the
|
||||
* runs handler reads `instructions`, NOT `system_message` — the latter was
|
||||
* a silent drop before HRUI-001), plus `stream` + `profile` legacy hints.
|
||||
* Synthetic history: plain text turns ride `conversation_history` (a real
|
||||
* upstream channel — entries are `{role, content}` objects); tool-call
|
||||
* pairs fold into the `instructions` digest. Attachments have no
|
||||
* supported channel here and are returned as dropped.
|
||||
*/
|
||||
internal fun buildRunStreamPayload(
|
||||
message: String,
|
||||
model: String? = null,
|
||||
@@ -51,36 +273,46 @@ internal fun buildRunStreamPayload(
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): JsonObject {
|
||||
): ChatPayloadResult {
|
||||
val resolvedModel = when {
|
||||
!modelOverride.isNullOrBlank() -> modelOverride
|
||||
!model.isNullOrBlank() -> model
|
||||
else -> "default"
|
||||
}
|
||||
return buildJsonObject {
|
||||
val digest = renderSyntheticHistoryDigest(voiceIntentMessages, includePlainTurns = false)
|
||||
val effectiveInstructions = mergeEphemeralContext(systemMessage, digest)
|
||||
val plainTurns = plainSyntheticTurns(voiceIntentMessages)
|
||||
val payload = buildJsonObject {
|
||||
put("model", resolvedModel)
|
||||
put("input", message)
|
||||
put("stream", true)
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
put("system_message", systemMessage)
|
||||
if (!effectiveInstructions.isNullOrBlank()) {
|
||||
put("instructions", effectiveInstructions)
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let { put("profile", it) }
|
||||
if (!attachments.isNullOrEmpty()) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
if (plainTurns.isNotEmpty()) {
|
||||
putJsonArray("conversation_history") {
|
||||
plainTurns.forEach { add(it) }
|
||||
}
|
||||
}
|
||||
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
|
||||
put("messages", voiceIntentMessages)
|
||||
}
|
||||
AgentDisplay.profileRequestName(profileName)?.let { put("profile", it) }
|
||||
}
|
||||
return ChatPayloadResult(payload, droppedAttachments = attachments.orEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* Body for `POST /v1/chat/completions`.
|
||||
*
|
||||
* Emits `model`, `stream`, and `messages` (all upstream-consumed) plus
|
||||
* the `profile` legacy hint. Synthetic history: plain text turns splice
|
||||
* into `messages` before the live user message (upstream keeps
|
||||
* `user`/`assistant` history entries verbatim); tool-call pairs fold into
|
||||
* the system message digest, because upstream SKIPS `tool`-role messages
|
||||
* and STRIPS `tool_calls` — splicing them produced junk empty-content
|
||||
* assistant entries and lost the results entirely. Image attachments ride
|
||||
* inline `image_url` content parts on the user message (upstream vision
|
||||
* format); non-image attachments have no channel and are returned as
|
||||
* dropped.
|
||||
*/
|
||||
internal fun buildChatCompletionsStreamPayload(
|
||||
message: String,
|
||||
model: String? = null,
|
||||
@@ -89,35 +321,37 @@ internal fun buildChatCompletionsStreamPayload(
|
||||
voiceIntentMessages: JsonArray? = null,
|
||||
modelOverride: String? = null,
|
||||
profileName: String? = null,
|
||||
): JsonObject {
|
||||
): ChatPayloadResult {
|
||||
val resolvedModel = when {
|
||||
!modelOverride.isNullOrBlank() -> modelOverride
|
||||
!model.isNullOrBlank() -> model
|
||||
else -> "default"
|
||||
}
|
||||
return buildJsonObject {
|
||||
val digest = renderSyntheticHistoryDigest(voiceIntentMessages, includePlainTurns = false)
|
||||
val effectiveSystem = mergeEphemeralContext(systemMessage, digest)
|
||||
val plainTurns = plainSyntheticTurns(voiceIntentMessages)
|
||||
val imageAttachments = attachments.orEmpty().filter { it.isImage }
|
||||
val payload = buildJsonObject {
|
||||
put("model", resolvedModel)
|
||||
put("stream", true)
|
||||
AgentDisplay.profileRequestName(profileName)?.let { put("profile", it) }
|
||||
putJsonArray("messages") {
|
||||
if (!systemMessage.isNullOrBlank()) {
|
||||
if (!effectiveSystem.isNullOrBlank()) {
|
||||
addJsonObject {
|
||||
put("role", "system")
|
||||
put("content", systemMessage)
|
||||
put("content", effectiveSystem)
|
||||
}
|
||||
}
|
||||
if (voiceIntentMessages != null && voiceIntentMessages.isNotEmpty()) {
|
||||
voiceIntentMessages.forEach { add(it) }
|
||||
}
|
||||
plainTurns.forEach { add(it) }
|
||||
addJsonObject {
|
||||
put("role", "user")
|
||||
if (!attachments.isNullOrEmpty() && attachments.any { it.isImage }) {
|
||||
if (imageAttachments.isNotEmpty()) {
|
||||
put("content", buildJsonArray {
|
||||
addJsonObject {
|
||||
put("type", "text")
|
||||
put("text", message)
|
||||
}
|
||||
attachments.filter { it.isImage }.forEach { att ->
|
||||
imageAttachments.forEach { att ->
|
||||
addJsonObject {
|
||||
put("type", "image_url")
|
||||
putJsonObject("image_url") {
|
||||
@@ -131,15 +365,9 @@ internal fun buildChatCompletionsStreamPayload(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!attachments.isNullOrEmpty() && attachments.any { !it.isImage }) {
|
||||
putJsonArray("attachments") {
|
||||
attachments.filter { !it.isImage }.forEach { att ->
|
||||
addJsonObject {
|
||||
put("contentType", att.contentType)
|
||||
put("content", att.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ChatPayloadResult(
|
||||
payload = payload,
|
||||
droppedAttachments = attachments.orEmpty().filter { !it.isImage },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -179,6 +179,58 @@ data class RenameSessionRequest(
|
||||
val title: String
|
||||
)
|
||||
|
||||
// --- Server-backed bulk cleanup (dashboard POST /api/sessions/prune) ---
|
||||
|
||||
/**
|
||||
* Client-side subset of upstream's `SessionPrune` body. Nulls are omitted from
|
||||
* the request; a fully-bare filter set is a "bare prune", where upstream
|
||||
* applies its own implicit ended-more-than-90-days-ago cutoff.
|
||||
*/
|
||||
data class SessionPruneFilters(
|
||||
val olderThanDays: Double? = null,
|
||||
val source: String? = null,
|
||||
val profile: String? = null,
|
||||
val includeArchived: Boolean = false,
|
||||
)
|
||||
|
||||
/** One row of the dry-run preview (`sessions` in the prune response). */
|
||||
@Serializable
|
||||
data class SessionPruneCandidate(
|
||||
@Serializable(with = FlexibleIdNonNullSerializer::class)
|
||||
val id: String = "",
|
||||
val source: String? = null,
|
||||
val title: String? = null,
|
||||
val model: String? = null,
|
||||
@SerialName("started_at")
|
||||
@Serializable(with = FlexibleTimestampSerializer::class)
|
||||
val startedAt: Double? = null,
|
||||
@SerialName("message_count") val messageCount: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Dry-run response: what a prune WOULD delete — count, started-at span, and
|
||||
* the candidate rows — without deleting anything. Upstream orders candidates
|
||||
* oldest-first.
|
||||
*/
|
||||
@Serializable
|
||||
data class SessionPrunePreview(
|
||||
val matched: Int = 0,
|
||||
@SerialName("oldest_started_at")
|
||||
@Serializable(with = FlexibleTimestampSerializer::class)
|
||||
val oldestStartedAt: Double? = null,
|
||||
@SerialName("newest_started_at")
|
||||
@Serializable(with = FlexibleTimestampSerializer::class)
|
||||
val newestStartedAt: Double? = null,
|
||||
val sessions: List<SessionPruneCandidate> = emptyList(),
|
||||
)
|
||||
|
||||
/** Apply response — how many sessions the server actually removed. */
|
||||
@Serializable
|
||||
data class SessionPruneResult(
|
||||
val ok: Boolean = true,
|
||||
val removed: Int = 0,
|
||||
)
|
||||
|
||||
// --- Messages ---
|
||||
|
||||
@Serializable
|
||||
|
||||
+43
@@ -8,6 +8,11 @@ import android.service.notification.StatusBarNotification
|
||||
import android.util.Log
|
||||
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
@@ -42,6 +47,11 @@ import java.util.concurrent.ConcurrentLinkedQueue
|
||||
*/
|
||||
class HermesNotificationCompanion : NotificationListenerService() {
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val triggerStore by lazy {
|
||||
NotificationTriggerStore(applicationContext.notificationTriggerDataStore)
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffer for entries that arrive before [multiplexer] has been
|
||||
* wired up (e.g. notifications during app cold-start). Bounded so
|
||||
@@ -68,6 +78,7 @@ class HermesNotificationCompanion : NotificationListenerService() {
|
||||
if (active === this) {
|
||||
active = null
|
||||
}
|
||||
serviceScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -75,6 +86,13 @@ class HermesNotificationCompanion : NotificationListenerService() {
|
||||
if (sbn == null) return
|
||||
|
||||
val entry = sbn.toEntry() ?: return
|
||||
// The trigger MVP posts its own local prompt notifications. Never feed
|
||||
// Hermes-Relay's notifications back into the rule engine, or a broad
|
||||
// rule could prompt on its own prompt. Still forward them to the relay
|
||||
// cache to preserve existing notification-companion semantics.
|
||||
if (entry.packageName != packageName) {
|
||||
evaluateNotificationTriggers(entry)
|
||||
}
|
||||
val envelope = entry.toEnvelope()
|
||||
|
||||
// Drain any backlog first so order is preserved.
|
||||
@@ -141,6 +159,31 @@ class HermesNotificationCompanion : NotificationListenerService() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun evaluateNotificationTriggers(entry: NotificationEntry) {
|
||||
serviceScope.launch {
|
||||
val match = triggerStore.firstMatchingRule(entry) ?: return@launch
|
||||
val result = when (match.rule.action) {
|
||||
NotificationTriggerAction.AskMe -> NotificationTriggerPromptNotifier.notifyAskMe(
|
||||
context = applicationContext,
|
||||
rule = match.rule,
|
||||
entry = entry,
|
||||
)
|
||||
}
|
||||
triggerStore.appendActivity(
|
||||
NotificationTriggerActivityEntry(
|
||||
ruleId = match.rule.id,
|
||||
ruleLabel = match.rule.label,
|
||||
action = match.rule.action,
|
||||
packageName = entry.packageName,
|
||||
title = entry.title,
|
||||
textPreview = entry.text?.take(160) ?: entry.subText?.take(160),
|
||||
matchedAt = System.currentTimeMillis(),
|
||||
result = result,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotificationEntry.toEnvelope(): Envelope {
|
||||
val payload = JSON.encodeToJsonElement(NotificationEntry.serializer(), this) as JsonObject
|
||||
return Envelope(
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
package com.hermesandroid.relay.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.hermesandroid.relay.MainActivity
|
||||
import com.hermesandroid.relay.R
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Minimal notification-trigger MVP schema and persistence.
|
||||
*
|
||||
* Storage location: Android DataStore preferences file `notification_triggers`
|
||||
* under the app-private data directory. Rules and the visible activity log are
|
||||
* JSON strings so schema evolution remains additive and lenient.
|
||||
*/
|
||||
@Serializable
|
||||
data class NotificationTriggerRule(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val label: String = "Ask me about matching notifications",
|
||||
val enabled: Boolean = true,
|
||||
@SerialName("app_package")
|
||||
val appPackage: String? = null,
|
||||
@SerialName("title_contains")
|
||||
val titleContains: String? = null,
|
||||
@SerialName("text_contains")
|
||||
val textContains: String? = null,
|
||||
val action: NotificationTriggerAction = NotificationTriggerAction.AskMe,
|
||||
@SerialName("require_confirmation")
|
||||
val requireConfirmation: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class NotificationTriggerAction {
|
||||
@SerialName("ask_me")
|
||||
AskMe,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class NotificationTriggerActivityEntry(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
@SerialName("rule_id")
|
||||
val ruleId: String,
|
||||
@SerialName("rule_label")
|
||||
val ruleLabel: String,
|
||||
val action: NotificationTriggerAction,
|
||||
@SerialName("package_name")
|
||||
val packageName: String,
|
||||
val title: String? = null,
|
||||
@SerialName("text_preview")
|
||||
val textPreview: String? = null,
|
||||
@SerialName("matched_at")
|
||||
val matchedAt: Long,
|
||||
val result: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NotificationTriggerSettings(
|
||||
@SerialName("master_enabled")
|
||||
val masterEnabled: Boolean = false,
|
||||
@SerialName("kill_switch")
|
||||
val killSwitch: Boolean = false,
|
||||
val rules: List<NotificationTriggerRule> = emptyList(),
|
||||
@SerialName("activity_log")
|
||||
val activityLog: List<NotificationTriggerActivityEntry> = emptyList(),
|
||||
)
|
||||
|
||||
data class NotificationTriggerMatch(
|
||||
val rule: NotificationTriggerRule,
|
||||
val entry: NotificationEntry,
|
||||
)
|
||||
|
||||
internal val Context.notificationTriggerDataStore: DataStore<Preferences> by
|
||||
preferencesDataStore(name = "notification_triggers")
|
||||
|
||||
class NotificationTriggerStore(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
val settings: Flow<NotificationTriggerSettings> = dataStore.data.map { prefs ->
|
||||
NotificationTriggerSettings(
|
||||
masterEnabled = prefs[KEY_MASTER_ENABLED] ?: false,
|
||||
killSwitch = prefs[KEY_KILL_SWITCH] ?: false,
|
||||
rules = decodeList<NotificationTriggerRule>(prefs[KEY_RULES_JSON]),
|
||||
activityLog = decodeList<NotificationTriggerActivityEntry>(prefs[KEY_ACTIVITY_LOG_JSON]),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun setMasterEnabled(enabled: Boolean) {
|
||||
dataStore.edit { prefs -> prefs[KEY_MASTER_ENABLED] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setKillSwitch(enabled: Boolean) {
|
||||
dataStore.edit { prefs -> prefs[KEY_KILL_SWITCH] = enabled }
|
||||
}
|
||||
|
||||
suspend fun saveSingleRule(rule: NotificationTriggerRule) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY_RULES_JSON] = json.encodeToString(listOf(rule.normalized()))
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearActivityLog() {
|
||||
dataStore.edit { prefs -> prefs.remove(KEY_ACTIVITY_LOG_JSON) }
|
||||
}
|
||||
|
||||
suspend fun firstMatchingRule(entry: NotificationEntry): NotificationTriggerMatch? {
|
||||
val snapshot = settings.first()
|
||||
if (!snapshot.masterEnabled || snapshot.killSwitch) return null
|
||||
val rule = snapshot.rules.firstOrNull { it.matches(entry) } ?: return null
|
||||
return NotificationTriggerMatch(rule = rule, entry = entry)
|
||||
}
|
||||
|
||||
suspend fun appendActivity(entry: NotificationTriggerActivityEntry) {
|
||||
dataStore.edit { prefs ->
|
||||
val current = decodeList<NotificationTriggerActivityEntry>(prefs[KEY_ACTIVITY_LOG_JSON])
|
||||
prefs[KEY_ACTIVITY_LOG_JSON] = json.encodeToString(
|
||||
(listOf(entry) + current).take(MAX_ACTIVITY_LOG_ENTRIES),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T> decodeList(raw: String?): List<T> {
|
||||
if (raw.isNullOrBlank()) return emptyList()
|
||||
return runCatching { json.decodeFromString<List<T>>(raw) }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun NotificationTriggerRule.normalized(): NotificationTriggerRule = copy(
|
||||
label = label.trim().ifBlank { "Ask me about matching notifications" },
|
||||
appPackage = appPackage.cleanBlank(),
|
||||
titleContains = titleContains.cleanBlank(),
|
||||
textContains = textContains.cleanBlank(),
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val KEY_MASTER_ENABLED = booleanPreferencesKey("notification_triggers_enabled")
|
||||
private val KEY_KILL_SWITCH = booleanPreferencesKey("notification_triggers_kill_switch")
|
||||
private val KEY_RULES_JSON = stringPreferencesKey("notification_trigger_rules_json")
|
||||
private val KEY_ACTIVITY_LOG_JSON = stringPreferencesKey("notification_trigger_activity_log_json")
|
||||
const val MAX_ACTIVITY_LOG_ENTRIES = 25
|
||||
|
||||
fun defaultRule(): NotificationTriggerRule = NotificationTriggerRule()
|
||||
}
|
||||
}
|
||||
|
||||
fun NotificationTriggerRule.matches(entry: NotificationEntry): Boolean {
|
||||
if (!enabled) return false
|
||||
val app = appPackage.cleanBlank()
|
||||
val titleNeedle = titleContains.cleanBlank()
|
||||
val textNeedle = textContains.cleanBlank()
|
||||
|
||||
// Avoid accidental "match every notification on the phone" rules. The UI
|
||||
// requires at least one filter too, but this keeps imported/future schema
|
||||
// data safe.
|
||||
if (app == null && titleNeedle == null && textNeedle == null) return false
|
||||
|
||||
if (app != null && !entry.packageName.equals(app, ignoreCase = true)) return false
|
||||
if (titleNeedle != null && !entry.title.orEmpty().contains(titleNeedle, ignoreCase = true)) {
|
||||
return false
|
||||
}
|
||||
if (textNeedle != null) {
|
||||
val haystack = listOfNotNull(entry.text, entry.subText).joinToString("\n")
|
||||
if (!haystack.contains(textNeedle, ignoreCase = true)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun NotificationTriggerRule.summary(): String {
|
||||
val parts = buildList {
|
||||
appPackage.cleanBlank()?.let { add("app $it") }
|
||||
titleContains.cleanBlank()?.let { add("title contains “$it”") }
|
||||
textContains.cleanBlank()?.let { add("text contains “$it”") }
|
||||
}
|
||||
return if (parts.isEmpty()) "No filters set" else parts.joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun String?.cleanBlank(): String? = this?.trim()?.takeIf { it.isNotBlank() }
|
||||
|
||||
object NotificationTriggerPromptNotifier {
|
||||
private const val TAG = "NotifTriggerPrompt"
|
||||
private const val CHANNEL_ID = "notification_triggers"
|
||||
private const val CHANNEL_NAME = "Notification triggers"
|
||||
private const val NOTIFICATION_ID_BASE = 4300
|
||||
private const val CHAT_ROUTE = "chat"
|
||||
|
||||
/**
|
||||
* Safe automatic action: post a local prompt that asks the user whether to
|
||||
* involve Hermes. It does not send an LLM request, reply, tap, text, route,
|
||||
* or otherwise act on another app without the user tapping first.
|
||||
*/
|
||||
@SuppressLint("MissingPermission", "NotificationPermission")
|
||||
fun notifyAskMe(
|
||||
context: Context,
|
||||
rule: NotificationTriggerRule,
|
||||
entry: NotificationEntry,
|
||||
): String {
|
||||
ensureChannel(context)
|
||||
if (!hasPostNotificationsPermission(context)) {
|
||||
Log.i(TAG, "POST_NOTIFICATIONS not granted — logging trigger without prompt")
|
||||
return "skipped: post-notifications permission missing"
|
||||
}
|
||||
|
||||
val tapIntent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(MainActivity.EXTRA_NAV_ROUTE, CHAT_ROUTE)
|
||||
}
|
||||
val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
val tapPending = PendingIntent.getActivity(context, notificationId(entry), tapIntent, pendingFlags)
|
||||
|
||||
val title = "Ask Hermes about this?"
|
||||
val source = entry.title?.takeIf { it.isNotBlank() } ?: entry.packageName
|
||||
val body = entry.text?.takeIf { it.isNotBlank() }
|
||||
?: "Rule matched: ${rule.summary()}"
|
||||
val expanded = "Matched ${rule.summary()}\n\n$source\n$body"
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText("$source — ${body.take(96)}")
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(expanded.take(700)))
|
||||
.setContentIntent(tapPending)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(false)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.build()
|
||||
|
||||
return runCatching {
|
||||
NotificationManagerCompat.from(context).notify(notificationId(entry), notification)
|
||||
"prompt posted"
|
||||
}.getOrElse { exc ->
|
||||
Log.w(TAG, "notifyAskMe: notify failed", exc)
|
||||
"skipped: prompt failed (${exc.javaClass.simpleName})"
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationId(entry: NotificationEntry): Int {
|
||||
val suffix = (entry.key.hashCode() and 0x0fff)
|
||||
return NOTIFICATION_ID_BASE + suffix
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
if (nm.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = "Prompts shown when an explicitly enabled notification trigger matches."
|
||||
setShowBadge(true)
|
||||
}
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun hasPostNotificationsPermission(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
|
||||
return ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
@@ -421,6 +421,7 @@ fun RelayApp() {
|
||||
System.currentTimeMillis() - lastPausedAtMs.value
|
||||
}
|
||||
connectionViewModel.revalidateOnResume(awayMs)
|
||||
voiceViewModel.onAppResumed()
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
@@ -791,6 +792,23 @@ fun RelayApp() {
|
||||
chatViewModel.notifyOnTurnComplete = notifyTurnComplete
|
||||
}
|
||||
|
||||
// Demo-mode composer wiring: unconditional — a demo session has no API
|
||||
// client, so the client-gated chat init effect above never runs and
|
||||
// ChatViewModel's own handler stays null. Lambdas read live state on
|
||||
// every send.
|
||||
LaunchedEffect(Unit) {
|
||||
chatViewModel.setDemoModeWiring(
|
||||
isDemo = { connectionViewModel.isDemoMode.value },
|
||||
handler = { connectionViewModel.chatHandler },
|
||||
)
|
||||
// Voice → chat breadcrumbs (e.g. "background task still running" when
|
||||
// voice mode exits with a detached run) land as system notices in the
|
||||
// shared chat transcript.
|
||||
voiceViewModel.chatNoticeSink = { notice ->
|
||||
connectionViewModel.chatHandler.addSystemNotice(notice)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync tool annotation parsing toggle to ChatHandler
|
||||
val parseAnnotations by connectionViewModel.parseToolAnnotations.collectAsState()
|
||||
LaunchedEffect(parseAnnotations) {
|
||||
|
||||
@@ -188,8 +188,8 @@ private fun Modifier.topFadeEdge(fade: Dp = 28.dp): Modifier = this
|
||||
)
|
||||
}
|
||||
|
||||
/** Resolved motion/accessibility posture for clean mode. */
|
||||
private data class CleanMotionState(
|
||||
/** Shared OS motion/accessibility posture for animated chat affordances. */
|
||||
internal data class AccessibleMotionState(
|
||||
/** OS animator scale is non-zero (i.e. system animations are ON). */
|
||||
val osAnimations: Boolean,
|
||||
/** TalkBack-style touch exploration is active — faded text is unreadable
|
||||
@@ -198,7 +198,7 @@ private data class CleanMotionState(
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun rememberCleanMotionState(): CleanMotionState {
|
||||
internal fun rememberAccessibleMotionState(): AccessibleMotionState {
|
||||
val context = LocalContext.current
|
||||
// ANIMATOR_DURATION_SCALE == 0 is the platform "remove animations" / many
|
||||
// OEM "reduce motion" toggles. Read once on entry; a mid-mode toggle is
|
||||
@@ -225,7 +225,10 @@ private fun rememberCleanMotionState(): CleanMotionState {
|
||||
a11y?.addTouchExplorationStateChangeListener(listener)
|
||||
onDispose { a11y?.removeTouchExplorationStateChangeListener(listener) }
|
||||
}
|
||||
return CleanMotionState(osAnimations = osAnimations, touchExploration = touchExploration)
|
||||
return AccessibleMotionState(
|
||||
osAnimations = osAnimations,
|
||||
touchExploration = touchExploration,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -511,7 +514,7 @@ fun CleanChatMode(
|
||||
onExit: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val motion = rememberCleanMotionState()
|
||||
val motion = rememberAccessibleMotionState()
|
||||
val sphereAnimated = animationEnabled && motion.osAnimations
|
||||
// Faded text is unreadable to touch exploration, so the text path goes
|
||||
// static (readable + announced) whenever TalkBack is exploring.
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BrokenImage
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import coil3.compose.SubcomposeAsyncImage
|
||||
import coil3.compose.SubcomposeAsyncImageContent
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.AttachmentRenderMode
|
||||
import com.hermesandroid.relay.data.AttachmentState
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* One item in a message's attachment render order. Loaded images are grouped
|
||||
* into [Gallery] only when there are at least two; every other attachment
|
||||
* keeps its original index so retry/manual-fetch callbacks still target the
|
||||
* exact [com.hermesandroid.relay.data.ChatMessage.attachments] entry.
|
||||
*/
|
||||
internal sealed interface AttachmentLayoutItem {
|
||||
data class Single(val attachmentIndex: Int) : AttachmentLayoutItem
|
||||
data class Gallery(val attachmentIndices: List<Int>) : AttachmentLayoutItem
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the attachment render plan without reordering non-image cards. The
|
||||
* gallery occupies the first eligible image's slot and absorbs the remaining
|
||||
* loaded images, including images separated by a PDF/file card.
|
||||
*/
|
||||
internal fun attachmentLayoutItems(attachments: List<Attachment>): List<AttachmentLayoutItem> {
|
||||
return buildList {
|
||||
var index = 0
|
||||
while (index < attachments.size) {
|
||||
if (!attachments[index].isGalleryImage()) {
|
||||
add(AttachmentLayoutItem.Single(index))
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
val run = buildList {
|
||||
var cursor = index
|
||||
while (cursor < attachments.size && attachments[cursor].isGalleryImage()) {
|
||||
add(cursor)
|
||||
cursor++
|
||||
}
|
||||
}
|
||||
if (run.size >= 2) add(AttachmentLayoutItem.Gallery(run))
|
||||
else add(AttachmentLayoutItem.Single(index))
|
||||
index += run.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Attachment.isGalleryImage(): Boolean =
|
||||
state == AttachmentState.LOADED && renderMode == AttachmentRenderMode.IMAGE
|
||||
|
||||
/** Two-column, non-lazy rows for a gallery nested inside the chat LazyColumn. */
|
||||
internal fun galleryRows(itemCount: Int): List<List<Int>> =
|
||||
(0 until itemCount.coerceAtLeast(0)).chunked(GALLERY_COLUMNS)
|
||||
|
||||
internal fun galleryPreviewIndices(itemCount: Int): List<Int> =
|
||||
(0 until itemCount.coerceAtLeast(0)).take(GALLERY_PREVIEW_LIMIT)
|
||||
|
||||
/**
|
||||
* Telegram-style media group for two or more loaded image attachments.
|
||||
*
|
||||
* The chat bubble shows a compact two-column grid. Tapping a tile opens the
|
||||
* full-screen horizontal pager at that image; per-image blur reveal, long-
|
||||
* press actions, and one-tap Save remain available instead of regressing the
|
||||
* single-image attachment behavior.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun AttachmentGallery(
|
||||
attachments: List<Attachment>,
|
||||
modifier: Modifier = Modifier,
|
||||
maxWidth: Dp = 280.dp,
|
||||
) {
|
||||
if (attachments.size < 2) return
|
||||
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
val revealed = remember { mutableStateMapOf<String, Boolean>() }
|
||||
var viewerStartIndex by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
viewerStartIndex?.let { startIndex ->
|
||||
AttachmentGalleryViewer(
|
||||
attachments = attachments,
|
||||
initialIndex = startIndex.coerceIn(attachments.indices),
|
||||
initiallyRevealedKeys = revealed
|
||||
.filterValues { it }
|
||||
.keys,
|
||||
onDismiss = { viewerStartIndex = null },
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.fillMaxWidth()
|
||||
.semantics { contentDescription = "${attachments.size} image gallery" },
|
||||
verticalArrangement = Arrangement.spacedBy(GALLERY_GAP),
|
||||
) {
|
||||
val previewIndices = galleryPreviewIndices(attachments.size)
|
||||
galleryRows(previewIndices.size).forEach { row ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(GALLERY_GAP),
|
||||
) {
|
||||
row.forEach { previewIndex ->
|
||||
val galleryIndex = previewIndices[previewIndex]
|
||||
val attachment = attachments[galleryIndex]
|
||||
val attachmentKey = galleryAttachmentKey(attachment, galleryIndex)
|
||||
val blurred = revealed[attachmentKey] != true &&
|
||||
shouldBlurImage(blurMode, attachment.sensitive)
|
||||
var menuExpanded by remember(attachment, galleryIndex) { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
// An odd final tile spans both columns without
|
||||
// becoming a full-width square taller than the grid.
|
||||
.aspectRatio(if (row.size == 1) 2f else 1f),
|
||||
) {
|
||||
BlurredMedia(
|
||||
blurred = blurred,
|
||||
onReveal = { revealed[attachmentKey] = true },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
GalleryImageTile(
|
||||
attachment = attachment,
|
||||
position = galleryIndex,
|
||||
count = attachments.size,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.testTag("attachment-gallery-tile-$galleryIndex")
|
||||
.clip(RoundedCornerShape(GALLERY_CORNER))
|
||||
.combinedClickable(
|
||||
onClick = { viewerStartIndex = galleryIndex },
|
||||
onLongClick = { menuExpanded = true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (!blurred) {
|
||||
SaveOverlayButton(
|
||||
onClick = {
|
||||
scope.launch { saveAttachment(context, attachment) }
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
AttachmentActionsMenu(
|
||||
expanded = menuExpanded,
|
||||
onDismiss = { menuExpanded = false },
|
||||
context = context,
|
||||
scope = scope,
|
||||
attachment = attachment,
|
||||
)
|
||||
|
||||
val hiddenCount = attachments.size - GALLERY_PREVIEW_LIMIT
|
||||
if (
|
||||
hiddenCount > 0 &&
|
||||
previewIndex == GALLERY_PREVIEW_LIMIT - 1
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(7.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.Black.copy(alpha = 0.68f))
|
||||
.padding(horizontal = 9.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "+$hiddenCount",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GalleryImageTile(
|
||||
attachment: Attachment,
|
||||
position: Int,
|
||||
count: Int,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val description = listOfNotNull(
|
||||
attachment.fileName?.takeIf { it.isNotBlank() },
|
||||
"image ${position + 1} of $count",
|
||||
).joinToString(", ")
|
||||
val cachedUri = attachment.cachedUri?.takeIf { it.isNotBlank() }
|
||||
|
||||
if (cachedUri != null) {
|
||||
SubcomposeAsyncImage(
|
||||
model = Uri.parse(cachedUri),
|
||||
contentDescription = description,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = modifier,
|
||||
) {
|
||||
val state by painter.state.collectAsState()
|
||||
when (state) {
|
||||
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
|
||||
is AsyncImagePainter.State.Loading -> GalleryImagePlaceholder(modifier = Modifier.fillMaxSize())
|
||||
else -> GalleryImageFailure(description, Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var bitmap by remember(attachment.content) { mutableStateOf<ImageBitmap?>(null) }
|
||||
var failed by remember(attachment.content) { mutableStateOf(false) }
|
||||
LaunchedEffect(attachment.content) {
|
||||
val decoded = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val bytes = android.util.Base64.decode(
|
||||
attachment.content,
|
||||
android.util.Base64.DEFAULT,
|
||||
)
|
||||
decodeGalleryBitmap(bytes)?.asImageBitmap()
|
||||
}.getOrNull()
|
||||
}
|
||||
if (decoded != null) bitmap = decoded else failed = true
|
||||
}
|
||||
|
||||
when {
|
||||
bitmap != null -> Image(
|
||||
bitmap = bitmap!!,
|
||||
contentDescription = description,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = modifier,
|
||||
)
|
||||
failed -> GalleryImageFailure(description, modifier)
|
||||
else -> GalleryImagePlaceholder(modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GalleryImagePlaceholder(modifier: Modifier) {
|
||||
Box(
|
||||
modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GalleryImageFailure(description: String, modifier: Modifier) {
|
||||
Box(
|
||||
modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.BrokenImage,
|
||||
contentDescription = "Couldn't load $description",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode a bounded thumbnail rather than retaining every full-size image. */
|
||||
private fun decodeGalleryBitmap(bytes: ByteArray): android.graphics.Bitmap? {
|
||||
if (bytes.isEmpty()) return null
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
var sample = 1
|
||||
while (
|
||||
bounds.outWidth / sample > GALLERY_DECODE_TARGET_PX ||
|
||||
bounds.outHeight / sample > GALLERY_DECODE_TARGET_PX
|
||||
) {
|
||||
sample *= 2
|
||||
}
|
||||
val options = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
|
||||
}
|
||||
|
||||
private const val GALLERY_COLUMNS = 2
|
||||
private const val GALLERY_PREVIEW_LIMIT = 4
|
||||
private const val GALLERY_DECODE_TARGET_PX = 512
|
||||
private val GALLERY_GAP = 3.dp
|
||||
private val GALLERY_CORNER = 8.dp
|
||||
|
||||
internal fun galleryAttachmentKey(attachment: Attachment, index: Int): String =
|
||||
attachment.relayToken?.takeIf { it.isNotBlank() }
|
||||
?: attachment.cachedUri?.takeIf { it.isNotBlank() }
|
||||
?: buildString {
|
||||
append(attachment.fileName.orEmpty())
|
||||
append('|')
|
||||
append(attachment.contentType)
|
||||
append('|')
|
||||
append(attachment.content.hashCode())
|
||||
append('|')
|
||||
append(index)
|
||||
}
|
||||
@@ -15,7 +15,8 @@ import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.gestures.rememberTransformableState
|
||||
import androidx.compose.foundation.gestures.transformable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -34,6 +35,8 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
@@ -59,6 +62,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -77,8 +81,10 @@ import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
@@ -98,6 +104,7 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sqrt
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -191,13 +198,62 @@ fun BlurredMedia(
|
||||
fun Modifier.zoomable(maxScale: Float = 6f): Modifier {
|
||||
var scale by remember { mutableStateOf(1f) }
|
||||
var offset by remember { mutableStateOf(Offset.Zero) }
|
||||
return this
|
||||
.pointerInput(Unit) {
|
||||
detectTransformGestures { _, pan, zoom, _ ->
|
||||
scale = (scale * zoom).coerceIn(1f, maxScale)
|
||||
offset = if (scale > 1f) offset + pan else Offset.Zero
|
||||
}
|
||||
var viewportSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
|
||||
fun maxOffset(forScale: Float): Offset = Offset(
|
||||
x = ((forScale - 1f) * viewportSize.width / 2f).coerceAtLeast(0f),
|
||||
y = ((forScale - 1f) * viewportSize.height / 2f).coerceAtLeast(0f),
|
||||
)
|
||||
|
||||
fun clampOffset(candidate: Offset, forScale: Float): Offset {
|
||||
val max = maxOffset(forScale)
|
||||
return Offset(
|
||||
x = candidate.x.coerceIn(-max.x, max.x),
|
||||
y = candidate.y.coerceIn(-max.y, max.y),
|
||||
)
|
||||
}
|
||||
|
||||
val transformState = rememberTransformableState { _, zoomChange, panChange, _ ->
|
||||
val nextScale = (scale * zoomChange).coerceIn(1f, maxScale)
|
||||
offset = if (nextScale > 1f) {
|
||||
clampOffset(offset + panChange, nextScale)
|
||||
} else {
|
||||
Offset.Zero
|
||||
}
|
||||
scale = nextScale
|
||||
}
|
||||
return this
|
||||
.onSizeChanged {
|
||||
viewportSize = it
|
||||
offset = clampOffset(offset, scale)
|
||||
}
|
||||
// Let a one-finger drag bubble to HorizontalPager at 1×. Once the
|
||||
// image is zoomed, the image owns panning; pinch zoom always works.
|
||||
.transformable(
|
||||
state = transformState,
|
||||
canPan = { pan ->
|
||||
if (scale <= 1f) {
|
||||
false
|
||||
} else {
|
||||
val max = maxOffset(scale)
|
||||
val canMoveHorizontally = when {
|
||||
pan.x > 0f -> offset.x < max.x
|
||||
pan.x < 0f -> offset.x > -max.x
|
||||
else -> false
|
||||
}
|
||||
val canMoveVertically = when {
|
||||
pan.y > 0f -> offset.y < max.y
|
||||
pan.y < 0f -> offset.y > -max.y
|
||||
else -> false
|
||||
}
|
||||
if (abs(pan.x) >= abs(pan.y)) {
|
||||
canMoveHorizontally
|
||||
} else {
|
||||
canMoveVertically
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onDoubleTap = {
|
||||
@@ -345,6 +401,7 @@ fun AttachmentViewer(
|
||||
MediaViewerToolbar(
|
||||
title = title,
|
||||
busy = busy,
|
||||
actionsEnabled = !blurred,
|
||||
onShare = onShare,
|
||||
onSave = onSave,
|
||||
onOpenExternal = onOpenExternal,
|
||||
@@ -355,11 +412,204 @@ fun AttachmentViewer(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen viewer for an image attachment group. The pager starts at the
|
||||
* tapped tile, swipes horizontally at 1×, and keeps the existing per-image
|
||||
* zoom, blur, Save, Share, and Open-externally behavior.
|
||||
*
|
||||
* [initiallyRevealedKeys] carries reveal state from the grid so a sensitive
|
||||
* image that was already uncovered is not unexpectedly hidden again on open.
|
||||
*/
|
||||
@Composable
|
||||
internal fun AttachmentGalleryViewer(
|
||||
attachments: List<Attachment>,
|
||||
initialIndex: Int,
|
||||
onDismiss: () -> Unit,
|
||||
initiallyRevealedKeys: Set<String> = emptySet(),
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (attachments.isEmpty()) return
|
||||
if (attachments.size == 1) {
|
||||
AttachmentViewer(
|
||||
attachment = attachments.first(),
|
||||
onDismiss = onDismiss,
|
||||
modifier = modifier,
|
||||
initiallyRevealed = galleryAttachmentKey(attachments.first(), 0) in
|
||||
initiallyRevealedKeys,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
val revealed = remember { mutableStateMapOf<String, Boolean>() }
|
||||
LaunchedEffect(initiallyRevealedKeys) {
|
||||
initiallyRevealedKeys.forEach { revealed[it] = true }
|
||||
}
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = initialIndex.coerceIn(attachments.indices),
|
||||
pageCount = { attachments.size },
|
||||
)
|
||||
|
||||
val currentIndex = pagerState.currentPage.coerceIn(attachments.indices)
|
||||
val attachment = attachments[currentIndex]
|
||||
val currentKey = galleryAttachmentKey(attachment, currentIndex)
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
val currentBlurred = revealed[currentKey] != true &&
|
||||
shouldBlurImage(blurMode, attachment.sensitive)
|
||||
val title = attachment.fileName
|
||||
?: attachment.contentType.substringBefore(';').ifBlank { "Image" }
|
||||
val toolbarTitle = "$title · ${currentIndex + 1} of ${attachments.size}"
|
||||
|
||||
// Capture the currently visible attachment in each click lambda. A
|
||||
// swipe while IO is running must not redirect Save/Share to a new page.
|
||||
fun runWithBytes(action: suspend (Attachment, ByteArray) -> Unit) {
|
||||
if (currentBlurred || busy) return
|
||||
val target = attachment
|
||||
scope.launch {
|
||||
busy = true
|
||||
try {
|
||||
val bytes = attachmentBytes(context, target)
|
||||
if (bytes == null) {
|
||||
viewerToast(context, "Couldn't read this image")
|
||||
return@launch
|
||||
}
|
||||
action(target, bytes)
|
||||
} catch (error: Exception) {
|
||||
viewerToast(
|
||||
context,
|
||||
error.message?.takeIf { it.isNotBlank() }
|
||||
?: "Couldn't complete that image action",
|
||||
)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onShare = {
|
||||
runWithBytes { target, bytes ->
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
target.fileName,
|
||||
target.contentType,
|
||||
)
|
||||
MediaSaver.share(context, uri, target.contentType)
|
||||
}
|
||||
}
|
||||
val onSave = {
|
||||
runWithBytes { target, bytes ->
|
||||
when (val result = MediaSaver.saveImage(
|
||||
context,
|
||||
bytes,
|
||||
target.fileName,
|
||||
target.contentType,
|
||||
)) {
|
||||
is MediaSaver.SaveResult.Saved ->
|
||||
viewerToast(context, "Saved to ${result.location}")
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
target.fileName,
|
||||
target.contentType,
|
||||
)
|
||||
MediaSaver.share(context, uri, target.contentType)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed ->
|
||||
viewerToast(context, "Save failed: ${result.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
val onOpenExternal: () -> Unit = openExternal@{
|
||||
if (currentBlurred || busy) return@openExternal
|
||||
val target = attachment
|
||||
val cached = target.cachedUri
|
||||
if (!cached.isNullOrBlank()) {
|
||||
runCatching {
|
||||
MediaSaver.open(context, Uri.parse(cached), target.contentType)
|
||||
}.onFailure {
|
||||
viewerToast(context, "Couldn't open this image")
|
||||
}
|
||||
} else {
|
||||
runWithBytes { item, bytes ->
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
item.fileName,
|
||||
item.contentType,
|
||||
)
|
||||
MediaSaver.open(context, uri, item.contentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.96f)),
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
beyondViewportPageCount = 0,
|
||||
pageSpacing = 12.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.testTag("attachment-gallery-pager"),
|
||||
) { page ->
|
||||
val pageAttachment = attachments[page]
|
||||
val pageKey = galleryAttachmentKey(pageAttachment, page)
|
||||
val blurred = revealed[pageKey] != true && shouldBlurImage(
|
||||
blurMode,
|
||||
pageAttachment.sensitive,
|
||||
)
|
||||
ImageBody(
|
||||
attachment = pageAttachment,
|
||||
blurred = blurred,
|
||||
onReveal = { revealed[pageKey] = true },
|
||||
)
|
||||
}
|
||||
|
||||
MediaViewerToolbar(
|
||||
title = toolbarTitle,
|
||||
busy = busy,
|
||||
actionsEnabled = !currentBlurred,
|
||||
onShare = onShare,
|
||||
onSave = onSave,
|
||||
onOpenExternal = onOpenExternal,
|
||||
onClose = onDismiss,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "${currentIndex + 1} / ${attachments.size}",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(bottom = 12.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.Black.copy(alpha = 0.55f))
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The single shared control bar used across every attachment type. */
|
||||
@Composable
|
||||
private fun MediaViewerToolbar(
|
||||
title: String,
|
||||
busy: Boolean,
|
||||
actionsEnabled: Boolean = true,
|
||||
onShare: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onOpenExternal: () -> Unit,
|
||||
@@ -393,13 +643,17 @@ private fun MediaViewerToolbar(
|
||||
modifier = Modifier.size(18.dp).padding(end = 4.dp),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onOpenExternal, colors = tint) {
|
||||
IconButton(
|
||||
onClick = onOpenExternal,
|
||||
enabled = actionsEnabled && !busy,
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.OpenInNew, contentDescription = "Open externally")
|
||||
}
|
||||
IconButton(onClick = onShare, colors = tint) {
|
||||
IconButton(onClick = onShare, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Share, contentDescription = "Share")
|
||||
}
|
||||
IconButton(onClick = onSave, colors = tint) {
|
||||
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Download, contentDescription = "Save")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.HourglassTop
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.BackgroundTaskPhase
|
||||
import com.hermesandroid.relay.data.BackgroundTaskState
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
|
||||
/**
|
||||
* The Chat-side identity for one promoted/durable Hermes run. It stays in the
|
||||
* owning assistant turn while [BackgroundTaskState.phase] advances, rather
|
||||
* than creating a running system notice and a second completion row.
|
||||
*
|
||||
* Tool activity is deliberately subordinate: the compact timeline expands
|
||||
* inside this card and reuses [CompactToolCall]/[SubagentLane], so background
|
||||
* work reads like the same task at every stage instead of a mini dashboard.
|
||||
*/
|
||||
@Composable
|
||||
fun BackgroundTaskCard(
|
||||
task: BackgroundTaskState,
|
||||
toolCalls: List<ToolCall>,
|
||||
showTimeline: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val terminal = task.phase in terminalBackgroundTaskPhases
|
||||
val timelineCalls = if (showTimeline) toolCalls else emptyList()
|
||||
val hasTimeline = timelineCalls.isNotEmpty()
|
||||
var expanded by rememberSaveable(task.id) { mutableStateOf(hasTimeline && !terminal) }
|
||||
|
||||
LaunchedEffect(terminal, hasTimeline) {
|
||||
if (!hasTimeline || terminal) expanded = false
|
||||
}
|
||||
|
||||
val phaseLabel = backgroundTaskPhaseLabel(task.phase)
|
||||
val meta = backgroundTaskMeta(task, timelineCalls)
|
||||
val icon: ImageVector
|
||||
val iconTint = when (task.phase) {
|
||||
BackgroundTaskPhase.COMPLETE -> {
|
||||
icon = Icons.Filled.Check
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
BackgroundTaskPhase.FAILED, BackgroundTaskPhase.CANCELLED -> {
|
||||
icon = Icons.Filled.Close
|
||||
MaterialTheme.colorScheme.error
|
||||
}
|
||||
else -> {
|
||||
icon = Icons.Filled.HourglassTop
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.semantics {
|
||||
contentDescription = buildString {
|
||||
append("Background task, ")
|
||||
append(task.title)
|
||||
append(", ")
|
||||
append(phaseLabel.lowercase())
|
||||
task.statusLine?.takeIf { it.isNotBlank() }?.let {
|
||||
append(", ")
|
||||
append(it)
|
||||
}
|
||||
if (meta.isNotBlank()) {
|
||||
append(", ")
|
||||
append(meta)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f),
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = hasTimeline) { expanded = !expanded }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = iconTint,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = task.title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
task.statusLine?.takeIf { it.isNotBlank() }?.let { status ->
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = status,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = phaseLabel,
|
||||
style = relayMetadataStyle(),
|
||||
color = iconTint,
|
||||
)
|
||||
if (meta.isNotBlank()) {
|
||||
Text(
|
||||
text = meta,
|
||||
style = relayMetadataStyle(),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (hasTimeline) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse task timeline" else "Expand task timeline",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!terminal) {
|
||||
// A fixed accent rail communicates active state without adding
|
||||
// another indeterminate animation to an already-live transcript.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
.background(MaterialTheme.colorScheme.tertiary.copy(alpha = 0.7f)),
|
||||
)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f))
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val lanes = timelineCalls.groupBy { it.taskIndex }
|
||||
lanes[null].orEmpty().forEach { call ->
|
||||
CompactToolCall(toolCall = call)
|
||||
}
|
||||
lanes.keys.filterNotNull().sorted().forEach { taskIndex ->
|
||||
SubagentLane(
|
||||
taskIndex = taskIndex,
|
||||
calls = lanes.getValue(taskIndex),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun backgroundTaskPhaseLabel(phase: BackgroundTaskPhase): String = when (phase) {
|
||||
BackgroundTaskPhase.RUNNING -> "Working"
|
||||
BackgroundTaskPhase.WAITING -> "Needs input"
|
||||
BackgroundTaskPhase.DELIVERING -> "Delivering"
|
||||
BackgroundTaskPhase.COMPLETE -> "Complete"
|
||||
BackgroundTaskPhase.FAILED -> "Failed"
|
||||
BackgroundTaskPhase.CANCELLED -> "Cancelled"
|
||||
}
|
||||
|
||||
internal fun backgroundTaskMeta(task: BackgroundTaskState, toolCalls: List<ToolCall>): String {
|
||||
val completed = maxOf(task.completedToolCount, toolCalls.count { it.isComplete })
|
||||
return buildList {
|
||||
if (completed > 0) add("$completed step${if (completed == 1) "" else "s"}")
|
||||
if (task.queuedCount > 0) add("+${task.queuedCount} queued")
|
||||
}.joinToString(" · ")
|
||||
}
|
||||
|
||||
private val terminalBackgroundTaskPhases = setOf(
|
||||
BackgroundTaskPhase.COMPLETE,
|
||||
BackgroundTaskPhase.FAILED,
|
||||
BackgroundTaskPhase.CANCELLED,
|
||||
)
|
||||
+37
-54
@@ -21,11 +21,15 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -38,6 +42,7 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import com.hermesandroid.relay.BuildConfig
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.util.DiagnosticIssuePrefill
|
||||
import com.hermesandroid.relay.util.IssueReport
|
||||
|
||||
/**
|
||||
@@ -57,6 +62,13 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
val plainText = remember(entry) { entry.toPlainText() }
|
||||
val severityName = entry.severity.name
|
||||
|
||||
// Info-severity pre-flight: routine log lines only become GitHub issues once
|
||||
// the reporter says what they expected instead (that answer replaces the
|
||||
// boilerplate "What happened" line). Error entries keep the direct flow.
|
||||
val needsExpectation = entry.severity == DiagnosticSeverity.Info
|
||||
var expectationVisible by remember(entry) { mutableStateOf(false) }
|
||||
var expectation by remember(entry) { mutableStateOf("") }
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
@@ -127,6 +139,20 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
)
|
||||
}
|
||||
|
||||
if (expectationVisible) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
OutlinedTextField(
|
||||
value = expectation,
|
||||
onValueChange = { expectation = it },
|
||||
label = { Text("What were you expecting to happen?") },
|
||||
supportingText = {
|
||||
Text("This is a routine log entry — telling us what looked wrong turns it into an answerable report.")
|
||||
},
|
||||
minLines = 2,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -155,16 +181,24 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
},
|
||||
) { Text("Export") }
|
||||
Button(
|
||||
enabled = !expectationVisible || expectation.isNotBlank(),
|
||||
onClick = {
|
||||
if (needsExpectation && !expectationVisible) {
|
||||
expectationVisible = true
|
||||
return@Button
|
||||
}
|
||||
// Copy full text first; the GitHub URL only carries the
|
||||
// head of long traces, so the user can paste the rest.
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
val opened = IssueReport.openUrl(
|
||||
context,
|
||||
IssueReport.buildGithubIssueUrl(
|
||||
title = "[Bug]: ${entry.title}",
|
||||
bodyMarkdown = entry.toIssueBody(),
|
||||
labels = "bug",
|
||||
title = DiagnosticIssuePrefill.issueTitle(entry),
|
||||
bodyMarkdown = DiagnosticIssuePrefill.issueBody(
|
||||
entry,
|
||||
expectation = expectation.takeIf { expectationVisible },
|
||||
),
|
||||
labels = DiagnosticIssuePrefill.issueLabels(entry),
|
||||
),
|
||||
)
|
||||
toast(
|
||||
@@ -245,54 +279,3 @@ private fun DiagnosticLogEntry.toPlainText(): String = buildString {
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown issue body mirroring the crash-report issue format: environment block
|
||||
* + the captured entry. Trace is capped so the prefilled GitHub URL stays within
|
||||
* browser limits (full text is on the clipboard).
|
||||
*/
|
||||
private const val MAX_TRACE_FOR_URL = 3000
|
||||
|
||||
private fun DiagnosticLogEntry.toIssueBody(): String {
|
||||
val trace = (stacktrace ?: detail).orEmpty().let {
|
||||
if (it.length > MAX_TRACE_FOR_URL) {
|
||||
it.take(MAX_TRACE_FOR_URL) + "\n… (truncated — full diagnostic copied to your clipboard)"
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
val surface = if (BuildConfig.FLAVOR.equals("sideload", ignoreCase = true)) "sideload APK" else "Google Play"
|
||||
return buildString {
|
||||
appendLine(
|
||||
"> ⚠️ Before submitting: remove any secrets, tokens, real hostnames/IPs, " +
|
||||
"or personal data from the detail below.",
|
||||
)
|
||||
appendLine()
|
||||
appendLine("### Affected area")
|
||||
appendLine("Android app")
|
||||
appendLine()
|
||||
appendLine("### What happened?")
|
||||
appendLine("Captured diagnostic from the in-app activity log.")
|
||||
appendLine()
|
||||
appendLine("### Environment")
|
||||
appendLine("- Hermes-Relay version/tag: ${BuildConfig.VERSION_NAME} (code ${BuildConfig.VERSION_CODE})")
|
||||
appendLine("- Install surface: $surface")
|
||||
appendLine("- Connection mode: LAN / Tailscale / public TLS / other")
|
||||
appendLine()
|
||||
appendLine("### Diagnostic")
|
||||
appendLine("- Title: $title")
|
||||
appendLine("- Category: ${category.label}")
|
||||
appendLine("- Severity: ${severity.name}")
|
||||
endpointRole?.let { appendLine("- Route: $it") }
|
||||
url?.let { appendLine("- URL: $it") }
|
||||
elapsedMs?.let { appendLine("- Elapsed: ${it}ms") }
|
||||
if (trace.isNotBlank()) {
|
||||
appendLine()
|
||||
appendLine("```")
|
||||
appendLine(trace)
|
||||
appendLine("```")
|
||||
}
|
||||
appendLine()
|
||||
append("<sub>Captured by the Hermes-Relay in-app diagnostics log</sub>")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,12 @@ data class ThinkingIndicatorConfig(
|
||||
/** Chat-root provided streaming-indicator config; see [ThinkingIndicatorConfig]. */
|
||||
val LocalThinkingIndicator = compositionLocalOf { ThinkingIndicatorConfig() }
|
||||
|
||||
internal fun shouldAnimateDotMatrix(
|
||||
appAnimationsEnabled: Boolean,
|
||||
osAnimationsEnabled: Boolean,
|
||||
touchExplorationEnabled: Boolean,
|
||||
): Boolean = appAnimationsEnabled && osAnimationsEnabled && !touchExplorationEnabled
|
||||
|
||||
/**
|
||||
* A compact dot-matrix "thinking" animation — a small grid of dots evoking a
|
||||
* dot-matrix / LED display (the dot-anime-react concept reimplemented natively
|
||||
@@ -142,10 +148,15 @@ fun DotMatrixIndicator(
|
||||
fps: Int = 30,
|
||||
animated: Boolean = true,
|
||||
) {
|
||||
val motion = rememberAccessibleMotionState()
|
||||
val phase = rememberAmbientPhase(
|
||||
periodMillis = pattern.periodMillis,
|
||||
fps = fps,
|
||||
running = animated,
|
||||
running = shouldAnimateDotMatrix(
|
||||
appAnimationsEnabled = animated,
|
||||
osAnimationsEnabled = motion.osAnimations,
|
||||
touchExplorationEnabled = motion.touchExploration,
|
||||
),
|
||||
)
|
||||
val gridWidth = columnSpacing * (columns - 1)
|
||||
val gridHeight = rowSpacing * (rows - 1)
|
||||
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.ErrorOutline
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcess
|
||||
|
||||
/**
|
||||
* Composer-adjacent summary of upstream Hermes processes for the active chat.
|
||||
* The process registry is session-scoped, so this deliberately does not live
|
||||
* in the global session/navigation drawer.
|
||||
*/
|
||||
@Composable
|
||||
fun GatewayBackgroundProcessStrip(
|
||||
processes: List<GatewayProcess>,
|
||||
loading: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Initial/switch refreshes are silent. The strip appears only after the
|
||||
// session actually owns a process, avoiding a transient "Checking" row on
|
||||
// every ordinary chat open.
|
||||
if (processes.isEmpty()) return
|
||||
|
||||
val running = processes.count { it.isRunning }
|
||||
val failed = processes.count { !it.isRunning && (it.exitCode ?: 0) != 0 }
|
||||
val displayedCount = if (running > 0) running else processes.size
|
||||
val status = when {
|
||||
running > 0 -> "$running running"
|
||||
failed > 0 -> "$failed failed"
|
||||
else -> "Complete"
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 3.dp)
|
||||
.heightIn(min = 48.dp)
|
||||
.semantics {
|
||||
contentDescription =
|
||||
"Background processes, $status. Open current chat activity."
|
||||
stateDescription = status
|
||||
}
|
||||
.clickable(
|
||||
onClickLabel = "Open background processes",
|
||||
onClick = onClick,
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f),
|
||||
tonalElevation = 1.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (running > 0 || loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = if (failed > 0) Icons.Filled.ErrorOutline else Icons.Filled.CheckCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(17.dp),
|
||||
tint = if (failed > 0) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(9.dp))
|
||||
Text(
|
||||
text = "Background · $displayedCount",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = status,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (failed > 0 && running == 0) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ExpandLess,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
.size(17.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Mobile analogue of Hermes Desktop's composer process stack + terminal viewer. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GatewayBackgroundProcessSheet(
|
||||
processes: List<GatewayProcess>,
|
||||
loading: Boolean,
|
||||
stoppingProcessIds: Set<String>,
|
||||
onRefresh: () -> Unit,
|
||||
onStop: (String) -> Unit,
|
||||
onDismissProcess: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
val running = processes.filter { it.isRunning }
|
||||
val recent = processes.filterNot { it.isRunning }
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 20.dp, end = 8.dp, bottom = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Background processes", style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
"Current chat · live output and recent results",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRefresh, enabled = !loading) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(Icons.Filled.Refresh, contentDescription = "Refresh processes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (processes.isEmpty() && !loading) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 36.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Terminal,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(30.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
"No background processes in this chat",
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 560.dp),
|
||||
) {
|
||||
if (running.isNotEmpty()) {
|
||||
item { ProcessSectionLabel("Running", running.size) }
|
||||
items(running, key = { it.id }) { process ->
|
||||
GatewayProcessRow(
|
||||
process = process,
|
||||
stopping = process.id in stoppingProcessIds,
|
||||
onStop = { onStop(process.id) },
|
||||
onDismiss = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (running.isNotEmpty() && recent.isNotEmpty()) {
|
||||
item { HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp)) }
|
||||
}
|
||||
if (recent.isNotEmpty()) {
|
||||
item { ProcessSectionLabel("Recent", recent.size) }
|
||||
items(recent, key = { it.id }) { process ->
|
||||
GatewayProcessRow(
|
||||
process = process,
|
||||
stopping = false,
|
||||
onStop = null,
|
||||
onDismiss = { onDismissProcess(process.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProcessSectionLabel(label: String, count: Int) {
|
||||
Text(
|
||||
text = "$label · $count",
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GatewayProcessRow(
|
||||
process: GatewayProcess,
|
||||
stopping: Boolean,
|
||||
onStop: (() -> Unit)?,
|
||||
onDismiss: (() -> Unit)?,
|
||||
) {
|
||||
var expanded by remember(process.id) { mutableStateOf(false) }
|
||||
val failed = !process.isRunning && (process.exitCode ?: 0) != 0
|
||||
val output = sanitizeTerminalText(
|
||||
process.outputTail.orEmpty().ifBlank { process.outputPreview.orEmpty() },
|
||||
).trimEnd()
|
||||
val command = sanitizeTerminalText(process.command)
|
||||
.lineSequence()
|
||||
.firstOrNull()
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
.ifBlank {
|
||||
"Background process"
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.animateContentSize()
|
||||
.clickable(
|
||||
enabled = output.isNotBlank(),
|
||||
onClickLabel = if (expanded) "Collapse process output" else "Expand process output",
|
||||
) { expanded = !expanded }
|
||||
.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
ProcessStateIcon(process = process, failed = failed, stopping = stopping)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = command,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = processMetadata(process, failed),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (failed) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (onStop != null) {
|
||||
TextButton(onClick = onStop, enabled = !stopping) {
|
||||
Icon(
|
||||
Icons.Filled.Stop,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(if (stopping) "Stopping" else "Stop")
|
||||
}
|
||||
} else if (onDismiss != null) {
|
||||
TextButton(onClick = onDismiss) { Text("Dismiss") }
|
||||
}
|
||||
if (output.isNotBlank()) {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = if (expanded) "Collapse output" else "Expand output",
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = expanded && output.isNotBlank()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 10.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = output,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping: Boolean) {
|
||||
val tint: Color = when {
|
||||
failed -> MaterialTheme.colorScheme.error
|
||||
process.isRunning || stopping -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.tertiary
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.size(30.dp),
|
||||
shape = CircleShape,
|
||||
color = tint.copy(alpha = 0.12f),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
when {
|
||||
process.isRunning || stopping -> CircularProgressIndicator(
|
||||
modifier = Modifier.size(17.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = tint,
|
||||
)
|
||||
failed -> Icon(
|
||||
Icons.Filled.ErrorOutline,
|
||||
contentDescription = "Failed",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = tint,
|
||||
)
|
||||
else -> Icon(
|
||||
Icons.Filled.CheckCircle,
|
||||
contentDescription = "Completed",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processMetadata(process: GatewayProcess, failed: Boolean): String {
|
||||
val state = when {
|
||||
process.isRunning -> "Running"
|
||||
failed -> "Failed${process.exitCode?.let { " · exit $it" }.orEmpty()}"
|
||||
else -> "Completed${process.exitCode?.let { " · exit $it" }.orEmpty()}"
|
||||
}
|
||||
return "$state · ${formatElapsed(process.uptimeSeconds)}" +
|
||||
if (process.detached) " · recovered" else ""
|
||||
}
|
||||
|
||||
private val ansiTerminalEscape = Regex(
|
||||
"\u001B(?:\\].*?(?:\u0007|\u001B\\\\)|\\[[0-?]*[ -/]*[@-~]|[ -/]*[@-~])",
|
||||
RegexOption.DOT_MATCHES_ALL,
|
||||
)
|
||||
private val unterminatedOsc = Regex("\u001B\\][^\\n]*")
|
||||
|
||||
/** Plain-text mobile output viewer: remove terminal control/ANSI while keeping layout text. */
|
||||
internal fun sanitizeTerminalText(raw: String): String {
|
||||
val withoutAnsi = unterminatedOsc.replace(
|
||||
ansiTerminalEscape.replace(raw, ""),
|
||||
"",
|
||||
)
|
||||
return withoutAnsi
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.filter { char ->
|
||||
char == '\n' || char == '\t' || (char.code >= 0x20 && char.code != 0x7F)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun formatElapsed(totalSeconds: Long): String {
|
||||
val seconds = totalSeconds.coerceAtLeast(0)
|
||||
val hours = seconds / 3_600
|
||||
val minutes = (seconds % 3_600) / 60
|
||||
val remainder = seconds % 60
|
||||
return when {
|
||||
hours > 0 -> "${hours}h ${minutes}m"
|
||||
minutes > 0 -> "${minutes}m ${remainder}s"
|
||||
else -> "${remainder}s"
|
||||
}
|
||||
}
|
||||
@@ -491,7 +491,7 @@ private fun FileCardRender(
|
||||
* the default tap previews in-app.
|
||||
*/
|
||||
@Composable
|
||||
private fun AttachmentActionsMenu(
|
||||
fun AttachmentActionsMenu(
|
||||
expanded: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
context: Context,
|
||||
@@ -527,7 +527,7 @@ private fun AttachmentActionsMenu(
|
||||
|
||||
/** Small circular download button overlaid on inline images (B2). */
|
||||
@Composable
|
||||
private fun SaveOverlayButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
fun SaveOverlayButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = Color.Black.copy(alpha = 0.45f),
|
||||
@@ -556,7 +556,7 @@ private suspend fun shareAttachment(context: Context, attachment: Attachment) {
|
||||
MediaSaver.share(context, uri, attachment.contentType)
|
||||
}
|
||||
|
||||
private suspend fun saveAttachment(context: Context, attachment: Attachment) {
|
||||
suspend fun saveAttachment(context: Context, attachment: Attachment) {
|
||||
val bytes = attachmentBytes(context, attachment)
|
||||
if (bytes == null) {
|
||||
attachmentToast(context, "Couldn't read this file")
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -26,8 +31,13 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.semantics.CollectionInfo
|
||||
import androidx.compose.ui.semantics.collectionInfo
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
@@ -38,18 +48,33 @@ import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import androidx.compose.ui.unit.times
|
||||
import com.mikepenz.markdown.compose.components.markdownComponents
|
||||
import com.mikepenz.markdown.compose.components.MarkdownComponentModel
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownColors
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownDimens
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownDivider
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeBlock
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownHighlightedCodeFence
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownTable
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownTableHeader
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownTableRow
|
||||
import com.mikepenz.markdown.compose.extendedspans.ExtendedSpans
|
||||
import com.mikepenz.markdown.compose.extendedspans.RoundedCornerSpanPainter
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import com.mikepenz.markdown.m3.markdownColor
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
import com.mikepenz.markdown.model.markdownDimens
|
||||
import com.mikepenz.markdown.model.markdownExtendedSpans
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import dev.snipme.highlights.Highlights
|
||||
import dev.snipme.highlights.model.SyntaxThemes
|
||||
import kotlinx.coroutines.delay
|
||||
import org.intellij.markdown.ast.findChildOfType
|
||||
import org.intellij.markdown.flavours.gfm.GFMElementTypes.HEADER
|
||||
import org.intellij.markdown.flavours.gfm.GFMElementTypes.ROW
|
||||
import org.intellij.markdown.flavours.gfm.GFMTokenTypes.CELL
|
||||
import org.intellij.markdown.flavours.gfm.GFMTokenTypes.TABLE_SEPARATOR
|
||||
|
||||
@Composable
|
||||
fun MarkdownContent(
|
||||
@@ -61,7 +86,6 @@ fun MarkdownContent(
|
||||
val highlightsBuilder = remember(isDarkTheme) {
|
||||
Highlights.Builder().theme(SyntaxThemes.atom(darkMode = isDarkTheme))
|
||||
}
|
||||
|
||||
Markdown(
|
||||
content = content,
|
||||
modifier = modifier,
|
||||
@@ -133,6 +157,13 @@ fun MarkdownContent(
|
||||
),
|
||||
),
|
||||
),
|
||||
// Tables get a phone-friendly minimum measure. The stock renderer uses
|
||||
// one-line cells; our table component below keeps the same AST/inline
|
||||
// annotator path but permits wrapping and exposes horizontal overflow.
|
||||
dimens = markdownDimens(
|
||||
tableCellWidth = 110.dp,
|
||||
tableCellPadding = 12.dp,
|
||||
),
|
||||
components = markdownComponents(
|
||||
codeBlock = {
|
||||
MarkdownHighlightedCodeBlock(
|
||||
@@ -149,7 +180,8 @@ fun MarkdownContent(
|
||||
highlightsBuilder = highlightsBuilder,
|
||||
showHeader = true
|
||||
)
|
||||
}
|
||||
},
|
||||
table = { WideMarkdownTable(it) },
|
||||
),
|
||||
extendedSpans = markdownExtendedSpans {
|
||||
remember { ExtendedSpans(RoundedCornerSpanPainter()) }
|
||||
@@ -157,20 +189,141 @@ fun MarkdownContent(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GFM table renderer tuned for a narrow chat bubble.
|
||||
*
|
||||
* Every column keeps the configured 110dp minimum and cells wrap instead of
|
||||
* truncating to one line. Tables wider than the bubble scroll horizontally;
|
||||
* the trailing fade is deliberately subtle and disappears once the reader has
|
||||
* reached the final column.
|
||||
*/
|
||||
@Composable
|
||||
private fun WideMarkdownTable(model: MarkdownComponentModel) {
|
||||
val columnsCount = remember(model.node) {
|
||||
model.node.findChildOfType(HEADER)?.children?.count { it.type == CELL } ?: 0
|
||||
}
|
||||
if (columnsCount == 0) {
|
||||
MarkdownTable(
|
||||
content = model.content,
|
||||
node = model.node,
|
||||
style = model.typography.table,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val rowsCount = remember(model.node) {
|
||||
model.node.children.count { it.type == ROW } + 1
|
||||
}
|
||||
val tableCellWidth = LocalMarkdownDimens.current.tableCellWidth
|
||||
val tableWidth = columnsCount * tableCellWidth
|
||||
val tableCornerSize = LocalMarkdownDimens.current.tableCornerSize
|
||||
val tableBackground = LocalMarkdownColors.current.tableBackground
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(tableCornerSize))
|
||||
.background(tableBackground)
|
||||
.semantics {
|
||||
collectionInfo = CollectionInfo(
|
||||
rowCount = rowsCount,
|
||||
columnCount = columnsCount,
|
||||
)
|
||||
},
|
||||
) {
|
||||
val scrollable = maxWidth < tableWidth
|
||||
Box {
|
||||
Column(
|
||||
modifier = if (scrollable) {
|
||||
Modifier
|
||||
.horizontalScroll(scrollState)
|
||||
.requiredWidth(tableWidth)
|
||||
} else {
|
||||
Modifier.fillMaxWidth()
|
||||
},
|
||||
) {
|
||||
var rowIndex = 1
|
||||
model.node.children.forEach { child ->
|
||||
when (child.type) {
|
||||
HEADER -> MarkdownTableHeader(
|
||||
content = model.content,
|
||||
header = child,
|
||||
tableWidth = tableWidth,
|
||||
style = model.typography.table,
|
||||
verticalAlignment = Alignment.Top,
|
||||
maxLines = Int.MAX_VALUE,
|
||||
overflow = TextOverflow.Clip,
|
||||
)
|
||||
|
||||
ROW -> {
|
||||
MarkdownTableRow(
|
||||
content = model.content,
|
||||
header = child,
|
||||
tableWidth = tableWidth,
|
||||
style = model.typography.table,
|
||||
rowIndex = rowIndex,
|
||||
verticalAlignment = Alignment.Top,
|
||||
maxLines = Int.MAX_VALUE,
|
||||
overflow = TextOverflow.Clip,
|
||||
)
|
||||
rowIndex++
|
||||
}
|
||||
|
||||
TABLE_SEPARATOR -> MarkdownDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scrollable && scrollState.canScrollForward) {
|
||||
Box(
|
||||
modifier = Modifier.matchParentSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.width(28.dp)
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
colors = listOf(Color.Transparent, tableBackground),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StreamingMarkdownContent(
|
||||
content: String,
|
||||
textColor: Color,
|
||||
modifier: Modifier = Modifier
|
||||
isStreaming: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val blocks = remember(content) { parseStreamingMarkdownBlocks(content) }
|
||||
val blocks = remember(content, isStreaming) {
|
||||
if (isStreaming) {
|
||||
parseStreamingMarkdownBlocks(content)
|
||||
} else {
|
||||
listOf(StreamingMarkdownBlock.Markdown(content))
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
// Match the final renderer's block spacer so moving the active tail
|
||||
// into the settled Markdown prefix does not add a second layout jump.
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
blocks.forEach { block ->
|
||||
when (block) {
|
||||
is StreamingMarkdownBlock.Markdown -> MarkdownContent(
|
||||
content = block.content,
|
||||
textColor = textColor,
|
||||
)
|
||||
|
||||
is StreamingMarkdownBlock.Text -> Text(
|
||||
text = block.text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -267,23 +420,96 @@ private fun CodeCopyButton(code: String) {
|
||||
}
|
||||
}
|
||||
|
||||
private sealed interface StreamingMarkdownBlock {
|
||||
internal sealed interface StreamingMarkdownBlock {
|
||||
data class Markdown(val content: String) : StreamingMarkdownBlock
|
||||
data class Text(val text: String) : StreamingMarkdownBlock
|
||||
data class Code(val language: String, val code: String) : StreamingMarkdownBlock
|
||||
}
|
||||
|
||||
private fun parseStreamingMarkdownBlocks(content: String): List<StreamingMarkdownBlock> {
|
||||
/**
|
||||
* Splits an in-flight response into stable Markdown and one structurally
|
||||
* incomplete tail.
|
||||
*
|
||||
* Only conservative, blank-terminated top-level prose/heading blocks promote
|
||||
* to the real renderer. Lists, quotes, tables, indented blocks, HTML, and code
|
||||
* remain on the lightweight streaming surface until the message settles; those
|
||||
* containers can legally absorb later lines, so promoting them early causes a
|
||||
* visible re-parenting jump when the final CommonMark tree is parsed.
|
||||
*/
|
||||
internal fun parseStreamingMarkdownBlocks(content: String): List<StreamingMarkdownBlock> {
|
||||
if (content.isBlank()) return emptyList()
|
||||
|
||||
val normalized = content
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
val blocks = mutableListOf<StreamingMarkdownBlock>()
|
||||
val settledEnd = findStableMarkdownBoundary(normalized).coerceAtLeast(0)
|
||||
|
||||
if (settledEnd > 0) {
|
||||
normalized.substring(0, settledEnd).trimEnd().let { stable ->
|
||||
if (stable.isNotBlank()) blocks += StreamingMarkdownBlock.Markdown(stable)
|
||||
}
|
||||
}
|
||||
|
||||
val activeTail = normalized.substring(settledEnd)
|
||||
if (activeTail.isNotBlank()) {
|
||||
blocks += parseActiveStreamingTail(activeTail)
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
/** Last unambiguous blank-line boundary in a contiguous simple-markdown prefix. */
|
||||
private fun findStableMarkdownBoundary(content: String): Int {
|
||||
var offset = 0
|
||||
var blockStart = 0
|
||||
var activeFence: StreamingFence? = null
|
||||
var lastStableBoundary = 0
|
||||
|
||||
while (offset < content.length) {
|
||||
val newline = content.indexOf('\n', offset)
|
||||
val lineEnd = if (newline >= 0) newline else content.length
|
||||
val line = content.substring(offset, lineEnd)
|
||||
|
||||
activeFence = when (val current = activeFence) {
|
||||
null -> streamingFence(line)
|
||||
else -> if (isClosingFence(line, current)) null else current
|
||||
}
|
||||
|
||||
// Whitespace-only lines can be meaningful indentation inside a list.
|
||||
// Require a truly empty delimiter and stop at the first ambiguous
|
||||
// container so every promoted prefix remains structurally final.
|
||||
if (activeFence == null && newline >= 0 && line.isEmpty()) {
|
||||
val candidate = content.substring(blockStart, offset).trimEnd()
|
||||
if (candidate.isNotBlank() && !isConservativeStableBlock(candidate)) break
|
||||
lastStableBoundary = newline + 1
|
||||
blockStart = lastStableBoundary
|
||||
}
|
||||
|
||||
if (newline < 0) break
|
||||
offset = newline + 1
|
||||
}
|
||||
|
||||
return lastStableBoundary
|
||||
}
|
||||
|
||||
private fun isConservativeStableBlock(block: String): Boolean = block
|
||||
.lineSequence()
|
||||
.filter { it.isNotEmpty() }
|
||||
.none { line ->
|
||||
line.firstOrNull()?.isWhitespace() == true ||
|
||||
AMBIGUOUS_STREAMING_BLOCK.matches(line)
|
||||
}
|
||||
|
||||
private fun parseActiveStreamingTail(content: String): List<StreamingMarkdownBlock> {
|
||||
val blocks = mutableListOf<StreamingMarkdownBlock>()
|
||||
val paragraph = StringBuilder()
|
||||
val code = StringBuilder()
|
||||
var inFence = false
|
||||
var activeFence = ""
|
||||
var activeFence: StreamingFence? = null
|
||||
var language = ""
|
||||
|
||||
fun flushParagraph() {
|
||||
val text = paragraph.toString().trimEnd()
|
||||
val text = paragraph.toString().trim('\n').trimEnd()
|
||||
if (text.isNotBlank()) {
|
||||
blocks += StreamingMarkdownBlock.Text(text)
|
||||
}
|
||||
@@ -298,35 +524,30 @@ private fun parseStreamingMarkdownBlocks(content: String): List<StreamingMarkdow
|
||||
code.clear()
|
||||
}
|
||||
|
||||
val lines = content
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.split('\n')
|
||||
val lines = content.split('\n')
|
||||
|
||||
lines.forEachIndexed { index, line ->
|
||||
val lineWithBreak = if (index == lines.lastIndex) line else "$line\n"
|
||||
|
||||
if (!inFence) {
|
||||
val fence = streamingFenceMarker(line)
|
||||
if (activeFence == null) {
|
||||
val fence = streamingFence(line)
|
||||
if (fence != null) {
|
||||
flushParagraph()
|
||||
inFence = true
|
||||
activeFence = fence
|
||||
language = streamingFenceLanguage(line, fence)
|
||||
} else {
|
||||
paragraph.append(lineWithBreak)
|
||||
}
|
||||
} else if (streamingFenceMarker(line) == activeFence) {
|
||||
} else if (isClosingFence(line, activeFence)) {
|
||||
flushCode()
|
||||
inFence = false
|
||||
activeFence = ""
|
||||
activeFence = null
|
||||
language = ""
|
||||
} else {
|
||||
code.append(lineWithBreak)
|
||||
}
|
||||
}
|
||||
|
||||
if (inFence) {
|
||||
if (activeFence != null) {
|
||||
flushCode()
|
||||
} else {
|
||||
flushParagraph()
|
||||
@@ -335,18 +556,32 @@ private fun parseStreamingMarkdownBlocks(content: String): List<StreamingMarkdow
|
||||
return blocks
|
||||
}
|
||||
|
||||
private fun streamingFenceMarker(line: String): String? {
|
||||
private data class StreamingFence(
|
||||
val marker: Char,
|
||||
val length: Int,
|
||||
)
|
||||
|
||||
private fun streamingFence(line: String): StreamingFence? {
|
||||
val trimmed = line.trimStart()
|
||||
return when {
|
||||
trimmed.startsWith("```") -> "```"
|
||||
trimmed.startsWith("~~~") -> "~~~"
|
||||
else -> null
|
||||
}
|
||||
val marker = trimmed.firstOrNull()?.takeIf { it == '`' || it == '~' } ?: return null
|
||||
val length = trimmed.takeWhile { it == marker }.length
|
||||
return length.takeIf { it >= 3 }?.let { StreamingFence(marker, it) }
|
||||
}
|
||||
|
||||
private fun streamingFenceLanguage(line: String, marker: String): String {
|
||||
val tail = line.trimStart().removePrefix(marker).trim()
|
||||
private fun isClosingFence(line: String, activeFence: StreamingFence): Boolean {
|
||||
val trimmed = line.trimStart()
|
||||
if (trimmed.firstOrNull() != activeFence.marker) return false
|
||||
val markerLength = trimmed.takeWhile { it == activeFence.marker }.length
|
||||
return markerLength >= activeFence.length && trimmed.drop(markerLength).isBlank()
|
||||
}
|
||||
|
||||
private fun streamingFenceLanguage(line: String, fence: StreamingFence): String {
|
||||
val tail = line.trimStart().drop(fence.length).trim()
|
||||
return tail
|
||||
.takeWhile { !it.isWhitespace() && it != '`' && it != '~' }
|
||||
.takeWhile { !it.isWhitespace() && it != fence.marker }
|
||||
.take(32)
|
||||
}
|
||||
|
||||
private val AMBIGUOUS_STREAMING_BLOCK = Regex(
|
||||
"""^(?:[-+*]\s|\d{1,9}[.)]\s|>|```|~~~|<|\||(?:-{3,}|={3,})\s*$).*""",
|
||||
)
|
||||
|
||||
@@ -121,6 +121,14 @@ fun MessageBubble(
|
||||
* conversation). Null hides the entry.
|
||||
*/
|
||||
onEditMessage: ((ChatMessage) -> Unit)? = null,
|
||||
/**
|
||||
* True while the ViewModel is recovering a dropped stream's answer by
|
||||
* polling the session transcript (issue #166) — the streaming
|
||||
* placeholder's slow-turn label reads "Reconnecting to your answer…"
|
||||
* instead of "Still working…" so the wait is honest about what's
|
||||
* happening.
|
||||
*/
|
||||
recoveringAnswer: Boolean = false,
|
||||
) {
|
||||
val isUser = message.role == MessageRole.USER
|
||||
val isSystem = message.role == MessageRole.SYSTEM
|
||||
@@ -387,21 +395,16 @@ fun MessageBubble(
|
||||
color = textColor
|
||||
)
|
||||
} else {
|
||||
// Use a stable lightweight renderer while streaming.
|
||||
// The full parser/highlighter can rebuild block shapes
|
||||
// on every partial fence/token and visibly flicker.
|
||||
// Settled blocks keep the real Markdown renderer while
|
||||
// only the structurally incomplete tail stays raw. The
|
||||
// same composable settles the final tail so retained
|
||||
// parser state survives the streaming -> final handoff.
|
||||
if (markdownBody.isNotEmpty()) {
|
||||
if (message.isStreaming) {
|
||||
StreamingMarkdownContent(
|
||||
content = markdownBody,
|
||||
textColor = textColor
|
||||
)
|
||||
} else {
|
||||
MarkdownContent(
|
||||
content = markdownBody,
|
||||
textColor = textColor
|
||||
)
|
||||
}
|
||||
StreamingMarkdownContent(
|
||||
content = markdownBody,
|
||||
textColor = textColor,
|
||||
isStreaming = message.isStreaming,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -445,22 +448,34 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments — dispatched through the unified InboundAttachmentCard
|
||||
// so outbound and inbound attachments share the same render pipeline.
|
||||
// Outbound attachments (user-authored) always have state=LOADED so
|
||||
// they route straight to the LOADED branch; inbound attachments (via
|
||||
// MEDIA markers) cycle through LOADING → LOADED / FAILED as the
|
||||
// background fetch progresses.
|
||||
// Attachments — two or more loaded images collapse into one
|
||||
// grid + swipe-across gallery. Every other item stays on the
|
||||
// unified InboundAttachmentCard path, and layout items retain
|
||||
// their original ChatMessage.attachments indices so retry /
|
||||
// manual-fetch callbacks cannot drift after grouping.
|
||||
if (message.attachments.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
message.attachments.forEachIndexed { index, attachment ->
|
||||
InboundAttachmentCard(
|
||||
attachment = attachment,
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp)
|
||||
)
|
||||
val attachmentItems = remember(message.attachments) {
|
||||
attachmentLayoutItems(message.attachments)
|
||||
}
|
||||
attachmentItems.forEach { item ->
|
||||
when (item) {
|
||||
is AttachmentLayoutItem.Gallery -> AttachmentGallery(
|
||||
attachments = item.attachmentIndices.map(message.attachments::get),
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
is AttachmentLayoutItem.Single -> {
|
||||
val index = item.attachmentIndex
|
||||
InboundAttachmentCard(
|
||||
attachment = message.attachments[index],
|
||||
onRetry = { onAttachmentRetry(message.id, index) },
|
||||
onManualFetch = { onAttachmentManualFetch(message.id, index) },
|
||||
maxWidth = maxBubbleWidth - 24.dp,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,10 +514,17 @@ fun MessageBubble(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
if (showStillWorking && awaitingFirstToken) {
|
||||
// During dropped-stream answer recovery the label shows
|
||||
// immediately (the 4s escalation is for a slow first
|
||||
// token; a recovery is already known to be slow).
|
||||
if ((showStillWorking || recoveringAnswer) && awaitingFirstToken) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Still working…",
|
||||
text = if (recoveringAnswer) {
|
||||
"Reconnecting to your answer…"
|
||||
} else {
|
||||
"Still working…"
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
|
||||
@@ -17,7 +17,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -26,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -54,6 +57,8 @@ import androidx.compose.ui.unit.dp
|
||||
@Composable
|
||||
fun ModelPickerSheet(
|
||||
options: List<ChatInputPickerOption>,
|
||||
refreshing: Boolean = false,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
onSelect: (ChatInputPickerOption) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
@@ -98,12 +103,35 @@ fun ModelPickerSheet(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(text = "Model", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = "${modelOptions.size} models",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = "Model", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = "${modelOptions.size} models",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (onRefresh != null) {
|
||||
TextButton(
|
||||
onClick = onRefresh,
|
||||
enabled = !refreshing,
|
||||
) {
|
||||
if (refreshing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.size(6.dp))
|
||||
Text(if (refreshing) "Refreshing" else "Refresh")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -40,3 +41,30 @@ private tailrec fun Context.findActivity(): Activity? = when (this) {
|
||||
is ContextWrapper -> baseContext.findActivity()
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds `Window.FLAG_KEEP_SCREEN_ON` while [enabled] is true, clearing it the
|
||||
* moment it flips false or this composable leaves the composition. This is
|
||||
* the Android-recommended mechanism for "keep the screen on while this UI is
|
||||
* active" — see [com.hermesandroid.relay.power.WakeLockManager]'s doc comment
|
||||
* for why a `PowerManager` wake lock is the wrong tool for a visible surface.
|
||||
*
|
||||
* Single call site by design: the flag is a plain bit on the window, not
|
||||
* ref-counted, so two independent callers toggling it independently could
|
||||
* stomp each other (one disposing clears a flag the other still wants held).
|
||||
* Callers that need to OR multiple conditions (e.g. "streaming a reply" OR
|
||||
* "voice mode is open") should combine them into one boolean and pass that.
|
||||
*/
|
||||
@Composable
|
||||
fun KeepScreenOnWhile(enabled: Boolean) {
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(enabled) {
|
||||
val window = context.findActivity()?.window
|
||||
if (enabled) {
|
||||
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
onDispose {
|
||||
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.data.HermesProcessNotification
|
||||
import com.hermesandroid.relay.ui.theme.relayMetadataStyle
|
||||
|
||||
/**
|
||||
* Compact transcript treatment for the user-role process events that upstream
|
||||
* Hermes injects when background work completes or matches a watch pattern.
|
||||
*
|
||||
* This component is intentionally quieter than a user bubble: the row is
|
||||
* machine-authored process state, while the following assistant message is the
|
||||
* conversational response. Command and output detail remain selectable behind
|
||||
* progressive disclosure.
|
||||
*/
|
||||
@Composable
|
||||
fun SyntheticProcessNotificationNotice(
|
||||
notification: HermesProcessNotification,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val detail = notification.detail?.let(::sanitizeTerminalText)
|
||||
val hasDetail = !detail.isNullOrBlank()
|
||||
var expanded by rememberSaveable(notification.processId, notification.headline) {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 560.dp)
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.clickable(
|
||||
enabled = hasDetail,
|
||||
onClickLabel = if (expanded) "Collapse process output" else "Expand process output",
|
||||
) { expanded = !expanded }
|
||||
.semantics {
|
||||
contentDescription = buildString {
|
||||
append("Background process notice. ")
|
||||
append(notification.headline)
|
||||
if (hasDetail) {
|
||||
append(if (expanded) ". Output expanded" else ". Output collapsed")
|
||||
}
|
||||
}
|
||||
if (hasDetail) {
|
||||
stateDescription = if (expanded) "Output expanded" else "Output collapsed"
|
||||
}
|
||||
}
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Code,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.62f),
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = notification.headline,
|
||||
style = relayMetadataStyle(),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (hasDetail) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "output",
|
||||
style = relayMetadataStyle(),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.58f),
|
||||
)
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.58f),
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = expanded && hasDetail) {
|
||||
SelectionContainer {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 192.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = detail.orEmpty(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,7 @@ fun VoiceModeOverlay(
|
||||
// Cancels the promoted/durable background Hermes run from the chip's ✕.
|
||||
// Default no-op so existing call sites/previews keep compiling.
|
||||
onBackgroundRunCancel: () -> Unit = {},
|
||||
onBackgroundRunTap: () -> Unit = {},
|
||||
onHermesConfirmationAnswer: (String) -> Unit = {},
|
||||
// === END v0.4.1 ===
|
||||
) {
|
||||
@@ -174,14 +175,12 @@ fun VoiceModeOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
// Pipe classified voice errors to the app-wide snackbar host. The inline
|
||||
// error banner stays as a belt-and-suspenders for longer-lived messages.
|
||||
val snackbarHost = LocalSnackbarHost.current
|
||||
LaunchedEffect(errorEvents) {
|
||||
errorEvents?.collect { err ->
|
||||
snackbarHost.showHumanError(err)
|
||||
}
|
||||
}
|
||||
// Voice errors surface ONLY on the overlay's own inline top banner
|
||||
// (uiState.error) while the overlay is up — we deliberately do NOT also pipe
|
||||
// them to the app-wide bottom snackbar. Doing both duplicated the message
|
||||
// and left a retry-only, un-dismissable toast at the bottom during long /
|
||||
// timed-out background runs. (errorEvents is still used by the chat + voice
|
||||
// settings surfaces when the overlay isn't the active surface.)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -351,6 +350,7 @@ fun VoiceModeOverlay(
|
||||
BackgroundRunChip(
|
||||
run = uiState.backgroundRun,
|
||||
onCancel = onBackgroundRunCancel,
|
||||
onTap = onBackgroundRunTap,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
@@ -447,6 +447,25 @@ fun VoiceModeOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
// Compact mode: the background-run chip must survive outside focus
|
||||
// mode too — a running task with no visible presence reads as lost
|
||||
// (the chip previously existed ONLY in the focus layout).
|
||||
AnimatedVisibility(
|
||||
visible = !focusMode && uiState.backgroundRun != null,
|
||||
enter = fadeIn(tween(140)),
|
||||
exit = fadeOut(tween(180)),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 120.dp, start = 24.dp, end = 24.dp),
|
||||
) {
|
||||
BackgroundRunChip(
|
||||
run = uiState.backgroundRun,
|
||||
onCancel = onBackgroundRunCancel,
|
||||
onTap = onBackgroundRunTap,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
// Error banner
|
||||
AnimatedVisibility(
|
||||
visible = uiState.error != null,
|
||||
@@ -470,8 +489,14 @@ fun VoiceModeOverlay(
|
||||
text = uiState.error.orEmpty(),
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Dismiss clears the error and returns to Idle without
|
||||
// retrying, so a failed/timed-out turn never traps the user
|
||||
// on a retry-only banner.
|
||||
TextButton(onClick = { onClearError() }) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
onClearError()
|
||||
@@ -1239,6 +1264,18 @@ private fun CompactTranscriptRow(
|
||||
)
|
||||
}
|
||||
val hasText = message.content.isNotBlank()
|
||||
// Tools run before the agent's reply, so render the tool rows above the
|
||||
// reply text — the bubble then reads in chronological order (tool calls
|
||||
// ran → answer) instead of showing the answer above the tools that
|
||||
// preceded it.
|
||||
if (message.toolCalls.isNotEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
message.toolCalls.forEach { toolCall ->
|
||||
VoiceToolStatusRow(toolCall)
|
||||
}
|
||||
}
|
||||
if (hasText) Spacer(Modifier.height(6.dp))
|
||||
}
|
||||
when {
|
||||
isVoiceActionBubble && hasText -> MarkdownContent(
|
||||
content = message.content,
|
||||
@@ -1259,14 +1296,6 @@ private fun CompactTranscriptRow(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (message.toolCalls.isNotEmpty()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
message.toolCalls.forEach { toolCall ->
|
||||
VoiceToolStatusRow(toolCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1480,6 +1509,7 @@ private fun DestructiveCountdownRow(
|
||||
private fun BackgroundRunChip(
|
||||
run: BackgroundRunState?,
|
||||
onCancel: () -> Unit,
|
||||
onTap: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var latest by remember { mutableStateOf<BackgroundRunState?>(null) }
|
||||
@@ -1509,14 +1539,19 @@ private fun BackgroundRunChip(
|
||||
animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
|
||||
label = "bgRunPulseAlpha",
|
||||
)
|
||||
val done = display.phase == BackgroundRunPhase.DONE
|
||||
val dotColor = when (display.phase) {
|
||||
BackgroundRunPhase.RECONNECTING -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.primary
|
||||
}
|
||||
// A settled (DONE) chip reads as an outcome, not activity: solid dot,
|
||||
// no pulse, no live ticker.
|
||||
val dotAlpha = if (done) 1f else pulse
|
||||
val title = when (display.phase) {
|
||||
BackgroundRunPhase.RECONNECTING -> "Reconnecting — your task is still running"
|
||||
BackgroundRunPhase.DELIVERING -> display.message
|
||||
BackgroundRunPhase.RUNNING -> display.message
|
||||
BackgroundRunPhase.DONE -> display.message
|
||||
}
|
||||
val detail = buildList {
|
||||
display.statusLine
|
||||
@@ -1528,12 +1563,18 @@ private fun BackgroundRunChip(
|
||||
if (display.completedToolCount == 1) "" else "s"
|
||||
)
|
||||
}
|
||||
add(elapsedLabel)
|
||||
if (display.queuedCount > 0) {
|
||||
add("+${display.queuedCount} queued")
|
||||
}
|
||||
if (!done) add(elapsedLabel)
|
||||
}.joinToString(" · ")
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
// Tap on a settled chip = respeak the delivered answer (the VM
|
||||
// no-ops the tap for live phases).
|
||||
onClick = onTap,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -1543,7 +1584,7 @@ private fun BackgroundRunChip(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(dotColor.copy(alpha = pulse)),
|
||||
.background(dotColor.copy(alpha = dotAlpha)),
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
@@ -1570,7 +1611,9 @@ private fun BackgroundRunChip(
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Close,
|
||||
contentDescription = "Cancel background task",
|
||||
// The VM treats ✕ on a DONE chip as a local dismiss,
|
||||
// never a cancel — label it accordingly for TalkBack.
|
||||
contentDescription = if (done) "Dismiss" else "Cancel background task",
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
|
||||
@@ -167,7 +167,7 @@ object PetImporter {
|
||||
val queue = ArrayDeque<File>()
|
||||
queue.add(root)
|
||||
while (queue.isNotEmpty()) {
|
||||
val dir = queue.removeFirst()
|
||||
val dir = queue.removeAt(0)
|
||||
val manifest = File(dir, "pet.json")
|
||||
if (manifest.isFile) return manifest
|
||||
dir.listFiles()?.forEach { if (it.isDirectory) queue.add(it) }
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -13,6 +14,8 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -69,66 +72,80 @@ fun OnboardingPage(
|
||||
}
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.widthIn(max = 560.dp)
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(232.dp)
|
||||
.gradientBorder(shape = heroShape, isDarkTheme = isDarkTheme),
|
||||
shape = heroShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (transparentHero) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer
|
||||
)
|
||||
) {
|
||||
val heroModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(232.dp)
|
||||
Box(
|
||||
modifier = if (transparentHero) heroModifier else heroModifier.background(heroBrush),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
heroContent()
|
||||
}
|
||||
// Short viewports (small phones, large font scale, split screen) shrink or
|
||||
// drop the hero so the body text fits; the vertical scroll below is the
|
||||
// safety net when even that isn't enough. The enclosing pager Box centers
|
||||
// short content, so no Arrangement.Center here — it conflicts with
|
||||
// verticalScroll when content overflows.
|
||||
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
|
||||
val heroHeight = when {
|
||||
maxHeight < 480.dp -> 0.dp
|
||||
maxHeight < 620.dp -> 160.dp
|
||||
else -> 232.dp
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
Card(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(shape = bodyShape, isDarkTheme = isDarkTheme),
|
||||
shape = bodyShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
.widthIn(max = 560.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 22.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
if (heroHeight > 0.dp) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(heroHeight)
|
||||
.gradientBorder(shape = heroShape, isDarkTheme = isDarkTheme),
|
||||
shape = heroShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (transparentHero) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer
|
||||
)
|
||||
) {
|
||||
val heroModifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(heroHeight)
|
||||
Box(
|
||||
modifier = if (transparentHero) heroModifier else heroModifier.background(heroBrush),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
heroContent()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(shape = bodyShape, isDarkTheme = isDarkTheme),
|
||||
shape = bodyShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 22.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
content()
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -208,11 +209,15 @@ fun OnboardingScreen(
|
||||
// Bottom navigation only on informational pages — the wizard
|
||||
// owns its own back/pair affordances.
|
||||
if (pages[pagerState.currentPage] != OnboardingPage.Connect) {
|
||||
// Short viewports get a tighter footer so more of the pager
|
||||
// content stays above the fold; indicator + Back/Next remain
|
||||
// pinned outside the (scrollable) pager pages either way.
|
||||
val compactHeight = LocalConfiguration.current.screenHeightDp < 620
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 32.dp)
|
||||
.padding(bottom = 48.dp),
|
||||
.padding(bottom = if (compactHeight) 16.dp else 48.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
PageIndicator(
|
||||
@@ -220,7 +225,7 @@ fun OnboardingScreen(
|
||||
currentPage = pagerState.currentPage
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Spacer(modifier = Modifier.height(if (compactHeight) 12.dp else 24.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -50,6 +50,8 @@ import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.BadgedBox
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -99,6 +101,9 @@ import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
@@ -145,7 +150,9 @@ import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
import com.hermesandroid.relay.ui.components.BackgroundTaskCard
|
||||
import com.hermesandroid.relay.ui.components.LocalRelayServerImageResolver
|
||||
import com.hermesandroid.relay.ui.components.RelayServerImageResolver
|
||||
import com.hermesandroid.relay.ui.components.ChatInputBar
|
||||
@@ -154,15 +161,19 @@ import com.hermesandroid.relay.ui.components.ChatInputPickerControl
|
||||
import com.hermesandroid.relay.ui.components.ChatInputPickerOption
|
||||
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.ConnectionStatusBadge
|
||||
import com.hermesandroid.relay.ui.components.CommandRow
|
||||
import com.hermesandroid.relay.ui.components.CompactToolCall
|
||||
import com.hermesandroid.relay.ui.components.ContextMeterBar
|
||||
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessSheet
|
||||
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
|
||||
import com.hermesandroid.relay.ui.components.InjectedContextSheet
|
||||
import com.hermesandroid.relay.ui.components.InlineAutocomplete
|
||||
import com.hermesandroid.relay.ui.components.loadedContentTransform
|
||||
import com.hermesandroid.relay.ui.components.MessageBubble
|
||||
import com.hermesandroid.relay.ui.components.SyntheticProcessNotificationNotice
|
||||
import com.hermesandroid.relay.ui.components.avatar.AvatarRenderState
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil3.compose.AsyncImage
|
||||
@@ -405,6 +416,7 @@ fun ChatScreen(
|
||||
onNavigateToProfileInspector: (String) -> Unit = {},
|
||||
) {
|
||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
|
||||
var voiceCompactMode by remember { mutableStateOf(false) }
|
||||
val chatAlpha by animateFloatAsState(
|
||||
targetValue = if (voiceUiState.voiceMode && !voiceCompactMode) 0.4f else 1f,
|
||||
@@ -435,9 +447,11 @@ fun ChatScreen(
|
||||
) { granted ->
|
||||
if (granted) {
|
||||
micPermissionDenied = false
|
||||
if (pendingVoiceEnter) {
|
||||
if (pendingVoiceEnter && !isDemoMode) {
|
||||
pendingVoiceEnter = false
|
||||
voiceViewModel.enterVoiceMode()
|
||||
} else {
|
||||
pendingVoiceEnter = false
|
||||
}
|
||||
} else {
|
||||
pendingVoiceEnter = false
|
||||
@@ -461,7 +475,16 @@ fun ChatScreen(
|
||||
|
||||
val messages by chatViewModel.messages.collectAsState()
|
||||
val isStreaming by chatViewModel.isStreaming.collectAsState()
|
||||
// Keep the screen on for the two "actively engaged, hands-off-keyboard"
|
||||
// cases: voice mode is a call-like continuous session (mirrors Assistant/
|
||||
// phone-call UIs, held the whole time the overlay is up), and an
|
||||
// in-flight chat reply is closer to video playback (held only while
|
||||
// isStreaming — reading/scrolling an idle transcript uses the OS default,
|
||||
// matching WhatsApp/Telegram/Signal norms). Single call site: the window
|
||||
// flag isn't ref-counted, see KeepScreenOnWhile's doc comment.
|
||||
KeepScreenOnWhile(enabled = voiceUiState.voiceMode || isStreaming)
|
||||
val turnStatus by chatViewModel.turnStatus.collectAsState()
|
||||
val recoveringAnswer by chatViewModel.recoveringAnswer.collectAsState()
|
||||
val voiceStats by voiceViewModel.voiceStats.collectAsState()
|
||||
var voiceOutputConfig by remember { mutableStateOf<VoiceOutputConfig?>(null) }
|
||||
var realtimeAgentConfig by remember { mutableStateOf<RealtimeVoiceConfig?>(null) }
|
||||
@@ -480,6 +503,9 @@ fun ChatScreen(
|
||||
val sessions by chatViewModel.sessions.collectAsState()
|
||||
val serverAutoTitles by chatViewModel.serverAutoTitles.collectAsState()
|
||||
val currentSessionId by chatViewModel.currentSessionId.collectAsState()
|
||||
val backgroundProcesses by chatViewModel.backgroundProcesses.collectAsState()
|
||||
val backgroundProcessesLoading by chatViewModel.backgroundProcessesLoading.collectAsState()
|
||||
val stoppingProcessIds by chatViewModel.stoppingProcessIds.collectAsState()
|
||||
val isLoadingHistory by chatViewModel.isLoadingHistory.collectAsState()
|
||||
val isLoadingSessions by chatViewModel.isLoadingSessions.collectAsState()
|
||||
val selectedPersonality by chatViewModel.selectedPersonality.collectAsState()
|
||||
@@ -499,6 +525,7 @@ fun ChatScreen(
|
||||
val serverModelName by chatViewModel.serverModelName.collectAsState()
|
||||
val availableModels by chatViewModel.availableModels.collectAsState()
|
||||
val modelProviders by chatViewModel.modelProviders.collectAsState()
|
||||
val modelOptionsRefreshing by chatViewModel.modelOptionsRefreshing.collectAsState()
|
||||
val selectedModelOverride by chatViewModel.selectedModelOverride.collectAsState()
|
||||
val gatewayCurrentModel by chatViewModel.gatewayCurrentModel.collectAsState()
|
||||
val selectedReasoningEffort by chatViewModel.selectedReasoningEffort.collectAsState()
|
||||
@@ -543,15 +570,15 @@ fun ChatScreen(
|
||||
connectionViewModel.resolveStreamingEndpoint(streamingEndpointPref) == "gateway"
|
||||
}
|
||||
|
||||
// Pre-warm the gateway (connect + resume the current session) whenever the
|
||||
// chat surface is visible, the app is foregrounded, and the gateway is the
|
||||
// resolved transport — so the first send is warm (tens of ms to first
|
||||
// token) instead of paying the cold connect + session.resume on the send
|
||||
// path. Best-effort / idempotent; re-fires on return-to-foreground.
|
||||
// Recover any durable in-flight chat checkpoint whenever Chat returns to
|
||||
// the foreground. On Gateway this also pre-warms/re-attaches the socket;
|
||||
// sessions-SSE falls back to bounded persisted-history reconciliation.
|
||||
val appForeground by com.hermesandroid.relay.util.AppForegroundTracker.isForeground.collectAsState()
|
||||
LaunchedEffect(isGatewayTransport, appForeground, chatReady) {
|
||||
if (isGatewayTransport && appForeground && chatReady) {
|
||||
if (appForeground && chatReady) {
|
||||
chatViewModel.prewarmGateway()
|
||||
}
|
||||
if (isGatewayTransport && appForeground && chatReady) {
|
||||
chatViewModel.refreshModelOptions()
|
||||
chatViewModel.refreshReasoningSettings()
|
||||
}
|
||||
@@ -635,6 +662,13 @@ fun ChatScreen(
|
||||
var showCommandPalette by remember { mutableStateOf(false) }
|
||||
var showModelSheet by remember { mutableStateOf(false) }
|
||||
var showAgentInfo by remember { mutableStateOf(false) }
|
||||
var showBackgroundProcesses by remember { mutableStateOf(false) }
|
||||
|
||||
// A process inventory is scoped to one gateway session. Never leave a
|
||||
// sheet opened onto a different chat after a drawer/profile switch.
|
||||
LaunchedEffect(currentSessionId, selectedProfile?.name, activeConnection?.id) {
|
||||
showBackgroundProcesses = false
|
||||
}
|
||||
|
||||
// Server command dispatch can ask the composer to prefill (e.g. /undo).
|
||||
LaunchedEffect(chatViewModel) {
|
||||
@@ -702,12 +736,14 @@ fun ChatScreen(
|
||||
voiceOutputConfig?.default_provider
|
||||
}
|
||||
val activeVoiceModel = if (realtimeAgentActive) {
|
||||
realtimeAgentConfig?.default_model
|
||||
voiceStats.realtimeModel.takeIf { it.isNotBlank() }
|
||||
?: realtimeAgentConfig?.default_model
|
||||
} else {
|
||||
voiceOutputConfig?.default_model
|
||||
}
|
||||
val activeVoiceName = if (realtimeAgentActive) {
|
||||
realtimeAgentConfig?.default_voice
|
||||
voiceStats.realtimeVoice.takeIf { it.isNotBlank() }
|
||||
?: realtimeAgentConfig?.default_voice
|
||||
} else {
|
||||
voiceOutputConfig?.default_voice
|
||||
}
|
||||
@@ -948,8 +984,26 @@ fun ChatScreen(
|
||||
// streaming auto-scroll effect respects this — it will not yank the
|
||||
// user back to the latest token while they are reading history.
|
||||
// Reset to false the moment the user returns to the bottom.
|
||||
var userScrolledAway by remember { mutableStateOf(false) }
|
||||
var userScrolledAway by remember(currentSessionId) { mutableStateOf(false) }
|
||||
var programmaticBottomScroll by remember { mutableStateOf(false) }
|
||||
val currentUnreadSnapshot = remember(messages) { messages.toUnreadSnapshot() }
|
||||
var lastReadSnapshot by remember(currentSessionId) {
|
||||
mutableStateOf(currentUnreadSnapshot)
|
||||
}
|
||||
LaunchedEffect(currentSessionId, currentUnreadSnapshot, userScrolledAway) {
|
||||
if (!userScrolledAway) lastReadSnapshot = currentUnreadSnapshot
|
||||
}
|
||||
val unreadMessageCount = remember(
|
||||
currentUnreadSnapshot,
|
||||
lastReadSnapshot,
|
||||
userScrolledAway,
|
||||
) {
|
||||
if (userScrolledAway) {
|
||||
countUnreadMessages(currentUnreadSnapshot, lastReadSnapshot)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun scrollConversationToBottom(animated: Boolean) {
|
||||
programmaticBottomScroll = true
|
||||
@@ -2032,6 +2086,7 @@ fun ChatScreen(
|
||||
|
||||
items(messages.size, key = { messages[it].id }) { index ->
|
||||
val message = messages[index]
|
||||
val processNotification = message.hermesProcessNotificationOrNull()
|
||||
|
||||
// Skip empty bubbles (content stripped by annotation parser, no tool calls,
|
||||
// no attachments). Attachments keep the bubble alive for inbound media;
|
||||
@@ -2040,6 +2095,7 @@ fun ChatScreen(
|
||||
message.toolCalls.isEmpty() &&
|
||||
message.attachments.isEmpty() &&
|
||||
message.cards.isEmpty() &&
|
||||
message.backgroundTask == null &&
|
||||
!message.isStreaming
|
||||
) return@items
|
||||
|
||||
@@ -2059,15 +2115,49 @@ fun ChatScreen(
|
||||
DateSeparator(timestamp = message.timestamp)
|
||||
}
|
||||
|
||||
MessageBubble(
|
||||
val hasBackgroundTask = message.backgroundTask != null
|
||||
val shouldRenderBubble =
|
||||
!hasBackgroundTask ||
|
||||
message.content.isNotBlank() ||
|
||||
message.thinkingContent.isNotBlank() ||
|
||||
message.attachments.isNotEmpty() ||
|
||||
message.cards.isNotEmpty()
|
||||
|
||||
message.backgroundTask?.let { task ->
|
||||
BackgroundTaskCard(
|
||||
task = task,
|
||||
toolCalls = message.toolCalls,
|
||||
showTimeline = toolDisplay != "off",
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
top = if (isFirstInGroup) 6.dp else 2.dp,
|
||||
bottom = if (shouldRenderBubble) 3.dp else 0.dp,
|
||||
)
|
||||
.animateItem(),
|
||||
)
|
||||
}
|
||||
|
||||
if (processNotification != null) {
|
||||
SyntheticProcessNotificationNotice(
|
||||
notification = processNotification,
|
||||
modifier = Modifier
|
||||
.padding(top = if (isFirstInGroup) 6.dp else 2.dp)
|
||||
.animateItem(),
|
||||
)
|
||||
} else if (shouldRenderBubble) MessageBubble(
|
||||
message = message,
|
||||
modifier = Modifier
|
||||
.padding(top = if (isFirstInGroup) 6.dp else 1.dp)
|
||||
.padding(
|
||||
top = if (hasBackgroundTask) 1.dp
|
||||
else if (isFirstInGroup) 6.dp
|
||||
else 1.dp,
|
||||
)
|
||||
.animateItem(),
|
||||
maxBubbleWidth = maxBubbleWidth,
|
||||
showThinking = showThinking,
|
||||
isFirstInGroup = isFirstInGroup,
|
||||
isLastInGroup = isLastInGroup,
|
||||
recoveringAnswer = recoveringAnswer,
|
||||
onAttachmentRetry = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
@@ -2158,7 +2248,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (toolDisplay != "off") {
|
||||
if (toolDisplay != "off" && !hasBackgroundTask) {
|
||||
// Subagent children (taskIndex != null) group
|
||||
// into lanes after the top-level tool cards;
|
||||
// the null group renders exactly as before.
|
||||
@@ -2217,7 +2307,16 @@ fun ChatScreen(
|
||||
.zIndex(8f)
|
||||
) {
|
||||
SmallFloatingActionButton(
|
||||
modifier = Modifier.size(48.dp),
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.semantics {
|
||||
contentDescription = if (unreadMessageCount > 0) {
|
||||
"Scroll to bottom, $unreadMessageCount unread " +
|
||||
if (unreadMessageCount == 1) "message" else "messages"
|
||||
} else {
|
||||
"Scroll to bottom"
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||
scope.launch {
|
||||
@@ -2230,10 +2329,25 @@ fun ChatScreen(
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = "Scroll to bottom"
|
||||
)
|
||||
BadgedBox(
|
||||
badge = {
|
||||
if (unreadMessageCount > 0) {
|
||||
Badge(
|
||||
modifier = Modifier.clearAndSetSemantics { },
|
||||
) {
|
||||
Text(
|
||||
if (unreadMessageCount > 99) "99+"
|
||||
else unreadMessageCount.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2247,6 +2361,14 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (isGatewayTransport) {
|
||||
GatewayBackgroundProcessStrip(
|
||||
processes = backgroundProcesses,
|
||||
loading = backgroundProcessesLoading,
|
||||
onClick = { showBackgroundProcesses = true },
|
||||
)
|
||||
}
|
||||
|
||||
// Inline slash command autocomplete
|
||||
AnimatedVisibility(visible = showAutocomplete) {
|
||||
InlineAutocomplete(
|
||||
@@ -2645,24 +2767,34 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
onVoice = {
|
||||
if (voiceReady) {
|
||||
requestVoiceMode()
|
||||
} else {
|
||||
android.widget.Toast.makeText(
|
||||
context,
|
||||
when (standardVoiceAvailability) {
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.SignInRequired ->
|
||||
standardVoiceSignInRouteHint?.let { route ->
|
||||
"Voice needs a one-time sign-in on the $route route — open Manage"
|
||||
} ?: "Voice needs dashboard sign-in — open Manage to sign in"
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.Unsupported ->
|
||||
"This Hermes build has no voice routes — update hermes-agent or pair Relay"
|
||||
else ->
|
||||
"Voice needs a reachable Hermes dashboard or Relay voice route"
|
||||
},
|
||||
android.widget.Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
dispatchChatVoiceAction(
|
||||
isDemoMode = isDemoMode,
|
||||
voiceReady = voiceReady,
|
||||
onDemoNotice = {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Voice is unavailable in the offline demo — connect to Hermes to use it",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
},
|
||||
onStartVoice = requestVoiceMode,
|
||||
onSetupNotice = {
|
||||
Toast.makeText(
|
||||
context,
|
||||
when (standardVoiceAvailability) {
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.SignInRequired ->
|
||||
standardVoiceSignInRouteHint?.let { route ->
|
||||
"Voice needs a one-time sign-in on the $route route — open Manage"
|
||||
} ?: "Voice needs dashboard sign-in — open Manage to sign in"
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.Unsupported ->
|
||||
"This Hermes build has no voice routes — update hermes-agent or pair Relay"
|
||||
else ->
|
||||
"Voice needs a reachable Hermes dashboard or Relay voice route"
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
},
|
||||
)
|
||||
},
|
||||
onStop = {
|
||||
chatViewModel.cancelStream()
|
||||
@@ -2708,6 +2840,8 @@ fun ChatScreen(
|
||||
if (showModelSheet) {
|
||||
ModelPickerSheet(
|
||||
options = modelPickerOptions,
|
||||
refreshing = modelOptionsRefreshing,
|
||||
onRefresh = { chatViewModel.refreshModelOptions(refresh = true) },
|
||||
onSelect = { option ->
|
||||
showModelSheet = false
|
||||
chatViewModel.selectModel(option.value, option.provider)
|
||||
@@ -2832,6 +2966,7 @@ fun ChatScreen(
|
||||
onModeChange = { voiceViewModel.setInteractionMode(it) },
|
||||
onClearError = { voiceViewModel.clearError() },
|
||||
onBackgroundRunCancel = { voiceViewModel.cancelBackgroundRun() },
|
||||
onBackgroundRunTap = { voiceViewModel.respeakBackgroundResult() },
|
||||
// Agent B's overlay collects this flow and renders classified
|
||||
// voice errors (mic capture, STT/TTS failures, relay drops).
|
||||
errorEvents = voiceViewModel.errorEvents,
|
||||
@@ -2901,6 +3036,18 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (showBackgroundProcesses) {
|
||||
GatewayBackgroundProcessSheet(
|
||||
processes = backgroundProcesses,
|
||||
loading = backgroundProcessesLoading,
|
||||
stoppingProcessIds = stoppingProcessIds,
|
||||
onRefresh = chatViewModel::refreshBackgroundProcesses,
|
||||
onStop = chatViewModel::stopBackgroundProcess,
|
||||
onDismissProcess = chatViewModel::dismissBackgroundProcess,
|
||||
onDismiss = { showBackgroundProcesses = false },
|
||||
)
|
||||
}
|
||||
|
||||
// Agent info sheet — one consolidated surface for agent state (profile,
|
||||
// personality, connection summary). Replaces the old AlertDialog and the
|
||||
// two top-bar chips (ProfilePicker + PersonalityPicker). Tap target is
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
|
||||
/**
|
||||
* Compact record of the conversation content that was visible at the bottom.
|
||||
* Only the tail can grow without increasing [messageCount], so keeping one
|
||||
* visible-content revision avoids hashing the entire transcript on each token.
|
||||
*/
|
||||
internal data class ChatUnreadSnapshot(
|
||||
val messageCount: Int,
|
||||
val lastMessageId: String?,
|
||||
val lastVisibleRevision: Int,
|
||||
)
|
||||
|
||||
internal fun List<ChatMessage>.toUnreadSnapshot(): ChatUnreadSnapshot {
|
||||
val last = lastOrNull()
|
||||
return ChatUnreadSnapshot(
|
||||
messageCount = size,
|
||||
lastMessageId = last?.id,
|
||||
lastVisibleRevision = last?.visibleUnreadRevision() ?: 0,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun countUnreadMessages(
|
||||
current: ChatUnreadSnapshot,
|
||||
lastRead: ChatUnreadSnapshot,
|
||||
): Int {
|
||||
if (current.messageCount < lastRead.messageCount) return 0
|
||||
val appended = (current.messageCount - lastRead.messageCount).coerceAtLeast(0)
|
||||
if (appended > 0) return appended
|
||||
if (current.messageCount == 0) return 0
|
||||
|
||||
return if (
|
||||
current.lastMessageId != lastRead.lastMessageId ||
|
||||
current.lastVisibleRevision != lastRead.lastVisibleRevision
|
||||
) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class ChatVoiceAction {
|
||||
ShowDemoNotice,
|
||||
StartVoice,
|
||||
ShowSetupNotice,
|
||||
}
|
||||
|
||||
/** Demo is offline, so it must win before any permission or route check. */
|
||||
internal fun resolveChatVoiceAction(
|
||||
isDemoMode: Boolean,
|
||||
voiceReady: Boolean,
|
||||
): ChatVoiceAction = when {
|
||||
isDemoMode -> ChatVoiceAction.ShowDemoNotice
|
||||
voiceReady -> ChatVoiceAction.StartVoice
|
||||
else -> ChatVoiceAction.ShowSetupNotice
|
||||
}
|
||||
|
||||
internal inline fun dispatchChatVoiceAction(
|
||||
isDemoMode: Boolean,
|
||||
voiceReady: Boolean,
|
||||
onDemoNotice: () -> Unit,
|
||||
onStartVoice: () -> Unit,
|
||||
onSetupNotice: () -> Unit,
|
||||
) {
|
||||
when (resolveChatVoiceAction(isDemoMode, voiceReady)) {
|
||||
ChatVoiceAction.ShowDemoNotice -> onDemoNotice()
|
||||
ChatVoiceAction.StartVoice -> onStartVoice()
|
||||
ChatVoiceAction.ShowSetupNotice -> onSetupNotice()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ChatMessage.visibleUnreadRevision(): Int {
|
||||
var revision = 17
|
||||
revision = 31 * revision + content.length
|
||||
revision = 31 * revision + thinkingContent.length
|
||||
revision = 31 * revision + toolCalls.fold(1) { acc, call ->
|
||||
var callRevision = 17
|
||||
callRevision = 31 * callRevision + (call.id?.hashCode() ?: 0)
|
||||
callRevision = 31 * callRevision + call.name.hashCode()
|
||||
callRevision = 31 * callRevision + (call.result?.hashCode() ?: 0)
|
||||
callRevision = 31 * callRevision + (call.error?.hashCode() ?: 0)
|
||||
callRevision = 31 * callRevision + (call.success?.hashCode() ?: 0)
|
||||
callRevision = 31 * callRevision + call.isComplete.hashCode()
|
||||
callRevision = 31 * callRevision + call.isGenerating.hashCode()
|
||||
31 * acc + callRevision
|
||||
}
|
||||
revision = 31 * revision + attachments.fold(1) { acc, attachment ->
|
||||
var attachmentRevision = 17
|
||||
attachmentRevision = 31 * attachmentRevision + attachment.contentType.hashCode()
|
||||
attachmentRevision = 31 * attachmentRevision + (attachment.fileName?.hashCode() ?: 0)
|
||||
attachmentRevision = 31 * attachmentRevision + attachment.state.hashCode()
|
||||
attachmentRevision = 31 * attachmentRevision + (attachment.errorMessage?.hashCode() ?: 0)
|
||||
31 * acc + attachmentRevision
|
||||
}
|
||||
revision = 31 * revision + cards.hashCode()
|
||||
revision = 31 * revision + cardDispatches.hashCode()
|
||||
revision = 31 * revision + (backgroundTask?.hashCode() ?: 0)
|
||||
return revision
|
||||
}
|
||||
+89
-31
@@ -54,6 +54,7 @@ import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -2444,24 +2445,29 @@ private data class ExpensiveModelConfirm(
|
||||
val warning: String,
|
||||
)
|
||||
|
||||
private data class ModelProviderOption(
|
||||
internal data class ModelProviderOption(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val authenticated: Boolean,
|
||||
val models: List<String>,
|
||||
/** Upstream setup hint for unconfigured rows, e.g. "paste OPENAI_API_KEY to activate". */
|
||||
val setupHint: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Tolerant reader for `GET /api/model/options` (the REST twin of the TUI's
|
||||
* `model.options` RPC). Unauthenticated providers come back as skeleton rows
|
||||
* — keep them visible but unselectable so the user learns which key to add
|
||||
* in the Keys section instead of the provider silently missing.
|
||||
* `model.options` RPC, requested with `include_unconfigured=1`).
|
||||
* Unauthenticated providers come back as skeleton rows — empty `models` on
|
||||
* newer upstream — and MUST survive parsing: they render greyed/unselectable
|
||||
* so the user learns which key to add in the Keys section instead of the
|
||||
* provider silently missing.
|
||||
*/
|
||||
private fun parseModelOptions(root: JsonObject): List<ModelProviderOption> {
|
||||
internal fun parseModelOptions(root: JsonObject): List<ModelProviderOption> {
|
||||
val providers = root["providers"] as? JsonArray ?: return emptyList()
|
||||
return providers.mapNotNull { element ->
|
||||
val obj = element as? JsonObject ?: return@mapNotNull null
|
||||
val id = obj.stringField("id")
|
||||
val id = obj.stringField("slug")
|
||||
?: obj.stringField("id")
|
||||
?: obj.stringField("provider")
|
||||
?: obj.stringField("name")
|
||||
?: return@mapNotNull null
|
||||
@@ -2472,15 +2478,17 @@ private fun parseModelOptions(root: JsonObject): List<ModelProviderOption> {
|
||||
else -> null
|
||||
}?.trim()?.takeIf { it.isNotBlank() }
|
||||
}.orEmpty()
|
||||
if (models.isEmpty()) return@mapNotNull null
|
||||
ModelProviderOption(
|
||||
id = id,
|
||||
label = obj.stringField("label")
|
||||
?: obj.stringField("display_name")
|
||||
?: obj.stringField("name")
|
||||
?: id,
|
||||
authenticated = obj.booleanField("authenticated") != false,
|
||||
// Absent hint field: a row with models is assumed usable; an empty
|
||||
// row can only be an unconfigured skeleton, so grey it.
|
||||
authenticated = obj.booleanField("authenticated") ?: models.isNotEmpty(),
|
||||
models = models,
|
||||
setupHint = obj.stringField("warning"),
|
||||
)
|
||||
}.sortedByDescending { it.authenticated }
|
||||
}
|
||||
@@ -2494,27 +2502,36 @@ private fun ModelPickerDialog(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
var refreshing by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var providers by remember { mutableStateOf<List<ModelProviderOption>>(emptyList()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun loadOptions(refresh: Boolean = false) {
|
||||
if (refresh && refreshing) return
|
||||
if (refresh) refreshing = true else loading = true
|
||||
error = null
|
||||
scope.launch {
|
||||
val result = try {
|
||||
withDashboardClient(clientFactory) { client -> client.getModelOptions(refresh = refresh) }
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
result.fold(
|
||||
onSuccess = { root ->
|
||||
providers = parseModelOptions(root)
|
||||
if (providers.isEmpty()) {
|
||||
error = "The dashboard returned no model options."
|
||||
}
|
||||
},
|
||||
onFailure = { err -> error = err.message ?: "Could not load model options" },
|
||||
)
|
||||
if (refresh) refreshing = false else loading = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(target) {
|
||||
loading = true
|
||||
error = null
|
||||
val result = try {
|
||||
withDashboardClient(clientFactory) { client -> client.getModelOptions() }
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
result.fold(
|
||||
onSuccess = { root ->
|
||||
providers = parseModelOptions(root)
|
||||
if (providers.isEmpty()) {
|
||||
error = "The dashboard returned no model options."
|
||||
}
|
||||
},
|
||||
onFailure = { err -> error = err.message ?: "Could not load model options" },
|
||||
)
|
||||
loading = false
|
||||
loadOptions()
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
@@ -2538,12 +2555,37 @@ private fun ModelPickerDialog(
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
else -> Column {
|
||||
Text(
|
||||
text = "Applies to new sessions. Greyed providers need a key — " +
|
||||
"add one under Manage → Keys.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Applies to new sessions. Greyed providers need a key — " +
|
||||
"add one under Manage → Keys.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
onClick = { loadOptions(refresh = true) },
|
||||
enabled = !refreshing && !actionInFlight,
|
||||
) {
|
||||
if (refreshing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
Text(if (refreshing) "Refreshing" else "Refresh")
|
||||
}
|
||||
}
|
||||
LazyColumn(modifier = Modifier.heightIn(max = 400.dp)) {
|
||||
providers.forEach { provider ->
|
||||
item(key = "provider-${provider.id}") {
|
||||
@@ -2559,6 +2601,22 @@ private fun ModelPickerDialog(
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
if (provider.models.isEmpty()) {
|
||||
// Unconfigured skeleton row (include_unconfigured=1):
|
||||
// no models until a key lands, so show the server's
|
||||
// setup hint in place of the model list.
|
||||
item(key = "setup-${provider.id}") {
|
||||
Text(
|
||||
text = provider.setupHint
|
||||
?: "Add a key under Manage → Keys to unlock models.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(
|
||||
items = provider.models,
|
||||
key = { model -> "model-${provider.id}-$model" },
|
||||
|
||||
+442
-204
@@ -19,8 +19,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
@@ -31,15 +31,21 @@ import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -51,20 +57,20 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import com.hermesandroid.relay.notifications.HermesNotificationCompanion
|
||||
import com.hermesandroid.relay.notifications.NotificationTriggerAction
|
||||
import com.hermesandroid.relay.notifications.NotificationTriggerRule
|
||||
import com.hermesandroid.relay.notifications.NotificationTriggerSettings
|
||||
import com.hermesandroid.relay.notifications.NotificationTriggerStore
|
||||
import com.hermesandroid.relay.notifications.notificationTriggerDataStore
|
||||
import com.hermesandroid.relay.notifications.summary
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Notification companion settings screen — opt-in helper that lets the
|
||||
* user's Hermes assistant read notifications they've explicitly granted
|
||||
* access to.
|
||||
*
|
||||
* Sections:
|
||||
* 1. Status — granted or not granted (live, observed via lifecycle)
|
||||
* 2. Open Android Settings — fires `ACTION_NOTIFICATION_LISTENER_SETTINGS`
|
||||
* 3. About — explains what the feature does and how to revoke
|
||||
*
|
||||
* Mirrors the layout/style of [VoiceSettingsScreen] for consistency.
|
||||
* access to, plus the event-trigger MVP rule editor/activity log.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -72,11 +78,18 @@ fun NotificationCompanionSettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val appContext = context.applicationContext
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val triggerStore = remember(appContext) {
|
||||
NotificationTriggerStore(appContext.notificationTriggerDataStore)
|
||||
}
|
||||
val triggerSettings by triggerStore.settings.collectAsState(
|
||||
initial = NotificationTriggerSettings(),
|
||||
)
|
||||
|
||||
// Track grant state. Re-check on every ON_RESUME so the screen
|
||||
// updates immediately when the user comes back from Android
|
||||
// Settings after toggling the listener.
|
||||
// updates immediately when the user comes back from Android Settings.
|
||||
var granted by remember {
|
||||
mutableStateOf(HermesNotificationCompanion.isAccessGranted(context))
|
||||
}
|
||||
@@ -117,214 +130,440 @@ fun NotificationCompanionSettingsScreen(
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
NotificationAccessCard(
|
||||
granted = granted,
|
||||
onRefresh = { granted = HermesNotificationCompanion.isAccessGranted(context) },
|
||||
onOpenSettings = {
|
||||
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching { context.startActivity(intent) }
|
||||
},
|
||||
)
|
||||
|
||||
// --- Status --- (action first: the user came to turn it on)
|
||||
NotifSectionCard(title = "Status") {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (granted) {
|
||||
Color(0xFF43A047) // green
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
),
|
||||
NotificationTriggerCard(
|
||||
settings = triggerSettings,
|
||||
onMasterChanged = { enabled ->
|
||||
scope.launch { triggerStore.setMasterEnabled(enabled) }
|
||||
},
|
||||
onKillSwitchChanged = { enabled ->
|
||||
scope.launch { triggerStore.setKillSwitch(enabled) }
|
||||
},
|
||||
onSaveRule = { rule ->
|
||||
scope.launch { triggerStore.saveSingleRule(rule) }
|
||||
},
|
||||
)
|
||||
|
||||
NotificationActivityLogCard(
|
||||
settings = triggerSettings,
|
||||
onClear = { scope.launch { triggerStore.clearActivityLog() } },
|
||||
)
|
||||
|
||||
NotificationAboutCard()
|
||||
|
||||
NotificationTestCard(granted = granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationAccessCard(
|
||||
granted: Boolean,
|
||||
onRefresh: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
) {
|
||||
NotifSectionCard(title = "Status") {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(12.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (granted) Color(0xFF43A047) else MaterialTheme.colorScheme.outline,
|
||||
),
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (granted) "Access granted" else "Access not granted",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = if (granted) {
|
||||
"Hermes-Relay can read posted notifications"
|
||||
} else {
|
||||
"Tap below to enable in Android Settings"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRefresh) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = "Refresh status",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
|
||||
|
||||
Button(
|
||||
onClick = onOpenSettings,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(if (granted) "Manage in Android Settings" else "Open Android Settings")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationTriggerCard(
|
||||
settings: NotificationTriggerSettings,
|
||||
onMasterChanged: (Boolean) -> Unit,
|
||||
onKillSwitchChanged: (Boolean) -> Unit,
|
||||
onSaveRule: (NotificationTriggerRule) -> Unit,
|
||||
) {
|
||||
val emptyRule = remember { NotificationTriggerStore.defaultRule() }
|
||||
val savedRule = settings.rules.firstOrNull() ?: emptyRule
|
||||
var ruleEnabled by remember { mutableStateOf(savedRule.enabled) }
|
||||
var label by remember { mutableStateOf(savedRule.label) }
|
||||
var appPackage by remember { mutableStateOf(savedRule.appPackage.orEmpty()) }
|
||||
var titleContains by remember { mutableStateOf(savedRule.titleContains.orEmpty()) }
|
||||
var textContains by remember { mutableStateOf(savedRule.textContains.orEmpty()) }
|
||||
|
||||
LaunchedEffect(
|
||||
savedRule.id,
|
||||
savedRule.enabled,
|
||||
savedRule.label,
|
||||
savedRule.appPackage,
|
||||
savedRule.titleContains,
|
||||
savedRule.textContains,
|
||||
) {
|
||||
ruleEnabled = savedRule.enabled
|
||||
label = savedRule.label
|
||||
appPackage = savedRule.appPackage.orEmpty()
|
||||
titleContains = savedRule.titleContains.orEmpty()
|
||||
textContains = savedRule.textContains.orEmpty()
|
||||
}
|
||||
|
||||
val hasAnyFilter = appPackage.isNotBlank() || titleContains.isNotBlank() || textContains.isNotBlank()
|
||||
|
||||
NotifSectionCard(title = "Event triggers (MVP)") {
|
||||
Text(
|
||||
text = "Rules are off by default. When enabled, the first MVP action is safe: post a local “Ask Hermes?” prompt when a matching notification arrives.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
LabeledSwitchRow(
|
||||
title = "Enable proactive triggers",
|
||||
subtitle = "Explicit opt-in. Existing notification forwarding still works when this is off.",
|
||||
checked = settings.masterEnabled,
|
||||
onCheckedChange = onMasterChanged,
|
||||
)
|
||||
LabeledSwitchRow(
|
||||
title = "Kill switch",
|
||||
subtitle = "Immediately pauses all trigger actions without deleting rules or the log.",
|
||||
checked = settings.killSwitch,
|
||||
onCheckedChange = onKillSwitchChanged,
|
||||
)
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
|
||||
|
||||
Text("Rule", style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
text = "Match by app package and optional title/text contains filters. Example package: com.slack.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
LabeledSwitchRow(
|
||||
title = "Rule enabled",
|
||||
subtitle = savedRule.summary(),
|
||||
checked = ruleEnabled,
|
||||
onCheckedChange = { ruleEnabled = it },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = label,
|
||||
onValueChange = { label = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text("Rule label") },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = appPackage,
|
||||
onValueChange = { appPackage = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text("App package") },
|
||||
placeholder = { Text("com.example.app") },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = titleContains,
|
||||
onValueChange = { titleContains = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text("Title contains (optional)") },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = textContains,
|
||||
onValueChange = { textContains = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text("Text contains (optional)") },
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
onSaveRule(
|
||||
NotificationTriggerRule(
|
||||
id = savedRule.id,
|
||||
label = label,
|
||||
enabled = ruleEnabled,
|
||||
appPackage = appPackage,
|
||||
titleContains = titleContains,
|
||||
textContains = textContains,
|
||||
action = NotificationTriggerAction.AskMe,
|
||||
requireConfirmation = false,
|
||||
),
|
||||
)
|
||||
},
|
||||
enabled = hasAnyFilter,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Save rule")
|
||||
}
|
||||
if (!hasAnyFilter) {
|
||||
Text(
|
||||
text = "Set at least one filter before saving so triggers do not match every notification on the phone.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationActivityLogCard(
|
||||
settings: NotificationTriggerSettings,
|
||||
onClear: () -> Unit,
|
||||
) {
|
||||
NotifSectionCard(title = "Activity log") {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = "Latest trigger matches",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
TextButton(
|
||||
onClick = onClear,
|
||||
enabled = settings.activityLog.isNotEmpty(),
|
||||
) { Text("Clear") }
|
||||
}
|
||||
|
||||
if (settings.activityLog.isEmpty()) {
|
||||
Text(
|
||||
text = "No trigger activity yet.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return@NotifSectionCard
|
||||
}
|
||||
|
||||
val df = remember { DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) }
|
||||
settings.activityLog.take(10).forEach { entry ->
|
||||
Column(modifier = Modifier.padding(vertical = 6.dp)) {
|
||||
Text(
|
||||
text = entry.ruleLabel,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = "${entry.packageName} · ${df.format(Date(entry.matchedAt))}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
entry.title?.let {
|
||||
Text(text = it, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
entry.textPreview?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
}
|
||||
Text(
|
||||
text = entry.result,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationAboutCard() {
|
||||
NotifSectionCard(title = "About") {
|
||||
Text(
|
||||
text = "Lets your Hermes assistant help you triage notifications. When enabled, your phone forwards each notification's app, title, and text to your paired Hermes server through the Relay pairing used by phone tools.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Requires Android's notification access permission. You can grant or revoke it at any time in Android Settings. This is the same permission Wear OS, Android Auto, and Tasker use.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Confirmation policy: local prompts and local log writes can run automatically after opt-in. Anything that sends a message, replies in another app, routes content to a person/channel, uses bridge gestures, or spends/changes data still requires an explicit user confirmation first.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationTestCard(granted: Boolean) {
|
||||
NotifSectionCard(title = "Test") {
|
||||
Text(
|
||||
text = "Tap below to fetch the last few notifications the listener has captured this session. Helpful for verifying the connection is working end-to-end.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
var lastSnapshot by remember {
|
||||
mutableStateOf<List<TestNotificationLine>>(emptyList())
|
||||
}
|
||||
var lastError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
val service = HermesNotificationCompanion.active
|
||||
if (service == null) {
|
||||
lastSnapshot = emptyList()
|
||||
lastError = if (granted) {
|
||||
"Listener has not bound yet. Try posting a test notification and re-tap."
|
||||
} else {
|
||||
"Notification access is not granted."
|
||||
}
|
||||
} else {
|
||||
val active = service.activeNotifications
|
||||
lastError = null
|
||||
lastSnapshot = active.orEmpty()
|
||||
.sortedByDescending { it.postTime }
|
||||
.take(5)
|
||||
.map {
|
||||
TestNotificationLine(
|
||||
pkg = it.packageName,
|
||||
title = it.notification?.extras
|
||||
?.getCharSequence(android.app.Notification.EXTRA_TITLE)
|
||||
?.toString(),
|
||||
text = it.notification?.extras
|
||||
?.getCharSequence(android.app.Notification.EXTRA_TEXT)
|
||||
?.toString(),
|
||||
postedAt = it.postTime,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Notifications,
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("Fetch recent")
|
||||
}
|
||||
|
||||
lastError?.let { err ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = err,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
if (lastSnapshot.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val df = remember { DateFormat.getTimeInstance(DateFormat.SHORT) }
|
||||
lastSnapshot.forEach { line ->
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = if (granted) "Access granted" else "Access not granted",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
text = line.title ?: "(no title)",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = if (granted) {
|
||||
"Hermes-Relay can read posted notifications"
|
||||
} else {
|
||||
"Tap below to enable in Android Settings"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
text = df.format(Date(line.postedAt)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
granted = HermesNotificationCompanion.isAccessGranted(context)
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Refresh,
|
||||
contentDescription = "Refresh status",
|
||||
Text(
|
||||
text = line.pkg,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
line.text?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching { context.startActivity(intent) }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.OpenInNew,
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(
|
||||
if (granted) "Manage in Android Settings" else "Open Android Settings",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- About ---
|
||||
NotifSectionCard(title = "About") {
|
||||
Text(
|
||||
text = (
|
||||
"Lets your Hermes assistant help you triage " +
|
||||
"notifications. When enabled, your phone " +
|
||||
"forwards each notification's app, title, " +
|
||||
"and text to your paired Hermes server " +
|
||||
"through the Relay pairing used by phone tools."
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = (
|
||||
"Requires Android's notification access " +
|
||||
"permission. You can grant or revoke it " +
|
||||
"at any time in Android Settings. This is " +
|
||||
"the same permission Wear OS, Android " +
|
||||
"Auto, and Tasker use."
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// --- Test (last received) ---
|
||||
NotifSectionCard(title = "Test") {
|
||||
Text(
|
||||
text = (
|
||||
"Tap below to fetch the last few notifications " +
|
||||
"the listener has captured this session. " +
|
||||
"Helpful for verifying the connection is " +
|
||||
"working end-to-end."
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
var lastSnapshot by remember {
|
||||
mutableStateOf<List<TestNotificationLine>>(emptyList())
|
||||
}
|
||||
var lastError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
// Pull from the live service if it's bound. We
|
||||
// don't have a server-round-trip helper here on
|
||||
// purpose — that would require sending a relay
|
||||
// round-trip and we want this screen to work
|
||||
// even when the relay is unreachable.
|
||||
val service = HermesNotificationCompanion.active
|
||||
if (service == null) {
|
||||
lastSnapshot = emptyList()
|
||||
lastError = if (granted) {
|
||||
"Listener has not bound yet. Try " +
|
||||
"posting a test notification " +
|
||||
"(any message) and re-tap."
|
||||
} else {
|
||||
"Notification access is not granted."
|
||||
}
|
||||
} else {
|
||||
val active = service.activeNotifications
|
||||
lastError = null
|
||||
lastSnapshot = active.orEmpty()
|
||||
.sortedByDescending { it.postTime }
|
||||
.take(5)
|
||||
.map {
|
||||
TestNotificationLine(
|
||||
pkg = it.packageName,
|
||||
title = it.notification?.extras
|
||||
?.getCharSequence(android.app.Notification.EXTRA_TITLE)
|
||||
?.toString(),
|
||||
text = it.notification?.extras
|
||||
?.getCharSequence(android.app.Notification.EXTRA_TEXT)
|
||||
?.toString(),
|
||||
postedAt = it.postTime,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Notifications,
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text("Fetch recent")
|
||||
}
|
||||
|
||||
lastError?.let { err ->
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = err,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
if (lastSnapshot.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val df = remember { DateFormat.getTimeInstance(DateFormat.SHORT) }
|
||||
lastSnapshot.forEach { line ->
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = line.title ?: "(no title)",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = df.format(Date(line.postedAt)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = line.pkg,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
line.text?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local tiny model for the screen's "Test" preview list — keeps the
|
||||
// composable scope contained without adding to the wire models file.
|
||||
@Composable
|
||||
private fun LabeledSwitchRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = title, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
private data class TestNotificationLine(
|
||||
val pkg: String,
|
||||
val title: String?,
|
||||
@@ -360,4 +599,3 @@ private fun NotifSectionCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
@@ -68,10 +70,15 @@ import com.hermesandroid.relay.data.BargeInSensitivity
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import com.hermesandroid.relay.data.VoiceEngineMode
|
||||
import com.hermesandroid.relay.data.VoiceModePreset
|
||||
import com.hermesandroid.relay.data.VoiceModePresetState
|
||||
import com.hermesandroid.relay.data.VoicePreferencesRepository
|
||||
import com.hermesandroid.relay.data.VoicePresetPromotionSettings
|
||||
import com.hermesandroid.relay.data.VoiceSettings
|
||||
import com.hermesandroid.relay.data.detectVoiceModePreset
|
||||
import com.hermesandroid.relay.network.relay.RealtimeProviderInfo
|
||||
import com.hermesandroid.relay.network.relay.RealtimeVoiceConfig
|
||||
import com.hermesandroid.relay.network.relay.RealtimeVoicePromotion
|
||||
import com.hermesandroid.relay.network.relay.RelayVoiceClient
|
||||
import com.hermesandroid.relay.network.relay.VoiceConfig
|
||||
import com.hermesandroid.relay.network.relay.VoiceOutputConfig
|
||||
@@ -187,6 +194,97 @@ fun VoiceSettingsScreen(
|
||||
// are shown here as well as the inline "unavailable" labels.
|
||||
val snackbarHost = LocalSnackbarHost.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var presetApplying by remember { mutableStateOf(false) }
|
||||
|
||||
val presetState = VoiceModePresetState(
|
||||
voiceSettings = voiceSettings,
|
||||
bargeInPreferences = bargeInPrefs,
|
||||
promotion = configState.realtimeConfig?.promotion?.toPresetSettings(),
|
||||
)
|
||||
val activePreset = detectVoiceModePreset(presetState)
|
||||
val presetsReady = voiceClient != null && presetState.promotion != null
|
||||
|
||||
fun applyPreset(preset: VoiceModePreset) {
|
||||
if (presetApplying) return
|
||||
val client = voiceClient
|
||||
val priorPromotion = presetState.promotion
|
||||
if (client == null || priorPromotion == null) {
|
||||
scope.launch {
|
||||
snackbarHost.showSnackbar(
|
||||
"Realtime Agent background settings must be available before applying a preset.",
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
val target = preset.applyTo(presetState)
|
||||
val update = preset.promotionUpdate
|
||||
scope.launch {
|
||||
presetApplying = true
|
||||
try {
|
||||
// Server first: if the relay rejects a preset, local controls
|
||||
// stay untouched and the UI cannot falsely report it active.
|
||||
val result = client.updateRealtimeAgentPromotion(
|
||||
promotionEnabled = update.enabled,
|
||||
promoteAfterMs = update.promoteAfterMs,
|
||||
spokenHandoff = update.spokenHandoff,
|
||||
resultDelivery = update.resultDelivery,
|
||||
backgroundDefaultMode = update.backgroundDefaultMode,
|
||||
progressSpokenAfterMs = update.progressSpokenAfterMs,
|
||||
progressRepeatMs = update.progressRepeatMs,
|
||||
maxBackgroundRuns = update.maxBackgroundRuns,
|
||||
)
|
||||
if (result.isFailure) {
|
||||
snackbarHost.showHumanError(
|
||||
classifyError(result.exceptionOrNull(), context = "voice_config"),
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
settingsViewModel.setRealtimeConfig(result.getOrNull())
|
||||
|
||||
try {
|
||||
// One DataStore transaction covers Voice + barge-in.
|
||||
prefsRepo.applyModePreset(preset)
|
||||
} catch (error: Exception) {
|
||||
// The network and DataStore cannot share one transaction.
|
||||
// Restore the captured relay values so a local write
|
||||
// failure does not leave a half-applied preset.
|
||||
val rollback = client.updateRealtimeAgentPromotion(
|
||||
promotionEnabled = priorPromotion.enabled,
|
||||
promoteAfterMs = priorPromotion.promoteAfterMs,
|
||||
spokenHandoff = priorPromotion.spokenHandoff,
|
||||
resultDelivery = priorPromotion.resultDelivery,
|
||||
backgroundDefaultMode = priorPromotion.backgroundDefaultMode,
|
||||
progressSpokenAfterMs = priorPromotion.progressSpokenAfterMs,
|
||||
progressRepeatMs = priorPromotion.progressRepeatMs,
|
||||
maxBackgroundRuns = priorPromotion.maxBackgroundRuns,
|
||||
)
|
||||
if (rollback.isSuccess) {
|
||||
settingsViewModel.setRealtimeConfig(rollback.getOrNull())
|
||||
snackbarHost.showHumanError(
|
||||
classifyError(error, context = "voice_config"),
|
||||
)
|
||||
} else {
|
||||
snackbarHost.showSnackbar(
|
||||
"Preset partly applied: Relay settings changed, but phone " +
|
||||
"settings could not be saved. Reapply a preset to recover.",
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
voiceViewModel.setInteractionMode(
|
||||
when (target.voiceSettings.interactionMode) {
|
||||
"hold" -> InteractionMode.HoldToTalk
|
||||
"continuous" -> InteractionMode.Continuous
|
||||
else -> InteractionMode.TapToTalk
|
||||
},
|
||||
)
|
||||
snackbarHost.showSnackbar("${preset.displayName} preset applied")
|
||||
} finally {
|
||||
presetApplying = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WP-V2/V3: point the screen's prefs repo at the active (connection,
|
||||
// profile) scope so the per-profile engine/route/enhanced toggles read and
|
||||
@@ -251,6 +349,8 @@ fun VoiceSettingsScreen(
|
||||
currentEngine = currentEngine,
|
||||
output = configState.voiceOutputConfig,
|
||||
realtime = configState.realtimeConfig,
|
||||
realtimeModel = voiceSettings.realtimeModel,
|
||||
realtimeVoice = voiceSettings.realtimeVoice,
|
||||
)
|
||||
|
||||
// --- Voice scope (single home; WP-V3 dedupe + WP-V4 honest label) ---
|
||||
@@ -261,6 +361,13 @@ fun VoiceSettingsScreen(
|
||||
selectedProfile = selectedProfile,
|
||||
)
|
||||
|
||||
VoiceModePresetCard(
|
||||
activePreset = activePreset,
|
||||
enabled = presetsReady,
|
||||
applying = presetApplying,
|
||||
onSelect = ::applyPreset,
|
||||
)
|
||||
|
||||
// --- Voice for this profile: engine + route ---
|
||||
VoiceForThisProfileCard(
|
||||
currentEngine = currentEngine,
|
||||
@@ -453,6 +560,98 @@ private fun scopeFallback(config: Any?): Boolean = when (config) {
|
||||
else -> false
|
||||
}
|
||||
|
||||
private fun RealtimeVoicePromotion.toPresetSettings(): VoicePresetPromotionSettings =
|
||||
VoicePresetPromotionSettings(
|
||||
enabled = enabled,
|
||||
promoteAfterMs = promoteAfterMs,
|
||||
backgroundDefaultMode = backgroundDefaultMode,
|
||||
spokenHandoff = spokenHandoff,
|
||||
progressSpokenAfterMs = progressSpokenAfterMs,
|
||||
progressRepeatMs = progressRepeatMs,
|
||||
resultDelivery = resultDelivery,
|
||||
maxBackgroundRuns = maxBackgroundRuns,
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mode presets — compact bundles over controls already present on this screen.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Composable
|
||||
private fun VoiceModePresetCard(
|
||||
activePreset: VoiceModePreset?,
|
||||
enabled: Boolean,
|
||||
applying: Boolean,
|
||||
onSelect: (VoiceModePreset) -> Unit,
|
||||
) {
|
||||
SectionCard(title = "Mode preset") {
|
||||
Text(
|
||||
text = "Tune interaction, interruption, trace, and long-task delivery together.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Two rows keep each target about 148dp wide on a 360dp screen after
|
||||
// screen/card padding; all four labels remain readable without tiny
|
||||
// type or ambiguous abbreviations.
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
VoiceModePreset.entries.chunked(2).forEach { rowPresets ->
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
rowPresets.forEachIndexed { index, preset ->
|
||||
SegmentedButton(
|
||||
shape = SegmentedButtonDefaults.itemShape(
|
||||
index = index,
|
||||
count = rowPresets.size,
|
||||
),
|
||||
onClick = { onSelect(preset) },
|
||||
selected = activePreset == preset,
|
||||
enabled = enabled && !applying,
|
||||
) {
|
||||
Text(
|
||||
text = preset.shortLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = activePreset?.displayName ?: "Custom",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = activePreset?.description
|
||||
?: "Your manual values do not exactly match a preset.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (!enabled) {
|
||||
Text(
|
||||
text = "Connect Relay voice so background delivery can be " +
|
||||
"applied with the local controls.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (applying) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Engine, route, provider, model, voice, and credentials stay unchanged.",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Voice for this profile — engine + STT/TTS route (per-profile prefs).
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1435,22 +1634,29 @@ private fun RealtimeAgentCard(
|
||||
var realtimeSampleRate by remember { mutableStateOf("24000") }
|
||||
var realtimeSaving by remember { mutableStateOf(false) }
|
||||
var realtimeManualOpen by remember { mutableStateOf(false) }
|
||||
var showDeliveryInfo by remember { mutableStateOf(false) }
|
||||
|
||||
val config = configState.realtimeConfig
|
||||
LaunchedEffect(
|
||||
config?.enabled,
|
||||
config?.default_provider,
|
||||
config?.default_model,
|
||||
config?.default_voice,
|
||||
config?.sample_rate,
|
||||
) {
|
||||
val c = config ?: return@LaunchedEffect
|
||||
realtimeEnabled = c.enabled
|
||||
realtimeProvider = c.default_provider.orEmpty()
|
||||
realtimeModel = c.default_model.orEmpty()
|
||||
realtimeVoice = c.default_voice.orEmpty()
|
||||
realtimeSampleRate = c.sample_rate.toString()
|
||||
}
|
||||
LaunchedEffect(
|
||||
config?.default_model,
|
||||
config?.default_voice,
|
||||
voiceSettings.realtimeModel,
|
||||
voiceSettings.realtimeVoice,
|
||||
) {
|
||||
val c = config ?: return@LaunchedEffect
|
||||
realtimeModel = voiceSettings.realtimeModel.ifBlank { c.default_model.orEmpty() }
|
||||
realtimeVoice = voiceSettings.realtimeVoice.ifBlank { c.default_voice.orEmpty() }
|
||||
}
|
||||
|
||||
fun refreshRealtimeProviderOptions(providerId: String, applyDefaults: Boolean) {
|
||||
settingsViewModel.refreshRealtimeProviderOptions(
|
||||
@@ -1535,10 +1741,10 @@ private fun RealtimeAgentCard(
|
||||
value = config?.default_provider
|
||||
?: (configState.realtimeConfigError?.let { "unavailable" } ?: "loading..."),
|
||||
)
|
||||
config?.default_model?.let { model ->
|
||||
realtimeModel.takeIf { it.isNotBlank() }?.let { model ->
|
||||
ProviderRow(label = "Model", value = model)
|
||||
}
|
||||
config?.default_voice?.let { voice ->
|
||||
realtimeVoice.takeIf { it.isNotBlank() }?.let { voice ->
|
||||
ProviderRow(label = "Voice", value = voice)
|
||||
}
|
||||
config?.let { c ->
|
||||
@@ -1610,17 +1816,34 @@ private fun RealtimeAgentCard(
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"When the answer is ready",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
"When the answer is ready",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
IconButton(
|
||||
onClick = { showDeliveryInfo = true },
|
||||
modifier = Modifier.size(36.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Info,
|
||||
contentDescription = "Delivery mode details",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val deliveryOptions = listOf(
|
||||
"speak_verbatim",
|
||||
"speak_when_idle",
|
||||
"notify_then_speak",
|
||||
"visual_only",
|
||||
)
|
||||
val deliveryLabels = listOf("Speak", "Notify", "Show only")
|
||||
val deliveryLabels = listOf("Exact", "Summary", "Notify", "Show")
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
deliveryOptions.forEachIndexed { index, option ->
|
||||
SegmentedButton(
|
||||
@@ -1649,6 +1872,10 @@ private fun RealtimeAgentCard(
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeliveryInfo) {
|
||||
DeliveryModeInfoDialog(onDismiss = { showDeliveryInfo = false })
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Row(
|
||||
@@ -1724,6 +1951,9 @@ private fun RealtimeAgentCard(
|
||||
selectedRealtimeProvider?.let { provider ->
|
||||
realtimeVoice = voiceForModel(provider, model, realtimeVoice)
|
||||
}
|
||||
scope.launch {
|
||||
prefsRepo.setRealtimeSelection(realtimeModel, realtimeVoice)
|
||||
}
|
||||
},
|
||||
enabled = voiceClient != null,
|
||||
)
|
||||
@@ -1735,7 +1965,10 @@ private fun RealtimeAgentCard(
|
||||
realtimeVoice,
|
||||
realtimeModel,
|
||||
),
|
||||
onValueChange = { realtimeVoice = it },
|
||||
onValueChange = { voice ->
|
||||
realtimeVoice = voice
|
||||
scope.launch { prefsRepo.setRealtimeVoice(voice) }
|
||||
},
|
||||
enabled = voiceClient != null,
|
||||
)
|
||||
compatibilityNotice(
|
||||
@@ -1774,14 +2007,20 @@ private fun RealtimeAgentCard(
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = realtimeModel,
|
||||
onValueChange = { realtimeModel = it },
|
||||
onValueChange = { model ->
|
||||
realtimeModel = model
|
||||
scope.launch { prefsRepo.setRealtimeModel(model) }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text("Model ID") },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = realtimeVoice,
|
||||
onValueChange = { realtimeVoice = it },
|
||||
onValueChange = { voice ->
|
||||
realtimeVoice = voice
|
||||
scope.launch { prefsRepo.setRealtimeVoice(voice) }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
label = { Text("Voice ID") },
|
||||
@@ -1839,6 +2078,7 @@ private fun RealtimeAgentCard(
|
||||
)
|
||||
realtimeSaving = false
|
||||
if (result.isSuccess) {
|
||||
prefsRepo.setRealtimeSelection(realtimeModel, realtimeVoice)
|
||||
settingsViewModel.setRealtimeConfig(result.getOrNull())
|
||||
} else {
|
||||
val human = classifyError(
|
||||
@@ -1866,6 +2106,51 @@ private fun RealtimeAgentCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeliveryModeInfoDialog(onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Answer delivery") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
DeliveryModeInfoRow(
|
||||
label = "Exact",
|
||||
body = "Recommended. The realtime voice reads the Hermes answer word for word, falling back to standard TTS only if it goes off-script.",
|
||||
)
|
||||
DeliveryModeInfoRow(
|
||||
label = "Summary",
|
||||
body = "The realtime voice rephrases the result in its own words. More conversational, less faithful to the exact answer.",
|
||||
)
|
||||
DeliveryModeInfoRow(
|
||||
label = "Notify",
|
||||
body = "Shows that the answer is ready first, then speaks when you re-engage.",
|
||||
)
|
||||
DeliveryModeInfoRow(
|
||||
label = "Show",
|
||||
body = "Keeps the completed answer visual only.",
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Got it")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeliveryModeInfoRow(label: String, body: String) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
text = body,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global voice controls — interaction mode + silence threshold.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -2844,6 +3129,8 @@ private fun voiceOutputSummary(
|
||||
currentEngine: VoiceEngineMode,
|
||||
output: VoiceOutputConfig?,
|
||||
realtime: RealtimeVoiceConfig?,
|
||||
realtimeModel: String = "",
|
||||
realtimeVoice: String = "",
|
||||
): Pair<String, String> {
|
||||
val profileLabel = profile?.description?.takeIf { it.isNotBlank() }
|
||||
?: profile?.name?.takeIf { it.isNotBlank() }
|
||||
@@ -2856,8 +3143,12 @@ private fun voiceOutputSummary(
|
||||
} ?: "loading voice output..."
|
||||
val realtimeLabel = realtime?.let { config ->
|
||||
val provider = config.default_provider?.takeIf { it.isNotBlank() } ?: "realtime ..."
|
||||
val voice = config.default_voice?.takeIf { it.isNotBlank() } ?: "voice ..."
|
||||
"$provider / $voice"
|
||||
val model = realtimeModel.takeIf { it.isNotBlank() }
|
||||
?: config.default_model?.takeIf { it.isNotBlank() }
|
||||
val voice = realtimeVoice.takeIf { it.isNotBlank() }
|
||||
?: config.default_voice?.takeIf { it.isNotBlank() }
|
||||
?: "voice ..."
|
||||
if (model == null) "$provider / $voice" else "$provider / $model / $voice"
|
||||
} ?: "realtime loading..."
|
||||
return profileLabel to when (currentEngine) {
|
||||
VoiceEngineMode.HermesVoiceOutput -> "Hermes Chat + Voice Output - $outputLabel"
|
||||
@@ -2871,12 +3162,16 @@ private fun VoiceProfileSummaryCard(
|
||||
currentEngine: VoiceEngineMode,
|
||||
output: VoiceOutputConfig?,
|
||||
realtime: RealtimeVoiceConfig?,
|
||||
realtimeModel: String,
|
||||
realtimeVoice: String,
|
||||
) {
|
||||
val (title, subtitle) = voiceOutputSummary(
|
||||
profile = selectedProfile,
|
||||
currentEngine = currentEngine,
|
||||
output = output,
|
||||
realtime = realtime,
|
||||
realtimeModel = realtimeModel,
|
||||
realtimeVoice = realtimeVoice,
|
||||
)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import com.hermesandroid.relay.BuildConfig
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
|
||||
/**
|
||||
* Pure builder for the GitHub "new issue" prefill derived from a diagnostics
|
||||
* entry — title, labels, and markdown body. Extracted from the detail dialog so
|
||||
* the prefill contract is unit-testable without Compose.
|
||||
*
|
||||
* Only [DiagnosticSeverity.Error] entries are bug reports. Routine Info/Warning
|
||||
* log lines ("Testing API connection", probe results, …) were landing on the
|
||||
* tracker as `[Bug]:` issues with an empty boilerplate body, so non-Error
|
||||
* entries prefill as `[Diagnostic]:` questions instead; for Info entries the
|
||||
* caller additionally collects the reporter's expectation before offering the
|
||||
* link (see [com.hermesandroid.relay.ui.components.DiagnosticDetailDialog]).
|
||||
*/
|
||||
object DiagnosticIssuePrefill {
|
||||
|
||||
/**
|
||||
* Cap for the trace embedded in the prefilled GitHub URL so it stays within
|
||||
* browser limits (the full text is on the clipboard).
|
||||
*/
|
||||
private const val MAX_TRACE_FOR_URL = 3000
|
||||
|
||||
private const val DEFAULT_WHAT_HAPPENED = "Captured diagnostic from the in-app activity log."
|
||||
|
||||
/** `[Bug]:` for Error entries, `[Diagnostic]:` for Info/Warning. */
|
||||
fun issueTitle(entry: DiagnosticLogEntry): String = when (entry.severity) {
|
||||
DiagnosticSeverity.Error -> "[Bug]: ${entry.title}"
|
||||
else -> "[Diagnostic]: ${entry.title}"
|
||||
}
|
||||
|
||||
/**
|
||||
* `bug` for Error entries; `question` (an existing repo label) for
|
||||
* Info/Warning so routine diagnostics don't pollute the bug queue.
|
||||
*/
|
||||
fun issueLabels(entry: DiagnosticLogEntry): String = when (entry.severity) {
|
||||
DiagnosticSeverity.Error -> "bug"
|
||||
else -> "question"
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual active route for the "Connection mode" line: the role stamped
|
||||
* on the entry when present, else inferred from the entry URL, else
|
||||
* `unknown` — never the old unedited "LAN / Tailscale / public TLS / other"
|
||||
* template text.
|
||||
*/
|
||||
fun connectionMode(entry: DiagnosticLogEntry): String =
|
||||
entry.endpointRole
|
||||
?: entry.url?.let { Connection.inferRouteRole(it) }
|
||||
?: "unknown"
|
||||
|
||||
/**
|
||||
* Markdown issue body mirroring the crash-report issue format: environment
|
||||
* block + the captured entry.
|
||||
*
|
||||
* @param expectation the reporter's free-text "What were you expecting to
|
||||
* happen?" answer collected by the Info-severity pre-flight; when
|
||||
* non-blank it replaces the boilerplate "What happened" line. Runs
|
||||
* through the shared diagnostics secret redaction before embedding.
|
||||
*/
|
||||
fun issueBody(entry: DiagnosticLogEntry, expectation: String? = null): String {
|
||||
val trace = (entry.stacktrace ?: entry.detail).orEmpty().let {
|
||||
if (it.length > MAX_TRACE_FOR_URL) {
|
||||
it.take(MAX_TRACE_FOR_URL) + "\n… (truncated — full diagnostic copied to your clipboard)"
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
val surface = if (BuildConfig.FLAVOR.equals("sideload", ignoreCase = true)) "sideload APK" else "Google Play"
|
||||
val whatHappened = DiagnosticsLog.redactReportText(expectation)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: DEFAULT_WHAT_HAPPENED
|
||||
return buildString {
|
||||
appendLine(
|
||||
"> ⚠️ Before submitting: remove any secrets, tokens, real hostnames/IPs, " +
|
||||
"or personal data from the detail below.",
|
||||
)
|
||||
appendLine()
|
||||
appendLine("### Affected area")
|
||||
appendLine("Android app")
|
||||
appendLine()
|
||||
appendLine("### What happened?")
|
||||
appendLine(whatHappened)
|
||||
appendLine()
|
||||
appendLine("### Environment")
|
||||
appendLine("- Hermes-Relay version/tag: ${BuildConfig.VERSION_NAME} (code ${BuildConfig.VERSION_CODE})")
|
||||
appendLine("- Install surface: $surface")
|
||||
appendLine("- Connection mode: ${connectionMode(entry)}")
|
||||
appendLine()
|
||||
appendLine("### Diagnostic")
|
||||
appendLine("- Title: ${entry.title}")
|
||||
appendLine("- Category: ${entry.category.label}")
|
||||
appendLine("- Severity: ${entry.severity.name}")
|
||||
entry.endpointRole?.let { appendLine("- Route: $it") }
|
||||
entry.url?.let { appendLine("- URL: $it") }
|
||||
entry.elapsedMs?.let { appendLine("- Elapsed: ${it}ms") }
|
||||
if (trace.isNotBlank()) {
|
||||
appendLine()
|
||||
appendLine("```")
|
||||
appendLine(trace)
|
||||
appendLine("```")
|
||||
}
|
||||
appendLine()
|
||||
append("<sub>Captured by the Hermes-Relay in-app diagnostics log</sub>")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,37 @@ object ServerAddress {
|
||||
/** True when [raw] forms a valid http(s) address once normalized. Blank → false. */
|
||||
fun isValidUserInput(raw: String?): Boolean = parseUserInput(raw) != null
|
||||
|
||||
/**
|
||||
* Advisory for a server address whose host is loopback / any-interface —
|
||||
* `localhost`, `127.x.x.x`, `::1`, `0.0.0.0`. Such an address works in a
|
||||
* browser *on the server* but can never reach the server from the phone,
|
||||
* a recurring source of "Testing API connection" dead-ends. Accepts the
|
||||
* same scheme-less input [parseUserInput] normalizes. Returns `null` for
|
||||
* blank, unparseable, or non-loopback addresses. NEVER throws.
|
||||
*
|
||||
* Pure helper only — not yet surfaced anywhere; UI wiring is a follow-up.
|
||||
*/
|
||||
fun loopbackHostWarning(raw: String): String? {
|
||||
val trimmed = raw.trim().trimEnd('/')
|
||||
if (trimmed.isEmpty()) return null
|
||||
// Bare "::1" never parses without brackets — normalize it directly.
|
||||
val host = parseUserInput(trimmed)?.host?.lowercase()
|
||||
?: trimmed.removePrefix("[").removeSuffix("]").lowercase().takeIf { it == "::1" }
|
||||
?: return null
|
||||
val loopback = host == "localhost" || host == "::1" || host == "0.0.0.0" || isLoopbackIpv4(host)
|
||||
return if (loopback) {
|
||||
"On your phone, localhost points at the phone itself — use the server's LAN IP or Tailscale address."
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLoopbackIpv4(host: String): Boolean {
|
||||
val labels = host.split('.')
|
||||
val parts = labels.mapNotNull { it.toIntOrNull() }
|
||||
return labels.size == 4 && parts.size == 4 && parts[0] == 127
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline error for a server-URL / host text field, or `null` when the value
|
||||
* is acceptable. Blank returns `null` so callers can gate required-ness
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Client-side answer recovery for a sessions-endpoint chat stream that died on
|
||||
* a transport error while the server kept working (issue #166).
|
||||
*
|
||||
* On slow local models + delegating skills, a turn can outlive the phone's SSE
|
||||
* socket (screen-off / Doze / Wi-Fi power-save kill it long before OkHttp's
|
||||
* read timeout). Upstream `api_server` behavior on that disconnect: the SSE
|
||||
* writer dies but the agent run continues in an uncancellable executor thread
|
||||
* and the FINAL ANSWER IS PERSISTED to the session store. So instead of
|
||||
* finalizing the turn as an error, this poller re-reads the session transcript
|
||||
* (the native upstream `/api/sessions/{id}/messages` route — standard-path
|
||||
* safe, no server changes) until the answer lands.
|
||||
*
|
||||
* Cadence: first poll after [Timing.pollIntervalMs], doubling each poll up to
|
||||
* [Timing.maxPollIntervalMs], for at most [Timing.recoveryWindowMs] of slept
|
||||
* time. Elapsed time is accumulated from the delays (not wall-clock reads) so
|
||||
* the loop is virtual-time friendly in tests.
|
||||
*
|
||||
* **Anchoring (positional, not text-only).** The pending send, if it landed,
|
||||
* is the `(priorUserMessageCount + 1)`-th user-role row in the server
|
||||
* transcript — i.e. the first user row AFTER the ones the client already knew
|
||||
* about. The candidate answer is the last non-blank assistant row after that
|
||||
* anchor. Anchoring by position (with a content-match sanity check) — rather
|
||||
* than a bare `indexOfLast` text search — is what stops a short repeated
|
||||
* prompt ("yes", "ok", "continue") from matching a STALE identical earlier row
|
||||
* and adopting a DIFFERENT turn's (static, therefore instantly "stable")
|
||||
* answer when this send never actually reached the server.
|
||||
*
|
||||
* Finish condition: an assistant message that postdates the anchor, is
|
||||
* non-empty, and is stable across two consecutive polls (the signature also
|
||||
* folds in the transcript length, so a still-running run that keeps appending
|
||||
* tool rows after an intermediate assistant message defers the finish).
|
||||
* Intermediate persisted rows are surfaced through [onIntermediateHistory] as
|
||||
* they appear — progressive recovery.
|
||||
*
|
||||
* Fail-fast: when the anchor can't be established — the transcript still holds
|
||||
* only the user rows the client already knew about (the POST died before the
|
||||
* server persisted the turn), or the positional row's content diverges from
|
||||
* the pending send (the transcript was edited/forked out from under us) — the
|
||||
* poller never adopts an answer, because a wrong answer is worse than an error.
|
||||
* It confirms the state across two consecutive polls (guarding against a
|
||||
* transient read mid-persist) and then gives up with [GiveUpReason.RUN_NOT_FOUND]
|
||||
* instead of polling to the 30-minute cap, since no answer for this turn can
|
||||
* ever arrive. An EMPTY transcript carries no information (the history read
|
||||
* maps fetch failures to an empty list too), so it keeps polling.
|
||||
*/
|
||||
class ChatStreamRecovery(
|
||||
private val scope: CoroutineScope,
|
||||
private val fetchHistory: suspend () -> List<MessageItem>,
|
||||
private val timing: Timing = Timing(),
|
||||
) {
|
||||
|
||||
data class Timing(
|
||||
val pollIntervalMs: Long = 5_000L,
|
||||
val maxPollIntervalMs: Long = 30_000L,
|
||||
val recoveryWindowMs: Long = 30L * 60_000L,
|
||||
)
|
||||
|
||||
enum class GiveUpReason {
|
||||
/**
|
||||
* The pending send couldn't be located as a new user row after the
|
||||
* ones the client already knew about — the POST never persisted the
|
||||
* turn, or the transcript diverged. No answer can arrive; resend.
|
||||
*/
|
||||
RUN_NOT_FOUND,
|
||||
|
||||
/** The recovery window elapsed without a stable answer. */
|
||||
TIMED_OUT,
|
||||
}
|
||||
|
||||
/** Whether a poll could establish the anchor for the pending send. */
|
||||
private sealed interface Anchor {
|
||||
/** The `(priorUserCount + 1)`-th user row exists and matches. */
|
||||
data class Found(val index: Int) : Anchor
|
||||
|
||||
/**
|
||||
* The anchor can't be proven: too few user rows (send not persisted)
|
||||
* or a positional row whose content diverges (edited/forked history).
|
||||
*/
|
||||
data object NotEstablished : Anchor
|
||||
}
|
||||
|
||||
private var job: Job? = null
|
||||
|
||||
val isActive: Boolean
|
||||
get() = job?.isActive == true
|
||||
|
||||
/**
|
||||
* Start polling. Exactly one poll loop per instance — a second [start]
|
||||
* replaces the first. Exactly one terminal callback fires per loop
|
||||
* ([onRecovered] or [onGaveUp]); cancellation fires none.
|
||||
*
|
||||
* @param priorUserMessageCount how many user-role messages the client knew
|
||||
* existed BEFORE this turn's pending send (excluding the in-flight pair).
|
||||
* Drives the positional anchor — see the class KDoc.
|
||||
*/
|
||||
fun start(
|
||||
pendingUserText: String,
|
||||
priorUserMessageCount: Int,
|
||||
onIntermediateHistory: (List<MessageItem>) -> Unit,
|
||||
onRecovered: (List<MessageItem>) -> Unit,
|
||||
onGaveUp: (GiveUpReason) -> Unit,
|
||||
) {
|
||||
job?.cancel()
|
||||
val pending = pendingUserText.trim()
|
||||
val priorUsers = priorUserMessageCount.coerceAtLeast(0)
|
||||
job = scope.launch {
|
||||
var delayMs = timing.pollIntervalMs
|
||||
var elapsedMs = 0L
|
||||
var lastSignature: String? = null
|
||||
var lastSurfacedCount = -1
|
||||
var unanchoredPolls = 0
|
||||
while (elapsedMs < timing.recoveryWindowMs) {
|
||||
delay(delayMs)
|
||||
elapsedMs += delayMs
|
||||
delayMs = (delayMs * 2).coerceAtMost(timing.maxPollIntervalMs)
|
||||
|
||||
val items = try {
|
||||
fetchHistory()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
continue // unreachable — keep waiting for the network
|
||||
}
|
||||
if (items.isEmpty()) continue
|
||||
|
||||
when (val anchor = resolveAnchor(items, pending, priorUsers)) {
|
||||
is Anchor.NotEstablished -> {
|
||||
// The pending send isn't (verifiably) persisted as a new
|
||||
// user row. A transient read while the server persists
|
||||
// could look like this, so require the state to hold
|
||||
// across two consecutive polls before giving up — but
|
||||
// never poll to the cap for an answer that can't arrive.
|
||||
lastSignature = null
|
||||
if (++unanchoredPolls >= 2) {
|
||||
onGaveUp(GiveUpReason.RUN_NOT_FOUND)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
is Anchor.Found -> {
|
||||
unanchoredPolls = 0
|
||||
val signature = answerSignature(items, anchor.index)
|
||||
if (signature != null && signature == lastSignature) {
|
||||
onRecovered(items)
|
||||
return@launch
|
||||
}
|
||||
lastSignature = signature
|
||||
|
||||
if (items.size != lastSurfacedCount) {
|
||||
lastSurfacedCount = items.size
|
||||
onIntermediateHistory(items)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onGaveUp(GiveUpReason.TIMED_OUT)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the positional anchor for the pending send. The send, if it
|
||||
* landed, is the `(priorUserCount + 1)`-th user-role row; that row must
|
||||
* ALSO match the pending text (secondary sanity check for edits/forks).
|
||||
* Any other shape is [Anchor.NotEstablished] — never adopt a guess.
|
||||
*/
|
||||
private fun resolveAnchor(
|
||||
items: List<MessageItem>,
|
||||
pendingUserText: String,
|
||||
priorUserCount: Int,
|
||||
): Anchor {
|
||||
val userIndices = items.indices.filter { items[it].role == "user" }
|
||||
if (userIndices.size <= priorUserCount) return Anchor.NotEstablished
|
||||
val anchorPos = userIndices[priorUserCount]
|
||||
if (items[anchorPos].contentText?.trim() != pendingUserText) return Anchor.NotEstablished
|
||||
return Anchor.Found(anchorPos)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stability signature of the candidate answer: the last non-blank
|
||||
* assistant message after the anchor, or null while none exists. The
|
||||
* transcript size is folded in so new rows (tool results of a
|
||||
* still-running run) change the signature and defer the finish.
|
||||
*/
|
||||
private fun answerSignature(items: List<MessageItem>, anchor: Int): String? {
|
||||
val answer = items.drop(anchor + 1).lastOrNull {
|
||||
it.role == "assistant" && !it.contentText.isNullOrBlank()
|
||||
} ?: return null
|
||||
return "${items.size}|${answer.id}|${answer.contentText?.length}"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,305 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcess
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcessCapability
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcessEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Small adapter seam around [com.hermesandroid.relay.network.upstream.GatewayChatClient].
|
||||
* Keeping the controller on this interface makes its session/race behavior
|
||||
* testable without opening a WebSocket.
|
||||
*/
|
||||
internal interface GatewayProcessSource {
|
||||
val capability: StateFlow<GatewayProcessCapability>
|
||||
|
||||
suspend fun listProcesses(): Result<List<GatewayProcess>>
|
||||
|
||||
suspend fun killProcess(processId: String): Result<Unit>
|
||||
|
||||
fun setEventListener(listener: ((GatewayProcessEvent) -> Unit)?)
|
||||
|
||||
/** False when background polling would reopen a deliberately closed socket. */
|
||||
fun isPollingAllowed(): Boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the active chat's upstream background-process snapshot.
|
||||
*
|
||||
* The gateway RPCs are scoped by a private, live session id. The UI only knows
|
||||
* the durable chat id, so a session becomes queryable only after [sessionReady]
|
||||
* confirms that the source has resumed/created its matching live session.
|
||||
* Every async result is fenced by both binding/session generation and request
|
||||
* sequence so a slow response from a previous chat can never repaint this one.
|
||||
*/
|
||||
internal class GatewayProcessController(
|
||||
private val scope: CoroutineScope,
|
||||
private val pollIntervalMs: Long = 5_000L,
|
||||
private val outputTailLimit: Int = 4_000,
|
||||
) {
|
||||
private val _processes = MutableStateFlow<List<GatewayProcess>>(emptyList())
|
||||
val processes: StateFlow<List<GatewayProcess>> = _processes.asStateFlow()
|
||||
|
||||
private val _capability = MutableStateFlow(GatewayProcessCapability.Unknown)
|
||||
val capability: StateFlow<GatewayProcessCapability> = _capability.asStateFlow()
|
||||
|
||||
private val _loading = MutableStateFlow(false)
|
||||
val loading: StateFlow<Boolean> = _loading.asStateFlow()
|
||||
|
||||
private val _stoppingProcessIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val stoppingProcessIds: StateFlow<Set<String>> = _stoppingProcessIds.asStateFlow()
|
||||
|
||||
private var source: GatewayProcessSource? = null
|
||||
private var selectedSessionId: String? = null
|
||||
private var selectedScopeKey: String? = null
|
||||
private var readySessionId: String? = null
|
||||
private var generation = 0L
|
||||
private var refreshSequence = 0L
|
||||
private var allProcesses: List<GatewayProcess> = emptyList()
|
||||
|
||||
/** A dismissal applies only to this concrete process identity. */
|
||||
private val dismissedIdentities = mutableMapOf<String, ProcessIdentity>()
|
||||
|
||||
private var capabilityJob: Job? = null
|
||||
private var refreshJob: Job? = null
|
||||
private var pollJob: Job? = null
|
||||
|
||||
/** Replace the gateway client and clear all session-owned state. */
|
||||
fun bind(newSource: GatewayProcessSource?, sessionId: String?, scopeKey: String? = null) {
|
||||
if (
|
||||
source === newSource &&
|
||||
selectedSessionId == sessionId &&
|
||||
selectedScopeKey == scopeKey
|
||||
) return
|
||||
|
||||
source?.setEventListener(null)
|
||||
capabilityJob?.cancel()
|
||||
source = newSource
|
||||
resetForSession(sessionId, scopeKey)
|
||||
_capability.value = newSource?.capability?.value ?: GatewayProcessCapability.Unknown
|
||||
|
||||
if (newSource == null) return
|
||||
newSource.setEventListener { event ->
|
||||
// Gateway callbacks arrive on the socket/callback dispatcher. Keep
|
||||
// snapshot transitions serialized with refresh/kill mutations.
|
||||
scope.launch {
|
||||
if (source === newSource) handleEvent(newSource, event)
|
||||
}
|
||||
}
|
||||
capabilityJob = scope.launch {
|
||||
newSource.capability.collect { value ->
|
||||
if (source !== newSource) return@collect
|
||||
_capability.value = value
|
||||
if (value == GatewayProcessCapability.Unsupported) {
|
||||
clearSnapshot(cancelDismissals = true)
|
||||
_loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Select a durable chat id. The live gateway session may follow later. */
|
||||
fun selectSession(sessionId: String?, scopeKey: String? = null) {
|
||||
if (selectedSessionId == sessionId && selectedScopeKey == scopeKey) return
|
||||
resetForSession(sessionId, scopeKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Admit process RPCs for [sessionId] after the gateway has created/resumed
|
||||
* that chat's live session. Stale ready callbacks are ignored.
|
||||
*/
|
||||
fun sessionReady(sessionId: String) {
|
||||
if (sessionId != selectedSessionId || source == null) return
|
||||
val firstReady = readySessionId != sessionId
|
||||
readySessionId = sessionId
|
||||
// A same-session ready callback can mean the socket was resumed after
|
||||
// being offline. Re-list even when this chat was already admitted: a
|
||||
// process may have started/finished while no event stream was present.
|
||||
refresh(showLoading = firstReady && allProcesses.isEmpty())
|
||||
}
|
||||
|
||||
fun refresh(showLoading: Boolean = allProcesses.isEmpty()) {
|
||||
val currentSource = source ?: return
|
||||
val sessionId = selectedSessionId ?: return
|
||||
if (readySessionId != sessionId) return
|
||||
if (_capability.value == GatewayProcessCapability.Unsupported) return
|
||||
|
||||
val expectedGeneration = generation
|
||||
val request = ++refreshSequence
|
||||
refreshJob?.cancel()
|
||||
refreshJob = scope.launch {
|
||||
if (showLoading && allProcesses.isEmpty()) _loading.value = true
|
||||
val result = currentSource.listProcesses()
|
||||
if (!owns(currentSource, sessionId, expectedGeneration) || request != refreshSequence) {
|
||||
return@launch
|
||||
}
|
||||
result.onSuccess(::applySnapshot)
|
||||
_loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop one running process; the authoritative follow-up snapshot wins. */
|
||||
fun stop(processId: String, onFailure: (String) -> Unit = {}) {
|
||||
val currentSource = source ?: return
|
||||
val sessionId = selectedSessionId ?: return
|
||||
if (readySessionId != sessionId) return
|
||||
if (processId in _stoppingProcessIds.value) return
|
||||
if (allProcesses.none { it.id == processId && it.isRunning }) return
|
||||
|
||||
val expectedGeneration = generation
|
||||
_stoppingProcessIds.value = _stoppingProcessIds.value + processId
|
||||
scope.launch {
|
||||
val result = currentSource.killProcess(processId)
|
||||
if (!owns(currentSource, sessionId, expectedGeneration)) return@launch
|
||||
_stoppingProcessIds.value = _stoppingProcessIds.value - processId
|
||||
result.fold(
|
||||
onSuccess = { refresh(showLoading = false) },
|
||||
onFailure = { error ->
|
||||
onFailure(error.message ?: "Couldn't stop background process")
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Finished rows are dismissed locally; upstream history remains untouched. */
|
||||
fun dismiss(processId: String) {
|
||||
val process = allProcesses.firstOrNull { it.id == processId && !it.isRunning } ?: return
|
||||
dismissedIdentities[processId] = process.identity()
|
||||
publishVisibleSnapshot()
|
||||
}
|
||||
|
||||
fun close() {
|
||||
source?.setEventListener(null)
|
||||
source = null
|
||||
capabilityJob?.cancel()
|
||||
capabilityJob = null
|
||||
resetForSession(null, null)
|
||||
_capability.value = GatewayProcessCapability.Unknown
|
||||
}
|
||||
|
||||
private fun resetForSession(sessionId: String?, scopeKey: String?) {
|
||||
generation += 1
|
||||
refreshSequence += 1
|
||||
selectedSessionId = sessionId
|
||||
selectedScopeKey = scopeKey
|
||||
readySessionId = null
|
||||
refreshJob?.cancel()
|
||||
refreshJob = null
|
||||
pollJob?.cancel()
|
||||
pollJob = null
|
||||
_loading.value = false
|
||||
_stoppingProcessIds.value = emptySet()
|
||||
clearSnapshot(cancelDismissals = true)
|
||||
}
|
||||
|
||||
private fun clearSnapshot(cancelDismissals: Boolean) {
|
||||
allProcesses = emptyList()
|
||||
_processes.value = emptyList()
|
||||
pollJob?.cancel()
|
||||
pollJob = null
|
||||
if (cancelDismissals) {
|
||||
dismissedIdentities.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySnapshot(incoming: List<GatewayProcess>) {
|
||||
val incomingById = incoming.associateBy(GatewayProcess::id)
|
||||
|
||||
// A server can eventually reuse a process id. Never let a local
|
||||
// dismissal hide the new command instance.
|
||||
dismissedIdentities.entries.removeAll { (id, dismissedIdentity) ->
|
||||
val next = incomingById[id]
|
||||
next == null || next.identity() != dismissedIdentity
|
||||
}
|
||||
allProcesses = incoming
|
||||
publishVisibleSnapshot()
|
||||
updatePoller()
|
||||
}
|
||||
|
||||
private fun publishVisibleSnapshot() {
|
||||
_processes.value = allProcesses.filterNot { process ->
|
||||
dismissedIdentities[process.id] == process.identity()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePoller() {
|
||||
val shouldPoll = allProcesses.any(GatewayProcess::isRunning) &&
|
||||
readySessionId == selectedSessionId &&
|
||||
source != null &&
|
||||
source?.isPollingAllowed() == true &&
|
||||
_capability.value != GatewayProcessCapability.Unsupported
|
||||
if (!shouldPoll) {
|
||||
pollJob?.cancel()
|
||||
pollJob = null
|
||||
return
|
||||
}
|
||||
if (pollJob?.isActive == true) return
|
||||
|
||||
val expectedGeneration = generation
|
||||
pollJob = scope.launch {
|
||||
while (generation == expectedGeneration && allProcesses.any(GatewayProcess::isRunning)) {
|
||||
delay(pollIntervalMs)
|
||||
if (
|
||||
generation != expectedGeneration ||
|
||||
!allProcesses.any(GatewayProcess::isRunning) ||
|
||||
source?.isPollingAllowed() != true
|
||||
) {
|
||||
break
|
||||
}
|
||||
// Avoid repeatedly cancelling a slow RPC. An invalidation can
|
||||
// own this interval; the next tick remains the safety net.
|
||||
if (refreshJob?.isActive != true) refresh(showLoading = false)
|
||||
}
|
||||
if (generation == expectedGeneration) pollJob = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEvent(currentSource: GatewayProcessSource, event: GatewayProcessEvent) {
|
||||
val sessionId = selectedSessionId ?: return
|
||||
if (!owns(currentSource, sessionId, generation) || readySessionId != sessionId) return
|
||||
when (event) {
|
||||
is GatewayProcessEvent.Invalidated,
|
||||
is GatewayProcessEvent.TerminalClosed -> refresh(showLoading = false)
|
||||
|
||||
is GatewayProcessEvent.Output -> {
|
||||
val index = allProcesses.indexOfFirst { it.id == event.processId }
|
||||
if (index < 0) {
|
||||
// Output can beat tool.complete/process.list by a frame.
|
||||
refresh(showLoading = false)
|
||||
return
|
||||
}
|
||||
val process = allProcesses[index]
|
||||
val tail = (process.outputTail.orEmpty() + event.chunk).takeLast(outputTailLimit)
|
||||
allProcesses = allProcesses.toMutableList().also {
|
||||
it[index] = process.copy(outputTail = tail)
|
||||
}
|
||||
publishVisibleSnapshot()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun owns(
|
||||
expectedSource: GatewayProcessSource,
|
||||
expectedSessionId: String,
|
||||
expectedGeneration: Long,
|
||||
): Boolean =
|
||||
source === expectedSource &&
|
||||
selectedSessionId == expectedSessionId &&
|
||||
readySessionId == expectedSessionId &&
|
||||
generation == expectedGeneration
|
||||
|
||||
private data class ProcessIdentity(
|
||||
val id: String,
|
||||
val command: String,
|
||||
val pid: Long?,
|
||||
val startedAt: String?,
|
||||
)
|
||||
|
||||
private fun GatewayProcess.identity() = ProcessIdentity(id, command, pid, startedAt)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,9 +47,36 @@ object RealtimeTurnSyncBuilder {
|
||||
return if (provenance.isBlank()) {
|
||||
trace.assistantText
|
||||
} else {
|
||||
"${trace.assistantText}\n\n[Realtime Agent provider-native voice turn: $provenance]"
|
||||
"${trace.assistantText}\n\n$PROVENANCE_PREFIX$provenance]"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect + strip the provenance marker [buildAssistantContent] appends to
|
||||
* a synced provider-answered turn. Returns the assistant text with the
|
||||
* marker removed, or null when no marker is present.
|
||||
*
|
||||
* Used by [com.hermesandroid.relay.network.upstream.ChatHandler.loadMessageHistory]
|
||||
* so a synced turn coming back in server history renders with the quiet
|
||||
* "Realtime Agent" badge instead of raw bracket noise — and so its
|
||||
* superseded local clientOnly bubble can be dropped instead of showing
|
||||
* the exchange twice. The marker must be the FINAL block of the content
|
||||
* (a single bracket line, no embedded newline/bracket) so ordinary
|
||||
* assistant prose that merely mentions the phrase is never stripped.
|
||||
*/
|
||||
fun stripProvenanceMarker(content: String): String? {
|
||||
val trimmed = content.trimEnd()
|
||||
if (!trimmed.endsWith("]")) return null
|
||||
val idx = trimmed.lastIndexOf("\n\n$PROVENANCE_PREFIX")
|
||||
if (idx < 0) return null
|
||||
val inner = trimmed.substring(
|
||||
idx + 2 + PROVENANCE_PREFIX.length,
|
||||
trimmed.length - 1,
|
||||
)
|
||||
if ('\n' in inner || ']' in inner) return null
|
||||
return trimmed.substring(0, idx).trimEnd()
|
||||
}
|
||||
|
||||
private const val PROVENANCE_PREFIX = "[Realtime Agent provider-native voice turn: "
|
||||
private const val MAX_CONTENT_CHARS = 4_000
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.hermesandroid.relay.voice
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Local actions that can be requested from a committed voice transcript.
|
||||
*
|
||||
* These are deliberately separate from normal Hermes prompts. Callers must
|
||||
* invoke [VoiceCommandInterpreter.interpretFinalTranscript] only after STT (or
|
||||
* a realtime provider) has emitted a final transcript; partial transcripts are
|
||||
* never safe command boundaries.
|
||||
*/
|
||||
internal enum class VoiceCommandAction {
|
||||
StopResponse,
|
||||
CancelBackgroundTask,
|
||||
PauseContinuousListening,
|
||||
ResumeContinuousListening,
|
||||
RepeatBackgroundAnswer,
|
||||
StartNewChat,
|
||||
}
|
||||
|
||||
/** State gates that keep an exact command phrase from becoming a global hotword. */
|
||||
internal data class VoiceCommandContext(
|
||||
val responseActive: Boolean = false,
|
||||
val backgroundTaskActive: Boolean = false,
|
||||
val backgroundAnswerAvailable: Boolean = false,
|
||||
val continuousModeSelected: Boolean = false,
|
||||
val continuousListeningActive: Boolean = false,
|
||||
val continuousListeningPaused: Boolean = false,
|
||||
val canStartNewChat: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Conservative, exact-only interpreter for hands-free Voice controls.
|
||||
*
|
||||
* False negatives are preferred: a phrase must match one complete normalized
|
||||
* utterance and its corresponding state gate. There is no prefix, substring,
|
||||
* edit-distance, or fuzzy matching, so ordinary prompts such as "How do I stop
|
||||
* talking too quickly?" continue to Hermes unchanged.
|
||||
*/
|
||||
internal object VoiceCommandInterpreter {
|
||||
private val stopResponsePhrases = setOf(
|
||||
"stop speaking",
|
||||
"stop talking",
|
||||
"stop the response",
|
||||
"stop your response",
|
||||
)
|
||||
private val cancelBackgroundTaskPhrases = setOf(
|
||||
"cancel the background task",
|
||||
"cancel my background task",
|
||||
"cancel that background task",
|
||||
"cancel the running background task",
|
||||
)
|
||||
private val pauseContinuousPhrases = setOf(
|
||||
"pause",
|
||||
"pause continuous listening",
|
||||
"pause hands free listening",
|
||||
)
|
||||
private val resumeContinuousPhrases = setOf(
|
||||
"resume",
|
||||
"resume continuous listening",
|
||||
"resume hands free listening",
|
||||
)
|
||||
private val repeatBackgroundAnswerPhrases = setOf(
|
||||
"repeat that",
|
||||
"repeat the background answer",
|
||||
"repeat the last background answer",
|
||||
"repeat that background answer",
|
||||
)
|
||||
private val newChatPhrases = setOf(
|
||||
"new chat",
|
||||
"start a new chat",
|
||||
"open a new chat",
|
||||
"create a new chat",
|
||||
)
|
||||
|
||||
fun interpretFinalTranscript(
|
||||
rawTranscript: String,
|
||||
context: VoiceCommandContext,
|
||||
): VoiceCommandAction? {
|
||||
val phrase = normalize(rawTranscript)
|
||||
if (phrase.isEmpty()) return null
|
||||
|
||||
return when {
|
||||
context.backgroundTaskActive && phrase in cancelBackgroundTaskPhrases ->
|
||||
VoiceCommandAction.CancelBackgroundTask
|
||||
context.responseActive && phrase in stopResponsePhrases ->
|
||||
VoiceCommandAction.StopResponse
|
||||
context.continuousModeSelected &&
|
||||
context.continuousListeningActive &&
|
||||
phrase in pauseContinuousPhrases -> VoiceCommandAction.PauseContinuousListening
|
||||
context.continuousModeSelected &&
|
||||
context.continuousListeningPaused &&
|
||||
phrase in resumeContinuousPhrases -> VoiceCommandAction.ResumeContinuousListening
|
||||
context.backgroundAnswerAvailable && phrase in repeatBackgroundAnswerPhrases ->
|
||||
VoiceCommandAction.RepeatBackgroundAnswer
|
||||
context.canStartNewChat && phrase in newChatPhrases -> VoiceCommandAction.StartNewChat
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalize(raw: String): String = raw
|
||||
.lowercase(Locale.ROOT)
|
||||
.replace(Regex("[\\p{Punct}\\p{P}]"), " ")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
class ChatTurnCheckpointStoreTest {
|
||||
|
||||
@get:Rule
|
||||
val tempFolder = TemporaryFolder()
|
||||
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var dataStore: DataStore<Preferences>
|
||||
private lateinit var store: DataStoreChatTurnCheckpointStore
|
||||
private var now = 10_000L
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
val file = tempFolder.newFile("chat_checkpoint.preferences_pb")
|
||||
file.delete()
|
||||
dataStore = PreferenceDataStoreFactory.create(
|
||||
scope = scope,
|
||||
produceFile = { file },
|
||||
)
|
||||
store = DataStoreChatTurnCheckpointStore(dataStore) { now }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullRichTurn_roundTrips() = runTest {
|
||||
val checkpoint = sampleCheckpoint()
|
||||
|
||||
store.write(checkpoint)
|
||||
|
||||
assertEquals(checkpoint, store.read())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun corruptJson_isDiscarded() = runTest {
|
||||
dataStore.edit { preferences ->
|
||||
preferences[stringPreferencesKey("chat_inflight_turn_checkpoint_v1")] = "{broken"
|
||||
}
|
||||
|
||||
assertNull(store.read())
|
||||
assertNull(store.read())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleCheckpoint_isDiscarded() = runTest {
|
||||
val checkpoint = sampleCheckpoint().copy(updatedAt = now)
|
||||
store.write(checkpoint)
|
||||
now += ChatTurnCheckpoint.MAX_AGE_MS + 1L
|
||||
|
||||
assertNull(store.read())
|
||||
assertNull(store.read())
|
||||
}
|
||||
|
||||
private fun sampleCheckpoint() = ChatTurnCheckpoint(
|
||||
contextKey = "connection-a/profile-default",
|
||||
sessionId = "stored-42",
|
||||
liveSessionId = "live-42",
|
||||
transport = "gateway",
|
||||
user = ChatTurnUserCheckpoint("user-1", "research this", 1_000L),
|
||||
assistant = ChatTurnAssistantCheckpoint(
|
||||
id = "assistant-1",
|
||||
content = "Working on it",
|
||||
timestamp = 1_001L,
|
||||
thinkingContent = "I should inspect the source",
|
||||
isThinkingStreaming = true,
|
||||
agentName = "Hermes",
|
||||
toolCalls = listOf(
|
||||
ChatTurnToolCheckpoint(
|
||||
id = "tool-1",
|
||||
name = "terminal",
|
||||
isComplete = false,
|
||||
startedAt = 1_002L,
|
||||
),
|
||||
),
|
||||
backgroundTask = ChatTurnBackgroundTaskCheckpoint(
|
||||
id = "run-1",
|
||||
title = "Research",
|
||||
tier = "durable",
|
||||
phase = BackgroundTaskPhase.RUNNING.name,
|
||||
statusLine = "Checking sources",
|
||||
startedAt = 1_003L,
|
||||
),
|
||||
),
|
||||
turnStatus = "Running terminal",
|
||||
priorUserMessageCount = 3,
|
||||
baselineAssistantCount = 3,
|
||||
pendingAsk = ChatTurnAskCheckpoint(
|
||||
kind = "APPROVAL",
|
||||
text = "Allow command?",
|
||||
timeoutSeconds = 0,
|
||||
messageId = "ask-1",
|
||||
cardKey = "approval-1",
|
||||
receivedAt = 1_004L,
|
||||
),
|
||||
startedAt = 1_001L,
|
||||
updatedAt = now,
|
||||
)
|
||||
}
|
||||
@@ -109,4 +109,24 @@ class DemoContentTest {
|
||||
// the demo looks the same every launch and the content is testable.
|
||||
assertEquals(DemoContent.transcript(), DemoContent.transcript())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun composerReplyFollowsTheDemoContentContract() {
|
||||
// The canned reply for a message typed inside demo mode (composer
|
||||
// no-op polish) must obey the same rules as the transcript: an
|
||||
// honest offline notice, clientOnly, terminal, zero network.
|
||||
val reply = DemoContent.composerReply(id = "demo-composer-reply-test", nowMs = 123L)
|
||||
assertEquals("demo-composer-reply-test", reply.id)
|
||||
assertEquals(123L, reply.timestamp)
|
||||
assertEquals(MessageRole.ASSISTANT, reply.role)
|
||||
assertTrue("composer reply must be clientOnly", reply.clientOnly)
|
||||
assertFalse("composer reply must be terminal", reply.isStreaming)
|
||||
assertTrue("composer reply carries the Demo badge", reply.badges.contains("Demo"))
|
||||
assertTrue(
|
||||
"composer reply should say it can't answer offline",
|
||||
reply.content.contains("demo", ignoreCase = true),
|
||||
)
|
||||
assertTrue("composer reply has no attachments", reply.attachments.isEmpty())
|
||||
assertEquals(DemoContent.DEMO_AGENT_NAME, reply.agentName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class HermesProcessNotificationTest {
|
||||
|
||||
@Test
|
||||
fun completionEnvelopeParsesWithoutChangingCanonicalMessageRole() {
|
||||
val content = """
|
||||
[IMPORTANT: Background process 42 completed normally (exit code 0).
|
||||
Command: ./gradlew test
|
||||
Output:
|
||||
BUILD SUCCESSFUL]
|
||||
""".trimIndent()
|
||||
val message = ChatMessage(
|
||||
id = "server-user-row",
|
||||
role = MessageRole.USER,
|
||||
content = content,
|
||||
timestamp = 1L,
|
||||
)
|
||||
|
||||
val parsed = message.hermesProcessNotificationOrNull()
|
||||
|
||||
assertEquals(MessageRole.USER, message.role)
|
||||
assertEquals("42", parsed?.processId)
|
||||
assertEquals(
|
||||
"Background process 42 completed normally (exit code 0).",
|
||||
parsed?.headline,
|
||||
)
|
||||
assertEquals(
|
||||
"Command: ./gradlew test\nOutput:\nBUILD SUCCESSFUL",
|
||||
parsed?.detail,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun watchMatchEnvelopePreservesMultilineDetail() {
|
||||
val content = """
|
||||
[IMPORTANT: Background process proc-7 matched watch pattern "ready".
|
||||
Command: python server.py
|
||||
Matched output:
|
||||
Server ready
|
||||
Listening on 127.0.0.1]
|
||||
""".trimIndent()
|
||||
|
||||
val parsed = HermesProcessNotificationParser.parse(content)
|
||||
|
||||
assertEquals("proc-7", parsed?.processId)
|
||||
assertEquals(
|
||||
"Command: python server.py\nMatched output:\nServer ready\nListening on 127.0.0.1",
|
||||
parsed?.detail,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compactEnvelopeWithoutOutputStillParses() {
|
||||
val parsed = HermesProcessNotificationParser.parse(
|
||||
"[IMPORTANT: Background process 123 finished]",
|
||||
)
|
||||
|
||||
assertEquals("123", parsed?.processId)
|
||||
assertEquals("Background process 123 finished", parsed?.headline)
|
||||
assertNull(parsed?.detail)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonProcessImportantMessageIsNotClaimed() {
|
||||
assertNull(
|
||||
HermesProcessNotificationParser.parse(
|
||||
"[IMPORTANT: Process watch was disabled]",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun markerEmbeddedInHumanTextIsNotClaimed() {
|
||||
assertNull(
|
||||
HermesProcessNotificationParser.parse(
|
||||
"Hermes said [IMPORTANT: Background process 12 finished] yesterday",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedEnvelopeWithoutStatusIsNotClaimed() {
|
||||
assertNull(
|
||||
HermesProcessNotificationParser.parse(
|
||||
"[IMPORTANT: Background process 12]",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun assistantCopyOfProcessEnvelopeUsesNormalPresentation() {
|
||||
val message = ChatMessage(
|
||||
id = "assistant-copy",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "[IMPORTANT: Background process 123 finished]",
|
||||
timestamp = 1L,
|
||||
)
|
||||
|
||||
assertNull(message.hermesProcessNotificationOrNull())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
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 VoiceModePresetTest {
|
||||
|
||||
private val current = VoiceModePresetState(
|
||||
voiceSettings = VoiceSettings(
|
||||
engineMode = VoiceEngineMode.HermesVoiceOutput.storageValue,
|
||||
audioRoute = VoiceAudioRoute.Relay.storageValue,
|
||||
interactionMode = "tap",
|
||||
silenceThresholdMs = 3000L,
|
||||
realtimeTraceDetails = false,
|
||||
realtimePersistentSession = false,
|
||||
realtimeModel = "custom-realtime-model",
|
||||
realtimeVoice = "custom-realtime-voice",
|
||||
enhancedVoice = "custom-output-voice",
|
||||
enhancedModel = "custom-output-model",
|
||||
enhancedAudioTags = true,
|
||||
enhancedPersona = "Warm and precise",
|
||||
enhancedLanguage = "en-US",
|
||||
),
|
||||
bargeInPreferences = BargeInPreferences(
|
||||
enabled = true,
|
||||
sensitivity = BargeInSensitivity.High,
|
||||
resumeAfterInterruption = false,
|
||||
),
|
||||
promotion = VoicePresetPromotionSettings(
|
||||
enabled = true,
|
||||
promoteAfterMs = 42000,
|
||||
backgroundDefaultMode = "foreground",
|
||||
spokenHandoff = true,
|
||||
progressSpokenAfterMs = 32000,
|
||||
progressRepeatMs = 123000,
|
||||
resultDelivery = "notify_then_speak",
|
||||
maxBackgroundRuns = 4,
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun handsFreeMapsContinuousListeningAndPreservesExperimentalBargeInChoice() {
|
||||
val source = current.copy(
|
||||
bargeInPreferences = BargeInPreferences(
|
||||
enabled = false,
|
||||
sensitivity = BargeInSensitivity.High,
|
||||
resumeAfterInterruption = false,
|
||||
),
|
||||
)
|
||||
val target = VoiceModePreset.HandsFree.applyTo(source)
|
||||
|
||||
assertEquals("continuous", target.voiceSettings.interactionMode)
|
||||
assertEquals(1250L, target.voiceSettings.silenceThresholdMs)
|
||||
assertTrue(target.voiceSettings.realtimeTraceDetails)
|
||||
assertTrue(target.voiceSettings.realtimePersistentSession)
|
||||
assertFalse(target.bargeInPreferences.enabled)
|
||||
assertEquals(BargeInSensitivity.High, target.bargeInPreferences.sensitivity)
|
||||
assertFalse(target.bargeInPreferences.resumeAfterInterruption)
|
||||
assertEquals(6000, target.promotion?.promoteAfterMs)
|
||||
assertTrue(target.promotion?.spokenHandoff == true)
|
||||
assertEquals(15000, target.promotion?.progressSpokenAfterMs)
|
||||
assertEquals(90000, target.promotion?.progressRepeatMs)
|
||||
assertEquals("speak_verbatim", target.promotion?.resultDelivery)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lowLatencyMapsShortestSilenceAndFastPromotion() {
|
||||
val target = VoiceModePreset.LowLatency.applyTo(current)
|
||||
|
||||
assertEquals("tap", target.voiceSettings.interactionMode)
|
||||
assertEquals(750L, target.voiceSettings.silenceThresholdMs)
|
||||
assertFalse(target.voiceSettings.realtimeTraceDetails)
|
||||
assertTrue(target.voiceSettings.realtimePersistentSession)
|
||||
assertFalse(target.bargeInPreferences.enabled)
|
||||
assertEquals(2500, target.promotion?.promoteAfterMs)
|
||||
assertFalse(target.promotion?.spokenHandoff == true)
|
||||
assertEquals(0, target.promotion?.progressSpokenAfterMs)
|
||||
assertEquals("speak_when_idle", target.promotion?.resultDelivery)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun carefulToolsKeepsRunsForegroundAndResultsExact() {
|
||||
val target = VoiceModePreset.CarefulTools.applyTo(current)
|
||||
|
||||
assertEquals("hold", target.voiceSettings.interactionMode)
|
||||
assertEquals(1750L, target.voiceSettings.silenceThresholdMs)
|
||||
assertTrue(target.voiceSettings.realtimeTraceDetails)
|
||||
assertFalse(target.bargeInPreferences.enabled)
|
||||
assertFalse(target.promotion?.enabled == true)
|
||||
assertEquals("foreground", target.promotion?.backgroundDefaultMode)
|
||||
assertEquals("speak_verbatim", target.promotion?.resultDelivery)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun quietVisualOnlyLeavesShortReplyBehaviorExplicitlyOutOfScope() {
|
||||
val target = VoiceModePreset.QuietVisualOnly.applyTo(current)
|
||||
|
||||
assertEquals("tap", target.voiceSettings.interactionMode)
|
||||
assertEquals(1250L, target.voiceSettings.silenceThresholdMs)
|
||||
assertTrue(target.voiceSettings.realtimeTraceDetails)
|
||||
assertFalse(target.bargeInPreferences.enabled)
|
||||
assertTrue(target.promotion?.enabled == true)
|
||||
assertFalse(target.promotion?.spokenHandoff == true)
|
||||
assertEquals(0, target.promotion?.progressSpokenAfterMs)
|
||||
assertEquals("visual_only", target.promotion?.resultDelivery)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detectorRecognizesEveryFullyAppliedPreset() {
|
||||
VoiceModePreset.entries.forEach { preset ->
|
||||
assertEquals(preset, detectVoiceModePreset(preset.applyTo(current)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun manualDivergenceReportsCustom() {
|
||||
val handsFree = VoiceModePreset.HandsFree.applyTo(current)
|
||||
val diverged = handsFree.copy(
|
||||
voiceSettings = handsFree.voiceSettings.copy(interactionMode = "tap"),
|
||||
)
|
||||
|
||||
assertNull(detectVoiceModePreset(diverged))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingPromotionSnapshotNeverClaimsAnActivePreset() {
|
||||
val noPromotion = current.copy(promotion = null)
|
||||
|
||||
assertNull(detectVoiceModePreset(VoiceModePreset.HandsFree.applyTo(noPromotion)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun presetsPreserveVoiceIdentityRoutingAndConcurrency() {
|
||||
VoiceModePreset.entries.forEach { preset ->
|
||||
val target = preset.applyTo(current)
|
||||
|
||||
assertEquals(current.voiceSettings.engineMode, target.voiceSettings.engineMode)
|
||||
assertEquals(current.voiceSettings.audioRoute, target.voiceSettings.audioRoute)
|
||||
assertEquals(current.voiceSettings.realtimeModel, target.voiceSettings.realtimeModel)
|
||||
assertEquals(current.voiceSettings.realtimeVoice, target.voiceSettings.realtimeVoice)
|
||||
assertEquals(current.voiceSettings.enhancedVoice, target.voiceSettings.enhancedVoice)
|
||||
assertEquals(current.voiceSettings.enhancedModel, target.voiceSettings.enhancedModel)
|
||||
assertEquals(
|
||||
current.voiceSettings.enhancedAudioTags,
|
||||
target.voiceSettings.enhancedAudioTags,
|
||||
)
|
||||
assertEquals(current.voiceSettings.enhancedPersona, target.voiceSettings.enhancedPersona)
|
||||
assertEquals(current.voiceSettings.enhancedLanguage, target.voiceSettings.enhancedLanguage)
|
||||
assertEquals(
|
||||
current.promotion?.maxBackgroundRuns,
|
||||
target.promotion?.maxBackgroundRuns,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disabledBargeInPresetsPreserveHiddenSensitivityPreferences() {
|
||||
listOf(
|
||||
VoiceModePreset.LowLatency,
|
||||
VoiceModePreset.CarefulTools,
|
||||
VoiceModePreset.QuietVisualOnly,
|
||||
).forEach { preset ->
|
||||
val target = preset.applyTo(current)
|
||||
|
||||
assertEquals(BargeInSensitivity.High, target.bargeInPreferences.sensitivity)
|
||||
assertFalse(target.bargeInPreferences.resumeAfterInterruption)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import java.io.File
|
||||
|
||||
class VoicePreferencesRepositoryTest {
|
||||
|
||||
@get:Rule
|
||||
val tempFolder = TemporaryFolder()
|
||||
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var dataStore: DataStore<Preferences>
|
||||
private lateinit var repository: VoicePreferencesRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
scope = CoroutineScope(Dispatchers.IO + Job())
|
||||
val file: File = tempFolder.newFile("voice_preferences_test.preferences_pb")
|
||||
if (file.exists()) file.delete()
|
||||
dataStore = PreferenceDataStoreFactory.create(
|
||||
scope = scope,
|
||||
produceFile = { file },
|
||||
)
|
||||
repository = VoicePreferencesRepository(dataStore)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun realtimeSelectionPersistsPerConnectionAndProfile() = runTest {
|
||||
repository.setActiveScope("connection-a", "coder")
|
||||
repository.setRealtimeSelection(
|
||||
model = " grok-voice-think-fast-1.0 ",
|
||||
voice = " leo ",
|
||||
)
|
||||
|
||||
var settings = repository.settings.first()
|
||||
assertEquals("grok-voice-think-fast-1.0", settings.realtimeModel)
|
||||
assertEquals("leo", settings.realtimeVoice)
|
||||
|
||||
repository.setActiveScope("connection-b", "coder")
|
||||
settings = repository.settings.first()
|
||||
assertEquals("", settings.realtimeModel)
|
||||
assertEquals("", settings.realtimeVoice)
|
||||
|
||||
repository.setActiveScope("connection-a", "coder")
|
||||
settings = repository.settings.first()
|
||||
assertEquals("grok-voice-think-fast-1.0", settings.realtimeModel)
|
||||
assertEquals("leo", settings.realtimeVoice)
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.hermesandroid.relay.network.relay
|
||||
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Guards the relay-socket half of the #131 "Invalid URL host" crash class.
|
||||
*
|
||||
* [ConnectionManager.doConnectInternal] builds an OkHttp `Request` on a
|
||||
* background coroutine, so a malformed relay URL (from a corrupt or hand-edited
|
||||
* pairing payload) used to let `Request.Builder.url()` throw
|
||||
* `IllegalArgumentException`, which — uncaught on the IO dispatcher — crashed
|
||||
* the app (observed on Play as an `okhttp3.HttpUrl$Builder.parse` crash).
|
||||
* [buildRelayRequestOrNull] must return null for such URLs so the connect path
|
||||
* fails gracefully (Disconnected + diagnostic) instead of crashing.
|
||||
*/
|
||||
class ConnectionManagerUrlGuardTest {
|
||||
|
||||
@Test
|
||||
fun `valid ws and wss urls build a request`() {
|
||||
assertNotNull(buildRelayRequestOrNull("wss://relay.example.com:8767"))
|
||||
assertNotNull(buildRelayRequestOrNull("ws://192.168.1.10:8767/path"))
|
||||
assertNotNull(buildRelayRequestOrNull("wss://host.tailnet.ts.net"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed relay urls return null instead of throwing`() {
|
||||
// Each makes OkHttp's Request.Builder.url() throw IllegalArgumentException
|
||||
// ("Invalid URL host"): empty host, and a space inside the host.
|
||||
for (bad in listOf(
|
||||
"wss://",
|
||||
"wss://in valid host:8767",
|
||||
"wss://a b",
|
||||
)) {
|
||||
assertNull("expected null for malformed relay url '$bad'", buildRelayRequestOrNull(bad))
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.hermesandroid.relay.network.relay
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
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 RealtimeVoiceEventParsingTest {
|
||||
|
||||
@Test
|
||||
fun brokerOkFieldMapsToTheSharedSuccessState() {
|
||||
val success = Json.parseToJsonElement("""{"ok":true}""").jsonObject
|
||||
val failure = Json.parseToJsonElement("""{"ok":false}""").jsonObject
|
||||
|
||||
assertTrue(realtimeEventSuccess(success) == true)
|
||||
assertFalse(realtimeEventSuccess(failure) == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explicitSuccessWinsWhenBothFieldsArePresent() {
|
||||
val event = Json.parseToJsonElement(
|
||||
"""{"success":false,"ok":true}""",
|
||||
).jsonObject
|
||||
|
||||
assertEquals(false, realtimeEventSuccess(event))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun absentOrMalformedFieldsRemainUnknown() {
|
||||
assertNull(realtimeEventSuccess(Json.parseToJsonElement("{}").jsonObject))
|
||||
assertNull(
|
||||
realtimeEventSuccess(
|
||||
Json.parseToJsonElement("""{"ok":"not-a-boolean"}""").jsonObject,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+1385
-1
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,10 @@ package com.hermesandroid.relay.network.upstream
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnToolCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.RealtimeTurnTrace
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
@@ -1350,6 +1354,173 @@ class ChatHandlerTest {
|
||||
assertEquals("boom", handler.error.value)
|
||||
}
|
||||
|
||||
// --- loadMessageHistory: synced realtime-turn provenance (marker → badge) ---
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_stripsRealtimeProvenanceMarkerIntoBadge() {
|
||||
// A provider-answered realtime turn synced by RealtimeTurnSyncBuilder
|
||||
// comes back in server history with a trailing provenance marker. The
|
||||
// reload must strip the bracket noise and restore the same quiet
|
||||
// "Realtime Agent" badge a live turn gets.
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "1",
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(
|
||||
"It syncs vault metadata.\n\n" +
|
||||
"[Realtime Agent provider-native voice turn: " +
|
||||
"provider=xai_realtime, model=grok-voice-latest]",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val msg = handler.messages.value.single()
|
||||
assertEquals("It syncs vault metadata.", msg.content)
|
||||
assertTrue(msg.badges.contains("Realtime Agent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_noBadgeWithoutProvenanceMarker() {
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(id = "1", role = "assistant", content = JsonPrimitive("Plain reply")),
|
||||
),
|
||||
)
|
||||
|
||||
val msg = handler.messages.value.single()
|
||||
assertEquals("Plain reply", msg.content)
|
||||
assertFalse(msg.badges.contains("Realtime Agent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_dropsSyncedRealtimeOrphanSupersededByServerCopy() {
|
||||
// Once a provider-answered turn's synced copy exists in the server
|
||||
// transcript, the pre-sync local clientOnly bubble is redundant —
|
||||
// preserving both would render the exchange twice.
|
||||
handler.onTextDelta("realtime-agent-1", "It syncs vault metadata.")
|
||||
handler.attachRealtimeTurnTrace(
|
||||
"realtime-agent-1",
|
||||
RealtimeTurnTrace(
|
||||
userText = "What does it do?",
|
||||
assistantText = "It syncs vault metadata.",
|
||||
provider = "xai_realtime",
|
||||
),
|
||||
)
|
||||
handler.markRealtimeTurnsSynced()
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(id = "10", role = "user", content = JsonPrimitive("What does it do?")),
|
||||
MessageItem(
|
||||
id = "11",
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(
|
||||
"It syncs vault metadata.\n\n" +
|
||||
"[Realtime Agent provider-native voice turn: provider=xai_realtime]",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val msgs = handler.messages.value
|
||||
// Only the server pair remains — the local orphan was dropped.
|
||||
assertEquals(2, msgs.size)
|
||||
assertTrue(msgs.none { it.id == "realtime-agent-1" })
|
||||
val serverCopy = msgs.single { it.id == "11" }
|
||||
assertEquals("It syncs vault metadata.", serverCopy.content)
|
||||
assertTrue(serverCopy.badges.contains("Realtime Agent"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadMessageHistory_preservesUnsyncedRealtimeOrphan() {
|
||||
// An UNSYNCED trace is still the only record of the turn — it must
|
||||
// survive the reload even though it has no server row.
|
||||
handler.onTextDelta("realtime-agent-1", "spoken answer")
|
||||
handler.attachRealtimeTurnTrace(
|
||||
"realtime-agent-1",
|
||||
RealtimeTurnTrace(userText = "hi", assistantText = "spoken answer"),
|
||||
)
|
||||
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(id = "10", role = "user", content = JsonPrimitive("unrelated")),
|
||||
),
|
||||
)
|
||||
|
||||
val orphan = handler.messages.value.single { it.id == "realtime-agent-1" }
|
||||
assertEquals("spoken answer", orphan.content)
|
||||
assertFalse(orphan.realtimeTurn!!.syncedToServer)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoreInFlightTurn_restoresThinkingAndToolState_withoutDuplicatingPersistedUser() {
|
||||
handler.addUserMessage(createUserMessage("old-user", "Earlier"))
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "old-assistant",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Earlier answer",
|
||||
timestamp = 2L,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
handler.onStreamComplete("old-assistant")
|
||||
// Models history having persisted the pending user before Android
|
||||
// reopens; the restore must not append a second identical row.
|
||||
handler.addUserMessage(createUserMessage("server-user", "Run the checks"))
|
||||
val checkpoint = ChatTurnCheckpoint(
|
||||
contextKey = "connection/profile",
|
||||
sessionId = "stored-1",
|
||||
liveSessionId = "live-1",
|
||||
transport = "gateway",
|
||||
user = ChatTurnUserCheckpoint("local-user", "Run the checks", 3L),
|
||||
assistant = ChatTurnAssistantCheckpoint(
|
||||
id = "assistant-live",
|
||||
content = "I am checking",
|
||||
timestamp = 4L,
|
||||
thinkingContent = "Inspect the project first",
|
||||
isThinkingStreaming = true,
|
||||
toolCalls = listOf(
|
||||
ChatTurnToolCheckpoint(
|
||||
id = "tool-1",
|
||||
name = "terminal",
|
||||
isComplete = false,
|
||||
startedAt = 5L,
|
||||
),
|
||||
ChatTurnToolCheckpoint(
|
||||
id = "tool-2",
|
||||
name = "search",
|
||||
result = "3 matches",
|
||||
success = true,
|
||||
isComplete = true,
|
||||
startedAt = 6L,
|
||||
completedAt = 7L,
|
||||
),
|
||||
),
|
||||
),
|
||||
turnStatus = "Running terminal",
|
||||
priorUserMessageCount = 1,
|
||||
baselineAssistantCount = 1,
|
||||
startedAt = 4L,
|
||||
updatedAt = 8L,
|
||||
)
|
||||
|
||||
handler.restoreInFlightTurn(checkpoint, upstreamAssistantText = "I am checking the tests")
|
||||
|
||||
assertEquals(2, handler.messages.value.count { it.role == MessageRole.USER })
|
||||
val restored = handler.messages.value.single { it.id == "assistant-live" }
|
||||
assertEquals("I am checking the tests", restored.content)
|
||||
assertEquals("Inspect the project first", restored.thinkingContent)
|
||||
assertTrue(restored.isThinkingStreaming)
|
||||
assertFalse(restored.toolCalls[0].isComplete)
|
||||
assertTrue(restored.toolCalls[1].isComplete)
|
||||
assertEquals(true, restored.toolCalls[1].success)
|
||||
assertTrue(handler.isStreaming.value)
|
||||
assertEquals("Running terminal", handler.turnStatus.value)
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
|
||||
private fun createUserMessage(id: String, content: String) = ChatMessage(
|
||||
|
||||
+234
@@ -1,5 +1,7 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionPruneFilters
|
||||
import com.hermesandroid.relay.network.upstream.models.SessionPrunePreview
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -57,6 +59,31 @@ class DashboardApiClientTest {
|
||||
assertEquals("0.16.0", status.version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getModelOptions_alwaysRequestsUnconfiguredProviders() = runTest {
|
||||
// HRUI-022: newer upstream hides unconfigured provider skeleton rows
|
||||
// unless the client opts in — without include_unconfigured=1 the
|
||||
// Manage picker loses its Keys-setup affordance. Both the cached and
|
||||
// the refresh path must carry the opt-in.
|
||||
val body = """{"providers": []}"""
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(body))
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(body))
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
|
||||
client.getModelOptions().getOrThrow()
|
||||
val bare = server.takeRequest().requestUrl!!
|
||||
assertEquals("/api/model/options", bare.encodedPath)
|
||||
assertEquals("1", bare.queryParameter("include_unconfigured"))
|
||||
assertEquals(null, bare.queryParameter("refresh"))
|
||||
|
||||
client.getModelOptions(refresh = true).getOrThrow()
|
||||
val refreshed = server.takeRequest().requestUrl!!
|
||||
assertEquals("/api/model/options", refreshed.encodedPath)
|
||||
assertEquals("1", refreshed.queryParameter("include_unconfigured"))
|
||||
assertEquals("1", refreshed.queryParameter("refresh"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun currentSession_onConnectionAbort_returnsFailure_doesNotThrow() = runTest {
|
||||
// Reproduces the crash: a stale pooled connection aborting mid-flight
|
||||
@@ -703,6 +730,213 @@ class DashboardApiClientTest {
|
||||
assertEquals("/api/config/schema", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun previewSessionPrune_postsDryRunAndParsesPreview() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(
|
||||
"""
|
||||
{"ok":true,"removed":0,"matched":2,
|
||||
"oldest_started_at":1000.5,"newest_started_at":2000.5,
|
||||
"sessions":[
|
||||
{"id":"sess-old","source":"phone","title":"Old plan","model":"claude-opus-4-8","started_at":1000.5,"message_count":3},
|
||||
{"id":"sess-new","source":"phone","started_at":2000.5,"message_count":1}
|
||||
]}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val preview = client.previewSessionPrune(
|
||||
SessionPruneFilters(olderThanDays = 30.0, source = "phone", profile = "mizu"),
|
||||
).getOrThrow()
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertEquals("POST", request.method)
|
||||
assertEquals("/api/sessions/prune", request.path)
|
||||
val body = request.body.readUtf8()
|
||||
// The preview MUST be a dry run — this call may never delete.
|
||||
assertTrue(body.contains(""""dry_run":true"""))
|
||||
assertTrue(body.contains(""""older_than_days":30.0"""))
|
||||
assertTrue(body.contains(""""source":"phone""""))
|
||||
assertTrue(body.contains(""""profile":"mizu""""))
|
||||
assertEquals(2, preview.matched)
|
||||
assertEquals(1000.5, preview.oldestStartedAt!!, 0.001)
|
||||
assertEquals(2000.5, preview.newestStartedAt!!, 0.001)
|
||||
assertEquals("sess-old", preview.sessions[0].id)
|
||||
assertEquals(3, preview.sessions[0].messageCount)
|
||||
assertEquals("Old plan", preview.sessions[0].title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun previewSessionPrune_bareFiltersOmitOptionalFields() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"ok":true,"removed":0,"matched":0,"sessions":[]}"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
client.previewSessionPrune(SessionPruneFilters()).getOrThrow()
|
||||
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
// A bare prune sends only dry_run; upstream then applies its own
|
||||
// implicit ended-more-than-90-days-ago cutoff.
|
||||
assertTrue(body.contains(""""dry_run":true"""))
|
||||
assertFalse(body.contains("older_than_days"))
|
||||
assertFalse(body.contains("source"))
|
||||
assertFalse(body.contains("profile"))
|
||||
assertFalse(body.contains("include_archived"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pruneSessions_appliesWithDryRunFalseAndParsesRemoved() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"ok":true,"removed":2}"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val filters = SessionPruneFilters(olderThanDays = 30.0, source = "phone")
|
||||
val preview = SessionPrunePreview(matched = 2)
|
||||
val result = client.pruneSessions(filters, confirmedPreview = preview).getOrThrow()
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertEquals("POST", request.method)
|
||||
assertEquals("/api/sessions/prune", request.path)
|
||||
val body = request.body.readUtf8()
|
||||
assertTrue(body.contains(""""dry_run":false"""))
|
||||
assertTrue(body.contains(""""older_than_days":30.0"""))
|
||||
assertEquals(2, result.removed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pruneSessions_skipsServerCallWhenPreviewMatchedNothing() = runTest {
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val result = client.pruneSessions(
|
||||
SessionPruneFilters(olderThanDays = 30.0),
|
||||
confirmedPreview = SessionPrunePreview(matched = 0),
|
||||
).getOrThrow()
|
||||
|
||||
// Nothing matched at preview time → nothing to delete. The client must
|
||||
// not fire the destructive POST at all (sessions that aged in after
|
||||
// the preview are not covered by what the user confirmed).
|
||||
assertEquals(0, server.requestCount)
|
||||
assertEquals(0, result.removed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exportSession_getsServerOwnedArchiveJsonScopedToProfile() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"id":"sess-old","messages":[]}"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
val exported = client.exportSession("sess-old", profile = "mizu").getOrThrow()
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertEquals("GET", request.method)
|
||||
assertEquals("/api/sessions/sess-old/export", request.requestUrl!!.encodedPath)
|
||||
assertEquals("mizu", request.requestUrl!!.queryParameter("profile"))
|
||||
assertEquals("sess-old", exported["id"].toString().trim('"'))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setSessionArchived_patchesArchivedScopedToProfile() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"ok":true,"title":"Old plan","archived":true}"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
client.setSessionArchived("sess-old", archived = true, profile = "mizu").getOrThrow()
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertEquals("PATCH", request.method)
|
||||
// Current upstream reads profile from the PATCH body (SessionRename
|
||||
// model); the query param rides along for builds that scoped by query.
|
||||
assertEquals("/api/sessions/sess-old", request.requestUrl!!.encodedPath)
|
||||
assertEquals("mizu", request.requestUrl!!.queryParameter("profile"))
|
||||
val body = request.body.readUtf8()
|
||||
assertTrue(body.contains(""""archived":true"""))
|
||||
assertTrue(body.contains(""""profile":"mizu""""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setSessionArchived_omitsProfileForDefaultSelection() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"ok":true,"title":"","archived":false}"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
client.setSessionArchived("sess-old", archived = false, profile = null).getOrThrow()
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertEquals(null, request.requestUrl!!.queryParameter("profile"))
|
||||
val body = request.body.readUtf8()
|
||||
assertTrue(body.contains(""""archived":false"""))
|
||||
assertFalse(body.contains("profile"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renameSession_carriesProfileInBodyAndQuery() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"ok":true,"title":"New title"}"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
client.renameSession("sess-a", title = "New title", profile = "mizu").getOrThrow()
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertEquals("PATCH", request.method)
|
||||
assertEquals("/api/sessions/sess-a", request.requestUrl!!.encodedPath)
|
||||
assertEquals("mizu", request.requestUrl!!.queryParameter("profile"))
|
||||
val body = request.body.readUtf8()
|
||||
assertTrue(body.contains(""""title":"New title""""))
|
||||
// Current upstream reads profile from the PATCH body, not the query.
|
||||
assertTrue(body.contains(""""profile":"mizu""""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listSessions_passesArchivedFilterThrough() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"sessions":[],"total":0,"limit":50,"offset":0}"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
client.listSessions(archived = "only").getOrThrow()
|
||||
|
||||
val url = server.takeRequest().requestUrl!!
|
||||
assertEquals("only", url.queryParameter("archived"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listSessions_omitsArchivedParamByDefault() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody("""{"sessions":[],"total":0,"limit":50,"offset":0}"""),
|
||||
)
|
||||
|
||||
val client = DashboardApiClient(baseUrl = server.url("/").toString())
|
||||
client.listSessions().getOrThrow()
|
||||
|
||||
// Default stays upstream's default (exclude) with no param, so older
|
||||
// hosts that predate the archived filter see an unchanged request.
|
||||
assertEquals(null, server.takeRequest().requestUrl!!.queryParameter("archived"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseChatDisplaySettings_mapsToolProgressNoneToOff() {
|
||||
val root = Json.parseToJsonElement(
|
||||
|
||||
+738
-16
@@ -4,7 +4,9 @@ import com.hermesandroid.relay.network.upstream.models.UsageInfo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -24,6 +26,8 @@ import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -51,6 +55,12 @@ class GatewayClientHarness(
|
||||
var failTicketMint = false
|
||||
var resumeFails = false
|
||||
|
||||
@Volatile
|
||||
var recoveryRunning = false
|
||||
|
||||
@Volatile
|
||||
var recoveryAssistant = ""
|
||||
|
||||
@Volatile
|
||||
var steerStatus = "queued"
|
||||
|
||||
@@ -63,6 +73,14 @@ class GatewayClientHarness(
|
||||
/** Methods answered with JSON-RPC -32601 — exercises the legacy-name fallback. */
|
||||
val methodNotFound: MutableSet<String> = ConcurrentHashMap.newKeySet()
|
||||
|
||||
/** One withheld JSON-RPC ack, capturable for delayed release via [releaseAck]. */
|
||||
class PendingAck(val ws: WebSocket, val method: String, val id: Long)
|
||||
|
||||
/** Methods whose ack is WITHHELD (queued in [pendingAcks]) instead of auto-answered —
|
||||
* models upstream's fire-and-forget `prompt.submit`, whose ack can trail the turn. */
|
||||
val suppressAckMethods: MutableSet<String> = ConcurrentHashMap.newKeySet()
|
||||
val pendingAcks = LinkedBlockingQueue<PendingAck>()
|
||||
|
||||
private val wsListener = object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: okhttp3.Response) {
|
||||
serverSockets.add(webSocket)
|
||||
@@ -77,6 +95,10 @@ class GatewayClientHarness(
|
||||
val params = frame["params"] as? JsonObject ?: JsonObject(emptyMap())
|
||||
rpcLog.add(method to params)
|
||||
if (!autoRespondEnabled) return
|
||||
if (method in suppressAckMethods) {
|
||||
pendingAcks.add(PendingAck(webSocket, method, id.toLong()))
|
||||
return
|
||||
}
|
||||
if (method in methodNotFound) {
|
||||
webSocket.send(
|
||||
buildJsonObject {
|
||||
@@ -97,9 +119,47 @@ class GatewayClientHarness(
|
||||
}
|
||||
"session.resume" ->
|
||||
if (resumeFails) null
|
||||
else buildJsonObject { put("session_id", "live-resumed") }
|
||||
else recoveryPayload("live-resumed")
|
||||
"session.activate" -> recoveryPayload(
|
||||
(params["session_id"] as? JsonPrimitive)?.contentOrNull ?: "live-activated",
|
||||
)
|
||||
"prompt.submit" -> buildJsonObject { put("ok", true) }
|
||||
"session.interrupt" -> buildJsonObject { put("ok", true) }
|
||||
"process.list" -> buildJsonObject {
|
||||
put(
|
||||
"processes",
|
||||
json.parseToJsonElement(
|
||||
"""
|
||||
[
|
||||
{
|
||||
"session_id": "proc-17",
|
||||
"command": "./gradlew test",
|
||||
"cwd": "/workspace/app",
|
||||
"pid": 4812,
|
||||
"started_at": "2026-07-10T09:30:00",
|
||||
"uptime_seconds": 42,
|
||||
"status": "running",
|
||||
"output_preview": "running tests",
|
||||
"output_tail": "running tests\n42 tests completed",
|
||||
"notify_on_complete": true,
|
||||
"session_scoped": true,
|
||||
"watch_patterns": ["BUILD SUCCESSFUL"],
|
||||
"watch_hit": false
|
||||
},
|
||||
{
|
||||
"session_id": "proc-18",
|
||||
"command": "npm run lint",
|
||||
"uptime_seconds": 7,
|
||||
"status": "exited",
|
||||
"exit_code": 1,
|
||||
"detached": true
|
||||
}
|
||||
]
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
}
|
||||
"process.kill" -> buildJsonObject { put("status", "killed") }
|
||||
"session.steer" -> buildJsonObject {
|
||||
put("status", steerStatus)
|
||||
put("text", (params["text"] as? JsonPrimitive)?.contentOrNull ?: "")
|
||||
@@ -125,6 +185,26 @@ class GatewayClientHarness(
|
||||
json.parseToJsonElement("""[["/help","Show help"],["/model","Pick model"]]"""),
|
||||
)
|
||||
}
|
||||
"model.options" -> buildJsonObject {
|
||||
put("model", "gpt-5.5")
|
||||
put("provider", "openai")
|
||||
put(
|
||||
"providers",
|
||||
json.parseToJsonElement(
|
||||
"""
|
||||
[
|
||||
{
|
||||
"slug": "openai",
|
||||
"name": "OpenAI",
|
||||
"models": ["gpt-5.5"],
|
||||
"is_current": true,
|
||||
"authenticated": true
|
||||
}
|
||||
]
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
}
|
||||
"config.get" -> when ((params["key"] as? JsonPrimitive)?.contentOrNull) {
|
||||
"reasoning" -> buildJsonObject {
|
||||
put("value", reasoningEffort)
|
||||
@@ -163,6 +243,19 @@ class GatewayClientHarness(
|
||||
|
||||
private val autoRespondEnabled = autoRespond
|
||||
|
||||
private fun recoveryPayload(sessionId: String): JsonObject = buildJsonObject {
|
||||
put("session_id", sessionId)
|
||||
put("running", recoveryRunning)
|
||||
put("status", if (recoveryRunning) "streaming" else "idle")
|
||||
if (recoveryRunning) {
|
||||
put("inflight", buildJsonObject {
|
||||
put("user", "research this")
|
||||
put("assistant", recoveryAssistant)
|
||||
put("streaming", true)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
server.dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
@@ -210,6 +303,23 @@ class GatewayClientHarness(
|
||||
error("rpc $method never arrived; saw ${rpcLog.map { it.first }}")
|
||||
}
|
||||
|
||||
fun awaitPendingAck(): PendingAck =
|
||||
pendingAcks.poll(5, TimeUnit.SECONDS) ?: error("suppressed ack never captured")
|
||||
|
||||
/** Release a withheld ack with a caller-supplied or generic success result. */
|
||||
fun releaseAck(
|
||||
ack: PendingAck,
|
||||
result: JsonObject = buildJsonObject { put("ok", true) },
|
||||
) {
|
||||
ack.ws.send(
|
||||
buildJsonObject {
|
||||
put("jsonrpc", "2.0")
|
||||
put("id", ack.id)
|
||||
put("result", result)
|
||||
}.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Waits until [method] has been seen at least [count] times; returns the params in arrival order. */
|
||||
fun awaitRpcCount(method: String, count: Int): List<JsonObject> {
|
||||
val deadline = System.currentTimeMillis() + 5_000
|
||||
@@ -247,11 +357,14 @@ class GatewayChatClientTest {
|
||||
private var unsupportedMarked = false
|
||||
|
||||
private class Recorder {
|
||||
val starts = AtomicInteger(0)
|
||||
val textDeltas = ConcurrentLinkedQueue<String>()
|
||||
val thinkingDeltas = ConcurrentLinkedQueue<String>()
|
||||
val sessionIds = ConcurrentLinkedQueue<String>()
|
||||
val errors = ConcurrentLinkedQueue<String>()
|
||||
val interactions = ConcurrentLinkedQueue<GatewayAsk>()
|
||||
val toolStarts = ConcurrentLinkedQueue<Pair<String, String>>()
|
||||
val toolDone = ConcurrentLinkedQueue<Pair<String, String?>>()
|
||||
|
||||
// ConcurrentLinkedQueue rejects nulls — unnamed generating events store "".
|
||||
val toolGenerating = ConcurrentLinkedQueue<String>()
|
||||
@@ -262,10 +375,11 @@ class GatewayChatClientTest {
|
||||
|
||||
val callbacks = GatewayTurnCallbacks(
|
||||
onSessionId = { sessionIds += it },
|
||||
onStart = { starts.incrementAndGet() },
|
||||
onTextDelta = { textDeltas += it },
|
||||
onThinkingDelta = { thinkingDeltas += it },
|
||||
onToolCallStart = { _, _ -> },
|
||||
onToolCallDone = { _, _ -> },
|
||||
onToolCallStart = { id, name -> toolStarts += id to name },
|
||||
onToolCallDone = { id, result -> toolDone += id to result },
|
||||
onToolCallFailed = { _, _ -> },
|
||||
onTurnComplete = { },
|
||||
onComplete = { completeLatch.countDown() },
|
||||
@@ -277,24 +391,48 @@ class GatewayChatClientTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildClient(
|
||||
rpcTimeoutMs: Long = 15_000L,
|
||||
promptSubmitTimeoutMs: Long = 1_800_000L,
|
||||
turnIdleTimeoutMs: Long = 180_000L,
|
||||
) = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = harness.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = OkHttpClient(),
|
||||
),
|
||||
okHttpClient = OkHttpClient(),
|
||||
callbackDispatcher = { it() },
|
||||
onGatewayUnsupported = { unsupportedMarked = true },
|
||||
scope = scope,
|
||||
// Keep the mid-turn reconnect window short so `failed rejoin`
|
||||
// surfaces its error well within the test's await budget.
|
||||
midTurnRejoinWindowMs = 3_000L,
|
||||
rpcTimeoutMs = rpcTimeoutMs,
|
||||
promptSubmitTimeoutMs = promptSubmitTimeoutMs,
|
||||
turnIdleTimeoutMs = turnIdleTimeoutMs,
|
||||
)
|
||||
|
||||
/**
|
||||
* Swap in a client with shortened timeout seams. Mints a FRESH scope:
|
||||
* shutdown() cancels the scope's Job, and the replacement client must
|
||||
* still be able to launch its sendTurn coroutines.
|
||||
*/
|
||||
private fun rebuildClient(
|
||||
rpcTimeoutMs: Long = 15_000L,
|
||||
promptSubmitTimeoutMs: Long = 1_800_000L,
|
||||
turnIdleTimeoutMs: Long = 180_000L,
|
||||
) {
|
||||
client.shutdown()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
client = buildClient(rpcTimeoutMs, promptSubmitTimeoutMs, turnIdleTimeoutMs)
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
harness = GatewayClientHarness()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
unsupportedMarked = false
|
||||
client = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = harness.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = OkHttpClient(),
|
||||
),
|
||||
okHttpClient = OkHttpClient(),
|
||||
callbackDispatcher = { it() },
|
||||
onGatewayUnsupported = { unsupportedMarked = true },
|
||||
scope = scope,
|
||||
// Keep the mid-turn reconnect window short so `failed rejoin`
|
||||
// surfaces its error well within the test's await budget.
|
||||
midTurnRejoinWindowMs = 3_000L,
|
||||
)
|
||||
client = buildClient()
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -354,6 +492,348 @@ class GatewayChatClientTest {
|
||||
assertTrue(r.preflightFailures.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsolicited assistant turn for resumed session streams without sendTurn`() = runBlocking {
|
||||
val r = Recorder()
|
||||
val registrations = ConcurrentLinkedQueue<String>()
|
||||
val processEvents = ConcurrentLinkedQueue<GatewayProcessEvent>()
|
||||
val processEventLatch = CountDownLatch(1)
|
||||
client.setProcessEventListener {
|
||||
processEvents += it
|
||||
processEventLatch.countDown()
|
||||
}
|
||||
client.setUnsolicitedTurnProvider { storedSessionId ->
|
||||
registrations += storedSessionId
|
||||
GatewayInboundTurnRegistration(
|
||||
callbacks = r.callbacks,
|
||||
onHandle = { true },
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(client.prewarmAwait("stored-session"))
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
|
||||
serverWs.send(harness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "Background task finished.") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Background task finished.") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue("unsolicited turn never completed", r.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertTrue("turn completion did not invalidate process inventory", processEventLatch.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(listOf("stored-session"), registrations.toList())
|
||||
assertEquals(1, r.starts.get())
|
||||
assertEquals(listOf("Background task finished."), r.textDeltas.toList())
|
||||
assertEquals(
|
||||
listOf(GatewayProcessEvent.Invalidated(GatewayProcessEvent.Trigger.MESSAGE_COMPLETE)),
|
||||
processEvents.toList(),
|
||||
)
|
||||
assertFalse(harness.rpcLog.any { it.first == "prompt.submit" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsolicited starts without exact live session are ignored`() = runBlocking {
|
||||
val r = Recorder()
|
||||
val registrations = AtomicInteger(0)
|
||||
client.setUnsolicitedTurnProvider {
|
||||
registrations.incrementAndGet()
|
||||
GatewayInboundTurnRegistration(r.callbacks) { true }
|
||||
}
|
||||
|
||||
assertTrue(client.prewarmAwait("stored-session"))
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
serverWs.send(harness.eventFrame("message.start", null, null))
|
||||
serverWs.send(harness.eventFrame("message.start", null, "someone-else"))
|
||||
|
||||
assertFalse("foreign turn was accepted", r.completeLatch.await(300, TimeUnit.MILLISECONDS))
|
||||
assertEquals(0, registrations.get())
|
||||
assertEquals(0, r.starts.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cold prewarm reports resumed stored session once`() = runBlocking {
|
||||
val resumedSessions = ConcurrentLinkedQueue<String>()
|
||||
val resumedLatch = CountDownLatch(1)
|
||||
client.setColdPrewarmSessionReadyListener { storedSessionId ->
|
||||
resumedSessions += storedSessionId
|
||||
resumedLatch.countDown()
|
||||
}
|
||||
|
||||
assertTrue(client.prewarmAwait("stored-session"))
|
||||
assertTrue("cold resume was not reported", resumedLatch.await(5, TimeUnit.SECONDS))
|
||||
assertTrue(client.prewarmAwait("stored-session"))
|
||||
Thread.sleep(100)
|
||||
|
||||
assertEquals(listOf("stored-session"), resumedSessions.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `newer prewarm selection wins when an older resume completes late`() = runBlocking {
|
||||
harness.suppressAckMethods += "session.resume"
|
||||
|
||||
val old = async(Dispatchers.IO) { client.prewarmAwait("old-session") }
|
||||
val oldAck = harness.awaitPendingAck()
|
||||
|
||||
// Starting the newer request advances the desired-session generation
|
||||
// even though it must wait for the older request's connect mutex.
|
||||
val newer = async(Dispatchers.IO) { client.prewarmAwait("new-session") }
|
||||
delay(100)
|
||||
harness.releaseAck(
|
||||
oldAck,
|
||||
buildJsonObject { put("session_id", "live-old") },
|
||||
)
|
||||
assertFalse(old.await())
|
||||
|
||||
val newerAck = harness.awaitPendingAck()
|
||||
harness.releaseAck(
|
||||
newerAck,
|
||||
buildJsonObject { put("session_id", "live-new") },
|
||||
)
|
||||
assertTrue(newer.await())
|
||||
|
||||
client.listProcesses().getOrThrow()
|
||||
val params = harness.awaitRpc("process.list")
|
||||
assertEquals("live-new", (params["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `same stored session id is resumed again when profile namespace changes`() = runBlocking {
|
||||
var profile = "profile-a"
|
||||
client.sessionProfileProvider = { profile }
|
||||
assertTrue(client.prewarmAwait("same-stored-id"))
|
||||
|
||||
profile = "profile-b"
|
||||
assertTrue(client.prewarmAwait("same-stored-id"))
|
||||
|
||||
val resumes = harness.awaitRpcCount("session.resume", 2)
|
||||
assertEquals(
|
||||
"profile-a",
|
||||
(resumes[0]["profile"] as? JsonPrimitive)?.contentOrNull,
|
||||
)
|
||||
assertEquals(
|
||||
"profile-b",
|
||||
(resumes[1]["profile"] as? JsonPrimitive)?.contentOrNull,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsolicited error clears turn so the next unsolicited response can arrive`() = runBlocking {
|
||||
val recorders = ConcurrentLinkedQueue<Recorder>()
|
||||
client.setUnsolicitedTurnProvider {
|
||||
val recorder = Recorder()
|
||||
recorders += recorder
|
||||
GatewayInboundTurnRegistration(recorder.callbacks) { true }
|
||||
}
|
||||
|
||||
assertTrue(client.prewarmAwait("stored-session"))
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
serverWs.send(harness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"error",
|
||||
buildJsonObject { put("message", "first failed") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
val first = awaitRecorder(recorders, 1)
|
||||
assertTrue(first.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(listOf("first failed"), first.errors.toList())
|
||||
|
||||
serverWs.send(harness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "second worked") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
val second = awaitRecorder(recorders, 2)
|
||||
assertTrue(second.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(listOf("second worked"), second.textDeltas.toList())
|
||||
}
|
||||
|
||||
private fun awaitRecorder(recorders: ConcurrentLinkedQueue<Recorder>, count: Int): Recorder {
|
||||
val deadline = System.currentTimeMillis() + 5_000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (recorders.size >= count) return recorders.elementAt(count - 1)
|
||||
Thread.sleep(20)
|
||||
}
|
||||
error("recorder $count was never registered")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `model options refresh flag rides gateway rpc only on explicit refresh`() = runBlocking {
|
||||
val normal = client.modelOptions().getOrThrow()
|
||||
val normalParams = harness.awaitRpc("model.options")
|
||||
assertEquals("gpt-5.5", normal.currentModel)
|
||||
assertFalse((normalParams["refresh"] as? JsonPrimitive)?.booleanOrNull == true)
|
||||
|
||||
val refreshed = client.modelOptions(refresh = true).getOrThrow()
|
||||
val refreshParams = harness.awaitRpcCount("model.options", 2).last()
|
||||
assertEquals("openai", refreshed.currentProvider)
|
||||
assertTrue((refreshParams["refresh"] as? JsonPrimitive)?.booleanOrNull == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process list uses live session id and parses typed snapshot`() = runBlocking {
|
||||
assertTrue(client.prewarmAwait("stored-session"))
|
||||
|
||||
val processes = client.listProcesses().getOrThrow()
|
||||
|
||||
val params = harness.awaitRpc("process.list")
|
||||
assertEquals("live-resumed", (params["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals(GatewayProcessCapability.Supported, client.processCapability.value)
|
||||
assertEquals(2, processes.size)
|
||||
assertEquals(
|
||||
GatewayProcess(
|
||||
id = "proc-17",
|
||||
command = "./gradlew test",
|
||||
cwd = "/workspace/app",
|
||||
pid = 4812L,
|
||||
startedAt = "2026-07-10T09:30:00",
|
||||
uptimeSeconds = 42L,
|
||||
status = "running",
|
||||
outputPreview = "running tests",
|
||||
outputTail = "running tests\n42 tests completed",
|
||||
notifyOnComplete = true,
|
||||
sessionScoped = true,
|
||||
watchPatterns = listOf("BUILD SUCCESSFUL"),
|
||||
),
|
||||
processes[0],
|
||||
)
|
||||
assertTrue(processes[0].isRunning)
|
||||
assertEquals(1, processes[1].exitCode)
|
||||
assertTrue(processes[1].detached)
|
||||
assertFalse(processes[1].isRunning)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process kill uses exact live session and process id`() = runBlocking {
|
||||
assertTrue(client.prewarmAwait("stored-session"))
|
||||
|
||||
assertTrue(client.killProcess("proc-17").isSuccess)
|
||||
|
||||
val params = harness.awaitRpc("process.kill")
|
||||
assertEquals("live-resumed", (params["session_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals("proc-17", (params["process_id"] as? JsonPrimitive)?.contentOrNull)
|
||||
assertEquals(GatewayProcessCapability.Supported, client.processCapability.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process method not found disables repeat probes for current socket`() = runBlocking {
|
||||
harness.methodNotFound.add("process.list")
|
||||
assertTrue(client.prewarmAwait("stored-session"))
|
||||
|
||||
assertTrue(client.listProcesses().isFailure)
|
||||
assertEquals(GatewayProcessCapability.Unsupported, client.processCapability.value)
|
||||
assertEquals(1, harness.rpcLog.count { it.first == "process.list" })
|
||||
|
||||
assertTrue(client.listProcesses().isFailure)
|
||||
assertEquals(1, harness.rpcLog.count { it.first == "process.list" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `process events bypass active turn gate but require exact live session`() = runBlocking {
|
||||
val events = ConcurrentLinkedQueue<GatewayProcessEvent>()
|
||||
val eventLatch = CountDownLatch(5)
|
||||
client.setProcessEventListener {
|
||||
events += it
|
||||
eventLatch.countDown()
|
||||
}
|
||||
assertTrue(client.prewarmAwait("stored-session"))
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"agent.terminal.output",
|
||||
buildJsonObject { put("process_id", "foreign"); put("chunk", "do not leak") },
|
||||
"someone-else",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"tool.complete",
|
||||
buildJsonObject { put("name", "browser"); put("tool_id", "tool-ignored") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"tool.complete",
|
||||
buildJsonObject { put("name", "terminal"); put("tool_id", "tool-1") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"status.update",
|
||||
buildJsonObject { put("kind", "process"); put("text", "process proc-17 completed") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"agent.terminal.output",
|
||||
buildJsonObject { put("process_id", "proc-17"); put("chunk", "BUILD SUCCESSFUL\n") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"terminal.close",
|
||||
buildJsonObject { put("process_id", "proc-17") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "foreign turn") },
|
||||
"someone-else",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "missing session id") },
|
||||
null,
|
||||
),
|
||||
)
|
||||
// Upstream can omit tool lifecycle events for a background launch;
|
||||
// every exact-session turn completion is therefore a list fallback.
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Started as proc-17") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue("process events were dropped without an active turn", eventLatch.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(
|
||||
listOf(
|
||||
GatewayProcessEvent.Invalidated(GatewayProcessEvent.Trigger.TOOL_COMPLETE),
|
||||
GatewayProcessEvent.Invalidated(GatewayProcessEvent.Trigger.STATUS_UPDATE),
|
||||
GatewayProcessEvent.Output("proc-17", "BUILD SUCCESSFUL\n"),
|
||||
GatewayProcessEvent.TerminalClosed("proc-17"),
|
||||
GatewayProcessEvent.Invalidated(GatewayProcessEvent.Trigger.MESSAGE_COMPLETE),
|
||||
),
|
||||
events.toList(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `foreign session events are dropped`() {
|
||||
val r = Recorder()
|
||||
@@ -990,4 +1470,246 @@ class GatewayChatClientTest {
|
||||
val submit = harness.awaitRpc("prompt.submit")
|
||||
assertFalse(submit.containsKey("truncate_before_user_ordinal"))
|
||||
}
|
||||
|
||||
// --- HRUI-016: long / fire-and-forget prompt.submit ack semantics.
|
||||
// Upstream treats prompt.submit as a long-running RPC (desktop passes a
|
||||
// 30-min PROMPT_SUBMIT_REQUEST_TIMEOUT_MS at every call site) because the
|
||||
// ack can trail a MoA/deep-reasoning/tool-heavy turn by minutes. A short
|
||||
// ack timeout used to preflight-fail into the SSE fallback → the same
|
||||
// prompt ran twice. ---
|
||||
|
||||
@Test
|
||||
fun `slow prompt submit ack outlives the generic rpc timeout without SSE fallback`() {
|
||||
// Shrink the GENERIC rpc timeout below the ack delay: if prompt.submit
|
||||
// (wrongly) rode the generic timeout again, the submit would fail at
|
||||
// 500ms and the preflight fallback would fire — failing this test.
|
||||
rebuildClient(rpcTimeoutMs = 500L)
|
||||
harness.suppressAckMethods.add("prompt.submit")
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "deep thought", null, r.callbacks) { r.preflightFailures += it }
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
val ack = harness.awaitPendingAck()
|
||||
|
||||
// Ack arrives well after the generic rpc timeout would have fired.
|
||||
Thread.sleep(1_500)
|
||||
harness.releaseAck(ack)
|
||||
|
||||
serverWs.send(harness.eventFrame("message.delta", buildJsonObject { put("text", "42") }, "live-1"))
|
||||
serverWs.send(harness.eventFrame("message.complete", buildJsonObject { put("text", "42") }, "live-1"))
|
||||
|
||||
assertTrue("turn never completed", r.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(listOf("42"), r.textDeltas.toList())
|
||||
assertTrue("slow ack must not preflight-fail (duplicate turn)", r.preflightFailures.isEmpty())
|
||||
assertTrue("slow ack must not surface a stream error, got ${r.errors}", r.errors.isEmpty())
|
||||
assertEquals(1, harness.rpcLog.count { it.first == "prompt.submit" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `turn completes when the ack never arrives and the late ack timeout does not fall back`() {
|
||||
// Shrink the SUBMIT timeout so its late failure fires inside the test
|
||||
// budget — after the turn has already completed via stream events.
|
||||
rebuildClient(promptSubmitTimeoutMs = 1_000L)
|
||||
harness.suppressAckMethods.add("prompt.submit")
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "hello", null, r.callbacks) { r.preflightFailures += it }
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
serverWs.send(harness.eventFrame("message.delta", buildJsonObject { put("text", "Hi!") }, "live-1"))
|
||||
serverWs.send(harness.eventFrame("message.complete", buildJsonObject { put("text", "Hi!") }, "live-1"))
|
||||
assertTrue("turn never completed", r.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
|
||||
// Let the shortened ack timeout fire AFTER completion — the late
|
||||
// failure must not resurrect the finished turn on the SSE fallback.
|
||||
Thread.sleep(1_500)
|
||||
assertTrue("late ack timeout fired the SSE fallback (duplicate turn)", r.preflightFailures.isEmpty())
|
||||
assertTrue(r.errors.isEmpty())
|
||||
assertEquals(1, harness.rpcLog.count { it.first == "prompt.submit" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverTurn activates exact live session and continues deltas and tool events`() {
|
||||
harness.recoveryRunning = true
|
||||
harness.recoveryAssistant = "partial answer"
|
||||
val recorder = Recorder()
|
||||
|
||||
val recovery = runBlocking {
|
||||
client.recoverTurn(
|
||||
storedId = "stored-42",
|
||||
preferredLiveId = "live-original",
|
||||
callbacks = recorder.callbacks,
|
||||
).getOrThrow()
|
||||
}
|
||||
|
||||
assertTrue(recovery.running)
|
||||
assertEquals("live-original", recovery.liveSessionId)
|
||||
assertEquals("partial answer", recovery.inflight?.assistant)
|
||||
assertNotNull(recovery.handle)
|
||||
assertEquals(1, harness.rpcLog.count { it.first == "session.activate" })
|
||||
assertEquals(0, harness.rpcLog.count { it.first == "session.resume" })
|
||||
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"reasoning.delta",
|
||||
buildJsonObject { put("text", "still thinking") },
|
||||
"live-original",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"tool.start",
|
||||
buildJsonObject {
|
||||
put("tool_id", "tool-1")
|
||||
put("name", "terminal")
|
||||
},
|
||||
"live-original",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"tool.complete",
|
||||
buildJsonObject {
|
||||
put("tool_id", "tool-1")
|
||||
put("name", "terminal")
|
||||
put("summary", "tests passed")
|
||||
},
|
||||
"live-original",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", " final") },
|
||||
"live-original",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "partial answer final") },
|
||||
"live-original",
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(recorder.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(listOf("still thinking"), recorder.thinkingDeltas.toList())
|
||||
assertEquals(listOf("tool-1" to "terminal"), recorder.toolStarts.toList())
|
||||
assertEquals(listOf("tool-1" to "tests passed"), recorder.toolDone.toList())
|
||||
assertEquals(listOf(" final"), recorder.textDeltas.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverTurn falls back to durable resume when activate is unsupported`() {
|
||||
harness.methodNotFound += "session.activate"
|
||||
harness.recoveryRunning = true
|
||||
val recorder = Recorder()
|
||||
|
||||
val recovery = runBlocking {
|
||||
client.recoverTurn(
|
||||
storedId = "stored-42",
|
||||
preferredLiveId = "expired-live-id",
|
||||
callbacks = recorder.callbacks,
|
||||
).getOrThrow()
|
||||
}
|
||||
|
||||
assertTrue(recovery.running)
|
||||
assertEquals("live-resumed", recovery.liveSessionId)
|
||||
assertNotNull(recovery.handle)
|
||||
assertEquals(1, harness.rpcLog.count { it.first == "session.activate" })
|
||||
assertEquals(1, harness.rpcLog.count { it.first == "session.resume" })
|
||||
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
serverWs.send(
|
||||
harness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "recovered") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
assertTrue(recorder.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertEquals(listOf("recovered"), recorder.textDeltas.toList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverTurn returns no handle for an already-settled session`() {
|
||||
harness.recoveryRunning = false
|
||||
|
||||
val recovery = runBlocking {
|
||||
client.recoverTurn(
|
||||
storedId = "stored-42",
|
||||
preferredLiveId = "live-original",
|
||||
callbacks = Recorder().callbacks,
|
||||
).getOrThrow()
|
||||
}
|
||||
|
||||
assertFalse(recovery.running)
|
||||
assertEquals("idle", recovery.status)
|
||||
assertNull(recovery.handle)
|
||||
assertFalse(client.hasActiveTurn())
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.interrupt" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `detaching recovered handle does not interrupt server turn`() {
|
||||
harness.recoveryRunning = true
|
||||
val recovery = runBlocking {
|
||||
client.recoverTurn(
|
||||
storedId = "stored-42",
|
||||
preferredLiveId = "live-original",
|
||||
callbacks = Recorder().callbacks,
|
||||
).getOrThrow()
|
||||
}
|
||||
|
||||
recovery.handle!!.detach()
|
||||
Thread.sleep(100)
|
||||
|
||||
assertFalse(client.hasActiveTurn())
|
||||
assertTrue(harness.rpcLog.none { it.first == "session.interrupt" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idle watchdog does not fire while events keep arriving slowly`() {
|
||||
rebuildClient(turnIdleTimeoutMs = 1_000L)
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "slow drip", null, r.callbacks) { r.preflightFailures += it }
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
|
||||
// Each event lands inside the (shortened) idle window but the run's
|
||||
// TOTAL wall-clock far exceeds it — an idle-progress watchdog stays
|
||||
// quiet; a hard turn cap would have killed the turn.
|
||||
repeat(8) { i ->
|
||||
serverWs.send(
|
||||
harness.eventFrame("message.delta", buildJsonObject { put("text", "d$i ") }, "live-1"),
|
||||
)
|
||||
Thread.sleep(250)
|
||||
}
|
||||
serverWs.send(harness.eventFrame("message.complete", buildJsonObject { put("text", "done") }, "live-1"))
|
||||
|
||||
assertTrue("turn never completed", r.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertTrue("watchdog fired despite live events: ${r.errors}", r.errors.isEmpty())
|
||||
assertTrue(r.preflightFailures.isEmpty())
|
||||
assertTrue(
|
||||
"watchdog must not have interrupted a live turn",
|
||||
harness.rpcLog.none { it.first == "session.interrupt" },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `idle watchdog fires when events stop flowing`() {
|
||||
rebuildClient(turnIdleTimeoutMs = 500L)
|
||||
val r = Recorder()
|
||||
client.sendTurn(null, "stalls", null, r.callbacks) { r.preflightFailures += it }
|
||||
val serverWs = harness.awaitServerSocket()
|
||||
harness.awaitRpc("prompt.submit")
|
||||
serverWs.send(harness.eventFrame("message.delta", buildJsonObject { put("text", "partial") }, "live-1"))
|
||||
// …then silence: the idle watchdog must fail the turn as a STREAM
|
||||
// error (never a preflight fallback — the turn started server-side)
|
||||
// and interrupt the server so it stops generating.
|
||||
assertTrue("watchdog never fired", r.completeLatch.await(5, TimeUnit.SECONDS))
|
||||
assertTrue("expected a stream error from the watchdog", r.errors.isNotEmpty())
|
||||
assertTrue(r.preflightFailures.isEmpty())
|
||||
harness.awaitRpc("session.interrupt")
|
||||
}
|
||||
}
|
||||
|
||||
+19
-1
@@ -26,6 +26,7 @@ class GatewayEventMapperTest {
|
||||
val subagentEvents = mutableListOf<GatewaySubagentEvent>()
|
||||
val interactions = mutableListOf<GatewayAsk>()
|
||||
val sessionIds = mutableListOf<String>()
|
||||
var starts = 0
|
||||
var turnCompletes = 0
|
||||
var completes = 0
|
||||
var usage: UsageInfo? = null
|
||||
@@ -34,6 +35,7 @@ class GatewayEventMapperTest {
|
||||
|
||||
val callbacks = GatewayTurnCallbacks(
|
||||
onSessionId = { sessionIds += it },
|
||||
onStart = { starts++ },
|
||||
onTextDelta = { textDeltas += it },
|
||||
onThinkingDelta = { thinkingDeltas += it },
|
||||
onToolCallStart = { id, name -> toolStarts += id to name },
|
||||
@@ -52,7 +54,10 @@ class GatewayEventMapperTest {
|
||||
private fun obj(jsonText: String): JsonObject =
|
||||
Json.parseToJsonElement(jsonText) as JsonObject
|
||||
|
||||
private fun mapperWith(recorder: Recorder) = GatewayEventMapper(recorder.callbacks)
|
||||
private fun mapperWith(
|
||||
recorder: Recorder,
|
||||
dedupeAdjacentMessageStarts: Boolean = false,
|
||||
) = GatewayEventMapper(recorder.callbacks, dedupeAdjacentMessageStarts)
|
||||
|
||||
// --- The feature: live thinking ---
|
||||
|
||||
@@ -118,6 +123,19 @@ class GatewayEventMapperTest {
|
||||
assertEquals(0, r.turnCompletes)
|
||||
mapper.onEvent("message.start", null)
|
||||
assertEquals(1, r.turnCompletes)
|
||||
assertEquals(2, r.starts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adjacent duplicate message starts are one boundary`() {
|
||||
val r = Recorder()
|
||||
val mapper = mapperWith(r, dedupeAdjacentMessageStarts = true)
|
||||
|
||||
mapper.onEvent("message.start", null)
|
||||
mapper.onEvent("message.start", null)
|
||||
|
||||
assertEquals(1, r.starts)
|
||||
assertEquals(0, r.turnCompletes)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -18,6 +18,28 @@ import org.junit.Test
|
||||
*/
|
||||
class HermesApiClientTest {
|
||||
|
||||
// --- buildApiRequestOrNull (#131 guard, streaming paths) ---
|
||||
|
||||
@Test
|
||||
fun buildApiRequestOrNull_validUrlsBuildARequest() {
|
||||
assertTrue(buildApiRequestOrNull("http://192.168.1.10:8642/api/sessions/x/chat/stream") != null)
|
||||
assertTrue(buildApiRequestOrNull("https://hermes.example.com/v1/runs") != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildApiRequestOrNull_malformedUrlsReturnNullInsteadOfThrowing() {
|
||||
// Each would make Request.Builder.url(String) throw
|
||||
// IllegalArgumentException on the streaming send path.
|
||||
for (bad in listOf(
|
||||
"http://", // empty host
|
||||
"http://in valid host:8642/v1/runs", // space in host
|
||||
"not-a-url/api/sessions/x/chat/stream", // no scheme (corrupt baseUrl)
|
||||
"/api/sessions/x/chat/stream", // blank baseUrl
|
||||
)) {
|
||||
assertNull("expected null for malformed url '$bad'", buildApiRequestOrNull(bad))
|
||||
}
|
||||
}
|
||||
|
||||
// --- HealthCheckResult sealed interface ---
|
||||
|
||||
@Test
|
||||
|
||||
+322
-6
@@ -1,23 +1,102 @@
|
||||
package com.hermesandroid.relay.network.upstream
|
||||
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.network.upstream.models.CreateSessionRequest
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* HRUI-001 contract tests: the sessions/runs/completions fallback payloads
|
||||
* must contain ONLY fields upstream consumes (plus the documented legacy
|
||||
* hint fields), synthetic phone-local history must land in a supported
|
||||
* channel, and undeliverable attachments must surface explicitly through
|
||||
* [ChatPayloadResult.droppedAttachments] — never a silent drop.
|
||||
*/
|
||||
class HermesChatPayloadsTest {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
// --- fixtures ---
|
||||
|
||||
private val imageAttachment = Attachment(
|
||||
contentType = "image/png",
|
||||
content = "IMGB64",
|
||||
fileName = "shot.png",
|
||||
)
|
||||
private val pdfAttachment = Attachment(
|
||||
contentType = "application/pdf",
|
||||
content = "PDFB64",
|
||||
fileName = "doc.pdf",
|
||||
)
|
||||
|
||||
/** OpenAI-format assistant tool-call + tool-result pair (voice intent / card dispatch shape). */
|
||||
private fun toolCallPair(
|
||||
callId: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
result: String,
|
||||
): List<JsonObject> = listOf(
|
||||
buildJsonObject {
|
||||
put("role", "assistant")
|
||||
put("content", "")
|
||||
putJsonArray("tool_calls") {
|
||||
add(buildJsonObject {
|
||||
put("id", callId)
|
||||
put("type", "function")
|
||||
putJsonObject("function") {
|
||||
put("name", name)
|
||||
put("arguments", arguments)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
buildJsonObject {
|
||||
put("role", "tool")
|
||||
put("tool_call_id", callId)
|
||||
put("content", result)
|
||||
},
|
||||
)
|
||||
|
||||
/** Plain text turn (realtime voice sync shape). */
|
||||
private fun plainTurn(role: String, content: String): JsonObject = buildJsonObject {
|
||||
put("role", role)
|
||||
put("content", content)
|
||||
}
|
||||
|
||||
private fun syntheticArray(entries: List<JsonObject>): JsonArray = buildJsonArray {
|
||||
entries.forEach { add(it) }
|
||||
}
|
||||
|
||||
private val voiceIntentPair = toolCallPair(
|
||||
callId = "call_voiceintent_1",
|
||||
name = "android_open_app",
|
||||
arguments = """{"app_name":"Chrome"}""",
|
||||
result = """{"ok":true,"package":"com.android.chrome"}""",
|
||||
)
|
||||
private val realtimeTurns = listOf(
|
||||
plainTurn("user", "what's the capital of France?"),
|
||||
plainTurn("assistant", "Paris."),
|
||||
)
|
||||
|
||||
// --- CreateSessionRequest (unchanged surface) ---
|
||||
|
||||
@Test
|
||||
fun createSessionRequest_serializesProfileWhenExplicitlySelected() {
|
||||
val body = json.encodeToString(
|
||||
@@ -34,6 +113,8 @@ class HermesChatPayloadsTest {
|
||||
assertEquals("mizu", parsed["profile"]?.jsonPrimitive?.contentOrNull)
|
||||
}
|
||||
|
||||
// --- sessions payload ---
|
||||
|
||||
@Test
|
||||
fun sessionChatPayload_includesProfileModelAndSystemMessage() {
|
||||
val payload = buildSessionChatStreamPayload(
|
||||
@@ -41,7 +122,7 @@ class HermesChatPayloadsTest {
|
||||
systemMessage = "You are Mizu.",
|
||||
modelOverride = "grok-mizu",
|
||||
profileName = "mizu",
|
||||
)
|
||||
).payload
|
||||
|
||||
assertEquals("hello", payload["message"]?.jsonPrimitive?.contentOrNull)
|
||||
assertEquals("You are Mizu.", payload["system_message"]?.jsonPrimitive?.contentOrNull)
|
||||
@@ -54,28 +135,167 @@ class HermesChatPayloadsTest {
|
||||
val payload = buildSessionChatStreamPayload(
|
||||
message = "hello",
|
||||
profileName = null,
|
||||
)
|
||||
).payload
|
||||
|
||||
assertFalse(payload.containsKey("profile"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runPayload_includesProfileAlongsideFallbackModelAndSystemMessage() {
|
||||
fun sessionChatPayload_sendsOnlyUpstreamContractFields() {
|
||||
val payload = buildSessionChatStreamPayload(
|
||||
message = "hello",
|
||||
systemMessage = "sys",
|
||||
attachments = listOf(imageAttachment, pdfAttachment),
|
||||
voiceIntentMessages = syntheticArray(voiceIntentPair + realtimeTurns),
|
||||
modelOverride = "grok-mizu",
|
||||
profileName = "mizu",
|
||||
).payload
|
||||
|
||||
// Upstream parses message + system_message; model + profile are
|
||||
// documented legacy hints. Nothing else may go on the wire —
|
||||
// in particular no top-level `messages` or `attachments`.
|
||||
assertEquals(
|
||||
setOf("message", "system_message", "model", "profile"),
|
||||
payload.keys,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionChatPayload_foldsSyntheticHistoryIntoSystemMessage() {
|
||||
val payload = buildSessionChatStreamPayload(
|
||||
message = "hello",
|
||||
systemMessage = "You are Mizu.",
|
||||
voiceIntentMessages = syntheticArray(voiceIntentPair + realtimeTurns),
|
||||
).payload
|
||||
|
||||
val system = payload["system_message"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
// Caller's per-turn system message stays first.
|
||||
assertTrue(system.startsWith("You are Mizu."))
|
||||
assertTrue(system.contains(SYNTHETIC_DIGEST_HEADER))
|
||||
// Tool-call pair renders name + arguments + result on one line.
|
||||
assertTrue(
|
||||
system.contains(
|
||||
"""- called android_open_app with {"app_name":"Chrome"} -> {"ok":true,"package":"com.android.chrome"}""",
|
||||
),
|
||||
)
|
||||
// Sessions has no history channel, so plain turns join the digest.
|
||||
assertTrue(system.contains("- user: what's the capital of France?"))
|
||||
assertTrue(system.contains("- assistant: Paris."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionChatPayload_digestAloneWhenNoSystemMessage() {
|
||||
val payload = buildSessionChatStreamPayload(
|
||||
message = "hello",
|
||||
voiceIntentMessages = syntheticArray(voiceIntentPair),
|
||||
).payload
|
||||
|
||||
val system = payload["system_message"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
assertTrue(system.startsWith(SYNTHETIC_DIGEST_HEADER))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionChatPayload_reportsAllAttachmentsDropped() {
|
||||
val result = buildSessionChatStreamPayload(
|
||||
message = "hello",
|
||||
attachments = listOf(imageAttachment, pdfAttachment),
|
||||
)
|
||||
|
||||
assertEquals(listOf(imageAttachment, pdfAttachment), result.droppedAttachments)
|
||||
assertFalse(result.payload.containsKey("attachments"))
|
||||
}
|
||||
|
||||
// --- runs payload ---
|
||||
|
||||
@Test
|
||||
fun runPayload_includesProfileAlongsideFallbackModelAndInstructions() {
|
||||
val payload = buildRunStreamPayload(
|
||||
message = "hello",
|
||||
model = "default-model",
|
||||
systemMessage = "You are Coder.",
|
||||
modelOverride = "grok-coder",
|
||||
profileName = "coder",
|
||||
)
|
||||
).payload
|
||||
|
||||
assertEquals("hello", payload["input"]?.jsonPrimitive?.contentOrNull)
|
||||
assertEquals("grok-coder", payload["model"]?.jsonPrimitive?.contentOrNull)
|
||||
assertEquals("You are Coder.", payload["system_message"]?.jsonPrimitive?.contentOrNull)
|
||||
// Upstream's runs handler reads `instructions`, not `system_message`.
|
||||
assertEquals("You are Coder.", payload["instructions"]?.jsonPrimitive?.contentOrNull)
|
||||
assertFalse(payload.containsKey("system_message"))
|
||||
assertEquals("coder", payload["profile"]?.jsonPrimitive?.contentOrNull)
|
||||
assertTrue(payload["stream"]?.jsonPrimitive?.boolean == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runPayload_sendsOnlyUpstreamContractFields() {
|
||||
val payload = buildRunStreamPayload(
|
||||
message = "hello",
|
||||
systemMessage = "sys",
|
||||
attachments = listOf(imageAttachment, pdfAttachment),
|
||||
voiceIntentMessages = syntheticArray(voiceIntentPair + realtimeTurns),
|
||||
modelOverride = "grok-coder",
|
||||
profileName = "coder",
|
||||
).payload
|
||||
|
||||
assertEquals(
|
||||
setOf("model", "input", "stream", "instructions", "conversation_history", "profile"),
|
||||
payload.keys,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runPayload_splicesPlainTurnsIntoConversationHistoryAndDigestsToolPairs() {
|
||||
val payload = buildRunStreamPayload(
|
||||
message = "hello",
|
||||
voiceIntentMessages = syntheticArray(voiceIntentPair + realtimeTurns),
|
||||
).payload
|
||||
|
||||
// Plain realtime turns ride the upstream-parsed history channel verbatim.
|
||||
val history = payload["conversation_history"]!!.jsonArray
|
||||
assertEquals(2, history.size)
|
||||
assertEquals("user", history[0].jsonObject["role"]?.jsonPrimitive?.contentOrNull)
|
||||
assertEquals(
|
||||
"what's the capital of France?",
|
||||
history[0].jsonObject["content"]?.jsonPrimitive?.contentOrNull,
|
||||
)
|
||||
assertEquals("assistant", history[1].jsonObject["role"]?.jsonPrimitive?.contentOrNull)
|
||||
assertEquals("Paris.", history[1].jsonObject["content"]?.jsonPrimitive?.contentOrNull)
|
||||
|
||||
// Tool-call pairs go through the instructions digest — and only them
|
||||
// (plain turns must not be delivered twice).
|
||||
val instructions = payload["instructions"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
assertTrue(instructions.contains("- called android_open_app"))
|
||||
assertFalse(instructions.contains("- user:"))
|
||||
assertFalse(instructions.contains("- assistant:"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runPayload_omitsConversationHistoryWhenOnlyToolPairs() {
|
||||
val payload = buildRunStreamPayload(
|
||||
message = "hello",
|
||||
voiceIntentMessages = syntheticArray(voiceIntentPair),
|
||||
).payload
|
||||
|
||||
assertFalse(payload.containsKey("conversation_history"))
|
||||
assertTrue(
|
||||
payload["instructions"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
.contains("- called android_open_app"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runPayload_reportsAllAttachmentsDropped() {
|
||||
val result = buildRunStreamPayload(
|
||||
message = "hello",
|
||||
attachments = listOf(imageAttachment, pdfAttachment),
|
||||
)
|
||||
|
||||
assertEquals(listOf(imageAttachment, pdfAttachment), result.droppedAttachments)
|
||||
assertFalse(result.payload.containsKey("attachments"))
|
||||
}
|
||||
|
||||
// --- completions payload ---
|
||||
|
||||
@Test
|
||||
fun chatCompletionsPayload_usesOpenAiMessagesAndSseStream() {
|
||||
val payload = buildChatCompletionsStreamPayload(
|
||||
@@ -84,7 +304,7 @@ class HermesChatPayloadsTest {
|
||||
systemMessage = "You are Coder.",
|
||||
modelOverride = "grok-coder",
|
||||
profileName = "coder",
|
||||
)
|
||||
).payload
|
||||
|
||||
assertEquals("grok-coder", payload["model"]?.jsonPrimitive?.contentOrNull)
|
||||
assertEquals("coder", payload["profile"]?.jsonPrimitive?.contentOrNull)
|
||||
@@ -97,4 +317,100 @@ class HermesChatPayloadsTest {
|
||||
assertEquals("user", messages[1].jsonObject["role"]?.jsonPrimitive?.contentOrNull)
|
||||
assertEquals("hello", messages[1].jsonObject["content"]?.jsonPrimitive?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatCompletionsPayload_inlinesImageAttachmentsAsImageUrlParts() {
|
||||
val result = buildChatCompletionsStreamPayload(
|
||||
message = "what is this?",
|
||||
attachments = listOf(imageAttachment),
|
||||
)
|
||||
|
||||
val messages = result.payload["messages"]!!.jsonArray
|
||||
val userContent = messages.last().jsonObject["content"]!!.jsonArray
|
||||
assertEquals("text", userContent[0].jsonObject["type"]?.jsonPrimitive?.contentOrNull)
|
||||
assertEquals("what is this?", userContent[0].jsonObject["text"]?.jsonPrimitive?.contentOrNull)
|
||||
assertEquals("image_url", userContent[1].jsonObject["type"]?.jsonPrimitive?.contentOrNull)
|
||||
assertEquals(
|
||||
"data:image/png;base64,IMGB64",
|
||||
userContent[1].jsonObject["image_url"]?.jsonObject?.get("url")?.jsonPrimitive?.contentOrNull,
|
||||
)
|
||||
// Images have a real channel here — nothing dropped.
|
||||
assertTrue(result.droppedAttachments.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatCompletionsPayload_splicesPlainTurnsAndDigestsToolPairs() {
|
||||
val payload = buildChatCompletionsStreamPayload(
|
||||
message = "hello",
|
||||
voiceIntentMessages = syntheticArray(voiceIntentPair + realtimeTurns),
|
||||
).payload
|
||||
|
||||
val messages = payload["messages"]!!.jsonArray
|
||||
val roles = messages.map { it.jsonObject["role"]?.jsonPrimitive?.contentOrNull }
|
||||
// system digest + spliced realtime turns + live user message; the
|
||||
// tool-call pair must NOT be spliced (upstream skips tool-role
|
||||
// messages and strips tool_calls, destroying the record).
|
||||
assertEquals(listOf("system", "user", "assistant", "user"), roles)
|
||||
assertFalse(messages.any { it.jsonObject.containsKey("tool_calls") })
|
||||
|
||||
val system = messages[0].jsonObject["content"]?.jsonPrimitive?.contentOrNull.orEmpty()
|
||||
assertTrue(system.contains("- called android_open_app"))
|
||||
assertEquals(
|
||||
"what's the capital of France?",
|
||||
messages[1].jsonObject["content"]?.jsonPrimitive?.contentOrNull,
|
||||
)
|
||||
assertEquals("Paris.", messages[2].jsonObject["content"]?.jsonPrimitive?.contentOrNull)
|
||||
assertEquals("hello", messages[3].jsonObject["content"]?.jsonPrimitive?.contentOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatCompletionsPayload_dropsOnlyNonImageAttachments() {
|
||||
val result = buildChatCompletionsStreamPayload(
|
||||
message = "hello",
|
||||
attachments = listOf(imageAttachment, pdfAttachment),
|
||||
)
|
||||
|
||||
assertEquals(listOf(pdfAttachment), result.droppedAttachments)
|
||||
// The dead top-level `attachments` field is gone for good.
|
||||
assertFalse(result.payload.containsKey("attachments"))
|
||||
}
|
||||
|
||||
// --- digest helper edge cases ---
|
||||
|
||||
@Test
|
||||
fun renderSyntheticHistoryDigest_nullWhenNothingRenders() {
|
||||
assertNull(renderSyntheticHistoryDigest(null, includePlainTurns = true))
|
||||
assertNull(renderSyntheticHistoryDigest(buildJsonArray {}, includePlainTurns = true))
|
||||
// Plain turns excluded (endpoints with a real history channel) and
|
||||
// no tool pairs present -> nothing to fold into the prompt.
|
||||
assertNull(
|
||||
renderSyntheticHistoryDigest(
|
||||
syntheticArray(realtimeTurns),
|
||||
includePlainTurns = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renderSyntheticHistoryDigest_pairsResultsByToolCallId() {
|
||||
val pairA = toolCallPair("call_a", "android_open_app", """{"app_name":"Maps"}""", """{"ok":true}""")
|
||||
val pairB = toolCallPair(
|
||||
"call_b",
|
||||
"hermes_card_action",
|
||||
"""{"card_key":"k","action_value":"/approve"}""",
|
||||
"""{"ok":true,"dispatched_at":1}""",
|
||||
)
|
||||
val digest = renderSyntheticHistoryDigest(
|
||||
syntheticArray(pairA + pairB),
|
||||
includePlainTurns = false,
|
||||
).orEmpty()
|
||||
|
||||
assertTrue(digest.startsWith(SYNTHETIC_DIGEST_HEADER))
|
||||
assertTrue(digest.contains("""- called android_open_app with {"app_name":"Maps"} -> {"ok":true}"""))
|
||||
assertTrue(
|
||||
digest.contains(
|
||||
"""- called hermes_card_action with {"card_key":"k","action_value":"/approve"} -> {"ok":true,"dispatched_at":1}""",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package com.hermesandroid.relay.notifications
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class NotificationTriggerStoreTest {
|
||||
|
||||
@Test
|
||||
fun disabledByDefaultDoesNotMatch() = runBlocking {
|
||||
val store = NotificationTriggerStore(InMemoryPreferencesDataStore())
|
||||
store.saveSingleRule(
|
||||
NotificationTriggerRule(appPackage = "com.slack"),
|
||||
)
|
||||
|
||||
assertNull(store.firstMatchingRule(entry(packageName = "com.slack")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun matchesEnabledRuleByAppAndTextFilter() = runBlocking {
|
||||
val store = NotificationTriggerStore(InMemoryPreferencesDataStore())
|
||||
store.setMasterEnabled(true)
|
||||
store.saveSingleRule(
|
||||
NotificationTriggerRule(
|
||||
label = "Slack from Sam",
|
||||
appPackage = "com.slack",
|
||||
textContains = "Sam",
|
||||
),
|
||||
)
|
||||
|
||||
assertNotNull(
|
||||
store.firstMatchingRule(
|
||||
entry(
|
||||
packageName = "com.slack",
|
||||
title = "Axiom",
|
||||
text = "Sam: deploy finished",
|
||||
),
|
||||
),
|
||||
)
|
||||
assertNull(
|
||||
store.firstMatchingRule(
|
||||
entry(
|
||||
packageName = "com.slack",
|
||||
title = "Axiom",
|
||||
text = "Alex: deploy finished",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun killSwitchBlocksMatchesWithoutDeletingRule() = runBlocking {
|
||||
val store = NotificationTriggerStore(InMemoryPreferencesDataStore())
|
||||
store.setMasterEnabled(true)
|
||||
store.saveSingleRule(NotificationTriggerRule(appPackage = "com.slack"))
|
||||
store.setKillSwitch(true)
|
||||
|
||||
assertNull(store.firstMatchingRule(entry(packageName = "com.slack")))
|
||||
assertEquals(1, store.settings.first().rules.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyFilterRuleNeverMatches() {
|
||||
val rule = NotificationTriggerRule(
|
||||
appPackage = " ",
|
||||
titleContains = "",
|
||||
textContains = null,
|
||||
)
|
||||
|
||||
assertEquals(false, rule.matches(entry(packageName = "com.any")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activityLogIsNewestFirstAndCapped() = runBlocking {
|
||||
val store = NotificationTriggerStore(InMemoryPreferencesDataStore())
|
||||
|
||||
repeat(NotificationTriggerStore.MAX_ACTIVITY_LOG_ENTRIES + 2) { idx ->
|
||||
store.appendActivity(
|
||||
NotificationTriggerActivityEntry(
|
||||
ruleId = "rule",
|
||||
ruleLabel = "Rule",
|
||||
action = NotificationTriggerAction.AskMe,
|
||||
packageName = "pkg.$idx",
|
||||
matchedAt = idx.toLong(),
|
||||
result = "prompt posted",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val log = store.settings.first().activityLog
|
||||
assertEquals(NotificationTriggerStore.MAX_ACTIVITY_LOG_ENTRIES, log.size)
|
||||
assertEquals("pkg.${NotificationTriggerStore.MAX_ACTIVITY_LOG_ENTRIES + 1}", log.first().packageName)
|
||||
}
|
||||
|
||||
private fun entry(
|
||||
packageName: String,
|
||||
title: String? = "Title",
|
||||
text: String? = "Text",
|
||||
subText: String? = null,
|
||||
) = NotificationEntry(
|
||||
packageName = packageName,
|
||||
title = title,
|
||||
text = text,
|
||||
subText = subText,
|
||||
postedAt = 123L,
|
||||
key = "$packageName:key",
|
||||
)
|
||||
|
||||
private class InMemoryPreferencesDataStore : DataStore<Preferences> {
|
||||
private val state = MutableStateFlow<Preferences>(emptyPreferences())
|
||||
|
||||
override val data: Flow<Preferences> = state
|
||||
|
||||
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
|
||||
val next = transform(state.value)
|
||||
state.value = next
|
||||
return next
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.hermesandroid.relay.screenshots
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Forum
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.unit.Density
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.hermesandroid.relay.ui.onboarding.OnboardingPage
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Regression harness for issue #145 — onboarding slide content overflowed
|
||||
* below the fold with no scroll affordance on short viewports / raised font
|
||||
* scale. Renders [OnboardingPage] the way the pager hosts it (a centering
|
||||
* fillMaxSize Box) at compact heights and a raised font scale, then scrolls
|
||||
* to the last body line to prove every line is reachable.
|
||||
*
|
||||
* Render-success + reachability assertions only — no golden PNGs are
|
||||
* committed; the store screenshot set is untouched.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w320dp-h480dp-xhdpi")
|
||||
class OnboardingCompactScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private val lastLine = "Final onboarding body line for reachability"
|
||||
|
||||
/** Mirrors the pager's page container: fillMaxSize Box, centered content. */
|
||||
@Composable
|
||||
private fun PagerHostedSlide(fontScale: Float) {
|
||||
val base = LocalDensity.current
|
||||
CompositionLocalProvider(
|
||||
LocalDensity provides Density(base.density, fontScale = fontScale)
|
||||
) {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
OnboardingPage(
|
||||
icon = Icons.Outlined.Forum,
|
||||
title = "Chat",
|
||||
description = "Your Hermes agent, streaming in real time.",
|
||||
) {
|
||||
Text(
|
||||
text = "Live responses with tool progress, markdown, and rich cards as the agent works.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
text = "Switch agent profiles mid-flow — each keeps its own sessions, model, and persona.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
text = lastLine,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 480dp-tall viewport at 1.5x font scale — the hero shrinks/drops and the
|
||||
// body overflows; the page must scroll so the last line stays reachable.
|
||||
@Test
|
||||
fun compactHeight_largeFontScale_scrollsToLastLine() {
|
||||
compose.setContent { PagerHostedSlide(fontScale = 1.5f) }
|
||||
compose.onNodeWithText(lastLine).performScrollTo().assertExists()
|
||||
compose.onRoot().captureRoboImage("build/onboarding-shots/compact_480dp_font1_5.png")
|
||||
}
|
||||
|
||||
// 600dp-tall viewport — the 160dp shrunk-hero tier; content should render
|
||||
// (and remain scroll-reachable) without dropping the hero entirely.
|
||||
@Test
|
||||
@Config(qualifiers = "w360dp-h600dp-xhdpi")
|
||||
fun mediumHeight_defaultFontScale_rendersShrunkHero() {
|
||||
compose.setContent { PagerHostedSlide(fontScale = 1.0f) }
|
||||
compose.onNodeWithText(lastLine).performScrollTo().assertExists()
|
||||
compose.onRoot().captureRoboImage("build/onboarding-shots/medium_600dp.png")
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import com.hermesandroid.relay.data.AttachmentState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class AttachmentGalleryLayoutTest {
|
||||
|
||||
@Test
|
||||
fun `one image keeps the existing single attachment renderer`() {
|
||||
val items = attachmentLayoutItems(listOf(image("one.png")))
|
||||
|
||||
assertEquals(listOf(AttachmentLayoutItem.Single(0)), items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two loaded images collapse into one gallery`() {
|
||||
val items = attachmentLayoutItems(
|
||||
listOf(image("one.png"), image("two.jpg")),
|
||||
)
|
||||
|
||||
assertEquals(listOf(AttachmentLayoutItem.Gallery(listOf(0, 1))), items)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `files split image runs so mixed attachment order is preserved`() {
|
||||
val items = attachmentLayoutItems(
|
||||
listOf(
|
||||
file("notes.pdf", "application/pdf"),
|
||||
image("one.png"),
|
||||
file("readme.txt", "text/plain"),
|
||||
image("two.jpg"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
AttachmentLayoutItem.Single(0),
|
||||
AttachmentLayoutItem.Single(1),
|
||||
AttachmentLayoutItem.Single(2),
|
||||
AttachmentLayoutItem.Single(3),
|
||||
),
|
||||
items,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading and failed images split runs and remain retryable standalone cards`() {
|
||||
val items = attachmentLayoutItems(
|
||||
listOf(
|
||||
image("ready.png"),
|
||||
image("loading.png", AttachmentState.LOADING),
|
||||
image("failed.png", AttachmentState.FAILED),
|
||||
image("ready-too.png"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
AttachmentLayoutItem.Single(0),
|
||||
AttachmentLayoutItem.Single(1),
|
||||
AttachmentLayoutItem.Single(2),
|
||||
AttachmentLayoutItem.Single(3),
|
||||
),
|
||||
items,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gallery rows use two columns and leave an odd final image spanning the row`() {
|
||||
assertEquals(listOf(listOf(0, 1)), galleryRows(2))
|
||||
assertEquals(listOf(listOf(0, 1), listOf(2)), galleryRows(3))
|
||||
assertEquals(listOf(listOf(0, 1), listOf(2, 3)), galleryRows(4))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only four bounded thumbnails are composed for a large gallery`() {
|
||||
assertEquals(listOf(0, 1, 2, 3), galleryPreviewIndices(12))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `separate contiguous image runs become separate galleries`() {
|
||||
val items = attachmentLayoutItems(
|
||||
listOf(
|
||||
image("one.png"),
|
||||
image("two.png"),
|
||||
file("notes.pdf", "application/pdf"),
|
||||
image("three.png"),
|
||||
image("four.png"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
AttachmentLayoutItem.Gallery(listOf(0, 1)),
|
||||
AttachmentLayoutItem.Single(2),
|
||||
AttachmentLayoutItem.Gallery(listOf(3, 4)),
|
||||
),
|
||||
items,
|
||||
)
|
||||
}
|
||||
|
||||
private fun image(
|
||||
name: String,
|
||||
state: AttachmentState = AttachmentState.LOADED,
|
||||
) = Attachment(
|
||||
contentType = if (name.endsWith(".jpg")) "image/jpeg" else "image/png",
|
||||
content = "bytes",
|
||||
fileName = name,
|
||||
state = state,
|
||||
)
|
||||
|
||||
private fun file(name: String, mime: String) = Attachment(
|
||||
contentType = mime,
|
||||
content = "bytes",
|
||||
fileName = name,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeLeft
|
||||
import com.hermesandroid.relay.data.Attachment
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = "w360dp-h720dp-xhdpi")
|
||||
class AttachmentGalleryUiTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `tile opens its page and the viewer swipes across the image group`() {
|
||||
val attachments = listOf("one.png", "two.png", "three.png").map { name ->
|
||||
Attachment(
|
||||
contentType = "image/png",
|
||||
content = ONE_PIXEL_PNG,
|
||||
fileName = name,
|
||||
)
|
||||
}
|
||||
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
AttachmentGallery(attachments = attachments)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithContentDescription("3 image gallery").assertExists()
|
||||
compose.onNodeWithTag("attachment-gallery-tile-0").performClick()
|
||||
compose.onNodeWithText("1 / 3").assertExists()
|
||||
|
||||
compose.onNodeWithTag("attachment-gallery-pager").performTouchInput { swipeLeft() }
|
||||
compose.waitUntil(timeoutMillis = 5_000) {
|
||||
compose.onAllNodesWithText("2 / 3").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
compose.onNodeWithText("2 / 3").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapped tile opens the matching page`() {
|
||||
val attachments = listOf("one.png", "two.png", "three.png").map { name ->
|
||||
Attachment(
|
||||
contentType = "image/png",
|
||||
content = ONE_PIXEL_PNG,
|
||||
fileName = name,
|
||||
)
|
||||
}
|
||||
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
AttachmentGallery(attachments = attachments)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag("attachment-gallery-tile-1").performClick()
|
||||
compose.waitUntil(timeoutMillis = 5_000) {
|
||||
compose.onAllNodesWithText("2 / 3").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
compose.onNodeWithText("2 / 3").assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sensitive page actions stay disabled until that page is revealed`() {
|
||||
val attachments = listOf(
|
||||
Attachment(
|
||||
contentType = "image/png",
|
||||
content = ONE_PIXEL_PNG,
|
||||
fileName = "safe.png",
|
||||
),
|
||||
Attachment(
|
||||
contentType = "image/png",
|
||||
content = ONE_PIXEL_PNG,
|
||||
fileName = "sensitive.png",
|
||||
sensitive = true,
|
||||
),
|
||||
)
|
||||
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
AttachmentGallery(attachments = attachments)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag("attachment-gallery-tile-0").performClick()
|
||||
compose.onNodeWithTag("attachment-gallery-pager").performTouchInput { swipeLeft() }
|
||||
compose.waitUntil(timeoutMillis = 5_000) {
|
||||
compose.onAllNodesWithText("2 / 2").fetchSemanticsNodes().isNotEmpty()
|
||||
}
|
||||
compose.onNodeWithContentDescription("Share").assertIsNotEnabled()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ONE_PIXEL_PNG =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import com.hermesandroid.relay.data.BackgroundTaskPhase
|
||||
import com.hermesandroid.relay.data.BackgroundTaskState
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class BackgroundTaskCardTest {
|
||||
|
||||
@Test
|
||||
fun phaseLabelsUseSharedTaskVocabulary() {
|
||||
assertEquals("Working", backgroundTaskPhaseLabel(BackgroundTaskPhase.RUNNING))
|
||||
assertEquals("Needs input", backgroundTaskPhaseLabel(BackgroundTaskPhase.WAITING))
|
||||
assertEquals("Delivering", backgroundTaskPhaseLabel(BackgroundTaskPhase.DELIVERING))
|
||||
assertEquals("Complete", backgroundTaskPhaseLabel(BackgroundTaskPhase.COMPLETE))
|
||||
assertEquals("Failed", backgroundTaskPhaseLabel(BackgroundTaskPhase.FAILED))
|
||||
assertEquals("Cancelled", backgroundTaskPhaseLabel(BackgroundTaskPhase.CANCELLED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun metaUsesLargestCompletedCountAndQueuedDepth() {
|
||||
val task = BackgroundTaskState(
|
||||
id = "run-1",
|
||||
title = "Check release",
|
||||
completedToolCount = 1,
|
||||
queuedCount = 2,
|
||||
)
|
||||
val calls = listOf(
|
||||
ToolCall(name = "one", args = null, result = null, success = true, isComplete = true),
|
||||
ToolCall(name = "two", args = null, result = null, success = true, isComplete = true),
|
||||
)
|
||||
|
||||
assertEquals("2 steps · +2 queued", backgroundTaskMeta(task, calls))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DotMatrixIndicatorTest {
|
||||
|
||||
@Test
|
||||
fun animationRunsOnlyWhenEveryMotionGateAllowsIt() {
|
||||
assertTrue(
|
||||
shouldAnimateDotMatrix(
|
||||
appAnimationsEnabled = true,
|
||||
osAnimationsEnabled = true,
|
||||
touchExplorationEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appAnimationPreferenceCanParkTheIndicator() {
|
||||
assertFalse(
|
||||
shouldAnimateDotMatrix(
|
||||
appAnimationsEnabled = false,
|
||||
osAnimationsEnabled = true,
|
||||
touchExplorationEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun systemReduceMotionCanParkTheIndicator() {
|
||||
assertFalse(
|
||||
shouldAnimateDotMatrix(
|
||||
appAnimationsEnabled = true,
|
||||
osAnimationsEnabled = false,
|
||||
touchExplorationEnabled = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun talkBackTouchExplorationCanParkTheIndicator() {
|
||||
assertFalse(
|
||||
shouldAnimateDotMatrix(
|
||||
appAnimationsEnabled = true,
|
||||
osAnimationsEnabled = true,
|
||||
touchExplorationEnabled = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class GatewayBackgroundProcessesTest {
|
||||
@Test
|
||||
fun `elapsed time stays compact from seconds through hours`() {
|
||||
assertEquals("0s", formatElapsed(-5))
|
||||
assertEquals("42s", formatElapsed(42))
|
||||
assertEquals("2m 5s", formatElapsed(125))
|
||||
assertEquals("1h 2m", formatElapsed(3_725))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal output strips ansi control and normalizes progress carriage returns`() {
|
||||
val raw =
|
||||
"\u001B[31mFAIL\u001B[0m\r50%\r100%\u001B]0;secret title\u0007\n" +
|
||||
"ok\t!\u0000"
|
||||
|
||||
assertEquals("FAIL\n50%\n100%\nok\t!", sanitizeTerminalText(raw))
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MarkdownStreamingParserTest {
|
||||
|
||||
@Test
|
||||
fun activeParagraph_remainsRawUntilAStableBlockBoundary() {
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text("A paragraph still arriving")),
|
||||
parseStreamingMarkdownBlocks("A paragraph still arriving"),
|
||||
)
|
||||
|
||||
// A single newline is a Markdown soft break, not a stable block split.
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text("line one\nline two")),
|
||||
parseStreamingMarkdownBlocks("line one\nline two"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankLine_promotesSettledPrefixToRealMarkdown() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
StreamingMarkdownBlock.Markdown("## Stable heading"),
|
||||
StreamingMarkdownBlock.Text("- one\n- two\n\nTail still arriving"),
|
||||
),
|
||||
parseStreamingMarkdownBlocks(
|
||||
"## Stable heading\n\n- one\n- two\n\nTail still arriving",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openFence_keepsBlankLinesInsideTheActiveCodeBlock() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
StreamingMarkdownBlock.Markdown("Intro"),
|
||||
StreamingMarkdownBlock.Code(
|
||||
language = "kotlin",
|
||||
code = "val first = 1\n\nval second = 2",
|
||||
),
|
||||
),
|
||||
parseStreamingMarkdownBlocks(
|
||||
"Intro\n\n```kotlin\nval first = 1\n\nval second = 2",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closedFence_staysOnTheStreamingCodeSurfaceUntilFinal() {
|
||||
val blocks = parseStreamingMarkdownBlocks(
|
||||
"```kotlin\nval answer = 42\n```\n\nNext paragraph",
|
||||
)
|
||||
|
||||
assertEquals(2, blocks.size)
|
||||
assertEquals(
|
||||
StreamingMarkdownBlock.Code(
|
||||
language = "kotlin",
|
||||
code = "val answer = 42",
|
||||
),
|
||||
blocks[0],
|
||||
)
|
||||
assertEquals(StreamingMarkdownBlock.Text("Next paragraph"), blocks[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun longerFence_isNotClosedByShorterFenceInsideCode() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
StreamingMarkdownBlock.Code(
|
||||
language = "markdown",
|
||||
code = "```\ninside\n```",
|
||||
),
|
||||
),
|
||||
parseStreamingMarkdownBlocks(
|
||||
"````markdown\n```\ninside\n```\n````",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun table_staysRawUntilTheFinalCommonMarkParse() {
|
||||
val content = "| Name | Value |\n| --- | --- |\n| Alpha | 1 |\n| Beta |"
|
||||
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text(content)),
|
||||
parseStreamingMarkdownBlocks(content),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun incompleteTableDelimiter_doesNotPrematurelyPromoteTheTable() {
|
||||
val content = "| Name | Value |\n| --- | --"
|
||||
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text(content)),
|
||||
parseStreamingMarkdownBlocks(content),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun escapedHeaderPipe_doesNotMakeTheTableLookSettled() {
|
||||
val content = "| Name \\| alias | Value |\n| --- | --- |\n| Alpha | 1 |\n| Beta |"
|
||||
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text(content)),
|
||||
parseStreamingMarkdownBlocks(content),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listAndContinuation_stayTogetherUntilFinal() {
|
||||
val content = "- first paragraph\n\n continuation\n- second"
|
||||
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text(content)),
|
||||
parseStreamingMarkdownBlocks(content),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lazyBlockQuoteContinuation_isNeverSplitIntoASettledPrefix() {
|
||||
val content = "> quoted line\n\nlazy continuation"
|
||||
|
||||
assertEquals(
|
||||
listOf(StreamingMarkdownBlock.Text(content)),
|
||||
parseStreamingMarkdownBlocks(content),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun crlfInput_isNormalizedWithoutLeakingCarriageReturns() {
|
||||
val blocks = parseStreamingMarkdownBlocks("First\r\n\r\nSecond")
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
StreamingMarkdownBlock.Markdown("First"),
|
||||
StreamingMarkdownBlock.Text("Second"),
|
||||
),
|
||||
blocks,
|
||||
)
|
||||
assertTrue(blocks.none { it.toString().contains('\r') })
|
||||
}
|
||||
}
|
||||
+72
@@ -3,14 +3,86 @@ package com.hermesandroid.relay.ui.components
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.viewmodel.InteractionMode
|
||||
import com.hermesandroid.relay.viewmodel.BackgroundRunState
|
||||
import com.hermesandroid.relay.viewmodel.BackgroundRunPhase
|
||||
import com.hermesandroid.relay.viewmodel.HermesConfirmationState
|
||||
import com.hermesandroid.relay.viewmodel.VoiceHandoffStatus
|
||||
import com.hermesandroid.relay.viewmodel.VoiceState
|
||||
import com.hermesandroid.relay.viewmodel.VoiceUiState
|
||||
import com.hermesandroid.relay.viewmodel.backgroundRunAfterCancelRequest
|
||||
import com.hermesandroid.relay.viewmodel.preserveRealtimeTurnOnStop
|
||||
import com.hermesandroid.relay.viewmodel.realtimeTranscriptState
|
||||
import com.hermesandroid.relay.viewmodel.realtimeTurnActiveAfterResponseDone
|
||||
import com.hermesandroid.relay.viewmodel.voiceSessionExitState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class VoiceModeOverlayStateTest {
|
||||
|
||||
@Test
|
||||
fun providerTranscript_isTranscribingAfterMicrophoneCaptureStops() {
|
||||
assertEquals(VoiceState.Transcribing, realtimeTranscriptState(micCaptureActive = false))
|
||||
assertEquals(VoiceState.Listening, realtimeTranscriptState(micCaptureActive = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun responseDone_keepsLogicalTurnActiveOnlyWhileBackgroundRunIsLive() {
|
||||
assertEquals(true, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.RUNNING))
|
||||
assertEquals(true, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.RECONNECTING))
|
||||
assertEquals(false, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.DELIVERING))
|
||||
assertEquals(false, realtimeTurnActiveAfterResponseDone(BackgroundRunPhase.DONE))
|
||||
assertEquals(false, realtimeTurnActiveAfterResponseDone(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stop_preservesSharedTurnWhileBackgroundSummaryCanStillArrive() {
|
||||
assertEquals(true, preserveRealtimeTurnOnStop(BackgroundRunPhase.RUNNING))
|
||||
assertEquals(true, preserveRealtimeTurnOnStop(BackgroundRunPhase.RECONNECTING))
|
||||
assertEquals(true, preserveRealtimeTurnOnStop(BackgroundRunPhase.DELIVERING))
|
||||
assertEquals(false, preserveRealtimeTurnOnStop(BackgroundRunPhase.DONE))
|
||||
assertEquals(false, preserveRealtimeTurnOnStop(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceExit_clearsDetachedReconnectStateBeforeNextEntry() {
|
||||
val exited = voiceSessionExitState(
|
||||
VoiceUiState(
|
||||
voiceMode = true,
|
||||
state = VoiceState.Thinking,
|
||||
handoffStatus = VoiceHandoffStatus(title = "Waiting for route"),
|
||||
hermesConfirmation = HermesConfirmationState(
|
||||
confirmationId = "confirmation-orphaned",
|
||||
message = "Approve this action?",
|
||||
),
|
||||
backgroundRun = BackgroundRunState(
|
||||
runId = "run-orphaned",
|
||||
phase = BackgroundRunPhase.RECONNECTING,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(false, exited.voiceMode)
|
||||
assertEquals(VoiceState.Idle, exited.state)
|
||||
assertNull(exited.handoffStatus)
|
||||
assertNull(exited.backgroundRun)
|
||||
assertNull(exited.hermesConfirmation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun backgroundCancel_clearsChipWhenSocketRejectsRequest() {
|
||||
val run = BackgroundRunState(
|
||||
runId = "run-offline",
|
||||
phase = BackgroundRunPhase.RECONNECTING,
|
||||
)
|
||||
|
||||
assertNull(backgroundRunAfterCancelRequest(run, cancelSent = false))
|
||||
assertEquals(
|
||||
"Cancelling…",
|
||||
backgroundRunAfterCancelRequest(run, cancelSent = true)?.message,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pendingTranscript_showsWhileThinkingBeforeChatHistoryCatchesUp() {
|
||||
val text = pendingVoiceTranscriptText(
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ChatUnreadStateTest {
|
||||
|
||||
@Test
|
||||
fun appendedMessagesAreCountedFromTheLastReadSnapshot() {
|
||||
val readMessages = listOf(message("user", "Hello"))
|
||||
val currentMessages = readMessages + listOf(
|
||||
message("assistant", "Hi there", MessageRole.ASSISTANT),
|
||||
message("system", "Connection restored", MessageRole.SYSTEM),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
2,
|
||||
countUnreadMessages(
|
||||
current = currentMessages.toUnreadSnapshot(),
|
||||
lastRead = readMessages.toUnreadSnapshot(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingGrowthCountsTheBubbleOnce() {
|
||||
val readMessages = listOf(message("assistant", "Partial", MessageRole.ASSISTANT))
|
||||
val firstUpdate = listOf(message("assistant", "Partial answer", MessageRole.ASSISTANT))
|
||||
val secondUpdate = listOf(message("assistant", "Partial answer completed", MessageRole.ASSISTANT))
|
||||
val lastRead = readMessages.toUnreadSnapshot()
|
||||
|
||||
assertEquals(1, countUnreadMessages(firstUpdate.toUnreadSnapshot(), lastRead))
|
||||
assertEquals(1, countUnreadMessages(secondUpdate.toUnreadSnapshot(), lastRead))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun visibleToolProgressCountsAsUnreadContent() {
|
||||
val pending = message("assistant", "Working", MessageRole.ASSISTANT).copy(
|
||||
toolCalls = listOf(
|
||||
ToolCall(
|
||||
id = "tool-1",
|
||||
name = "search",
|
||||
args = null,
|
||||
result = null,
|
||||
success = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
val completed = pending.copy(
|
||||
toolCalls = pending.toolCalls.map {
|
||||
it.copy(result = "done", success = true, isComplete = true)
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
1,
|
||||
countUnreadMessages(
|
||||
listOf(completed).toUnreadSnapshot(),
|
||||
listOf(pending).toUnreadSnapshot(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamingFlagOnlyChangeDoesNotCreateUnreadContent() {
|
||||
val streaming = message("assistant", "Complete text", MessageRole.ASSISTANT)
|
||||
.copy(isStreaming = true)
|
||||
val settled = streaming.copy(isStreaming = false)
|
||||
|
||||
assertEquals(
|
||||
0,
|
||||
countUnreadMessages(
|
||||
listOf(settled).toUnreadSnapshot(),
|
||||
listOf(streaming).toUnreadSnapshot(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aTranscriptThatShrinksDoesNotCreateUnreadContent() {
|
||||
val lastRead = listOf(
|
||||
message("user", "Question"),
|
||||
message("assistant", "Answer", MessageRole.ASSISTANT),
|
||||
)
|
||||
val current = listOf(message("user", "Question"))
|
||||
|
||||
assertEquals(
|
||||
0,
|
||||
countUnreadMessages(current.toUnreadSnapshot(), lastRead.toUnreadSnapshot()),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun demoModeTakesPriorityOverLiveVoiceReadiness() {
|
||||
assertEquals(
|
||||
ChatVoiceAction.ShowDemoNotice,
|
||||
resolveChatVoiceAction(isDemoMode = true, voiceReady = true),
|
||||
)
|
||||
assertEquals(
|
||||
ChatVoiceAction.ShowDemoNotice,
|
||||
resolveChatVoiceAction(isDemoMode = true, voiceReady = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun demoDispatchNeverInvokesTheLiveVoiceCallback() {
|
||||
var demoNotices = 0
|
||||
var voiceStarts = 0
|
||||
var setupNotices = 0
|
||||
|
||||
dispatchChatVoiceAction(
|
||||
isDemoMode = true,
|
||||
voiceReady = true,
|
||||
onDemoNotice = { demoNotices += 1 },
|
||||
onStartVoice = { voiceStarts += 1 },
|
||||
onSetupNotice = { setupNotices += 1 },
|
||||
)
|
||||
|
||||
assertEquals(1, demoNotices)
|
||||
assertEquals(0, voiceStarts)
|
||||
assertEquals(0, setupNotices)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun liveChatUsesTheExistingVoiceReadinessGate() {
|
||||
assertEquals(
|
||||
ChatVoiceAction.StartVoice,
|
||||
resolveChatVoiceAction(isDemoMode = false, voiceReady = true),
|
||||
)
|
||||
assertEquals(
|
||||
ChatVoiceAction.ShowSetupNotice,
|
||||
resolveChatVoiceAction(isDemoMode = false, voiceReady = false),
|
||||
)
|
||||
}
|
||||
|
||||
private fun message(
|
||||
id: String,
|
||||
content: String,
|
||||
role: MessageRole = MessageRole.USER,
|
||||
) = ChatMessage(
|
||||
id = id,
|
||||
role = role,
|
||||
content = content,
|
||||
timestamp = 0L,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* HRUI-022 — `parseModelOptions` must keep the full provider catalog visible.
|
||||
*
|
||||
* Newer upstream only returns unconfigured provider skeleton rows when the
|
||||
* client opts in via `include_unconfigured=1`; those rows arrive with EMPTY
|
||||
* `models` plus picker hints (`authenticated=false`, `key_env`, `warning`).
|
||||
* Dropping them silently hides every provider that still needs an API key,
|
||||
* killing the Manage → Keys setup affordance.
|
||||
*/
|
||||
class ModelOptionsParserTest {
|
||||
|
||||
private fun parse(json: String): List<ModelProviderOption> =
|
||||
parseModelOptions(Json.parseToJsonElement(json).jsonObject)
|
||||
|
||||
@Test
|
||||
fun newUpstream_keepsUnconfiguredSkeletonRowAlongsideAuthenticatedProvider() {
|
||||
// Shape from upstream build_models_payload(include_unconfigured=True,
|
||||
// picker_hints=True): one authenticated row with models, one canonical
|
||||
// skeleton row with empty models + setup-hint fields.
|
||||
val options = parse(
|
||||
"""
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"slug": "openai",
|
||||
"name": "OpenAI",
|
||||
"is_current": true,
|
||||
"is_user_defined": false,
|
||||
"models": ["gpt-5.5", "gpt-5.5-mini"],
|
||||
"total_models": 2,
|
||||
"authenticated": true
|
||||
},
|
||||
{
|
||||
"slug": "anthropic",
|
||||
"name": "Anthropic",
|
||||
"is_current": false,
|
||||
"is_user_defined": false,
|
||||
"models": [],
|
||||
"total_models": 0,
|
||||
"source": "canonical",
|
||||
"authenticated": false,
|
||||
"auth_type": "api_key",
|
||||
"key_env": "ANTHROPIC_API_KEY",
|
||||
"warning": "paste ANTHROPIC_API_KEY to activate"
|
||||
}
|
||||
],
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
// Both rows survive — the empty-models skeleton must NOT be dropped.
|
||||
assertEquals(2, options.size)
|
||||
|
||||
val authenticated = options[0]
|
||||
assertEquals("openai", authenticated.id)
|
||||
assertEquals("OpenAI", authenticated.label)
|
||||
assertTrue(authenticated.authenticated)
|
||||
assertEquals(listOf("gpt-5.5", "gpt-5.5-mini"), authenticated.models)
|
||||
|
||||
// Skeleton row: greyed, sorted after authenticated rows, and keeps the
|
||||
// Keys-guidance affordance (setup hint + unauthenticated flag).
|
||||
val skeleton = options[1]
|
||||
assertEquals("anthropic", skeleton.id)
|
||||
assertEquals("Anthropic", skeleton.label)
|
||||
assertFalse(skeleton.authenticated)
|
||||
assertTrue(skeleton.models.isEmpty())
|
||||
assertEquals("paste ANTHROPIC_API_KEY to activate", skeleton.setupHint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oldUpstream_fullListByDefault_parsesIdentically() {
|
||||
// Old upstream returned the universe without an opt-in; unauthenticated
|
||||
// rows could still carry curated models. Nothing about that shape may
|
||||
// parse differently after the include_unconfigured change.
|
||||
val options = parse(
|
||||
"""
|
||||
{
|
||||
"providers": [
|
||||
{
|
||||
"slug": "openai",
|
||||
"name": "OpenAI",
|
||||
"models": ["gpt-5.5"],
|
||||
"authenticated": true
|
||||
},
|
||||
{
|
||||
"slug": "xai",
|
||||
"name": "xAI",
|
||||
"models": ["grok-4"],
|
||||
"authenticated": false,
|
||||
"warning": "paste XAI_API_KEY to activate"
|
||||
}
|
||||
],
|
||||
"model": "gpt-5.5",
|
||||
"provider": "openai"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(2, options.size)
|
||||
assertEquals("openai", options[0].id)
|
||||
assertTrue(options[0].authenticated)
|
||||
// Unauthenticated-with-models keeps its catalog visible (greyed rows).
|
||||
assertEquals("xai", options[1].id)
|
||||
assertFalse(options[1].authenticated)
|
||||
assertEquals(listOf("grok-4"), options[1].models)
|
||||
assertEquals("paste XAI_API_KEY to activate", options[1].setupHint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingAuthenticatedHint_defaultsByModelPresence() {
|
||||
// Payloads without picker hints: a row with models is assumed usable;
|
||||
// an empty row can only be an unconfigured skeleton, so grey it.
|
||||
val options = parse(
|
||||
"""
|
||||
{
|
||||
"providers": [
|
||||
{"slug": "openai", "name": "OpenAI", "models": ["gpt-5.5"]},
|
||||
{"slug": "anthropic", "name": "Anthropic", "models": []}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(2, options.size)
|
||||
assertTrue(options[0].authenticated)
|
||||
assertFalse(options[1].authenticated)
|
||||
assertNull(options[1].setupHint)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun idResolution_prefersSlugThenFallsBackForLegacyShapes() {
|
||||
val options = parse(
|
||||
"""
|
||||
{
|
||||
"providers": [
|
||||
{"slug": "openai", "id": "ignored", "name": "OpenAI", "models": ["gpt-5.5"]},
|
||||
{"id": "legacy-id", "name": "Legacy", "models": ["m1"]},
|
||||
{"name": "name-only", "models": ["m2"]},
|
||||
{"models": ["orphan-model"]}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
// The selectable id must be the canonical provider slug when present —
|
||||
// /api/model/set expects the slug, not the display label.
|
||||
assertEquals(listOf("openai", "legacy-id", "name-only"), options.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authenticatedRowsSortAheadOfSkeletons_preservingServerOrder() {
|
||||
val options = parse(
|
||||
"""
|
||||
{
|
||||
"providers": [
|
||||
{"slug": "a-skel", "name": "A", "models": [], "authenticated": false},
|
||||
{"slug": "z-auth", "name": "Z", "models": ["m1"], "authenticated": true},
|
||||
{"slug": "b-auth", "name": "B", "models": ["m2"], "authenticated": true}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
// Authenticated first; server (canonical) order kept within each group.
|
||||
assertEquals(listOf("z-auth", "b-auth", "a-skel"), options.map { it.id })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticLogEntry
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticSeverity
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import java.io.IOException
|
||||
@@ -92,6 +93,95 @@ class IssueReportAndDiagnosticsTest {
|
||||
assertTrue(DiagnosticsLog.recent().isEmpty())
|
||||
}
|
||||
|
||||
// --- DiagnosticIssuePrefill: severity-dependent title / labels / body ---
|
||||
|
||||
private fun sampleEntry(
|
||||
severity: DiagnosticSeverity,
|
||||
title: String = "Testing API connection",
|
||||
endpointRole: String? = null,
|
||||
url: String? = null,
|
||||
detail: String? = null,
|
||||
) = DiagnosticLogEntry(
|
||||
timestampMs = 0L,
|
||||
category = DiagnosticCategory.Api,
|
||||
severity = severity,
|
||||
title = title,
|
||||
detail = detail,
|
||||
endpointRole = endpointRole,
|
||||
url = url,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun errorEntriesKeepBugTitleAndLabel() {
|
||||
val entry = sampleEntry(DiagnosticSeverity.Error, title = "API key rejected")
|
||||
assertEquals("[Bug]: API key rejected", DiagnosticIssuePrefill.issueTitle(entry))
|
||||
assertEquals("bug", DiagnosticIssuePrefill.issueLabels(entry))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun infoAndWarningEntriesRetitleAsDiagnosticWithQuestionLabel() {
|
||||
for (severity in listOf(DiagnosticSeverity.Info, DiagnosticSeverity.Warning)) {
|
||||
val entry = sampleEntry(severity)
|
||||
assertEquals("[Diagnostic]: Testing API connection", DiagnosticIssuePrefill.issueTitle(entry))
|
||||
// "question" already exists on the repo — the prefill must not invent labels.
|
||||
assertEquals("question", DiagnosticIssuePrefill.issueLabels(entry))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bodyUsesTheExpectationAnswerAsWhatHappened() {
|
||||
val body = DiagnosticIssuePrefill.issueBody(
|
||||
sampleEntry(DiagnosticSeverity.Info),
|
||||
expectation = "I expected the app to connect to my server",
|
||||
)
|
||||
assertTrue(body.contains("### What happened?\nI expected the app to connect to my server\n"))
|
||||
assertFalse(body.contains("Captured diagnostic from the in-app activity log."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bodyFallsBackToBoilerplateWithoutAnExpectation() {
|
||||
val body = DiagnosticIssuePrefill.issueBody(sampleEntry(DiagnosticSeverity.Error))
|
||||
assertTrue(body.contains("### What happened?\nCaptured diagnostic from the in-app activity log.\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expectationAnswerIsSecretRedactedBeforeEmbedding() {
|
||||
val body = DiagnosticIssuePrefill.issueBody(
|
||||
sampleEntry(DiagnosticSeverity.Info),
|
||||
expectation = "it failed with token=super-secret-value somehow",
|
||||
)
|
||||
assertFalse(body.contains("super-secret-value"))
|
||||
assertTrue(body.contains("token=[hidden]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionModeUsesTheEntryRoleWhenPresent() {
|
||||
val body = DiagnosticIssuePrefill.issueBody(
|
||||
sampleEntry(DiagnosticSeverity.Error, endpointRole = "tailscale", url = "http://10.0.0.5:8642"),
|
||||
)
|
||||
assertTrue(body.contains("- Connection mode: tailscale"))
|
||||
assertFalse(body.contains("LAN / Tailscale / public TLS / other"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionModeIsInferredFromTheEntryUrl() {
|
||||
val lan = DiagnosticIssuePrefill.issueBody(
|
||||
sampleEntry(DiagnosticSeverity.Info, url = "http://localhost:8642"),
|
||||
)
|
||||
assertTrue(lan.contains("- Connection mode: lan"))
|
||||
|
||||
val tailscale = DiagnosticIssuePrefill.issueBody(
|
||||
sampleEntry(DiagnosticSeverity.Info, url = "http://100.75.1.2:8642"),
|
||||
)
|
||||
assertTrue(tailscale.contains("- Connection mode: tailscale"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionModeIsUnknownWithoutRouteOrUrl() {
|
||||
val body = DiagnosticIssuePrefill.issueBody(sampleEntry(DiagnosticSeverity.Warning))
|
||||
assertTrue(body.contains("- Connection mode: unknown"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recordErrorRedactsSecretsInTheStacktrace() {
|
||||
DiagnosticsLog.clear()
|
||||
|
||||
@@ -101,6 +101,52 @@ class ServerAddressTest {
|
||||
assertEquals("https", ServerAddress.parse("https://h.example")?.scheme)
|
||||
}
|
||||
|
||||
// --- loopbackHostWarning: loopback addresses can't reach the server from a phone ---
|
||||
|
||||
@Test
|
||||
fun loopbackHostWarningFlagsLoopbackAndAnyInterfaceHosts() {
|
||||
val loopbacks = listOf(
|
||||
"localhost",
|
||||
"localhost:8642",
|
||||
"http://localhost:8642",
|
||||
"127.0.0.1",
|
||||
"https://127.0.0.1:9119",
|
||||
"::1",
|
||||
"[::1]",
|
||||
"http://[::1]:8642",
|
||||
"0.0.0.0",
|
||||
"http://0.0.0.0:8642",
|
||||
)
|
||||
for (value in loopbacks) {
|
||||
assertNotNull("expected warning: '$value'", ServerAddress.loopbackHostWarning(value))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loopbackHostWarningIsNullForReachableAddresses() {
|
||||
val reachable = listOf(
|
||||
"192.168.1.10",
|
||||
"192.168.1.10:8642",
|
||||
"http://10.0.0.5:8642",
|
||||
"100.64.0.1:8642",
|
||||
"hermes.tail1234.ts.net",
|
||||
"https://hermes.example.com:8642",
|
||||
"hermes-box",
|
||||
// Not loopback: hostname that merely starts with 127.
|
||||
"127.evil.example.com",
|
||||
)
|
||||
for (value in reachable) {
|
||||
assertNull("expected no warning: '$value'", ServerAddress.loopbackHostWarning(value))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loopbackHostWarningIsNullForBlankAndJunk() {
|
||||
assertNull(ServerAddress.loopbackHostWarning(""))
|
||||
assertNull(ServerAddress.loopbackHostWarning(" "))
|
||||
assertNull(ServerAddress.loopbackHostWarning("not a host"))
|
||||
}
|
||||
|
||||
// --- fieldError: inline UI message contract ---
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Virtual-time tests for [ChatStreamRecovery] — the issue #166 poller that
|
||||
* recovers a dropped sessions-stream turn from the persisted transcript.
|
||||
*
|
||||
* Timing uses the production defaults (5s → ×2 backoff → 30s cap, 30 min
|
||||
* window); `runTest` virtual time makes even the 30-minute cap instant.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ChatStreamRecoveryTest {
|
||||
|
||||
private val pending = "what's the weather?"
|
||||
|
||||
private fun user(id: String, text: String = pending) = MessageItem(
|
||||
id = id,
|
||||
role = "user",
|
||||
content = JsonPrimitive(text),
|
||||
)
|
||||
|
||||
private fun assistant(id: String, text: String) = MessageItem(
|
||||
id = id,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(text),
|
||||
)
|
||||
|
||||
/** Scripted fetch: returns [responses] in order, repeating the last one. */
|
||||
private class ScriptedHistory(private val responses: List<() -> List<MessageItem>>) {
|
||||
val fetchTimesMs = mutableListOf<Long>()
|
||||
private var calls = 0
|
||||
|
||||
fun fetcher(now: () -> Long): suspend () -> List<MessageItem> = {
|
||||
fetchTimesMs += now()
|
||||
val step = responses[minOf(calls, responses.lastIndex)]
|
||||
calls++
|
||||
step()
|
||||
}
|
||||
|
||||
val fetchCount: Int get() = calls
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoversWhenAnswerIsStableAcrossTwoConsecutivePolls() = runTest {
|
||||
val script = ScriptedHistory(
|
||||
listOf(
|
||||
{ listOf(user("u1")) },
|
||||
{ listOf(user("u1"), assistant("a1", "recovered answer")) },
|
||||
{ listOf(user("u1"), assistant("a1", "recovered answer")) },
|
||||
),
|
||||
)
|
||||
val intermediate = mutableListOf<List<MessageItem>>()
|
||||
var recovered: List<MessageItem>? = null
|
||||
var gaveUp: ChatStreamRecovery.GiveUpReason? = null
|
||||
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(
|
||||
pendingUserText = pending,
|
||||
priorUserMessageCount = 0,
|
||||
onIntermediateHistory = { intermediate += it },
|
||||
onRecovered = { recovered = it },
|
||||
onGaveUp = { gaveUp = it },
|
||||
)
|
||||
|
||||
advanceTimeBy(5_001) // poll 1 — user only, no answer yet
|
||||
assertEquals(1, intermediate.size)
|
||||
assertNull(recovered)
|
||||
|
||||
advanceTimeBy(10_000) // poll 2 — answer appears (signature recorded)
|
||||
assertEquals(2, intermediate.size)
|
||||
assertNull(recovered)
|
||||
|
||||
advanceTimeBy(20_000) // poll 3 — unchanged → stable → recovered
|
||||
assertEquals("recovered answer", recovered?.last()?.contentText)
|
||||
assertNull(gaveUp)
|
||||
assertFalse(recovery.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pollCadenceBacksOffExponentiallyToTheCap() = runTest {
|
||||
val script = ScriptedHistory(listOf({ emptyList() }))
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(pending, 0, {}, {}, {})
|
||||
|
||||
// 5s, +10s, +20s, +30s, +30s… (cap) — cumulative fetch times.
|
||||
advanceTimeBy(5_000 + 10_000 + 20_000 + 30_000 + 30_000 + 1)
|
||||
assertEquals(
|
||||
listOf(5_000L, 15_000L, 35_000L, 65_000L, 95_000L),
|
||||
script.fetchTimesMs,
|
||||
)
|
||||
recovery.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyTranscriptCarriesNoInformationAndKeepsPolling() = runTest {
|
||||
val script = ScriptedHistory(listOf({ emptyList() }))
|
||||
var gaveUp: ChatStreamRecovery.GiveUpReason? = null
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(pending, 0, {}, {}, { gaveUp = it })
|
||||
|
||||
advanceTimeBy(31L * 60_000)
|
||||
assertEquals(ChatStreamRecovery.GiveUpReason.TIMED_OUT, gaveUp)
|
||||
assertTrue("should have kept polling to the cap", script.fetchCount > 10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reachableTranscriptWithoutThePendingSendFailsFast() = runTest {
|
||||
// Server reachable, but the turn's POST never landed: the transcript
|
||||
// still holds only the PREVIOUS exchange (one user row the client
|
||||
// already knew about). No new user row → no anchor → waiting cannot
|
||||
// produce an answer, so give up quickly (after confirming, not on the
|
||||
// very first read) rather than polling to the 30-minute cap.
|
||||
val script = ScriptedHistory(
|
||||
listOf(
|
||||
{
|
||||
listOf(
|
||||
user("u0", "an earlier prompt"),
|
||||
assistant("a0", "an earlier answer"),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
var gaveUp: ChatStreamRecovery.GiveUpReason? = null
|
||||
var recovered = false
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(pending, 1, {}, { recovered = true }, { gaveUp = it })
|
||||
|
||||
advanceTimeBy(5_001) // poll 1 — not established, not yet confirmed
|
||||
assertNull("must confirm across a couple polls before giving up", gaveUp)
|
||||
advanceTimeBy(10_000) // poll 2 — still not established → fail fast
|
||||
assertEquals(ChatStreamRecovery.GiveUpReason.RUN_NOT_FOUND, gaveUp)
|
||||
assertFalse("stale prior answer must never count as the recovery", recovered)
|
||||
assertEquals(2, script.fetchCount)
|
||||
assertFalse(recovery.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleIdenticalEarlierUserMessageIsNotAdoptedWhenSendNeverLanded() = runTest {
|
||||
// History the client already knew: "continue" → A, "tell me more" → B.
|
||||
// The new send is ALSO "continue" but its POST never reached the
|
||||
// server, so the transcript is unchanged. A bare `indexOfLast` text
|
||||
// match would anchor on the STALE first "continue" and adopt B (static
|
||||
// → instantly "stable"). The positional invariant (this send would be
|
||||
// the 3rd user row, but only 2 exist) must refuse to adopt and fail.
|
||||
val stale = listOf(
|
||||
user("u1", "continue"),
|
||||
assistant("a1", "answer A"),
|
||||
user("u2", "tell me more"),
|
||||
assistant("a2", "answer B"),
|
||||
)
|
||||
val script = ScriptedHistory(listOf({ stale }))
|
||||
var recovered: List<MessageItem>? = null
|
||||
var gaveUp: ChatStreamRecovery.GiveUpReason? = null
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(
|
||||
pendingUserText = "continue",
|
||||
priorUserMessageCount = 2, // u1 + u2 were known before this send
|
||||
onIntermediateHistory = {},
|
||||
onRecovered = { recovered = it },
|
||||
onGaveUp = { gaveUp = it },
|
||||
)
|
||||
|
||||
advanceTimeBy(5_000 + 10_000 + 1) // two polls of the static transcript
|
||||
assertNull("must NOT adopt a different turn's answer", recovered)
|
||||
assertEquals(ChatStreamRecovery.GiveUpReason.RUN_NOT_FOUND, gaveUp)
|
||||
assertFalse(recovery.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatedShortPromptAnchorsTheNewTurnNotTheStaleOne() = runTest {
|
||||
// "continue" → A is already in history; the new "continue" DID land, so
|
||||
// the server appended a second identical user row + its own answer C.
|
||||
// The positional anchor must pick the SECOND "continue" and adopt C —
|
||||
// never the stale A that an `indexOfLast`-only heuristic risks.
|
||||
val landed = listOf(
|
||||
user("u1", "continue"),
|
||||
assistant("a1", "answer A"),
|
||||
user("u2", "continue"),
|
||||
assistant("a2", "answer C"),
|
||||
)
|
||||
val script = ScriptedHistory(listOf({ landed }))
|
||||
var recovered: List<MessageItem>? = null
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(
|
||||
pendingUserText = "continue",
|
||||
priorUserMessageCount = 1, // only the first "continue" was known
|
||||
onIntermediateHistory = {},
|
||||
onRecovered = { recovered = it },
|
||||
onGaveUp = {},
|
||||
)
|
||||
|
||||
advanceTimeBy(5_000 + 10_000 + 1) // stable across two polls
|
||||
assertEquals("answer C", recovered?.last()?.contentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchExceptionKeepsPollingUntilTheServerComesBack() = runTest {
|
||||
val script = ScriptedHistory(
|
||||
listOf(
|
||||
{ throw IOException("network still down") },
|
||||
{ listOf(user("u1"), assistant("a1", "late answer")) },
|
||||
{ listOf(user("u1"), assistant("a1", "late answer")) },
|
||||
),
|
||||
)
|
||||
var recovered: List<MessageItem>? = null
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(pending, 0, {}, { recovered = it }, {})
|
||||
|
||||
advanceTimeBy(5_000 + 10_000 + 20_000 + 1)
|
||||
assertEquals("late answer", recovered?.last()?.contentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun growingTranscriptDefersTheFinishUntilStable() = runTest {
|
||||
val script = ScriptedHistory(
|
||||
listOf(
|
||||
{ listOf(user("u1"), assistant("a1", "thinking about it")) },
|
||||
// Run still going: a new assistant row appended → not stable.
|
||||
{
|
||||
listOf(
|
||||
user("u1"),
|
||||
assistant("a1", "thinking about it"),
|
||||
assistant("a2", "final answer"),
|
||||
)
|
||||
},
|
||||
{
|
||||
listOf(
|
||||
user("u1"),
|
||||
assistant("a1", "thinking about it"),
|
||||
assistant("a2", "final answer"),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
var recovered: List<MessageItem>? = null
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(pending, 0, {}, { recovered = it }, {})
|
||||
|
||||
advanceTimeBy(15_001) // polls 1+2 — signature changed between them
|
||||
assertNull(recovered)
|
||||
advanceTimeBy(20_000) // poll 3 — stable now
|
||||
assertEquals("final answer", recovered?.last()?.contentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancelStopsPollingWithoutAnyTerminalCallback() = runTest {
|
||||
val script = ScriptedHistory(listOf({ listOf(user("u1")) }))
|
||||
var recovered = false
|
||||
var gaveUp = false
|
||||
val recovery = ChatStreamRecovery(this, script.fetcher { testScheduler.currentTime })
|
||||
recovery.start(pending, 0, {}, { recovered = true }, { gaveUp = true })
|
||||
|
||||
advanceTimeBy(5_001)
|
||||
assertEquals(1, script.fetchCount)
|
||||
recovery.cancel()
|
||||
|
||||
advanceTimeBy(60L * 60_000)
|
||||
assertEquals("no polls after cancel", 1, script.fetchCount)
|
||||
assertFalse(recovered)
|
||||
assertFalse(gaveUp)
|
||||
assertFalse(recovery.isActive)
|
||||
}
|
||||
}
|
||||
+683
@@ -0,0 +1,683 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.ChatTurnAskCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnAssistantCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnCheckpointStore
|
||||
import com.hermesandroid.relay.data.ChatTurnToolCheckpoint
|
||||
import com.hermesandroid.relay.data.ChatTurnUserCheckpoint
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayClientHarness
|
||||
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
|
||||
import com.hermesandroid.relay.network.upstream.HermesApiClient
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import okhttp3.mockwebserver.SocketPolicy
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class ChatViewModelGatewayInboundTurnTest {
|
||||
|
||||
private class MemoryCheckpointStore(
|
||||
var checkpoint: ChatTurnCheckpoint? = null,
|
||||
) : ChatTurnCheckpointStore {
|
||||
override suspend fun read(): ChatTurnCheckpoint? = checkpoint
|
||||
override suspend fun write(checkpoint: ChatTurnCheckpoint) {
|
||||
this.checkpoint = checkpoint
|
||||
}
|
||||
override suspend fun clear() {
|
||||
checkpoint = null
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var gatewayHarness: GatewayClientHarness
|
||||
private lateinit var apiServer: MockWebServer
|
||||
private lateinit var gatewayScope: CoroutineScope
|
||||
private lateinit var gatewayClient: GatewayChatClient
|
||||
private lateinit var serverWs: WebSocket
|
||||
private lateinit var handler: ChatHandler
|
||||
private lateinit var viewModel: ChatViewModel
|
||||
@Volatile
|
||||
private var persistedHistory: List<MessageItem> = emptyList()
|
||||
@Volatile
|
||||
private var holdCompletionsStream = false
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
gatewayHarness = GatewayClientHarness()
|
||||
apiServer = MockWebServer().apply {
|
||||
dispatcher = object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||
if (holdCompletionsStream && request.path == "/v1/chat/completions") {
|
||||
MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)
|
||||
} else {
|
||||
MockResponse().setResponseCode(404)
|
||||
}
|
||||
}
|
||||
start()
|
||||
}
|
||||
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
gatewayClient = GatewayChatClient(
|
||||
initialDashboardClient = DashboardApiClient(
|
||||
baseUrl = gatewayHarness.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = OkHttpClient(),
|
||||
),
|
||||
okHttpClient = OkHttpClient(),
|
||||
// Match production ordering: Gateway callbacks are posted from the
|
||||
// OkHttp WebSocket thread onto Android's main looper.
|
||||
callbackDispatcher = { block ->
|
||||
Handler(Looper.getMainLooper()).post(block)
|
||||
},
|
||||
scope = gatewayScope,
|
||||
)
|
||||
handler = ChatHandler().also { it.setSessionId(STORED_SESSION_ID) }
|
||||
persistedHistory = emptyList()
|
||||
holdCompletionsStream = false
|
||||
viewModel = ChatViewModel().also {
|
||||
it.initialize(
|
||||
HermesApiClient(apiServer.url("/").toString(), "test-key"),
|
||||
handler,
|
||||
)
|
||||
it.streamingEndpoint = "gateway"
|
||||
it.setProfileMessageLoader {
|
||||
Result.success(persistedHistory)
|
||||
}
|
||||
it.updateGatewayClient(gatewayClient)
|
||||
}
|
||||
assertTrue(runBlocking { gatewayClient.prewarmAwait(STORED_SESSION_ID) })
|
||||
serverWs = gatewayHarness.awaitServerSocket()
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
viewModel.updateGatewayClient(null)
|
||||
gatewayClient.shutdown()
|
||||
gatewayScope.cancel()
|
||||
gatewayHarness.shutdown()
|
||||
apiServer.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsolicitedGatewayCompletionAppearsAsOneAssistantTurnAndSettles() {
|
||||
// Upstream's process-completion poller currently emits this adjacent
|
||||
// duplicate pair; it must still create exactly one placeholder.
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", BACKGROUND_ANSWER) },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
awaitCondition {
|
||||
handler.messages.value.singleOrNull()?.content == BACKGROUND_ANSWER
|
||||
}
|
||||
assertTrue(handler.isStreaming.value)
|
||||
assertEquals(1, handler.messages.value.size)
|
||||
assertEquals(MessageRole.ASSISTANT, handler.messages.value.single().role)
|
||||
|
||||
persistedHistory = persistedAnswerHistory()
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", BACKGROUND_ANSWER) },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
awaitCondition { !handler.isStreaming.value }
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
awaitCondition {
|
||||
handler.messages.value.singleOrNull()?.content == BACKGROUND_ANSWER
|
||||
}
|
||||
assertFalse(handler.messages.value.single().isStreaming)
|
||||
assertFalse(gatewayHarness.rpcLog.any { it.first == "prompt.submit" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queuedMainDispatchAdmitsBackgroundStartAfterLocalCompletion() {
|
||||
viewModel.sendMessage("Local gateway turn")
|
||||
gatewayHarness.awaitRpc("prompt.submit")
|
||||
awaitCondition { gatewayClient.hasActiveTurn() }
|
||||
|
||||
// Queue the local completion and the server-initiated start back to
|
||||
// back. The inbound admission must run behind the local completion on
|
||||
// main, rather than reading stale activeStream state on the socket.
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Local answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", BACKGROUND_ANSWER) },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", BACKGROUND_ANSWER) },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
awaitCondition {
|
||||
handler.messages.value.any {
|
||||
it.id.startsWith("gateway-inbound-") && it.content == BACKGROUND_ANSWER
|
||||
}
|
||||
}
|
||||
awaitCondition { !handler.isStreaming.value }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stopOnUnsolicitedTurnInterruptsTheGatewaySession() {
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "Still composing") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
awaitCondition { handler.isStreaming.value }
|
||||
|
||||
viewModel.cancelStream()
|
||||
|
||||
gatewayHarness.awaitRpc("session.interrupt")
|
||||
assertFalse(handler.isStreaming.value)
|
||||
|
||||
// Upstream can emit the interrupted turn's terminal event after the
|
||||
// interrupt RPC. It is a drain marker, not a new background answer.
|
||||
persistedHistory = persistedAnswerHistory("Canceled answer", "canceled-server-answer")
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Canceled answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
Thread.sleep(150)
|
||||
shadowOf(Looper.getMainLooper()).idleFor(250, TimeUnit.MILLISECONDS)
|
||||
assertFalse(handler.messages.value.any { it.content == "Canceled answer" })
|
||||
assertTrue(handler.messages.value.any { "Stopped" in it.badges })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reopenedChatRestoresRichStateAndReattachesLiveGatewayTurn() {
|
||||
val now = System.currentTimeMillis()
|
||||
val checkpointStore = MemoryCheckpointStore(
|
||||
ChatTurnCheckpoint(
|
||||
contextKey = PROFILE_CONTEXT,
|
||||
sessionId = STORED_SESSION_ID,
|
||||
liveSessionId = "live-resumed",
|
||||
transport = "gateway",
|
||||
user = ChatTurnUserCheckpoint("pending-user", "Research this", now - 2_000L),
|
||||
assistant = ChatTurnAssistantCheckpoint(
|
||||
id = "pending-assistant",
|
||||
content = "Partial",
|
||||
timestamp = now - 1_900L,
|
||||
thinkingContent = "Inspecting sources",
|
||||
isThinkingStreaming = true,
|
||||
toolCalls = listOf(
|
||||
ChatTurnToolCheckpoint(
|
||||
id = "tool-1",
|
||||
name = "terminal",
|
||||
isComplete = false,
|
||||
startedAt = now - 1_500L,
|
||||
),
|
||||
),
|
||||
),
|
||||
turnStatus = "Running terminal",
|
||||
priorUserMessageCount = 0,
|
||||
baselineAssistantCount = 0,
|
||||
pendingAsk = ChatTurnAskCheckpoint(
|
||||
kind = "APPROVAL",
|
||||
text = "Allow the command?",
|
||||
timeoutSeconds = 0,
|
||||
messageId = "ask-approval-1",
|
||||
cardKey = "approval-1",
|
||||
receivedAt = now - 1_000L,
|
||||
),
|
||||
startedAt = now - 1_900L,
|
||||
updatedAt = now,
|
||||
),
|
||||
)
|
||||
gatewayHarness.recoveryRunning = true
|
||||
gatewayHarness.recoveryAssistant = "Partial answer from upstream"
|
||||
viewModel.setChatTurnCheckpointStore(checkpointStore)
|
||||
// Cold process start: ConnectionViewModel's fresh handler has not yet
|
||||
// adopted the persisted lastSessionId when the profile context binds.
|
||||
handler.setSessionId(null)
|
||||
viewModel.switchProfileContext(PROFILE_CONTEXT, STORED_SESSION_ID)
|
||||
|
||||
viewModel.prewarmGateway()
|
||||
|
||||
gatewayHarness.awaitRpc("session.activate")
|
||||
awaitCondition {
|
||||
handler.messages.value.any { it.id == "pending-assistant" } &&
|
||||
handler.isStreaming.value
|
||||
}
|
||||
val restored = handler.messages.value.single { it.id == "pending-assistant" }
|
||||
assertEquals("Partial answer from upstream", restored.content)
|
||||
assertEquals("Inspecting sources", restored.thinkingContent)
|
||||
assertEquals("terminal", restored.toolCalls.single().name)
|
||||
assertFalse(restored.toolCalls.single().isComplete)
|
||||
assertEquals("Running terminal", handler.turnStatus.value)
|
||||
assertEquals("approval-1", viewModel.pendingAsk.value?.cardKey)
|
||||
assertTrue(handler.messages.value.any { it.id == "ask-approval-1" && it.cards.isNotEmpty() })
|
||||
assertTrue(
|
||||
handler.messages.value.indexOfFirst { it.id == "pending-assistant" } <
|
||||
handler.messages.value.indexOfFirst { it.id == "ask-approval-1" },
|
||||
)
|
||||
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"tool.complete",
|
||||
buildJsonObject {
|
||||
put("tool_id", "tool-1")
|
||||
put("name", "terminal")
|
||||
put("summary", "done")
|
||||
},
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", " and finished") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
persistedHistory = listOf(
|
||||
MessageItem(id = "server-user", role = "user", content = JsonPrimitive("Research this")),
|
||||
MessageItem(
|
||||
id = "server-assistant",
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Partial answer from upstream and finished"),
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Partial answer from upstream and finished") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
awaitCondition { !handler.isStreaming.value }
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
awaitCondition { checkpointStore.checkpoint == null }
|
||||
assertTrue(handler.messages.value.any {
|
||||
it.role == MessageRole.ASSISTANT &&
|
||||
it.content == "Partial answer from upstream and finished"
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lateCanceledCompletionDrainsBeforeImmediateNextTurn() {
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "Old partial") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
awaitCondition { handler.isStreaming.value }
|
||||
|
||||
viewModel.cancelStream()
|
||||
gatewayHarness.awaitRpc("session.interrupt")
|
||||
viewModel.sendMessage("Start the next turn")
|
||||
|
||||
// Let the bounded next-submit wait elapse first. The started turn's
|
||||
// tombstone must still drain its eventual terminal event rather than
|
||||
// routing it into the new active mapper.
|
||||
Thread.sleep(2_100)
|
||||
awaitCondition {
|
||||
gatewayHarness.rpcLog.any { (method, params) ->
|
||||
method == "prompt.submit" && params["text"] == JsonPrimitive("Start the next turn")
|
||||
}
|
||||
}
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Canceled answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
persistedHistory = persistedAnswerHistory("New answer", "new-server-answer")
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "New answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "New answer") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
awaitCondition { handler.messages.value.any { it.content == "New answer" } }
|
||||
awaitCondition { !handler.isStreaming.value }
|
||||
assertFalse(handler.messages.value.any { it.content == "Canceled answer" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queuedMessageDrainsAfterUnsolicitedTurnCompletes() {
|
||||
gatewayHarness.steerStatus = "rejected"
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "Finishing background work") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
awaitCondition { handler.isStreaming.value }
|
||||
|
||||
viewModel.sendMessage("Run this next")
|
||||
gatewayHarness.awaitRpc("session.steer")
|
||||
awaitCondition { viewModel.queuedMessages.value == listOf("Run this next") }
|
||||
|
||||
persistedHistory = persistedAnswerHistory()
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Finishing background work") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
awaitCondition {
|
||||
gatewayHarness.rpcLog.any { (method, params) ->
|
||||
method == "prompt.submit" && params["text"] == JsonPrimitive("Run this next")
|
||||
}
|
||||
}
|
||||
assertTrue(viewModel.queuedMessages.value.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coldForegroundPrewarmReloadsACompletionMissedWhileDisconnected() {
|
||||
persistedHistory = persistedAnswerHistory()
|
||||
serverWs.close(1012, "test disconnect")
|
||||
awaitCondition { gatewayClient.connectionState.value == GatewayConnectionState.Idle }
|
||||
|
||||
viewModel.prewarmGateway()
|
||||
gatewayHarness.awaitServerSocket()
|
||||
gatewayHarness.awaitRpcCount("session.resume", 2)
|
||||
|
||||
awaitCondition {
|
||||
handler.messages.value.singleOrNull()?.content == BACKGROUND_ANSWER
|
||||
}
|
||||
assertFalse(handler.isStreaming.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsolicitedTurnDoesNotReplaceAnActiveForcedSseTurn() {
|
||||
holdCompletionsStream = true
|
||||
viewModel.sendVoiceMessage("local voice turn", "Respond for spoken playback")
|
||||
awaitCondition { handler.isStreaming.value }
|
||||
val localPlaceholderId = handler.messages.value.last().id
|
||||
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", BACKGROUND_ANSWER) },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
persistedHistory = persistedAnswerHistory()
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", BACKGROUND_ANSWER) },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
Thread.sleep(150)
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
|
||||
assertTrue("the forced SSE turn must still own streaming", handler.isStreaming.value)
|
||||
assertTrue(handler.messages.value.any { it.id == localPlaceholderId && it.isStreaming })
|
||||
assertFalse(handler.messages.value.any { it.content == BACKGROUND_ANSWER })
|
||||
|
||||
viewModel.cancelStream()
|
||||
awaitCondition { !handler.isStreaming.value }
|
||||
awaitCondition { handler.messages.value.any { it.content == BACKGROUND_ANSWER } }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptedInboundTurnSettlesAfterGatewayDowngrade() {
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", BACKGROUND_ANSWER) },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
awaitCondition { handler.isStreaming.value }
|
||||
|
||||
viewModel.streamingEndpoint = "sessions"
|
||||
viewModel.updateGatewayClient(null)
|
||||
persistedHistory = persistedAnswerHistory()
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", BACKGROUND_ANSWER) },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
awaitCondition { !handler.isStreaming.value }
|
||||
awaitCondition {
|
||||
handler.messages.value.singleOrNull()?.id == "persisted-background-answer"
|
||||
}
|
||||
assertEquals(BACKGROUND_ANSWER, handler.messages.value.single().content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lateOldCompletionDoesNotClearNewGatewayTurnSteering() {
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", "Old inbound") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
awaitCondition { handler.isStreaming.value }
|
||||
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", "Old inbound") },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
while (gatewayClient.hasActiveTurn() && System.nanoTime() < deadline) {
|
||||
// Deliberately do not idle main: keep the old completion callback
|
||||
// queued while the socket-side mapper reaches its terminal state.
|
||||
Thread.sleep(20)
|
||||
}
|
||||
assertFalse("old gateway turn never ended", gatewayClient.hasActiveTurn())
|
||||
|
||||
viewModel.cancelStream()
|
||||
viewModel.sendMessage("New gateway turn")
|
||||
awaitCondition { gatewayClient.hasActiveTurn() }
|
||||
|
||||
// Pump the queued old callback. It no longer owns activeStream and must
|
||||
// not clear the new turn's steering affordance.
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
assertTrue(viewModel.steerableTurn.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconnectAfterMissedStartRecoversOnExactSessionCompletion() {
|
||||
serverWs.close(1012, "missed start")
|
||||
awaitCondition { gatewayClient.connectionState.value == GatewayConnectionState.Idle }
|
||||
viewModel.prewarmGateway()
|
||||
serverWs = gatewayHarness.awaitServerSocket()
|
||||
gatewayHarness.awaitRpcCount("session.resume", 2)
|
||||
|
||||
// Reconnected midway through the synthetic turn: no message.start is
|
||||
// replayed, so the delta is intentionally ignored and completion drives
|
||||
// authoritative history recovery.
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", BACKGROUND_ANSWER) },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
persistedHistory = persistedAnswerHistory()
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", BACKGROUND_ANSWER) },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
|
||||
awaitCondition { handler.messages.value.any { it.content == BACKGROUND_ANSWER } }
|
||||
assertFalse(handler.isStreaming.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleHistoryReadCannotEraseATurnCompletedDuringTheFetch() {
|
||||
val loadCount = AtomicInteger(0)
|
||||
val firstLoadStarted = CompletableDeferred<Unit>()
|
||||
val releaseFirstLoad = CompletableDeferred<Unit>()
|
||||
viewModel.setProfileMessageLoader {
|
||||
when (loadCount.incrementAndGet()) {
|
||||
1 -> {
|
||||
firstLoadStarted.complete(Unit)
|
||||
releaseFirstLoad.await()
|
||||
Result.success(persistedAnswerHistory())
|
||||
}
|
||||
else -> Result.success(
|
||||
persistedAnswerHistory() +
|
||||
MessageItem(
|
||||
id = "newer-local-answer",
|
||||
sessionId = STORED_SESSION_ID,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Newer answer"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.delta",
|
||||
buildJsonObject { put("text", BACKGROUND_ANSWER) },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
serverWs.send(
|
||||
gatewayHarness.eventFrame(
|
||||
"message.complete",
|
||||
buildJsonObject { put("text", BACKGROUND_ANSWER) },
|
||||
"live-resumed",
|
||||
),
|
||||
)
|
||||
awaitCondition { firstLoadStarted.isCompleted }
|
||||
|
||||
handler.addPlaceholderMessage(
|
||||
ChatMessage(
|
||||
id = "newer-local-answer",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Newer answer",
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
handler.onStreamComplete("newer-local-answer")
|
||||
releaseFirstLoad.complete(Unit)
|
||||
|
||||
awaitCondition {
|
||||
handler.messages.value.any { it.content == "Newer answer" } && loadCount.get() >= 2
|
||||
}
|
||||
assertTrue(handler.messages.value.any { it.content == BACKGROUND_ANSWER })
|
||||
}
|
||||
|
||||
private fun persistedAnswerHistory(
|
||||
answer: String = BACKGROUND_ANSWER,
|
||||
id: String = "persisted-background-answer",
|
||||
): List<MessageItem> = listOf(
|
||||
MessageItem(
|
||||
id = id,
|
||||
sessionId = STORED_SESSION_ID,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(answer),
|
||||
),
|
||||
)
|
||||
|
||||
private fun awaitCondition(condition: () -> Boolean) {
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
while (System.nanoTime() < deadline) {
|
||||
// Advance Robolectric's paused main clock so coroutine delay-based
|
||||
// reconciliation retries can resume as they do on-device.
|
||||
shadowOf(Looper.getMainLooper()).idleFor(20, TimeUnit.MILLISECONDS)
|
||||
if (condition()) return
|
||||
Thread.sleep(20)
|
||||
}
|
||||
assertTrue("condition not met; messages=${handler.messages.value}", condition())
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val STORED_SESSION_ID = "stored-session"
|
||||
private const val PROFILE_CONTEXT = "connection-a/profile-default"
|
||||
private const val BACKGROUND_ANSWER = "Background task finished."
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user