Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8cd13e5bc | ||
|
|
ad4175bb6d | ||
|
|
6b7cb706e0 | ||
|
|
6031427ffb | ||
|
|
53dd21400a | ||
|
|
802a0b0844 | ||
|
|
7658329ca7 | ||
|
|
1622db0b23 | ||
|
|
bca3cd0e48 | ||
|
|
bb2f1e6c0c | ||
|
|
ff23d54332 | ||
|
|
a4a0575688 | ||
|
|
0509ac8377 | ||
|
|
3fdc2260dd | ||
|
|
1800bee7b1 |
+45
-13
@@ -6,37 +6,69 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android Bot Chats render loaded history immediately.** Route-owned chat screens observe their own handler state from first composition, including fast history loads that settle before another frame. (Supersedes #453.)
|
||||
|
||||
## [Android 1.14.0] - 2026-08-30
|
||||
|
||||
### Added
|
||||
|
||||
- **Android can preview delegated agent work without leaving the parent chat.** The current-chat activity sheet shows bounded lifecycle, progress, and tool previews for concurrent children, opens vanilla Hermes child history read-only when the Gateway exposes it, and stays explicit when reconnect gaps or older routes leave details unavailable.
|
||||
- **Android presents Relay Git as a first-class native workspace.** A compact optional Chat rail opens repository status, line totals, filters, diffs, branches, staging, commits, and remotes; the full workspace remains available from Settings when Chat controls are hidden.
|
||||
- **Hermes-Relay Plugin provides a bounded Git workspace API for authenticated Dashboard clients.** Configured repository roots, path validation, tracked line totals, scoped write grants, and explicit confirmation protect repository reads and mutations.
|
||||
- **Android can preview delegated agent work without leaving the parent chat.** The current-chat activity sheet shows bounded lifecycle, progress, and tool previews for concurrent children, opens vanilla Hermes child history read-only when the Gateway exposes it, and stays explicit when reconnect gaps or older routes leave details unavailable. (#447)
|
||||
- **Android presents Relay Git as a first-class native workspace.** A compact optional Chat rail opens repository status, line totals, filters, diffs, branches, staging, commits, and remotes; the full workspace remains available from Settings when Chat controls are hidden. An updated optional Hermes-Relay Plugin is required for Git operations.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Hermes-Relay Dashboard management is organized around operator tasks.** Overview, Devices, Activity, Remote Access, Git, and Settings now have separate native Dashboard surfaces; pairing is QR-first, paired clients use responsive cards, and token-backed media is labeled as a bounded diagnostic instead of a health counter.
|
||||
- **New Relay pairing uses each Dashboard origin as the network ingress.** Dashboard, CLI, and TUI QR flows advertise the same-origin Relay transport for LAN, Tailscale, and public HTTPS routes. Recommended Tailscale uses dedicated HTTPS `:10443` for local Dashboard `:9119`, avoiding conflicts with an existing Traefik, Caddy, or nginx listener on `:443`; old `:443`/`:9119` routes and direct `:8767` remain explicit migration compatibility while `:8642` stays an optional API fallback.
|
||||
- **Dashboard pairing now explains and verifies route security before exposing an invite.** Endpoint receipts show Dashboard, Relay, and API URLs with priority and per-surface probe status, distinguish tailnet encryption from application TLS, and block malformed or plaintext public candidates.
|
||||
- **Connections now explain and recover each Dashboard, Relay, and optional API route independently.** LAN, Tailscale, and public HTTPS can fail over without allowing an unauthenticated or different-origin Relay route to borrow Dashboard credentials. Protected same-origin Relay health challenges are recognized as authentication boundaries instead of outages. An updated optional Hermes-Relay Plugin is required for same-origin Relay ingress. (Related: #399)
|
||||
- **Android What's New leads with one curated release highlight without interrupting startup.** A timed post-update toast can be swiped or closed, previews additional feature/fix counts when a release has meaningful secondary items, expands into the centered highlight view on request, and keeps the full technical history available. Each release can present one plain-language summary, up to three primary benefits, and up to two quieter improvements, while release checks keep the structured entry, fallback, Play copy, and public release records aligned.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android wake-word detection now loads a compatible native ONNX Runtime.** Packaged sherpa and Java JNI consumers are checked against the shared runtime for every supported ABI before release.
|
||||
- **Android wake-word detection now loads a compatible native ONNX Runtime.** Packaged sherpa and Java JNI consumers are checked against the shared runtime for every supported ABI before release. (#444)
|
||||
- **Android Continuous voice waits for barge-in microphone teardown before listening again.** Multi-turn hands-free conversations no longer lose the microphone after a response finishes with barge-in enabled. (#464)
|
||||
- **Opening Android no longer claims or interrupts a turn already running in Hermes Desktop/TUI.** Passive foreground and session browsing now use read-only Gateway status plus profile-scoped history; live-session resume remains reserved for explicit Android actions and exact Android-owned recovery.
|
||||
- **Android provisional Threads can be removed without touching server history.** The drawer now offers a local-only removal action, reconciles promoted phone sessions without duplicate rows, and keeps Thread routing isolated to the active saved connection.
|
||||
- **Android Clarify cards make custom answers explicit and keyboard-friendly.** Choice prompts label their Other answer field, submit trimmed text from the keyboard, and do not restore an authoritatively expired prompt after session navigation.
|
||||
- **Opening Android no longer claims or interrupts a turn already running in Hermes Desktop/TUI.** Passive foreground and session browsing now use read-only Gateway status plus profile-scoped history; live-session resume remains reserved for explicit Android actions and exact Android-owned recovery. (Related: #365)
|
||||
- **Android provisional Threads can be removed without touching server history.** The drawer now offers a local-only removal action, reconciles promoted phone sessions without duplicate rows, and keeps Thread routing isolated to the active saved connection. (#461)
|
||||
- **Android Clarify cards make custom answers explicit and keyboard-friendly.** Choice prompts label their Other answer field, submit trimmed text from the keyboard, and do not restore an authoritatively expired prompt after session navigation. (#446)
|
||||
- **The visible Android Sphere keeps its smooth procedural motion across startup and chat.** Backgrounded and motion-disabled surfaces remain still without reducing foreground animation to a stepped ambient pulse.
|
||||
- **Android Voice Focus keeps Stop and immediate spoken steering available across every interaction mode.** Hold-to-talk now interrupts Thinking and Transcribing turns before capturing the replacement direction, remains operable through TalkBack, Switch Access, and keyboard controls, preserves pointer press-and-release behavior across floating controls, and Google Play no longer offers the sideload-only system overlay action.
|
||||
- **Android Assistant sessions explain when no speech was captured instead of appearing stuck at Ready.** Retry feedback survives the separate system overlay process, recreated session UI requests the current turn state, and locked sessions keep transcript, response, and technical error text private.
|
||||
- **Android New Chat keeps the current profile and stays fresh across profile switches.** Starting from All Profiles no longer forces the literal default profile, choosing another profile from an empty draft no longer reopens that profile's previous session after route settlement or restart, and leaving a provisional phone Thread cannot route the next turn to its old chat under the new profile.
|
||||
- **Android self-hosted OIDC keeps sign-in on one trusted Dashboard origin.** Android follows upstream `native_pkce` capability for every interactive provider and falls back to exact-host cookies only when needed. Private and Tailscale routes can discover a provider-declared callback, verify the same installation, and ask before retaining a different authenticated Dashboard/Gateway origin; public origins require HTTPS while reviewed local or overlay HTTP remains compatible. Routes presents that origin separately from optional network paths and no longer exposes internal roles as a VPN. (#399)
|
||||
- **Android Assistant sessions explain when no speech was captured instead of appearing stuck at Ready.** Retry feedback survives the separate system overlay process, recreated session UI requests the current turn state, and locked sessions keep transcript, response, and technical error text private. (Related: #424)
|
||||
- **Android New Chat keeps the current profile and stays fresh across profile switches.** Starting from All Profiles no longer forces the literal default profile, choosing another profile from an empty draft no longer reopens that profile's previous session after route settlement or restart, and leaving a provisional phone Thread cannot route the next turn to its old chat under the new profile. (#436)
|
||||
- **Android Dashboard connections and profile drawers no longer wait on unavailable optional routes.** Dashboard, API fallback, and Relay probes run independently; API/Relay never gate a normal Dashboard connection, Gateway auth/ticket failures are not blindly retried, and authenticated session history remains available without a live Gateway socket. Concurrent route probes are shared and generation-safe, healthy same-priority routes win immediately, superseded session reads cancel their HTTP calls, and optional PR decoration stays outside the session-list critical path.
|
||||
|
||||
### Removed
|
||||
|
||||
- **Android Chat no longer includes the hidden clean-focus presentation.** The long-press gesture, overlapping instructional pill, reduced composer, and alternate fading transcript were removed so Chat keeps one complete interaction model. Voice Focus remains available.
|
||||
|
||||
## [Plugin 1.11.0] - 2026-08-30
|
||||
|
||||
### Added
|
||||
|
||||
- **Hermes-Relay Plugin provides a bounded Git workspace API for authenticated Dashboard clients.** Configured repository roots, path validation, tracked line totals, scoped write grants, and explicit confirmation protect repository reads and mutations.
|
||||
- **Relay extensions can use the authenticated Dashboard origin as one network ingress.** Fixed allowlisted HTTP and WebSocket paths proxy to the local Relay while Dashboard admission and Relay session authentication remain separate. (Related: #399)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Hermes-Relay Dashboard management is organized around operator tasks.** Overview, Devices, Activity, Remote Access, Git, and Settings now have separate native Dashboard surfaces; pairing is QR-first, paired clients use responsive cards, and token-backed media is labeled as a bounded diagnostic instead of a health counter. (#486)
|
||||
- **Dashboard, CLI, and TUI pairing advertise the same explicit route set.** Recommended Tailscale uses dedicated HTTPS `:10443` for local Dashboard `:9119`, public HTTPS and LAN stay visible fallbacks, and old `:443`/`:9119` plus direct `:8767` remain migration compatibility.
|
||||
- **Pairing receipts explain transport protection before exposing an invite.** Per-surface probes distinguish application TLS, tailnet encryption, optional API fallback, and authenticated Relay ingress.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Public and roaming pairing no longer invent closed direct Relay or Dashboard ports.** Exact Dashboard origins own their same-origin Relay paths, ambiguous or plaintext public candidates fail closed, and inactive optional API routes are omitted.
|
||||
- **Dense pairing QRs scan reliably.** Dashboard, CLI, and TUI render integer-sized modules with a full quiet zone.
|
||||
- **Remote-access migration keeps existing listeners safe.** Recommended setup avoids taking over `:443`, explicit legacy cleanup remains available, and default disable actions remove only the listeners they own.
|
||||
|
||||
## [0.4.0-beta.6] - 2026-08-30
|
||||
|
||||
### Changed
|
||||
|
||||
- **Hermes-Relay CLI+UI preserves the complete multi-route pairing topology.** Dashboard, Relay, optional API, priorities, and transport protection remain attached to one saved host across LAN, Tailscale, and public routes. (Related: #399)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Desktop rejects Dashboard-ingress Relay dials until it can mint Dashboard WebSocket tickets.** The daemon and host selector choose a compatible direct Relay fallback instead of attempting an unauthenticated same-origin ingress.
|
||||
- **API-less pairing remains valid.** Dashboard and direct Relay routes can pair without inventing an optional API server, while secure-first ranking keeps plain LAN as the final fallback.
|
||||
|
||||
## [Android 1.13.2] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
+6
-22
@@ -1,33 +1,22 @@
|
||||
# Hermes-Relay CLI+UI v__VERSION__
|
||||
|
||||
**Release Date:** 2026-08-25
|
||||
**Release Date:** 2026-08-30
|
||||
|
||||
This beta makes the Desktop connector resilient through Relay interruptions,
|
||||
aligns Windows computer control with current CUA Driver releases, adds a native
|
||||
Linux ARM64 build, and hardens installation and update discovery.
|
||||
This beta preserves complete multi-route pairing while preventing Desktop from dialing Dashboard-ingress Relay routes before Dashboard WebSocket ticket support is available. (Related: #399)
|
||||
|
||||
**Beta phase.** Assets remain unsigned, so Windows SmartScreen and macOS Gatekeeper may warn on first launch. Standalone CLI binaries ship for Windows x64, Linux x64/arm64, and macOS x64/arm64; the management UI is Windows-only.
|
||||
|
||||
## What's changed
|
||||
|
||||
### Added
|
||||
|
||||
- **Linux ARM64 is a first-class release target.** The one-line installer,
|
||||
updater, checksums, and release artifacts now cover both Linux x64 and arm64.
|
||||
- **The public site shows the real Windows CLI UI.** Deterministic screenshots
|
||||
cover connections, host access, activity, computer control, and updates.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Public naming is aligned.** Releases use `Hermes-Relay CLI+UI` while the
|
||||
beta keeps its existing `desktop-v*` tag and updater contract.
|
||||
- **Saved hosts retain the full route topology.** Dashboard, Relay, optional API, route priority, transport protection, certificate pins, and the selected host survive LAN, Tailscale, and public-route changes without creating duplicate hosts.
|
||||
- **API-less pairing is first-class.** Dashboard plus direct Relay can pair without inventing an optional API server, while secure-first ranking retains plain LAN as the final fallback.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The daemon reconnects instead of exiting after an interrupted Relay socket.** Relay restarts and repeated transient replacement failures stay on bounded automatic backoff, and terminal failures persist an accurate stopped reason for the UI.
|
||||
- **Oversized desktop-tool output no longer closes the shared connection.** PowerShell output and every serialized desktop response stay inside the Relay WebSocket budget.
|
||||
- **Current CUA Driver releases remain compatible by contract.** Driver 0.20 and newer are accepted when their manifest and required tools match Hermes, and Windows uses the manifest-declared direct standard-mode runtime instead of a stale machine-wide daemon.
|
||||
- **Install and update discovery paginates the multi-surface release history.** Desktop releases remain discoverable after more Android and Server releases, Windows cooperative updates clean their released backup, and unsigned installers retain the normal SmartScreen warning.
|
||||
- **Dashboard-ingress Relay routes fail closed on Desktop.** The daemon, host selector, and Relay transport reject ingress that requires a Dashboard WebSocket ticket and choose a compatible direct Relay fallback instead of attempting an unauthenticated dial.
|
||||
- **Pairing accepts the current v3 candidate shape.** Optional API records, same-origin Dashboard/Relay routes, and legacy top-level payloads remain compatible without collapsing route ownership.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -58,9 +47,4 @@ hermes-relay --version
|
||||
hermes-relay hosts list --json
|
||||
hermes-relay daemon start
|
||||
hermes-relay daemon status --json
|
||||
hermes-relay computer-use status --json
|
||||
```
|
||||
|
||||
On Windows, click the Hermes-Relay CLI UI notification-area icon to open the management popup directly above it.
|
||||
|
||||
See the [CLI and tray guide](https://hermes-relay.dev/docs/desktop/) for installation, access modes, grants, and troubleshooting.
|
||||
|
||||
+14
-9
@@ -1,26 +1,29 @@
|
||||
# Hermes-Relay Plugin v__VERSION__
|
||||
|
||||
**Release Date:** August 25, 2026
|
||||
**Release Date:** August 30, 2026
|
||||
|
||||
## Summary
|
||||
|
||||
This release adds a provider-neutral account-usage surface for Android and Dashboard clients. Relay resolves Codex credential pools, structured Nous balances, and OpenCode Go windows on the Hermes host without returning provider credentials.
|
||||
|
||||
Standard chat, session history, and Vanilla Hermes voice remain upstream-owned and do not require this plugin.
|
||||
This release lets one authenticated Hermes Dashboard origin carry Gateway plus optional Relay extensions, adds a bounded Git workspace, and reorganizes the Dashboard plugin around operator tasks. Standard chat, session history, profiles, Manage, and standard voice remain upstream-owned and do not require this plugin.
|
||||
|
||||
## Added
|
||||
|
||||
- **Provider-neutral usage snapshots.** Authenticated Dashboard clients can resolve the exact active Codex pool entry, Nous balances, and OpenCode Go account windows through one normalized schema.
|
||||
- **Bounded paired-client fallback.** Operators may explicitly enable the Relay usage route for paired standalone clients while credentials remain host-side.
|
||||
- **Dashboard same-origin Relay ingress.** Fixed allowlisted HTTP and WebSocket routes proxy to the local Relay while Dashboard admission and Relay session authentication remain independent. (Related: #399)
|
||||
- **Bounded Git workspace.** Configured roots, path containment, line totals, diffs, branches, staging, commits, remotes, grants, and explicit confirmations protect repository operations.
|
||||
|
||||
## Changed
|
||||
|
||||
- **Usage capabilities are explicit.** Responses identify Relay-enhanced credential pools, structured balances, and provider adapters instead of implying unsupported upstream data.
|
||||
- **Public product naming is aligned.** Releases use `Hermes-Relay Plugin` while retaining the `server-v*` tag and installation contract.
|
||||
- **Task-oriented Dashboard UI.** Overview, Devices, Activity, Remote Access, Git, and Settings now have dedicated surfaces with QR-first pairing, responsive device cards, and honest media diagnostics. (#486)
|
||||
- **One explicit route topology.** Dashboard, CLI, and TUI pairing advertise Dashboard, Relay, and optional API surfaces with stable priorities across Tailscale, public HTTPS, and LAN.
|
||||
- **Dedicated Tailscale listener.** Recommended setup uses tailnet HTTPS `:10443` to local Dashboard `:9119`, avoiding ownership of a reverse proxy's `:443`. Existing `:443`, `:9119`, and direct `:8767` routes remain migration compatibility.
|
||||
|
||||
## Fixed
|
||||
|
||||
- **Custom Hermes homes resolve correctly.** Relay profile discovery and session persistence follow `HERMES_HOME` by default while preserving the explicit `RELAY_HERMES_CONFIG` override.
|
||||
- Public and roaming invites no longer synthesize closed direct Relay `:8767` or wrong Dashboard `:9119` routes.
|
||||
- Ambiguous, credential-bearing, or plaintext public candidates fail closed before an invite is exposed.
|
||||
- Dense pairing QRs use integer-sized modules and a full quiet zone.
|
||||
- Inactive optional API routes are omitted; protected Dashboard-ingress `401/403` responses display as authentication-required while direct Relay and API failures remain failures.
|
||||
- Default Tailscale disable actions remove only owned listeners, and explicit migration cleanup accepts only the bounded supported ports.
|
||||
|
||||
## Install / update
|
||||
|
||||
@@ -32,6 +35,8 @@ Standard chat, session history, and Vanilla Hermes voice remain upstream-owned a
|
||||
# or, if already installed:
|
||||
hermes-relay-update
|
||||
|
||||
Restart or reload the Hermes Dashboard and Relay after updating so the new manifest, routes, and committed Dashboard bundle are active.
|
||||
|
||||
## Verify
|
||||
|
||||
hermes relay doctor
|
||||
|
||||
+24
-11
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay Android v1.13.2
|
||||
# Hermes-Relay Android v1.14.0
|
||||
|
||||
**Release Date:** August 25, 2026
|
||||
**Release Date:** August 30, 2026
|
||||
|
||||
## Download
|
||||
|
||||
> Installing on your phone? Download `hermes-relay-1.13.2-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
> Installing on your phone? Download `hermes-relay-1.14.0-sideload-release.apk` and tap it for the full feature set, or install the conservative build from [Google Play](https://play.google.com/store/apps/details?id=com.axiomlabs.hermesrelay).
|
||||
|
||||
The `.aab` file is a Play Console upload bundle and cannot be installed by tapping it on a phone.
|
||||
|
||||
@@ -12,20 +12,33 @@ Verify the download against `SHA256SUMS.txt`. See the [sideload guide](https://h
|
||||
|
||||
## Summary
|
||||
|
||||
This release adds a parent-configured Supervised Mode and improves its return from full settings. It also keeps session rows neutral until live activity is confirmed.
|
||||
This release makes saved Hermes connections reliable across LAN, Tailscale, and public HTTPS while keeping Dashboard authentication bound to its exact trusted origin. It also adds delegated-agent previews and an optional native Git workspace, and improves Voice, Assistant, Threads, profile drafts, and Clarify interactions.
|
||||
|
||||
## Added
|
||||
|
||||
- Use a profile-pinned Supervised Mode with parent-controlled attachments, Standard voice, generated media, history, actions, and technical details. Device authentication protects full settings; this remains a client-side restricted view rather than a server-enforced account boundary.
|
||||
- **Delegated-agent previews.** Follow bounded lifecycle, progress, tool previews, and available read-only child history without leaving the parent chat. Partial history and reconnect gaps remain explicit. (#447)
|
||||
- **Native Git workspace.** Review repository state, diffs, branches, staging, commits, and remotes from Chat or Settings. Git operations require Hermes-Relay Plugin v1.11.0 and retain confirmation and grant boundaries.
|
||||
|
||||
## Changed
|
||||
|
||||
- **Route-aware connections.** Dashboard, Relay, and optional API health are evaluated independently across LAN, Tailscale, and public HTTPS. Same-origin Relay ingress stays on the Dashboard origin that owns authentication, while direct compatibility routes keep separate credentials. (Related: #399)
|
||||
- **Voice Focus controls.** Stop and immediate spoken steering remain accessible while Hermes is Thinking, Transcribing, or Speaking, including TalkBack, Switch Access, keyboard, and sideload overlay surfaces.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Keep session rows neutral while optional live activity is unavailable or still loading, and reserve full-row activity borders for actual Starting or Working turns.
|
||||
- Keep Supervised Chat rendered when parent access relocks after visiting full settings.
|
||||
- Wake-word detection packages one compatible ONNX Runtime for sherpa and Java JNI on every supported ABI. (#444)
|
||||
- Continuous voice waits for barge-in microphone teardown before listening again. (#464)
|
||||
- Fresh chats retain their selected profile without reopening a previous session or carrying a proactive Thread route across profiles. (#436)
|
||||
- Provisional Threads can be removed locally and reconcile with promoted sessions without deleting server history. (#461)
|
||||
- Clarify cards expose a reachable Other answer, keyboard Send, and authoritative expiry behavior. (#446)
|
||||
- Passive Android browsing no longer claims or interrupts a turn owned by another client. (Related: #365)
|
||||
- Assistant sessions show retryable no-speech feedback, recover their active state after recreation, and redact conversation details behind the keyguard. (Related: #424)
|
||||
- Protected Relay ingress `401/403` responses are recognized as authentication boundaries rather than outages; malformed, different-origin, and direct unauthorized routes still fail closed.
|
||||
|
||||
## Install / Verify
|
||||
|
||||
- App version: **1.13.2** (versionCode **51**).
|
||||
- Standard Chat, sessions, Manage, sharing, profile switching, and Vanilla Hermes voice continue to work against unmodified upstream Hermes.
|
||||
- Granular Device Control remains sideload-only; the Google Play build continues to ship Hermes Bridge Core without AccessibilityService Device Control.
|
||||
- The optional Relay plugin remains unnecessary for standard Android chat, sessions, Manage, and Vanilla Hermes voice.
|
||||
- App version: **1.14.0** (versionCode **52**).
|
||||
- Standard Chat, sessions, profiles, Manage, and standard voice continue to work against unmodified upstream Hermes without the optional Relay plugin.
|
||||
- Install Hermes-Relay Plugin v1.11.0 for same-origin Relay extensions, Git workspace actions, Bridge, media, proactive features, and enhanced voice.
|
||||
- Granular Device Control and the system Voice Focus overlay remain sideload-only; the Google Play build does not declare their restricted permissions.
|
||||
- Existing connections, drafts, sessions, profile ownership, and legacy direct Relay routes remain data-preserving compatibility paths.
|
||||
|
||||
@@ -399,8 +399,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.72.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.72.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.73.0")
|
||||
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.73.0")
|
||||
testImplementation(libs.compose.ui.test.junit4)
|
||||
testImplementation(libs.compose.ui.test.manifest)
|
||||
testImplementation("androidx.test.ext:junit:1.3.0")
|
||||
|
||||
-45
@@ -7,7 +7,6 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
@@ -28,50 +27,6 @@ class AmbientVisualizationVisibilityTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun cleanMode_backgroundOff_hidesSphereAndKeepsComposer() {
|
||||
composeTestRule.setContent {
|
||||
AmbientTestProviders(enabled = false) {
|
||||
CleanChatMode(
|
||||
messages = emptyList(),
|
||||
isStreaming = false,
|
||||
sphereState = SphereState.Idle,
|
||||
streamingIntensity = 0f,
|
||||
toolCallBurst = 0f,
|
||||
animationEnabled = true,
|
||||
enabled = true,
|
||||
onSend = {},
|
||||
onExit = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithContentDescription(targetString(R.string.agent_text_send_cd))
|
||||
.assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cleanMode_backgroundOn_rendersSphere() {
|
||||
composeTestRule.setContent {
|
||||
AmbientTestProviders(enabled = true) {
|
||||
CleanChatMode(
|
||||
messages = emptyList(),
|
||||
isStreaming = false,
|
||||
sphereState = SphereState.Idle,
|
||||
streamingIntensity = 0f,
|
||||
toolCallBurst = 0f,
|
||||
animationEnabled = false,
|
||||
enabled = true,
|
||||
onSend = {},
|
||||
onExit = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag(AMBIENT_RENDERER_TAG).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceMode_backgroundOff_hidesSphereAndKeepsVoiceUi() {
|
||||
composeTestRule.setContent {
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.hermesandroid.relay.data.BotGatewayRoute
|
||||
import com.hermesandroid.relay.data.BotGatewayRouteKey
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.viewmodel.AndroidGatewayContractFixture
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/** On-device proof for the route-owned first-composition collection boundary. */
|
||||
class BotChatScreenBindingInstrumentedTest {
|
||||
private lateinit var fixture: AndroidGatewayContractFixture
|
||||
private lateinit var gatewayScope: CoroutineScope
|
||||
private lateinit var dashboardClient: DashboardApiClient
|
||||
private lateinit var gatewayClient: GatewayChatClient
|
||||
private lateinit var viewModel: ChatViewModel
|
||||
private lateinit var handler: ChatHandler
|
||||
private var activityScenario: ActivityScenario<BotChatBindingTestActivity>? = null
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
fixture = AndroidGatewayContractFixture()
|
||||
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
dashboardClient = DashboardApiClient(
|
||||
baseUrl = fixture.server.url("/").toString().trimEnd('/'),
|
||||
okHttpClient = OkHttpClient(),
|
||||
)
|
||||
gatewayClient = GatewayChatClient(
|
||||
initialDashboardClient = dashboardClient,
|
||||
okHttpClient = OkHttpClient(),
|
||||
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
|
||||
scope = gatewayScope,
|
||||
reconnectJitterUnit = { 0.0 },
|
||||
)
|
||||
viewModel = ChatViewModel()
|
||||
handler = ChatHandler()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
activityScenario?.close()
|
||||
viewModel.updateGatewayClient(null)
|
||||
gatewayClient.shutdown()
|
||||
gatewayScope.cancel()
|
||||
dashboardClient.shutdown()
|
||||
fixture.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fastInitialHistoryRendersBeforeNavigationAndSurvivesLifecycleResume() {
|
||||
val route = BotGatewayRoute(
|
||||
key = BotGatewayRouteKey("fixture-gateway", PROFILE_NAME),
|
||||
connectionLabel = "Fixture gateway",
|
||||
)
|
||||
val bot = BotRosterEntry(
|
||||
profile = Profile(
|
||||
name = PROFILE_NAME,
|
||||
model = "fixture-model",
|
||||
description = "Fixture profile",
|
||||
),
|
||||
displayName = "Research",
|
||||
route = route,
|
||||
)
|
||||
val scenario = ActivityScenario.launch(BotChatBindingTestActivity::class.java)
|
||||
.also { activityScenario = it }
|
||||
|
||||
scenario.onActivity { activity ->
|
||||
activity.setContent {
|
||||
MaterialTheme {
|
||||
BotChatScreen(
|
||||
route = route,
|
||||
bot = bot,
|
||||
sessionId = STORED_SESSION_ID,
|
||||
gatewayClient = gatewayClient,
|
||||
dashboardClient = dashboardClient,
|
||||
chatViewModel = viewModel,
|
||||
onBack = {},
|
||||
handlerFactory = { handler },
|
||||
historyLoader = { _, _, _ ->
|
||||
Result.success(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = HISTORY_ID,
|
||||
sessionId = STORED_SESSION_ID,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(HISTORY_TEXT),
|
||||
timestamp = 1.0,
|
||||
finishReason = "stop",
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
profileIconFlow = { _, _ -> MutableStateFlow(null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
waitUntil { handler.messages.value.singleOrNull()?.content == HISTORY_TEXT }
|
||||
waitUntil { renderedTextExists(HISTORY_TEXT) }
|
||||
|
||||
scenario.moveToState(Lifecycle.State.STARTED)
|
||||
scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
waitUntil { renderedTextExists(HISTORY_TEXT) }
|
||||
assertEquals(0, fixture.rpcCount("prompt.submit"))
|
||||
}
|
||||
|
||||
private fun renderedTextExists(expected: String): Boolean {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
instrumentation.waitForIdleSync()
|
||||
val root = instrumentation.uiAutomation.rootInActiveWindow ?: return false
|
||||
return root.containsText(expected)
|
||||
}
|
||||
|
||||
private fun AccessibilityNodeInfo.containsText(expected: String): Boolean {
|
||||
if (text?.toString() == expected || contentDescription?.toString() == expected) return true
|
||||
return (0 until childCount).any { index -> getChild(index)?.containsText(expected) == true }
|
||||
}
|
||||
|
||||
private fun waitUntil(condition: () -> Boolean) {
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
|
||||
while (System.nanoTime() < deadline) {
|
||||
if (condition()) return
|
||||
Thread.sleep(25)
|
||||
}
|
||||
assertTrue("Condition was not satisfied within 5 seconds", condition())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PROFILE_NAME = "research"
|
||||
const val STORED_SESSION_ID = "20260829_120000_bot_chat"
|
||||
const val HISTORY_ID = "persisted-bot-history"
|
||||
const val HISTORY_TEXT = "Durable Bot Chat history is ready."
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application>
|
||||
<activity
|
||||
android:name="com.hermesandroid.relay.ui.screens.BotChatBindingTestActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait" />
|
||||
<activity
|
||||
android:name="com.hermesandroid.relay.ui.screens.VoiceSettingsDesignQaActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
|
||||
/** Empty debug-only host populated by the Bot Chat lifecycle instrumentation. */
|
||||
class BotChatBindingTestActivity : ComponentActivity()
|
||||
@@ -1,3 +1,3 @@
|
||||
v1.13.2 - Supervised Mode and clearer activity
|
||||
v1.14.0 - Connections that follow you
|
||||
|
||||
Supervised Mode creates a simpler, profile-focused chat with device-protected parent settings and control over attachments, Standard voice, generated media, history, actions, and technical details. Activity indicators now appear only while Hermes is genuinely working, and returning from parent settings keeps Supervised Chat open.
|
||||
Connections now recover independently across LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication. Preview delegated agents, use the optional native Git workspace, and get safer Continuous voice, Voice Focus, Assistant, Threads, profile drafts, and Clarify controls. Wake-word detection also packages a compatible native runtime.
|
||||
|
||||
@@ -1,6 +1,56 @@
|
||||
{
|
||||
"schema": 2,
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.14.0",
|
||||
"title": "Connections that follow you",
|
||||
"date": "2026-08-30",
|
||||
"highlight": {
|
||||
"title": "Reliable routes",
|
||||
"summary": "Move between LAN, Tailscale, and public HTTPS without mixing Dashboard or Relay authentication.",
|
||||
"bullets": [
|
||||
"Keep Chat and sessions on the trusted Dashboard while optional Relay routes recover independently.",
|
||||
"Preview delegated agent work and use the optional native Git workspace.",
|
||||
"Use more reliable Continuous voice, Voice Focus, Assistant, Threads, profiles, and Clarify controls."
|
||||
]
|
||||
},
|
||||
"improvements": [
|
||||
"Wake-word detection now packages a compatible native runtime for every supported ABI.",
|
||||
"Protected Relay health checks no longer appear as broken routes."
|
||||
],
|
||||
"toastDigest": {
|
||||
"additionalFeatureCount": 2,
|
||||
"fixCount": 10,
|
||||
"preview": [
|
||||
"delegated-agent previews",
|
||||
"safer voice and sessions"
|
||||
]
|
||||
},
|
||||
"playNotes": "Connections now recover independently across LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication. Preview delegated agents, use the optional native Git workspace, and get safer Continuous voice, Voice Focus, Assistant, Threads, profile drafts, and Clarify controls. Wake-word detection also packages a compatible native runtime.",
|
||||
"sections": [
|
||||
{
|
||||
"header": "Use the best available route",
|
||||
"bullets": [
|
||||
"Resolve Dashboard, Relay, and optional API health independently across LAN, Tailscale, and public HTTPS.",
|
||||
"Keep same-origin Relay ingress on the exact Dashboard origin that owns authentication."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Follow active work",
|
||||
"bullets": [
|
||||
"Preview delegated-agent lifecycle, progress, tools, and available read-only child history from the parent chat.",
|
||||
"Review Git status, diffs, branches, staging, commits, and remotes through the optional Relay plugin."
|
||||
]
|
||||
},
|
||||
{
|
||||
"header": "Keep voice and conversations owned correctly",
|
||||
"bullets": [
|
||||
"Serialize microphone handoff and keep Voice Focus Stop and steering accessible across active phases.",
|
||||
"Preserve fresh profile drafts, provisional Thread ownership, Clarify answers, passive observation, and Assistant privacy through reconnects."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "1.13.2",
|
||||
"title": "Supervised Mode and clearer activity",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
v1.13.2 - Supervised Mode and clearer activity
|
||||
v1.14.0 - Connections that follow you
|
||||
|
||||
Supervised Mode
|
||||
* Protect parent settings with your phone's device authentication.
|
||||
* Choose access to attachments, Standard voice, generated media, history, actions, and technical details.
|
||||
* Keep Supervised Chat open when returning from parent settings.
|
||||
Reliable routes
|
||||
* Keep Chat and sessions on the trusted Dashboard while optional Relay routes recover independently.
|
||||
* Preview delegated agent work and use the optional native Git workspace.
|
||||
* Use more reliable Continuous voice, Voice Focus, Assistant, Threads, profiles, and Clarify controls.
|
||||
|
||||
Also improved
|
||||
* Activity indicators now appear only while Hermes is genuinely working.
|
||||
* Wake-word detection now packages a compatible native runtime for every supported ABI.
|
||||
* Protected Relay health checks no longer appear as broken routes.
|
||||
|
||||
@@ -56,11 +56,21 @@ import com.hermesandroid.relay.data.BotRosterEntry
|
||||
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.SessionMessageLoadMode
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.ui.components.MessageBubble
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
internal typealias BotChatHistoryLoader = suspend (
|
||||
profileName: String,
|
||||
sessionId: String,
|
||||
mode: SessionMessageLoadMode,
|
||||
) -> Result<List<MessageItem>>
|
||||
internal typealias BotChatProfileIconFlow = (connectionId: String, profileName: String) -> Flow<String?>
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -74,14 +84,52 @@ fun BotChatScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val handler = remember(route.key) { ChatHandler() }
|
||||
BotChatScreen(
|
||||
route = route,
|
||||
bot = bot,
|
||||
sessionId = sessionId,
|
||||
gatewayClient = gatewayClient,
|
||||
dashboardClient = dashboardClient,
|
||||
chatViewModel = chatViewModel,
|
||||
onBack = onBack,
|
||||
handlerFactory = ::ChatHandler,
|
||||
historyLoader = { profileName, storedSessionId, mode ->
|
||||
dashboardClient.getSessionMessages(
|
||||
sessionId = storedSessionId,
|
||||
profile = profileName,
|
||||
mode = mode,
|
||||
)
|
||||
},
|
||||
profileIconFlow = connectionViewModel::profileIconFlow,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun BotChatScreen(
|
||||
route: BotGatewayRoute,
|
||||
bot: BotRosterEntry,
|
||||
sessionId: String,
|
||||
gatewayClient: GatewayChatClient,
|
||||
dashboardClient: DashboardApiClient,
|
||||
chatViewModel: ChatViewModel,
|
||||
onBack: () -> Unit,
|
||||
handlerFactory: () -> ChatHandler,
|
||||
historyLoader: BotChatHistoryLoader,
|
||||
profileIconFlow: BotChatProfileIconFlow,
|
||||
) {
|
||||
val handler = remember(route.key) { handlerFactory() }
|
||||
val context = LocalContext.current
|
||||
val messages by chatViewModel.messages.collectAsState()
|
||||
val isStreaming by chatViewModel.isStreaming.collectAsState()
|
||||
// This route owns the handler but binds it to the ViewModel only after the
|
||||
// first composition. Collecting delegated ViewModel getters here can pin
|
||||
// Compose to their empty pre-bind fallback when history settles before the
|
||||
// next frame. Observe the route-owned source directly so StateFlow replay
|
||||
// covers fast history, live streaming, completion, and errors.
|
||||
val messages by handler.messages.collectAsState()
|
||||
val isStreaming by handler.isStreaming.collectAsState()
|
||||
val isLoading by chatViewModel.isLoadingHistory.collectAsState()
|
||||
val error by chatViewModel.error.collectAsState()
|
||||
val iconPath by connectionViewModel
|
||||
.profileIconFlow(route.connectionId, route.profileName)
|
||||
val error by handler.error.collectAsState()
|
||||
val iconPath by profileIconFlow(route.connectionId, route.profileName)
|
||||
.collectAsState(initial = null)
|
||||
val listState = rememberLazyListState()
|
||||
var composer by remember(route.key, sessionId) { mutableStateOf("") }
|
||||
@@ -101,11 +149,7 @@ fun BotChatScreen(
|
||||
selected?.name == route.profileName
|
||||
}
|
||||
chatViewModel.setProfileMessageLoaderWithMode { _, storedSessionId, mode ->
|
||||
dashboardClient.getSessionMessages(
|
||||
sessionId = storedSessionId,
|
||||
profile = route.profileName,
|
||||
mode = mode,
|
||||
)
|
||||
historyLoader(route.profileName, storedSessionId, mode)
|
||||
}
|
||||
chatViewModel.updateApiClient(null)
|
||||
chatViewModel.updateGatewayClient(gatewayClient)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.hermesandroid.relay.data.BotGatewayRoute
|
||||
import com.hermesandroid.relay.data.BotGatewayRouteKey
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.network.upstream.ChatHandler
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.network.upstream.GatewayChatClient
|
||||
import com.hermesandroid.relay.network.upstream.models.MessageItem
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [35], qualifiers = "w390dp-h844dp-432dpi")
|
||||
class BotChatScreenBindingTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private val resources = CopyOnWriteArrayList<ScreenResources>()
|
||||
@After
|
||||
fun tearDown() {
|
||||
resources.forEach(ScreenResources::close)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fastHistoryPublishedDuringInitialBindRendersWithoutNavigation() {
|
||||
val screen = resources("research")
|
||||
val handler = ChatHandler()
|
||||
|
||||
compose.mainClock.autoAdvance = false
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
screen.content(
|
||||
handler = handler,
|
||||
historyLoader = { _, _, _ ->
|
||||
Result.success(history(screen.sessionId, FAST_HISTORY))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.mainClock.advanceTimeByFrame()
|
||||
compose.waitUntil(5_000) { handler.messages.value.singleOrNull()?.content == FAST_HISTORY }
|
||||
compose.mainClock.autoAdvance = true
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithText(FAST_HISTORY).assertIsDisplayed()
|
||||
|
||||
compose.runOnIdle { handler.onTextDelta("live-tail", LIVE_TAIL) }
|
||||
compose.onNodeWithText(LIVE_TAIL).assertIsDisplayed()
|
||||
|
||||
compose.runOnIdle { handler.onStreamError(HANDLER_ERROR) }
|
||||
compose.onNodeWithText(HANDLER_ERROR).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun delayedHistoryAfterCompositionRendersFromTheSameHandler() {
|
||||
val screen = resources("builder")
|
||||
val handler = ChatHandler()
|
||||
val releaseHistory = CompletableDeferred<Unit>()
|
||||
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
screen.content(
|
||||
handler = handler,
|
||||
historyLoader = { _, _, _ ->
|
||||
releaseHistory.await()
|
||||
Result.success(history(screen.sessionId, DELAYED_HISTORY))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.waitUntil(5_000) { screen.viewModel.isLoadingHistory.value }
|
||||
compose.onNodeWithText(DELAYED_HISTORY).assertDoesNotExist()
|
||||
releaseHistory.complete(Unit)
|
||||
compose.waitUntil(5_000) { handler.messages.value.isNotEmpty() }
|
||||
compose.onNodeWithText(DELAYED_HISTORY).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replacementHandlerRejectsLateHistoryAndOldHandlerPublications() {
|
||||
val screen = resources("operator")
|
||||
val firstHandler = ChatHandler()
|
||||
val secondHandler = ChatHandler()
|
||||
val releaseFirstHistory = CompletableDeferred<Unit>()
|
||||
val target = mutableStateOf(
|
||||
Target(
|
||||
revision = 0,
|
||||
handler = firstHandler,
|
||||
loader = { _, _, _ ->
|
||||
releaseFirstHistory.await()
|
||||
Result.success(history(screen.sessionId, OLD_HISTORY))
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
compose.setContent {
|
||||
val current = target.value
|
||||
key(current.revision) {
|
||||
MaterialTheme {
|
||||
screen.content(current.handler, current.loader)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compose.waitUntil(5_000) { screen.viewModel.isLoadingHistory.value }
|
||||
compose.runOnIdle {
|
||||
target.value = Target(
|
||||
revision = 1,
|
||||
handler = secondHandler,
|
||||
loader = { _, _, _ -> Result.success(history(screen.sessionId, NEW_HISTORY)) },
|
||||
)
|
||||
}
|
||||
compose.waitForIdle()
|
||||
compose.runOnIdle { assertSame(secondHandler, screen.viewModel.boundHandler) }
|
||||
compose.waitUntil(5_000) { secondHandler.messages.value.singleOrNull()?.content == NEW_HISTORY }
|
||||
compose.onNodeWithText(NEW_HISTORY).assertIsDisplayed()
|
||||
|
||||
releaseFirstHistory.complete(Unit)
|
||||
compose.waitForIdle()
|
||||
assertEquals(emptyList<String>(), firstHandler.messages.value.map { it.content })
|
||||
compose.runOnIdle { firstHandler.onTextDelta("old-tail", OLD_TAIL) }
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithText(OLD_HISTORY).assertDoesNotExist()
|
||||
compose.onNodeWithText(OLD_TAIL).assertDoesNotExist()
|
||||
assertEquals(listOf(OLD_TAIL), firstHandler.messages.value.map { it.content })
|
||||
assertEquals(listOf(NEW_HISTORY), secondHandler.messages.value.map { it.content })
|
||||
}
|
||||
|
||||
private fun resources(profileName: String): ScreenResources = ScreenResources(profileName).also {
|
||||
resources += it
|
||||
}
|
||||
|
||||
private fun history(sessionId: String, text: String) = listOf(
|
||||
MessageItem(
|
||||
id = "history-$sessionId",
|
||||
sessionId = sessionId,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(text),
|
||||
timestamp = 1.0,
|
||||
finishReason = "stop",
|
||||
),
|
||||
)
|
||||
|
||||
private inner class ScreenResources(profileName: String) {
|
||||
val route = BotGatewayRoute(
|
||||
key = BotGatewayRouteKey("gateway-$profileName", profileName),
|
||||
connectionLabel = "Fixture gateway",
|
||||
)
|
||||
val bot = BotRosterEntry(
|
||||
profile = Profile(
|
||||
name = profileName,
|
||||
model = "fixture-model",
|
||||
description = "Fixture profile",
|
||||
),
|
||||
displayName = profileName.replaceFirstChar(Char::uppercase),
|
||||
route = route,
|
||||
)
|
||||
val sessionId = "fixture-$profileName-session"
|
||||
val viewModel = ChatViewModel()
|
||||
private val gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val dashboardClient = DashboardApiClient("http://127.0.0.1:1")
|
||||
private val gatewayClient = GatewayChatClient(
|
||||
initialDashboardClient = dashboardClient,
|
||||
fixedSessionProfile = profileName,
|
||||
scope = gatewayScope,
|
||||
reconnectJitterUnit = { 0.0 },
|
||||
)
|
||||
|
||||
@androidx.compose.runtime.Composable
|
||||
fun content(handler: ChatHandler, historyLoader: BotChatHistoryLoader) {
|
||||
BotChatScreen(
|
||||
route = route,
|
||||
bot = bot,
|
||||
sessionId = sessionId,
|
||||
gatewayClient = gatewayClient,
|
||||
dashboardClient = dashboardClient,
|
||||
chatViewModel = viewModel,
|
||||
onBack = {},
|
||||
handlerFactory = { handler },
|
||||
historyLoader = historyLoader,
|
||||
profileIconFlow = { _, _ -> MutableStateFlow(null) },
|
||||
)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
viewModel.updateGatewayClient(null)
|
||||
gatewayClient.shutdown()
|
||||
gatewayScope.cancel()
|
||||
dashboardClient.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
private data class Target(
|
||||
val revision: Int,
|
||||
val handler: ChatHandler,
|
||||
val loader: BotChatHistoryLoader,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val FAST_HISTORY = "History loaded before the next frame."
|
||||
const val DELAYED_HISTORY = "History loaded after composition."
|
||||
const val LIVE_TAIL = "Live tail from the owned handler."
|
||||
const val HANDLER_ERROR = "Owned handler error"
|
||||
const val OLD_HISTORY = "Late history from the old route."
|
||||
const val OLD_TAIL = "Old handler live tail."
|
||||
const val NEW_HISTORY = "History from the replacement route."
|
||||
}
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@hermes-relay/cli",
|
||||
"version": "0.4.0-beta.5",
|
||||
"version": "0.4.0-beta.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@hermes-relay/cli",
|
||||
"version": "0.4.0-beta.5",
|
||||
"version": "0.4.0-beta.6",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"hermes-relay": "bin/hermes-relay.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hermes-relay/cli",
|
||||
"version": "0.4.0-beta.5",
|
||||
"version": "0.4.0-beta.6",
|
||||
"description": "Thin-client CLI for Hermes-Relay — talk to a remote Hermes agent over WSS with pairing auth, stream-renders tool calls and responses to plain stdout.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Regenerated from package.json by gen:version script. Do not edit by hand.
|
||||
export const VERSION = "0.4.0-beta.5" as const
|
||||
export const VERSION = "0.4.0-beta.6" as const
|
||||
|
||||
Generated
+1
-1
@@ -1232,7 +1232,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermes-relay-tray"
|
||||
version = "0.4.0-beta.5"
|
||||
version = "0.4.0-beta.6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hermes-relay-tray"
|
||||
version = "0.4.0-beta.5"
|
||||
version = "0.4.0-beta.6"
|
||||
description = "Compact Windows management UI for Hermes-Relay CLI"
|
||||
authors = ["Axiom Labs"]
|
||||
edition = "2021"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@hermes-relay/tray-ui",
|
||||
"version": "0.4.0-beta.5",
|
||||
"version": "0.4.0-beta.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@hermes-relay/tray-ui",
|
||||
"version": "0.4.0-beta.5",
|
||||
"version": "0.4.0-beta.6",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hermes-relay/tray-ui",
|
||||
"private": true,
|
||||
"version": "0.4.0-beta.5",
|
||||
"version": "0.4.0-beta.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hermes-Relay CLI UI",
|
||||
"version": "0.4.0-beta.5",
|
||||
"version": "0.4.0-beta.6",
|
||||
"identifier": "com.axiomlabs.hermes-relay-tray",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -66,6 +66,7 @@ the upstream contract identifiers it depends on.
|
||||
|
||||
| Scenario | Contract exercised |
|
||||
|---|---|
|
||||
| `initial_history_bind` | Durable, profile-scoped history is already available when the client resumes and first binds its rendered transcript |
|
||||
| `ordinary_turn` | Normal message start, deltas, completion, and persisted history |
|
||||
| `rapid_tools_interims` | Rapid chunks, reasoning, tool activity, and interim assistant boundaries |
|
||||
| `queued_follow_up` | Two explicitly owned turns and ordered queue drainage |
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"sourceFingerprint": {
|
||||
"algorithm": "sha256",
|
||||
"normalization": "text-lf-v1",
|
||||
"digest": "8cf00c04da9e80621b439563145be49ede9539ac95ab903b1903763004e370bc",
|
||||
"digest": "38253cb9fb1124a629625ea0bc5be09a4f61af91e3fca0103cbb12a5279aae54",
|
||||
"files": [
|
||||
"desktop/tray/ui/App.tsx",
|
||||
"desktop/tray/ui/main.tsx",
|
||||
|
||||
@@ -91,9 +91,9 @@ This app is a community project and is not affiliated with or endorsed by NousRe
|
||||
Paste into Play Console → **What's new** (≤500 characters):
|
||||
|
||||
```
|
||||
v1.13.2 - Supervised Mode and clearer activity
|
||||
v1.14.0 - Connections that follow you
|
||||
|
||||
Supervised Mode creates a simpler, profile-focused chat with device-protected parent settings and control over attachments, Standard voice, generated media, history, actions, and technical details. Activity indicators now appear only while Hermes is genuinely working, and returning from parent settings keeps Supervised Chat open.
|
||||
Connections now recover independently across LAN, Tailscale, and public HTTPS without mixing Dashboard and Relay authentication. Preview delegated agents, use the optional native Git workspace, and get safer Continuous voice, Voice Focus, Assistant, Threads, profile drafts, and Clarify controls. Wake-word detection also packages a compatible native runtime.
|
||||
```
|
||||
## Category
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
[versions]
|
||||
appVersionName = "1.13.2"
|
||||
appVersionCode = "51"
|
||||
appVersionName = "1.14.0"
|
||||
appVersionCode = "52"
|
||||
agp = "9.3.2"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.08.00"
|
||||
navigation-compose = "2.9.8"
|
||||
navigation-compose = "2.10.0"
|
||||
okhttp = "5.5.0"
|
||||
kotlinx-serialization = "1.11.0"
|
||||
kotlinx-coroutines = "1.11.0"
|
||||
@@ -21,9 +21,9 @@ core-ktx = "1.19.0"
|
||||
exifinterface = "1.4.2"
|
||||
datastore = "1.2.1"
|
||||
splashscreen = "1.2.0"
|
||||
markdown-renderer = "0.44.0"
|
||||
coil = "3.5.0"
|
||||
haze = "1.7.2"
|
||||
markdown-renderer = "0.45.0"
|
||||
coil = "3.6.0"
|
||||
haze = "1.7.3"
|
||||
mlkit-barcode = "17.3.0"
|
||||
zxing-core = "3.5.4"
|
||||
camera = "1.6.1"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"label": "Hermes-Relay",
|
||||
"description": "Paired devices, Bridge activity, media tokens, and remote access for Hermes-Relay",
|
||||
"icon": "Activity",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"tab": {
|
||||
"path": "/relay",
|
||||
"position": "after:skills"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.12",
|
||||
"qrcode": "^1.5.4"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"private": true,
|
||||
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
|
||||
"scripts": {
|
||||
|
||||
+181
-31
@@ -20,9 +20,12 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from threading import Lock, RLock
|
||||
from typing import Any
|
||||
|
||||
from .config import raw_config_value
|
||||
@@ -48,6 +51,12 @@ _SSH_USERINFO_RE = re.compile(r"^([^/@:]+)@([^:]+):")
|
||||
_ERROR_USERINFO_RE = re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*://)([^\s/@]+)@")
|
||||
_ERROR_SSH_USERINFO_RE = re.compile(r"(?<![\w@])([^\s/@:]+)@([^\s:]+):")
|
||||
|
||||
# FastAPI executes synchronous routes concurrently. These locks serialize Git
|
||||
# operations per repository without creating worker threads, so one API call
|
||||
# cannot replace a validated working-tree path underneath another.
|
||||
_REPO_LOCKS_GUARD = Lock()
|
||||
_REPO_LOCKS: dict[str, RLock] = {}
|
||||
|
||||
|
||||
class GitStateError(ValueError):
|
||||
"""A caller supplied an invalid repo id, path, or diff kind."""
|
||||
@@ -91,13 +100,18 @@ def _run_git_bounded(
|
||||
args: list[str],
|
||||
*,
|
||||
mutation: bool,
|
||||
literal_pathspecs: bool = False,
|
||||
) -> tuple[int, str, str]:
|
||||
"""Run Git without materializing unbounded stdout or stderr in memory."""
|
||||
error_type = GitError if mutation else GitStateError
|
||||
with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
|
||||
try:
|
||||
command = ["git", "-C", str(repo)]
|
||||
if literal_pathspecs:
|
||||
command.append("--literal-pathspecs")
|
||||
command.extend(args)
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo), *args],
|
||||
command,
|
||||
stdout=stdout_file,
|
||||
stderr=stderr_file,
|
||||
timeout=GIT_TIMEOUT_SECONDS,
|
||||
@@ -139,9 +153,14 @@ def _safe_git_error(text: str) -> str:
|
||||
return scrubbed[:MAX_GIT_ERROR_BYTES].strip()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
def _git(repo: Path, *args: str, literal_pathspecs: bool = False) -> str:
|
||||
"""Run ``git -C <repo> <args>`` and return bounded stdout."""
|
||||
returncode, stdout, stderr = _run_git_bounded(repo, list(args), mutation=False)
|
||||
returncode, stdout, stderr = _run_git_bounded(
|
||||
repo,
|
||||
list(args),
|
||||
mutation=False,
|
||||
literal_pathspecs=literal_pathspecs,
|
||||
)
|
||||
if returncode != 0:
|
||||
raise GitStateError(
|
||||
f"git {args[0] if args else 'command'} failed for {repo.name}: "
|
||||
@@ -157,13 +176,79 @@ def _is_git_repo(path: Path) -> bool:
|
||||
|
||||
def _is_link_or_junction(path: Path) -> bool:
|
||||
is_junction = getattr(path, "is_junction", None)
|
||||
return path.is_symlink() or bool(is_junction and is_junction())
|
||||
if path.is_symlink() or bool(is_junction and is_junction()):
|
||||
return True
|
||||
if os.name != "nt":
|
||||
return False
|
||||
try:
|
||||
attributes = os.lstat(path).st_file_attributes
|
||||
except (AttributeError, FileNotFoundError, OSError):
|
||||
return False
|
||||
reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400)
|
||||
return bool(attributes & reparse_point)
|
||||
|
||||
|
||||
def _is_within(root: Path, candidate: Path) -> bool:
|
||||
return candidate == root or root in candidate.parents
|
||||
|
||||
|
||||
def _canonical_repo_root(repo: Path) -> Path:
|
||||
"""Return an unlinked absolute repository root or fail closed."""
|
||||
lexical = Path(os.path.abspath(repo))
|
||||
try:
|
||||
if _is_link_or_junction(lexical):
|
||||
raise GitStateError("repository root changed during operation")
|
||||
canonical = lexical.resolve(strict=True)
|
||||
except GitStateError:
|
||||
raise
|
||||
except (OSError, RuntimeError) as exc:
|
||||
raise GitStateError("repository root changed during operation") from exc
|
||||
if os.path.normcase(str(canonical)) != os.path.normcase(str(lexical)):
|
||||
raise GitStateError("repository root changed during operation")
|
||||
return canonical
|
||||
|
||||
|
||||
def _serialized_repo_operation(function: Any) -> Any:
|
||||
"""Serialize a Git operation against other operations on the same repo."""
|
||||
|
||||
@wraps(function)
|
||||
def wrapped(repo: Path, *args: Any, **kwargs: Any) -> Any:
|
||||
key = os.path.normcase(os.path.abspath(repo))
|
||||
with _REPO_LOCKS_GUARD:
|
||||
lock = _REPO_LOCKS.setdefault(key, RLock())
|
||||
with lock:
|
||||
return function(repo, *args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _resolve_repo_disk_path(
|
||||
repo: Path,
|
||||
path: str,
|
||||
*,
|
||||
strict: bool,
|
||||
) -> Path:
|
||||
"""Resolve a validated relative path and prove canonical repo containment.
|
||||
|
||||
``Path.resolve`` follows symlinks and junctions before ``commonpath``
|
||||
compares the canonical filesystem paths. Different-drive paths fail
|
||||
closed on Windows. Callers that open the result must revalidate the opened
|
||||
handle before using it as defense in depth against an unexpected path
|
||||
replacement. Concurrent external same-user filesystem mutation is outside
|
||||
the plugin trust boundary.
|
||||
"""
|
||||
safe_path = resolve_repo_path(repo, path)
|
||||
root = _canonical_repo_root(repo)
|
||||
candidate = (root / safe_path).resolve(strict=strict)
|
||||
try:
|
||||
common = Path(os.path.commonpath((str(root), str(candidate))))
|
||||
except ValueError as exc:
|
||||
raise GitStateError(f"path escapes repository: {safe_path}") from exc
|
||||
if os.path.normcase(str(common)) != os.path.normcase(str(root)):
|
||||
raise GitStateError(f"path escapes repository: {safe_path}")
|
||||
return candidate
|
||||
|
||||
|
||||
def _has_link_component(base: Path, path: Path) -> bool:
|
||||
current = base
|
||||
try:
|
||||
@@ -177,6 +262,19 @@ def _has_link_component(base: Path, path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_untracked_delete_path(repo: Path, path: str) -> str:
|
||||
"""Validate one exact untracked file before delegating deletion to Git."""
|
||||
safe_path = resolve_repo_path(repo, path)
|
||||
root = _canonical_repo_root(repo)
|
||||
lexical = root / safe_path
|
||||
if _has_link_component(root, lexical):
|
||||
raise GitStateError(f"path contains a link or junction: {safe_path}")
|
||||
candidate = _resolve_repo_disk_path(repo, safe_path, strict=False)
|
||||
if candidate.exists() and candidate.is_dir():
|
||||
raise GitStateError(f"path is not a file: {safe_path}")
|
||||
return safe_path
|
||||
|
||||
|
||||
def repo_id(repo: Path, base: Path | None = None) -> str:
|
||||
"""Stable collision-free id relative to the configured canonical base."""
|
||||
if base is None:
|
||||
@@ -433,19 +531,27 @@ def repo_diff(repo: Path, path: str, kind: str) -> dict[str, Any]:
|
||||
args = ["diff", "--no-color", "--"]
|
||||
if kind == "staged":
|
||||
args = ["diff", "--cached", "--no-color", "--"]
|
||||
output = _git(repo, *args, safe_path)
|
||||
output = _git(repo, *args, safe_path, literal_pathspecs=True)
|
||||
truncated = len(output) > MAX_DIFF_BYTES
|
||||
if truncated:
|
||||
output = output[:MAX_DIFF_BYTES]
|
||||
return {"path": safe_path, "kind": kind, "diff": output, "truncated": truncated}
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def read_file(repo: Path, path: str) -> dict[str, Any]:
|
||||
"""Read a tracked file's working-tree content. Untracked/binary/missing → error."""
|
||||
safe_path = resolve_repo_path(repo, path)
|
||||
# Confirm the file is tracked before reading.
|
||||
try:
|
||||
_git(repo, "ls-files", "--error-unmatch", "--", safe_path)
|
||||
_git(
|
||||
repo,
|
||||
"ls-files",
|
||||
"--error-unmatch",
|
||||
"--",
|
||||
safe_path,
|
||||
literal_pathspecs=True,
|
||||
)
|
||||
except GitStateError as exc:
|
||||
raise GitStateError(f"file is not tracked: {safe_path}") from exc
|
||||
|
||||
@@ -453,12 +559,16 @@ def read_file(repo: Path, path: str) -> dict[str, Any]:
|
||||
# modified-but-uncommitted file returns what is on disk. Read bytes first:
|
||||
# binary content dies on the NUL check (before any decode), and non-UTF-8
|
||||
# text raises a clear GitStateError instead of an unhandled 500.
|
||||
root = repo.resolve()
|
||||
try:
|
||||
disk_path = (repo / safe_path).resolve(strict=True)
|
||||
if not _is_within(root, disk_path):
|
||||
raise GitStateError(f"path escapes repository: {safe_path}")
|
||||
disk_path = _resolve_repo_disk_path(repo, safe_path, strict=True)
|
||||
with disk_path.open("rb") as handle:
|
||||
# Re-resolve after opening, then prove the open handle still names
|
||||
# that in-repo file. This fails closed if a parent, junction, repo
|
||||
# root, or leaf is replaced between validation and open; reads use
|
||||
# the already-verified handle after this point.
|
||||
revalidated = _resolve_repo_disk_path(repo, safe_path, strict=True)
|
||||
if not os.path.samestat(os.fstat(handle.fileno()), revalidated.stat()):
|
||||
raise GitStateError(f"path changed during read: {safe_path}")
|
||||
raw = handle.read(MAX_FILE_BYTES + 1)
|
||||
except OSError as exc:
|
||||
raise GitStateError(f"could not read file: {safe_path}") from exc
|
||||
@@ -573,12 +683,22 @@ def _is_git_repo(path: Path) -> bool:
|
||||
return (path / ".git").exists()
|
||||
|
||||
|
||||
def _run_mutation(repo: Path, args: list[str]) -> str:
|
||||
def _run_mutation(
|
||||
repo: Path,
|
||||
args: list[str],
|
||||
*,
|
||||
literal_pathspecs: bool = False,
|
||||
) -> str:
|
||||
"""Run a mutating ``git -C <repo> <args>``; raise a classified GitError.
|
||||
|
||||
Arg lists only (never shell interpolation); bounded by a timeout.
|
||||
"""
|
||||
returncode, stdout, stderr_output = _run_git_bounded(repo, args, mutation=True)
|
||||
returncode, stdout, stderr_output = _run_git_bounded(
|
||||
repo,
|
||||
args,
|
||||
mutation=True,
|
||||
literal_pathspecs=literal_pathspecs,
|
||||
)
|
||||
if returncode != 0:
|
||||
stderr = _safe_git_error(stderr_output or stdout)
|
||||
raise GitError(
|
||||
@@ -588,11 +708,33 @@ def _run_mutation(repo: Path, args: list[str]) -> str:
|
||||
return stdout
|
||||
|
||||
|
||||
def _mutate(repo: Path, args: list[str]) -> str:
|
||||
def _mutate(
|
||||
repo: Path,
|
||||
args: list[str],
|
||||
*,
|
||||
literal_pathspecs: bool = False,
|
||||
) -> str:
|
||||
"""Validate ``repo`` is a real git work tree, then run the mutation."""
|
||||
if not _is_git_repo(repo):
|
||||
raise GitError(f"not a git repository: {repo.name}", code="non-repo")
|
||||
return _run_mutation(repo, args)
|
||||
return _run_mutation(repo, args, literal_pathspecs=literal_pathspecs)
|
||||
|
||||
|
||||
def _is_tracked_path(repo: Path, path: str) -> bool:
|
||||
"""Classify one literal path without treating Git failures as untracked."""
|
||||
returncode, stdout, stderr = _run_git_bounded(
|
||||
repo,
|
||||
["ls-files", "--error-unmatch", "--", path],
|
||||
mutation=False,
|
||||
literal_pathspecs=True,
|
||||
)
|
||||
if returncode == 0:
|
||||
return True
|
||||
if returncode == 1:
|
||||
return False
|
||||
raise GitStateError(
|
||||
f"git ls-files failed for {repo.name}: {_safe_git_error(stderr or stdout)}"
|
||||
)
|
||||
|
||||
|
||||
def _is_dirty(repo: Path) -> bool:
|
||||
@@ -712,20 +854,23 @@ def _fresh_mutation_result(
|
||||
return result
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def stage(repo: Path, paths: list[str]) -> dict[str, Any]:
|
||||
"""Stage ``paths`` (repo-relative) and return fresh status."""
|
||||
safe = _validate_paths(paths)
|
||||
_mutate(repo, ["add", "--"] + safe)
|
||||
_mutate(repo, ["add", "--"] + safe, literal_pathspecs=True)
|
||||
return _fresh_mutation_result(repo)
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def unstage(repo: Path, paths: list[str]) -> dict[str, Any]:
|
||||
"""Unstage ``paths`` and return fresh status."""
|
||||
safe = _validate_paths(paths)
|
||||
_mutate(repo, ["restore", "--staged", "--"] + safe)
|
||||
_mutate(repo, ["restore", "--staged", "--"] + safe, literal_pathspecs=True)
|
||||
return _fresh_mutation_result(repo)
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def discard(
|
||||
repo: Path,
|
||||
paths: list[str],
|
||||
@@ -740,26 +885,25 @@ def discard(
|
||||
_require_confirmation(confirmation, CONFIRM_DISCARD)
|
||||
safe = _validate_paths(paths)
|
||||
tracked: list[str] = []
|
||||
untracked: list[str] = []
|
||||
for path in safe:
|
||||
try:
|
||||
_git(repo, "ls-files", "--error-unmatch", "--", path)
|
||||
if _is_tracked_path(repo, path):
|
||||
tracked.append(path)
|
||||
except GitStateError:
|
||||
# Untracked path — only touched when delete_untracked is set.
|
||||
if not delete_untracked:
|
||||
continue
|
||||
root = repo.resolve()
|
||||
candidate = (repo / path).resolve()
|
||||
if candidate != root and root not in candidate.parents:
|
||||
raise GitStateError(f"path escapes repository: {path}")
|
||||
if candidate.is_file():
|
||||
candidate.unlink()
|
||||
elif delete_untracked:
|
||||
untracked.append(path)
|
||||
# Reject links and directories before asking Git to remove only the exact
|
||||
# literal file names. Concurrent same-user filesystem mutation is outside
|
||||
# the plugin trust boundary; stationary redirections fail closed here.
|
||||
deletable = [_validate_untracked_delete_path(repo, path) for path in untracked]
|
||||
if deletable:
|
||||
_mutate(repo, ["clean", "-f", "--"] + deletable, literal_pathspecs=True)
|
||||
# Revert tracked modifications for the given paths.
|
||||
if tracked:
|
||||
_mutate(repo, ["checkout", "--"] + tracked)
|
||||
_mutate(repo, ["checkout", "--"] + tracked, literal_pathspecs=True)
|
||||
return _fresh_mutation_result(repo)
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def commit(repo: Path, message: str) -> dict[str, Any]:
|
||||
"""Create a commit from the staged index. Empty message is rejected."""
|
||||
message = _validate_commit_message(message)
|
||||
@@ -767,14 +911,16 @@ def commit(repo: Path, message: str) -> dict[str, Any]:
|
||||
return _fresh_mutation_result(repo)
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def commit_selected(repo: Path, message: str, paths: list[str]) -> dict[str, Any]:
|
||||
"""Commit only the given ``paths`` (staged + modified) under ``message``."""
|
||||
message = _validate_commit_message(message)
|
||||
safe = _validate_paths(paths)
|
||||
_mutate(repo, ["commit", "-m", message, "--"] + safe)
|
||||
_mutate(repo, ["commit", "-m", message, "--"] + safe, literal_pathspecs=True)
|
||||
return _fresh_mutation_result(repo)
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def fetch(repo: Path, remote: str = "origin") -> dict[str, Any]:
|
||||
"""Fetch from ``remote`` (default origin) and return fresh status/branches."""
|
||||
remote = _validate_remote(repo, remote)
|
||||
@@ -782,6 +928,7 @@ def fetch(repo: Path, remote: str = "origin") -> dict[str, Any]:
|
||||
return _fresh_mutation_result(repo, {"branches": repo_branches(repo)})
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def pull(repo: Path, remote: str = "origin", branch: str = "") -> dict[str, Any]:
|
||||
"""Pull from ``remote``/``branch`` (defaults: origin + current branch).
|
||||
|
||||
@@ -805,6 +952,7 @@ def pull(repo: Path, remote: str = "origin", branch: str = "") -> dict[str, Any]
|
||||
return _fresh_mutation_result(repo)
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def push(
|
||||
repo: Path,
|
||||
remote: str = "origin",
|
||||
@@ -826,6 +974,7 @@ def push(
|
||||
return _fresh_mutation_result(repo, {"branches": repo_branches(repo)})
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def checkout(
|
||||
repo: Path,
|
||||
ref: str,
|
||||
@@ -857,7 +1006,7 @@ def _staged_diff(repo: Path, paths: list[str] | None) -> tuple[str, bool]:
|
||||
if paths:
|
||||
args.append("--")
|
||||
args.extend(paths)
|
||||
output = _git(repo, *args)
|
||||
output = _git(repo, *args, literal_pathspecs=bool(paths))
|
||||
truncated = len(output) > MAX_DIFF_BYTES
|
||||
if truncated:
|
||||
output = output[:MAX_DIFF_BYTES]
|
||||
@@ -945,6 +1094,7 @@ async def commit_message_selected(
|
||||
return await _generate_message(diff)
|
||||
|
||||
|
||||
@_serialized_repo_operation
|
||||
def stash_checkout(
|
||||
repo: Path,
|
||||
ref: str,
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: hermes-relay
|
||||
manifest_version: 2
|
||||
api_version: 1
|
||||
version: 1.10.0
|
||||
version: 1.11.0
|
||||
description: "Hermes-Relay plugin for QR pairing, relay sessions, dashboard management, remote desktop/phone tooling, and optional legacy compatibility diagnostics. Standard chat, Manage, and dashboard voice remain vanilla upstream Hermes surfaces."
|
||||
author: Axiom Labs
|
||||
license: MIT
|
||||
|
||||
@@ -19,7 +19,7 @@ See ``plugin/relay/server.py`` for the aiohttp server,
|
||||
# CLI+UI releases use desktop/package.json and desktop-v* tags. The /health endpoint
|
||||
# reports this plugin version, and stale values make live diagnosis harder than
|
||||
# it should be.
|
||||
__version__ = "1.10.0"
|
||||
__version__ = "1.11.0"
|
||||
|
||||
from .server import create_app, main # noqa: E402 — must come after __version__
|
||||
|
||||
|
||||
@@ -313,6 +313,15 @@ class GitStateFileTests(unittest.TestCase):
|
||||
with self.assertRaises(ValueError):
|
||||
git_state.read_file(self.repo, "untracked.txt")
|
||||
|
||||
def test_read_tracking_check_treats_pathspec_magic_as_literal(self) -> None:
|
||||
(self.repo / "name1.txt").write_text("tracked", encoding="utf-8")
|
||||
_git(self.repo, "add", "name1.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked pathspec sibling")
|
||||
(self.repo / "name[1].txt").write_text("untracked", encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "file is not tracked"):
|
||||
git_state.read_file(self.repo, "name[1].txt")
|
||||
|
||||
def test_read_missing_file_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
git_state.read_file(self.repo, "nope.txt")
|
||||
@@ -358,6 +367,57 @@ class GitStateFileTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "escapes repository"):
|
||||
git_state.read_file(self.repo, tracked_path)
|
||||
|
||||
def test_read_rejects_parent_swapped_after_validation(self) -> None:
|
||||
nested = self.repo / "nested"
|
||||
nested.mkdir()
|
||||
tracked = nested / "tracked.txt"
|
||||
tracked.write_text("safe", encoding="utf-8")
|
||||
_git(self.repo, "add", "nested/tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add nested file")
|
||||
|
||||
parked = self.repo / "nested-original"
|
||||
outside = self.base / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "tracked.txt").write_text("secret", encoding="utf-8")
|
||||
original_open = Path.open
|
||||
swapped = False
|
||||
|
||||
def swap_before_open(path: Path, *args: object, **kwargs: object):
|
||||
nonlocal swapped
|
||||
if not swapped and path == tracked:
|
||||
swapped = True
|
||||
nested.rename(parked)
|
||||
_link_directory(nested, outside)
|
||||
return original_open(path, *args, **kwargs)
|
||||
|
||||
with patch.object(Path, "open", new=swap_before_open):
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "escapes repository"):
|
||||
git_state.read_file(self.repo, "nested/tracked.txt")
|
||||
|
||||
self.assertTrue((parked / "tracked.txt").exists())
|
||||
self.assertEqual("secret", (outside / "tracked.txt").read_text(encoding="utf-8"))
|
||||
|
||||
def test_read_rejects_repo_root_swapped_after_validation(self) -> None:
|
||||
parked = self.base / "file-repo-original"
|
||||
outside = _init_repo(self.base, "outside-repo")
|
||||
(outside / "README.md").write_text("# Secret\n", encoding="utf-8")
|
||||
original_open = Path.open
|
||||
swapped = False
|
||||
|
||||
def swap_before_open(path: Path, *args: object, **kwargs: object):
|
||||
nonlocal swapped
|
||||
if not swapped and path == self.repo / "README.md":
|
||||
swapped = True
|
||||
self.repo.rename(parked)
|
||||
_link_directory(self.repo, outside)
|
||||
return original_open(path, *args, **kwargs)
|
||||
|
||||
with patch.object(Path, "open", new=swap_before_open):
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "repository root changed"):
|
||||
git_state.read_file(self.repo, "README.md")
|
||||
|
||||
self.assertEqual("# Secret\n", (outside / "README.md").read_text(encoding="utf-8"))
|
||||
|
||||
def test_read_tracked_file_is_bounded_during_read(self) -> None:
|
||||
(self.repo / "large.txt").write_text("x" * (git_state.MAX_FILE_BYTES + 100), encoding="utf-8")
|
||||
_git(self.repo, "add", "large.txt")
|
||||
|
||||
@@ -12,6 +12,8 @@ import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from threading import Event, Thread
|
||||
from unittest.mock import patch
|
||||
|
||||
from plugin import git_state
|
||||
|
||||
@@ -50,6 +52,15 @@ def _init_bare_remote(root: Path, name: str) -> Path:
|
||||
return remote
|
||||
|
||||
|
||||
def _link_directory(link: Path, target: Path) -> None:
|
||||
try:
|
||||
link.symlink_to(target, target_is_directory=True)
|
||||
except OSError:
|
||||
if os.name != "nt":
|
||||
raise
|
||||
_run(["cmd", "/c", "mklink", "/J", str(link), str(target)], link.parent)
|
||||
|
||||
|
||||
class _MutationBase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
@@ -99,6 +110,46 @@ class StageUnstageTests(_MutationBase):
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
git_state.stage(self.repo, ["../outside"])
|
||||
|
||||
def test_stage_treats_wildcard_as_literal_path(self) -> None:
|
||||
(self.repo / "first.txt").write_text("first", encoding="utf-8")
|
||||
(self.repo / "second.txt").write_text("second", encoding="utf-8")
|
||||
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.stage(self.repo, ["*.txt"])
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertEqual([], status["staged"])
|
||||
self.assertEqual(
|
||||
{"first.txt", "second.txt"},
|
||||
{entry["path"] for entry in status["untracked"]},
|
||||
)
|
||||
|
||||
def test_stage_treats_pathspec_magic_as_literal_path(self) -> None:
|
||||
literal = "name[1].txt"
|
||||
expanded = "name1.txt"
|
||||
(self.repo / literal).write_text("literal", encoding="utf-8")
|
||||
(self.repo / expanded).write_text("expanded", encoding="utf-8")
|
||||
|
||||
git_state.stage(self.repo, [literal])
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertEqual({literal}, {entry["path"] for entry in status["staged"]})
|
||||
self.assertEqual({expanded}, {entry["path"] for entry in status["untracked"]})
|
||||
|
||||
def test_unstage_treats_wildcard_as_literal_path(self) -> None:
|
||||
for name in ("first.txt", "second.txt"):
|
||||
(self.repo / name).write_text(name, encoding="utf-8")
|
||||
_git(self.repo, "add", name)
|
||||
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.unstage(self.repo, ["*.txt"])
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertEqual(
|
||||
{"first.txt", "second.txt"},
|
||||
{entry["path"] for entry in status["staged"]},
|
||||
)
|
||||
|
||||
|
||||
class CommitTests(_MutationBase):
|
||||
def test_commit_creates_a_real_commit(self) -> None:
|
||||
@@ -147,6 +198,17 @@ class CommitTests(_MutationBase):
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.commit_selected(self.repo, "", ["a.txt"])
|
||||
|
||||
def test_commit_selected_treats_wildcard_as_literal_path(self) -> None:
|
||||
for name in ("first.txt", "second.txt"):
|
||||
(self.repo / name).write_text(name, encoding="utf-8")
|
||||
_git(self.repo, "add", name)
|
||||
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.commit_selected(self.repo, "must stay scoped", ["*.txt"])
|
||||
|
||||
self.assertNotIn("first.txt", _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD"))
|
||||
self.assertNotIn("second.txt", _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD"))
|
||||
|
||||
|
||||
class DiscardConfirmationTests(_MutationBase):
|
||||
def test_discard_requires_confirmation_string(self) -> None:
|
||||
@@ -179,6 +241,222 @@ class DiscardConfirmationTests(_MutationBase):
|
||||
)
|
||||
self.assertFalse((self.repo / "untracked.txt").exists())
|
||||
|
||||
def test_discard_delete_untracked_rejects_link_outside_repo(self) -> None:
|
||||
outside = self.base / "outside.txt"
|
||||
outside.write_text("keep", encoding="utf-8")
|
||||
link = self.repo / "untracked-link.txt"
|
||||
try:
|
||||
link.symlink_to(outside)
|
||||
except OSError as exc:
|
||||
self.skipTest(f"symlink creation unavailable: {exc}")
|
||||
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "link or junction"):
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["untracked-link.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
delete_untracked=True,
|
||||
)
|
||||
|
||||
self.assertEqual("keep", outside.read_text(encoding="utf-8"))
|
||||
self.assertTrue(link.is_symlink())
|
||||
|
||||
@unittest.skipUnless(os.name == "nt", "Windows junction fallback")
|
||||
def test_discard_rejects_junction_on_python_311_fallback(self) -> None:
|
||||
target = self.repo / "target"
|
||||
target.mkdir()
|
||||
(target / "keep.txt").write_text("keep", encoding="utf-8")
|
||||
junction = self.repo / "junction"
|
||||
_run(["cmd", "/c", "mklink", "/J", str(junction), str(target)], self.repo)
|
||||
|
||||
with patch.object(Path, "is_junction", return_value=False, create=True):
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "link or junction"):
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["junction/keep.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
delete_untracked=True,
|
||||
)
|
||||
|
||||
self.assertEqual("keep", (target / "keep.txt").read_text(encoding="utf-8"))
|
||||
|
||||
def test_discard_delete_untracked_treats_wildcard_as_literal_path(self) -> None:
|
||||
for name in ("first.txt", "second.txt"):
|
||||
(self.repo / name).write_text(name, encoding="utf-8")
|
||||
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["*.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
delete_untracked=True,
|
||||
)
|
||||
|
||||
self.assertTrue((self.repo / "first.txt").exists())
|
||||
self.assertTrue((self.repo / "second.txt").exists())
|
||||
|
||||
def test_discard_does_not_delete_when_tracking_check_fails(self) -> None:
|
||||
target = self.repo / "keep.txt"
|
||||
target.write_text("keep", encoding="utf-8")
|
||||
|
||||
with patch.object(
|
||||
git_state,
|
||||
"_run_git_bounded",
|
||||
return_value=(128, "", "fatal: repository unavailable"),
|
||||
):
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "ls-files failed"):
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["keep.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
delete_untracked=True,
|
||||
)
|
||||
|
||||
self.assertEqual("keep", target.read_text(encoding="utf-8"))
|
||||
|
||||
def test_discard_delete_untracked_does_not_follow_swapped_parent(self) -> None:
|
||||
nested = self.repo / "nested"
|
||||
nested.mkdir()
|
||||
(nested / "delete.txt").write_text("repo", encoding="utf-8")
|
||||
parked = self.repo / "nested-original"
|
||||
outside = self.base / "outside"
|
||||
outside.mkdir()
|
||||
outside_file = outside / "delete.txt"
|
||||
outside_file.write_text("keep", encoding="utf-8")
|
||||
original_validate = git_state._validate_untracked_delete_path
|
||||
|
||||
def swap_before_delete(repo: Path, path: str) -> str:
|
||||
if nested.exists() and not nested.is_symlink():
|
||||
nested.rename(parked)
|
||||
_link_directory(nested, outside)
|
||||
return original_validate(repo, path)
|
||||
|
||||
with patch.object(
|
||||
git_state,
|
||||
"_validate_untracked_delete_path",
|
||||
side_effect=swap_before_delete,
|
||||
):
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "link or junction"):
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["nested/delete.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
delete_untracked=True,
|
||||
)
|
||||
|
||||
self.assertEqual("keep", outside_file.read_text(encoding="utf-8"))
|
||||
self.assertTrue((parked / "delete.txt").exists())
|
||||
|
||||
def test_discard_delete_untracked_rejects_swapped_repo_root(self) -> None:
|
||||
(self.repo / "delete.txt").write_text("repo", encoding="utf-8")
|
||||
parked = self.base / "write-repo-original"
|
||||
outside = self.base / "outside-repo"
|
||||
outside.mkdir()
|
||||
outside_file = outside / "delete.txt"
|
||||
outside_file.write_text("keep", encoding="utf-8")
|
||||
original_validate = git_state._validate_untracked_delete_path
|
||||
|
||||
def swap_before_delete(repo: Path, path: str) -> str:
|
||||
self.repo.rename(parked)
|
||||
_link_directory(self.repo, outside)
|
||||
return original_validate(repo, path)
|
||||
|
||||
with patch.object(
|
||||
git_state,
|
||||
"_validate_untracked_delete_path",
|
||||
side_effect=swap_before_delete,
|
||||
):
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "repository root changed"):
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["delete.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
delete_untracked=True,
|
||||
)
|
||||
|
||||
self.assertEqual("keep", outside_file.read_text(encoding="utf-8"))
|
||||
self.assertTrue((parked / "delete.txt").exists())
|
||||
|
||||
def test_discard_tracked_treats_wildcard_as_literal_path(self) -> None:
|
||||
for name in ("first.txt", "second.txt"):
|
||||
(self.repo / name).write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", name)
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked files")
|
||||
for name in ("first.txt", "second.txt"):
|
||||
(self.repo / name).write_text("v2", encoding="utf-8")
|
||||
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["*.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
)
|
||||
|
||||
self.assertEqual("v2", (self.repo / "first.txt").read_text(encoding="utf-8"))
|
||||
self.assertEqual("v2", (self.repo / "second.txt").read_text(encoding="utf-8"))
|
||||
|
||||
def test_discard_serializes_against_checkout_on_same_repo(self) -> None:
|
||||
target = self.repo / "delete.txt"
|
||||
target.write_text("delete", encoding="utf-8")
|
||||
_git(self.repo, "branch", "other")
|
||||
validation_reached = Event()
|
||||
allow_discard = Event()
|
||||
checkout_started = Event()
|
||||
checkout_completed = Event()
|
||||
errors: list[BaseException] = []
|
||||
original_validate = git_state._validate_untracked_delete_path
|
||||
|
||||
def blocked_validate(repo: Path, path: str) -> str:
|
||||
result = original_validate(repo, path)
|
||||
validation_reached.set()
|
||||
if not allow_discard.wait(5):
|
||||
raise AssertionError("test timed out waiting to continue discard")
|
||||
return result
|
||||
|
||||
def run_discard() -> None:
|
||||
try:
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["delete.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
delete_untracked=True,
|
||||
)
|
||||
except BaseException as exc: # pragma: no cover - reported below
|
||||
errors.append(exc)
|
||||
|
||||
def run_checkout() -> None:
|
||||
checkout_started.set()
|
||||
try:
|
||||
git_state.checkout(
|
||||
self.repo,
|
||||
"other",
|
||||
confirmation=git_state.CONFIRM_DIRTY_CHECKOUT,
|
||||
)
|
||||
except BaseException as exc: # pragma: no cover - reported below
|
||||
errors.append(exc)
|
||||
finally:
|
||||
checkout_completed.set()
|
||||
|
||||
with patch.object(
|
||||
git_state,
|
||||
"_validate_untracked_delete_path",
|
||||
side_effect=blocked_validate,
|
||||
):
|
||||
discard_thread = Thread(target=run_discard)
|
||||
checkout_thread = Thread(target=run_checkout)
|
||||
discard_thread.start()
|
||||
self.assertTrue(validation_reached.wait(5))
|
||||
checkout_thread.start()
|
||||
self.assertTrue(checkout_started.wait(5))
|
||||
self.assertFalse(checkout_completed.wait(0.2))
|
||||
allow_discard.set()
|
||||
discard_thread.join(5)
|
||||
checkout_thread.join(5)
|
||||
|
||||
self.assertFalse(discard_thread.is_alive())
|
||||
self.assertFalse(checkout_thread.is_alive())
|
||||
self.assertEqual([], errors)
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual("other", _git(self.repo, "branch", "--show-current"))
|
||||
|
||||
def test_discard_returns_fresh_status(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hermes-relay"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
description = "Hermes-Relay plugin — Android device control toolset, QR pairing CLI, and WSS relay server for hermes-agent"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -88,6 +88,10 @@ The initial catalog covers ordinary streaming, rapid chunks/reasoning/tool
|
||||
events, queued turns, scoped and foreign/unscoped inputs, persisted history,
|
||||
and both issue #365 terminal-gap forms:
|
||||
|
||||
- `initial_history_bind`: a durable, profile-scoped transcript exists before
|
||||
the client resumes, so rendered clients can exercise first-composition
|
||||
binding without relying on a new turn to trigger recomposition.
|
||||
|
||||
- `subagent_child_preview`: interleaved concurrent child lifecycle events carry
|
||||
stable child/session identity, thinking/progress/tool previews, and distinct
|
||||
completed/interrupted terminal states. Its upstream requirement also proves
|
||||
|
||||
@@ -77,6 +77,29 @@ class FixtureTestCase(unittest.IsolatedAsyncioTestCase):
|
||||
await rejected.release()
|
||||
await ws.close()
|
||||
|
||||
async def test_initial_history_is_available_before_session_resume(self) -> None:
|
||||
fixture, base_url = await self.start("initial_history_bind")
|
||||
async with self.session.get(
|
||||
f"{base_url}/api/sessions/{fixture.scenario.stored_session_id}/messages",
|
||||
params={"profile": "research", "limit": 500, "offset": 0, "order": "asc"},
|
||||
) as response:
|
||||
history = await response.json()
|
||||
self.assertEqual(
|
||||
["Open the durable Bot Chat.", "Durable Bot Chat history is ready."],
|
||||
[row["content"] for row in history["messages"]],
|
||||
)
|
||||
|
||||
ws, _ = await self.connect(base_url)
|
||||
await self.rpc(
|
||||
ws,
|
||||
1,
|
||||
"session.resume",
|
||||
{"session_id": fixture.scenario.stored_session_id, "profile": "research"},
|
||||
)
|
||||
resumed = (await ws.receive_json())["result"]
|
||||
self.assertEqual(fixture.scenario.live_session_id, resumed["session_id"])
|
||||
self.assertEqual(fixture.scenario.stored_session_id, resumed["stored_session_id"])
|
||||
|
||||
async def test_ordinary_turn_persists_authoritative_history(self) -> None:
|
||||
fixture, base_url = await self.start("ordinary_turn")
|
||||
ws, _ = await self.connect(base_url)
|
||||
@@ -307,6 +330,7 @@ class ScenarioTestCase(unittest.TestCase):
|
||||
"active_status_profile_scope",
|
||||
"active_status_unsupported",
|
||||
"cross_client_observation",
|
||||
"initial_history_bind",
|
||||
"ordinary_turn",
|
||||
"rapid_tools_interims",
|
||||
"subagent_child_preview",
|
||||
@@ -334,7 +358,11 @@ class ScenarioTestCase(unittest.TestCase):
|
||||
from vanilla_gateway.scenario import Scenario
|
||||
Scenario.from_dict(scenario)
|
||||
|
||||
def test_terminal_gap_manifests_select_upstream_contracts(self) -> None:
|
||||
def test_contract_manifests_select_upstream_contracts(self) -> None:
|
||||
self.assertEqual(
|
||||
("gateway.session_resume_durable",),
|
||||
load_scenario("initial_history_bind").contract_requirements,
|
||||
)
|
||||
self.assertEqual(
|
||||
(
|
||||
"gateway.message_complete",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "initial_history_bind",
|
||||
"live_session_id": "fixture-live-history",
|
||||
"stored_session_id": "20260829_120000_bot_chat",
|
||||
"profile": "research",
|
||||
"contract_requirements": [
|
||||
"gateway.session_resume_durable"
|
||||
],
|
||||
"initial_history": [
|
||||
{"id": 1, "role": "user", "content": "Open the durable Bot Chat.", "timestamp": 1.0},
|
||||
{"id": 2, "role": "assistant", "content": "Durable Bot Chat history is ready.", "timestamp": 2.0, "finish_reason": "stop"}
|
||||
],
|
||||
"turns": []
|
||||
}
|
||||
@@ -17,7 +17,7 @@ plugins {
|
||||
kotlin("jvm")
|
||||
// Compose compiler — version inherited from the root plugins {} block.
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.compose") version "1.11.1"
|
||||
id("org.jetbrains.compose") version "1.12.0"
|
||||
}
|
||||
|
||||
kotlin {
|
||||
|
||||
Reference in New Issue
Block a user