Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b32c7aeef | ||
|
|
29706e1548 | ||
|
|
6579b621ff | ||
|
|
befe8399ab | ||
|
|
c10b87b94c | ||
|
|
5e9d8840ae | ||
|
|
c7c24b2874 | ||
|
|
3e8e0728db | ||
|
|
1658439d05 | ||
|
|
90ab705a88 | ||
|
|
054aab1c09 | ||
|
|
bae1762951 |
@@ -19,6 +19,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Android fresh chats no longer inherit a stale busy composer.** New-chat navigation settles visible streaming ownership even when a Gateway turn has already lost its live handle, and Stop remains an immediate escape hatch after the terminal bubble has settled. (#416, #418)
|
||||
- **The Android Sphere remains gently animated while visibly idle.** New chats and the ambient Sphere behind messages now use a low-cost layer breath, while hidden/backgrounded and motion-disabled surfaces stay still and active agent/voice states retain their full procedural animation.
|
||||
- **Android retries Windows-hosted `MEDIA:` attachments through Relay's by-path route.** A document deferred on cellular no longer treats `C:\...` as an opaque media token and reports it as expired.
|
||||
- **Relay profile discovery follows `HERMES_HOME` by default.** Custom Hermes installations surface their real default profile and persist Relay sessions beside the active config while retaining the explicit `RELAY_HERMES_CONFIG` override.
|
||||
- **Desktop daemon connections recover instead of exiting after an interrupted Relay socket.** Healthy daemons retry through Relay restarts and repeated failed reconnect attempts, oversized desktop-tool results fail within a bounded response instead of closing the shared WebSocket, and terminal failures leave an accurate stopped status for the tray.
|
||||
- **Desktop computer control follows Hermes' current CUA Driver contract.** CUA Driver 0.20 and newer are accepted when their manifest, daemon/MCP arguments, required tools, and canonical path remain compatible, and Windows sessions use the manifest-declared direct standard-mode runtime instead of a potentially stale machine-wide daemon. Current 0.21 installations no longer fall back solely because of an obsolete upper version pin or daemon contract.
|
||||
|
||||
|
||||
@@ -62,6 +62,18 @@ automotive device verified foreground preservation, AssistStructure and screensh
|
||||
delivery, immediate listening, contextual response, and one-shot consumption;
|
||||
broader firmware certification remains tracked in `TODO.md`.
|
||||
|
||||
## 2026-08-23 — Windows attachment retry and Hermes-home resolution
|
||||
|
||||
Android now recognizes Windows absolute paths during manual inbound-media retry.
|
||||
Cellular-deferred `MEDIA:C:\...` documents use Relay's authenticated
|
||||
`/media/by-path` route instead of being sent to the opaque-token route and
|
||||
misreported as expired. A Robolectric/MockWebServer regression covers a spaced
|
||||
Markdown filename and asserts the exact route and decoded path query.
|
||||
|
||||
Relay configuration now derives its default `config.yaml` and session-persistence
|
||||
paths from `HERMES_HOME` when present. `RELAY_HERMES_CONFIG` remains the explicit
|
||||
override. Focused Python tests cover both resolution paths.
|
||||
|
||||
## 2026-08-23 — GitHub Discussions community surface
|
||||
|
||||
GitHub Discussions is enabled as the repository's lightweight community surface.
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
@@ -25,6 +26,8 @@ import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* ASCII morphing sphere — the visual embodiment of the AI agent.
|
||||
@@ -53,6 +56,32 @@ import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
private const val SPHERE_TIME_UNITS_PER_SEC = 1f
|
||||
private const val SPHERE_TWO_PI = 6.2832f
|
||||
private const val SPHERE_COLOR_RADIANS_PER_SEC = 0.7854f
|
||||
private const val SPHERE_IDLE_BREATH_RADIANS_PER_SEC = 0.72f
|
||||
private const val SPHERE_IDLE_BREATH_SCALE = 0.012f
|
||||
private const val SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS = 184L
|
||||
|
||||
internal enum class SphereMotionMode {
|
||||
Still,
|
||||
AmbientLayer,
|
||||
Procedural,
|
||||
}
|
||||
|
||||
internal fun sphereMotionMode(
|
||||
state: SphereState,
|
||||
voiceMode: Boolean,
|
||||
motionVisible: Boolean,
|
||||
fixedTime: Float?,
|
||||
fixedColorPhase: Float?,
|
||||
): SphereMotionMode {
|
||||
if (!motionVisible || fixedTime != null || fixedColorPhase != null) {
|
||||
return SphereMotionMode.Still
|
||||
}
|
||||
return if (state == SphereState.Idle && !voiceMode) {
|
||||
SphereMotionMode.AmbientLayer
|
||||
} else {
|
||||
SphereMotionMode.Procedural
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MorphingSphere(
|
||||
@@ -64,7 +93,8 @@ fun MorphingSphere(
|
||||
voiceMode: Boolean = false,
|
||||
skin: SphereSkin = LocalSphereSkin.current,
|
||||
fixedTime: Float? = null,
|
||||
fixedColorPhase: Float? = null
|
||||
fixedColorPhase: Float? = null,
|
||||
motionVisible: Boolean = true,
|
||||
) {
|
||||
val brand = LocalBrand.current
|
||||
// Gate reactive inputs on what the skin declares it honors — this is the
|
||||
@@ -104,16 +134,21 @@ fun MorphingSphere(
|
||||
val cg2 by animateFloatAsState(targetC.g2, spec, label = "cg2")
|
||||
val cb2 by animateFloatAsState(targetC.b2, spec, label = "cb2")
|
||||
|
||||
// Continuous motion runs only for active agent/voice states. Idle is a
|
||||
// stable frame: the 58x34 text grid is expensive enough that even a
|
||||
// throttled cosmetic drift dominated measured screen-on CPU. Active states
|
||||
// retain full display-rate motion and dt-based timing.
|
||||
// Active states retain the full procedural animation. Visible Idle uses a
|
||||
// lightweight graphics-layer breath: redrawing the 58x34 glyph grid just
|
||||
// for ambient drift was the measured screen-on hotspot, while transforming
|
||||
// its cached layer preserves the intended living Sphere at far lower cost.
|
||||
val animatedTime = remember { mutableFloatStateOf(0f) }
|
||||
val animatedColorPhase = remember { mutableFloatStateOf(0f) }
|
||||
val fullFrameRate = state != SphereState.Idle || effVoiceMode
|
||||
val driveAnimation = (fixedTime == null || fixedColorPhase == null) && fullFrameRate
|
||||
if (driveAnimation) {
|
||||
LaunchedEffect(fullFrameRate) {
|
||||
val motionMode = sphereMotionMode(
|
||||
state = state,
|
||||
voiceMode = effVoiceMode,
|
||||
motionVisible = motionVisible,
|
||||
fixedTime = fixedTime,
|
||||
fixedColorPhase = fixedColorPhase,
|
||||
)
|
||||
if (motionMode == SphereMotionMode.Procedural) {
|
||||
LaunchedEffect(motionMode) {
|
||||
var lastNanos = withFrameNanos { it }
|
||||
while (true) {
|
||||
val now = withFrameNanos { it }
|
||||
@@ -127,6 +162,25 @@ fun MorphingSphere(
|
||||
}
|
||||
}
|
||||
}
|
||||
val idleBreathPhase = remember { mutableFloatStateOf(0f) }
|
||||
LaunchedEffect(motionMode) {
|
||||
if (motionMode != SphereMotionMode.AmbientLayer) {
|
||||
idleBreathPhase.floatValue = 0f
|
||||
return@LaunchedEffect
|
||||
}
|
||||
var lastNanos = withFrameNanos { it }
|
||||
while (true) {
|
||||
val now = withFrameNanos { it }
|
||||
val dtSec = (now - lastNanos).coerceAtLeast(0L) / 1_000_000_000f
|
||||
lastNanos = now
|
||||
idleBreathPhase.floatValue =
|
||||
(idleBreathPhase.floatValue + dtSec * SPHERE_IDLE_BREATH_RADIANS_PER_SEC) %
|
||||
SPHERE_TWO_PI
|
||||
// The frame wait plus this delay caps the gentle layer-only pulse
|
||||
// near 5fps while active procedural states retain display-rate motion.
|
||||
delay(SPHERE_IDLE_LAYER_FRAME_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
val time = fixedTime ?: animatedTime.floatValue
|
||||
val colorPhase = fixedColorPhase ?: animatedColorPhase.floatValue
|
||||
@@ -138,7 +192,18 @@ fun MorphingSphere(
|
||||
val textMeasurer = rememberTextMeasurer(cacheSize = 64)
|
||||
val glyphStrings = remember { HashMap<Char, String>(32) }
|
||||
|
||||
Canvas(modifier = modifier.fillMaxSize().clipToBounds()) {
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
if (motionMode == SphereMotionMode.AmbientLayer) {
|
||||
val scale = 1f + sin(idleBreathPhase.floatValue) * SPHERE_IDLE_BREATH_SCALE
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
}
|
||||
.clipToBounds(),
|
||||
) {
|
||||
val canvasW = size.width
|
||||
val canvasH = size.height
|
||||
val cellW = canvasW / cols
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.hermesandroid.relay.ui.components.avatar
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.hermesandroid.relay.ui.components.MorphingSphere
|
||||
import com.hermesandroid.relay.ui.components.SphereReactivity
|
||||
import com.hermesandroid.relay.util.AppForegroundTracker
|
||||
|
||||
/**
|
||||
* Default ambient visualization — the ASCII [MorphingSphere].
|
||||
@@ -34,6 +37,7 @@ object SphereAvatar : AgentAvatar {
|
||||
|
||||
@Composable
|
||||
override fun Render(state: AvatarRenderState, modifier: Modifier) {
|
||||
val appForeground by AppForegroundTracker.isForeground.collectAsState()
|
||||
MorphingSphere(
|
||||
modifier = modifier,
|
||||
state = state.state,
|
||||
@@ -46,6 +50,7 @@ object SphereAvatar : AgentAvatar {
|
||||
// call did with fixedTime/fixedColorPhase = 0f.
|
||||
fixedTime = if (state.paused) 0f else null,
|
||||
fixedColorPhase = if (state.paused) 0f else null,
|
||||
motionVisible = appForeground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +444,7 @@ class ChatViewModel : ViewModel() {
|
||||
// === END PHASE3-status ===
|
||||
const val MEDIA_TAP_TO_DOWNLOAD = "Tap to download"
|
||||
private const val MEDIA_FETCH_TIMEOUT_MS = 120_000L
|
||||
private val WINDOWS_ABSOLUTE_MEDIA_PATH_REGEX = Regex("""^[A-Za-z]:[\\/].+""")
|
||||
|
||||
/** Upper bound on the rolling tool-call history flow. */
|
||||
const val TOOL_CALL_HISTORY_LIMIT = 10
|
||||
@@ -5958,6 +5959,13 @@ class ChatViewModel : ViewModel() {
|
||||
activeStream?.cancel()
|
||||
activeStream = null
|
||||
activeStreamIsGateway = false
|
||||
// Navigation owns the visible composer even when the live handle has
|
||||
// already ended or could not be detached. Do not wait for a late
|
||||
// cancel callback to clear a handler-wide busy bit after the new
|
||||
// transcript has replaced its streaming bubble.
|
||||
handler.clearStreamingStatus()
|
||||
_steerableTurn.value = false
|
||||
_steerNotice.value = null
|
||||
}
|
||||
|
||||
/** Last-chance synchronous flush before the ViewModel scope is cancelled. */
|
||||
@@ -8804,6 +8812,11 @@ class ChatViewModel : ViewModel() {
|
||||
if (streamingMsg != null) {
|
||||
handler.markStopped(streamingMsg.id)
|
||||
handler.onStreamComplete(streamingMsg.id)
|
||||
} else {
|
||||
// The terminal bubble can settle before the handler-wide busy
|
||||
// flag (or navigation can already have cleared the transcript).
|
||||
// Stop must still be an unconditional escape hatch.
|
||||
handler.clearStreamingStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8890,11 +8903,10 @@ class ChatViewModel : ViewModel() {
|
||||
* Re-run the fetch for an attachment that's in the "Tap to download"
|
||||
* deferred state. Used by the inbound-media card's CTA on cellular.
|
||||
*
|
||||
* Works for both flavors of inbound attachment: if the stored key starts
|
||||
* with `/` it's an absolute path (bare-media form, use
|
||||
* [RelayHttpClient.fetchMediaByPath]); otherwise it's a relay token
|
||||
* (use [RelayHttpClient.fetchMedia]). `secrets.token_urlsafe` never
|
||||
* produces `/` so the prefix check is unambiguous.
|
||||
* Works for both flavors of inbound attachment: POSIX paths start with `/`
|
||||
* and Windows paths match `C:\...`; both use
|
||||
* [RelayHttpClient.fetchMediaByPath]. Everything else is an opaque relay
|
||||
* token and uses [RelayHttpClient.fetchMedia].
|
||||
*/
|
||||
fun manualFetchAttachment(messageId: String, attachmentIndex: Int) {
|
||||
val handler = chatHandler ?: return
|
||||
@@ -8927,7 +8939,10 @@ class ChatViewModel : ViewModel() {
|
||||
settings,
|
||||
expectedRole = expectedRole,
|
||||
) {
|
||||
if (fetchKey.startsWith("/")) {
|
||||
if (
|
||||
fetchKey.startsWith("/") ||
|
||||
WINDOWS_ABSOLUTE_MEDIA_PATH_REGEX.matches(fetchKey)
|
||||
) {
|
||||
relay.fetchMediaByPath(fetchKey)
|
||||
} else {
|
||||
relay.fetchMedia(fetchKey)
|
||||
@@ -9022,9 +9037,9 @@ class ChatViewModel : ViewModel() {
|
||||
},
|
||||
content = "",
|
||||
state = AttachmentState.LOADING,
|
||||
// Reuse relayToken as a generic inbound-fetch key. Paths always
|
||||
// start with `/`, real tokens never do — downstream helpers
|
||||
// that need to distinguish can check the prefix.
|
||||
// Reuse relayToken as a generic inbound-fetch key. Downstream
|
||||
// helpers distinguish POSIX or Windows absolute paths from opaque
|
||||
// relay tokens.
|
||||
relayToken = originalPath,
|
||||
fileName = originalPath.substringAfterLast('/').substringAfterLast('\\').ifBlank { null }
|
||||
)
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class MorphingSphereMotionPolicyTest {
|
||||
@Test
|
||||
fun `visible idle sphere uses lightweight ambient motion`() {
|
||||
assertEquals(
|
||||
SphereMotionMode.AmbientLayer,
|
||||
sphereMotionMode(
|
||||
state = SphereState.Idle,
|
||||
voiceMode = false,
|
||||
motionVisible = true,
|
||||
fixedTime = null,
|
||||
fixedColorPhase = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hidden or paused idle sphere is still`() {
|
||||
assertEquals(
|
||||
SphereMotionMode.Still,
|
||||
sphereMotionMode(SphereState.Idle, false, false, null, null),
|
||||
)
|
||||
assertEquals(
|
||||
SphereMotionMode.Still,
|
||||
sphereMotionMode(SphereState.Idle, false, true, 0f, 0f),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible active and voice states keep procedural motion`() {
|
||||
assertEquals(
|
||||
SphereMotionMode.Procedural,
|
||||
sphereMotionMode(SphereState.Thinking, false, true, null, null),
|
||||
)
|
||||
assertEquals(
|
||||
SphereMotionMode.Procedural,
|
||||
sphereMotionMode(SphereState.Idle, true, true, null, null),
|
||||
)
|
||||
}
|
||||
}
|
||||
+27
@@ -1003,6 +1003,33 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
assertTrue(handler.messages.value.any { "Stopped" in it.badges })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stopClearsStaleBusyStateAfterTerminalBubbleAlreadySettled() {
|
||||
handler.onTextDelta("stale-answer", "Finished answer")
|
||||
handler.onTurnComplete("stale-answer")
|
||||
assertTrue(handler.isStreaming.value)
|
||||
assertFalse(handler.messages.value.single().isStreaming)
|
||||
|
||||
viewModel.cancelStream()
|
||||
|
||||
assertFalse(handler.isStreaming.value)
|
||||
assertNull(handler.turnStatus.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newChatClearsStaleBusyStateWhenNoLiveGatewayTurnRemains() {
|
||||
handler.onTextDelta("stale-answer", "Finished answer")
|
||||
handler.onTurnComplete("stale-answer")
|
||||
assertTrue(handler.isStreaming.value)
|
||||
assertFalse(gatewayClient.hasActiveTurn())
|
||||
|
||||
viewModel.createNewChat()
|
||||
|
||||
assertFalse(handler.isStreaming.value)
|
||||
assertTrue(handler.messages.value.isEmpty())
|
||||
assertNull(handler.currentSessionId.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reopenedChatRestoresRichStateAndReattachesLiveGatewayTurn() {
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
@@ -103,6 +103,54 @@ class ChatViewModelMediaStateTest {
|
||||
assertEquals("content://com.axiomlabs.hermesrelay.fileprovider/hermes-media/photo.jpg", loaded.cachedUri)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun assistantWindowsPathCellularRetryUsesByPathEndpoint() {
|
||||
val windowsPath =
|
||||
"C:\\Users\\Example\\AppData\\Local\\Temp\\Sovereign Intelligence copy.md"
|
||||
handler.loadMessageHistory(
|
||||
listOf(
|
||||
MessageItem(
|
||||
id = "assistant-file-1",
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("MEDIA:$windowsPath"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val deferred = awaitMessage {
|
||||
it.attachments.singleOrNull()?.errorMessage == "Tap to download"
|
||||
}
|
||||
assertEquals(AttachmentState.FAILED, deferred.attachments.single().state)
|
||||
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "text/markdown")
|
||||
.setHeader(
|
||||
"Content-Disposition",
|
||||
"inline; filename=\"Sovereign Intelligence copy.md\"",
|
||||
)
|
||||
.setBody("# copy")
|
||||
)
|
||||
viewModel.cellularNetworkOverride = false
|
||||
viewModel.manualFetchAttachment("assistant-file-1", 0)
|
||||
|
||||
val loaded = awaitMessage {
|
||||
it.attachments.singleOrNull()?.state == AttachmentState.LOADED
|
||||
}.attachments.single()
|
||||
assertEquals("text/markdown", loaded.contentType)
|
||||
assertEquals("Sovereign Intelligence copy.md", loaded.fileName)
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertEquals("/media/by-path", request.requestUrl?.encodedPath)
|
||||
assertEquals(
|
||||
"path=C%3A%5CUsers%5CExample%5CAppData%5CLocal%5CTemp%5C" +
|
||||
"Sovereign%20Intelligence%20copy.md",
|
||||
request.requestUrl?.encodedQuery,
|
||||
)
|
||||
assertEquals(windowsPath, request.requestUrl?.queryParameter("path"))
|
||||
}
|
||||
|
||||
private fun awaitMessage(predicate: (ChatMessage) -> Boolean): ChatMessage {
|
||||
val deadline = System.nanoTime() + 5_000_000_000L
|
||||
while (System.nanoTime() < deadline) {
|
||||
|
||||
@@ -68,6 +68,25 @@ Rule of thumb: where a surface is CI-gateable, write the **failing test first**
|
||||
is the deliberate exception — CI only covers lint + unit there, so on-device
|
||||
verification stays a manual maintainer step and a fix is never "done" from CI alone.
|
||||
|
||||
### Emulator UI evidence
|
||||
|
||||
Hermes Android is dark-mode-first. Before emulator screenshots, animation
|
||||
review, or renderer performance measurements, explicitly enable Android dark
|
||||
mode and restart the app so evidence is not captured in the emulator's light
|
||||
default:
|
||||
|
||||
```bash
|
||||
adb -s <emulator-serial> shell cmd uimode night yes
|
||||
adb -s <emulator-serial> shell am force-stop com.axiomlabs.hermesrelay.sideload
|
||||
adb -s <emulator-serial> shell am start -n \
|
||||
com.axiomlabs.hermesrelay.sideload/com.hermesandroid.relay.MainActivity
|
||||
adb -s <emulator-serial> shell cmd uimode night
|
||||
```
|
||||
|
||||
Confirm the final command reports `Night mode: yes` before capturing evidence.
|
||||
Use host GPU acceleration where available; software rendering is useful for
|
||||
compatibility but is not representative performance evidence.
|
||||
|
||||
## Local bridge: `scripts/start-issue.sh`
|
||||
|
||||
```bash
|
||||
|
||||
@@ -160,6 +160,12 @@ class RelayConfig:
|
||||
@classmethod
|
||||
def from_env(cls) -> RelayConfig:
|
||||
"""Build config from environment variables, falling back to defaults."""
|
||||
hermes_home = (os.getenv("HERMES_HOME") or "").strip()
|
||||
default_hermes_config = (
|
||||
str(Path(hermes_home).expanduser() / "config.yaml")
|
||||
if hermes_home
|
||||
else cls.hermes_config_path
|
||||
)
|
||||
config = cls(
|
||||
host=os.getenv("RELAY_HOST", cls.host),
|
||||
port=int(os.getenv("RELAY_PORT", str(cls.port))),
|
||||
@@ -167,7 +173,7 @@ class RelayConfig:
|
||||
ssl_key=os.getenv("RELAY_SSL_KEY"),
|
||||
webapi_url=os.getenv("RELAY_WEBAPI_URL", cls.webapi_url),
|
||||
hermes_config_path=os.getenv(
|
||||
"RELAY_HERMES_CONFIG", cls.hermes_config_path
|
||||
"RELAY_HERMES_CONFIG", default_hermes_config
|
||||
),
|
||||
log_level=os.getenv("RELAY_LOG_LEVEL", cls.log_level),
|
||||
terminal_shell=os.getenv("RELAY_TERMINAL_SHELL") or None,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Relay environment-resolution regression tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from plugin.relay.config import RelayConfig
|
||||
|
||||
|
||||
class RelayConfigEnvironmentTests(unittest.TestCase):
|
||||
def test_hermes_home_supplies_default_config_and_session_paths(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp)
|
||||
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
|
||||
|
||||
with mock.patch.dict(os.environ, {"HERMES_HOME": str(home)}, clear=False):
|
||||
os.environ.pop("RELAY_HERMES_CONFIG", None)
|
||||
os.environ.pop("RELAY_SESSIONS_FILE", None)
|
||||
config = RelayConfig.from_env()
|
||||
|
||||
self.assertEqual(str(home / "config.yaml"), config.hermes_config_path)
|
||||
self.assertEqual(
|
||||
str(home / "hermes-relay-sessions.json"),
|
||||
config.session_persistence_path,
|
||||
)
|
||||
|
||||
def test_explicit_relay_config_override_wins_over_hermes_home(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as home_tmp, tempfile.TemporaryDirectory() as override_tmp:
|
||||
home = Path(home_tmp)
|
||||
override = Path(override_tmp) / "relay-config.yaml"
|
||||
override.write_text("{}\n", encoding="utf-8")
|
||||
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"HERMES_HOME": str(home),
|
||||
"RELAY_HERMES_CONFIG": str(override),
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
os.environ.pop("RELAY_SESSIONS_FILE", None)
|
||||
config = RelayConfig.from_env()
|
||||
|
||||
self.assertEqual(str(override), config.hermes_config_path)
|
||||
self.assertEqual(
|
||||
str(override.parent / "hermes-relay-sessions.json"),
|
||||
config.session_persistence_path,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user