Compare commits

..
Author SHA1 Message Date
Bailey Dixon 75feb55adf Merge pull request #540 from Codename-11/dev
release: Android 1.15.1 and CLI+UI 0.4.0-beta.7
2026-09-02 22:02:09 -04:00
Bailey Dixon f4b366389b release: Android 1.15.0, Plugin 1.11.1, CLI+UI 0.4.0-beta.6 (#525) 2026-08-31 23:45:58 -04:00
Bailey Dixon 2ec7b7aac9 release: Android 1.14.0 and Plugin 1.11.0 (#490) 2026-08-30 23:50:41 -04:00
107 changed files with 1199 additions and 6107 deletions
+2 -2
View File
@@ -115,8 +115,8 @@ assert.match(
/ref: \$\{\{ contains\(inputs\.version, '-'\) && 'dev' \|\| 'main' \}\}/, /ref: \$\{\{ contains\(inputs\.version, '-'\) && 'dev' \|\| 'main' \}\}/,
); );
assert.match(cliReleaseWorkflow, /workflow_dispatch:[\s\S]*?Approved CLI\+UI version/); assert.match(cliReleaseWorkflow, /workflow_dispatch:[\s\S]*?Approved CLI\+UI version/);
assert.match(cliReleaseWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v6/); assert.match(cliReleaseWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v5/);
assert.match(desktopCiWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v6/); assert.match(desktopCiWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v5/);
assert.match(pluginReleaseWorkflow, /workflow_dispatch:[\s\S]*?Approved Plugin version/); assert.match(pluginReleaseWorkflow, /workflow_dispatch:[\s\S]*?Approved Plugin version/);
assert.match(androidPreflightWorkflow, /Package immutable preflight artifacts/); assert.match(androidPreflightWorkflow, /Package immutable preflight artifacts/);
assert.match(androidApprovalWorkflow, /Android public approval accepts stable SemVer only/); assert.match(androidApprovalWorkflow, /Android public approval accepts stable SemVer only/);
+1 -1
View File
@@ -108,7 +108,7 @@ jobs:
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
- name: Restore exact-source tray build cache - name: Restore exact-source tray build cache
uses: actions/cache@v6 uses: actions/cache@v5
with: with:
path: | path: |
~/.cargo/registry/index ~/.cargo/registry/index
+1 -5
View File
@@ -111,8 +111,4 @@ jobs:
plugin/tests/test_git_state.py \ plugin/tests/test_git_state.py \
plugin/tests/test_git_state_write.py \ plugin/tests/test_git_state_write.py \
plugin/tests/test_git_state_extras.py \ plugin/tests/test_git_state_extras.py \
plugin/tests/test_mobile_plugin_store.py \ plugin/tests/test_mobile_plugin_store.py
plugin/tests/test_android_tool.py \
plugin/tests/test_android_navigate.py \
plugin/tests/test_phone_platform.py \
plugin/tests/test_desktop_tool_availability.py
+1 -1
View File
@@ -308,7 +308,7 @@ jobs:
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
- name: Restore exact-source tray build cache - name: Restore exact-source tray build cache
uses: actions/cache@v6 uses: actions/cache@v5
with: with:
path: | path: |
~/.cargo/registry/index ~/.cargo/registry/index
+1 -5
View File
@@ -107,11 +107,7 @@ jobs:
plugin/tests/test_voice_routes.py \ plugin/tests/test_voice_routes.py \
plugin/tests/test_session_grants.py \ plugin/tests/test_session_grants.py \
plugin/tests/test_proactive_channel.py \ plugin/tests/test_proactive_channel.py \
plugin/tests/test_android_phone_status.py \ plugin/tests/test_android_phone_status.py
plugin/tests/test_android_tool.py \
plugin/tests/test_android_navigate.py \
plugin/tests/test_phone_platform.py \
plugin/tests/test_desktop_tool_availability.py
package: package:
name: Build and publish Plugin package name: Build and publish Plugin package
-14
View File
@@ -6,20 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased] ## [Unreleased]
### Fixed
- Android Dashboard sign-in removes pasted line breaks from username and password fields, matching the browser login while preserving every other credential character. (#541)
- **Relay tool availability avoids repeated Windows loopback delays and preserves multi-PC capabilities.** Host-local Android, Desktop, and Phone paths use explicit IPv4 loopback, while Desktop checks share a bounded health snapshot that preserves per-client advertisements and fails closed when Hermes-Relay is unavailable. (#562, #563)
- Android keeps saved Dashboard sign-ins bound to their connection when switching gateways, rather than letting a stale resolver route invalidate another connection's session.
- Bot Mode no longer crashes when different connections have bots with the same profile name. Both the conversation list and Active Now strip preserve each bot's connection, and opening progress appears only on the selected bot.
- Android feedback uses themed banners and action cards instead of platform toasts and default snackbars. Dashboard errors no longer misidentify missing resources as an outdated Relay. Developer settings includes local-only message previews.
- Missing chat attachments show their error and retry in the attachment card without repeated global popups. Global action messages occupy the top message area instead of covering the composer.
- Chat distinguishes session preparation from response streaming and retains initialization errors that arrive before the session acknowledgement. Long-press the agent header to open a live session-diagnostics drawer.
- Delegated-agent activity survives parent replies and leaves compact history entries for later read-only review. The activity strip appears only while work runs; historical process views cannot stop or dismiss live work. (#447)
- **`android_*` tools resolve bridge credentials written after host startup.** Requests retry profile-scoped env and active bridge-session credentials after a stale token is rejected, and vision navigation now shares the same current Relay transport instead of the retired standalone default.
- **`android_setup` accepts both its canonical and legacy schema keys.** `bridge_session_token` and `pairing_code` are accepted, while a missing token returns a structured error.
- **Android tool setup tests use a temporary Hermes home.** Test runs no longer write bridge settings into a developer environment.
## [Android 1.15.1] - 2026-09-02 ## [Android 1.15.1] - 2026-09-02
### Changed ### Changed
@@ -1,65 +0,0 @@
package com.hermesandroid.relay.ui.screens
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.ui.components.ChatDebugDrawer
import com.hermesandroid.relay.ui.components.ChatDebugOverlay
import com.hermesandroid.relay.ui.components.chatDebugHeaderGesture
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class ChatDebugDrawerInstrumentedTest {
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
@Test
fun longPressOpensDiagnosticsBelowHeaderAndCloseRestoresChat() {
compose.setContent {
var open by remember { mutableStateOf(false) }
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
Box(Modifier.fillMaxSize()) {
Text("Hermes", Modifier.fillMaxWidth().height(64.dp).testTag("header")
.chatDebugHeaderGesture(true, onClick = {}, onHold = { open = true }))
ChatDebugOverlay(open, 64.dp, onClose = { open = false }) {
ChatDebugDrawer(
profile = "Server Default", model = "Example", sessionId = "session",
gateway = true, signedIn = true, signInRequired = false,
socketState = GatewayConnectionState.Ready, preparing = false,
streaming = false, loadingHistory = false, directoryUnavailable = false,
failure = null, onClose = { open = false }, onConnections = {},
)
}
}
}
}
val header = compose.onNodeWithTag("header")
val before = header.fetchSemanticsNode().boundsInRoot
header.performTouchInput { longClick() }
compose.onNodeWithText("Session diagnostics").assertIsDisplayed()
assertEquals(before, header.fetchSemanticsNode().boundsInRoot)
compose.onNodeWithContentDescription("Close session diagnostics").performClick()
compose.onNodeWithText("Session diagnostics").assertDoesNotExist()
header.assertIsDisplayed()
}
}
@@ -1,183 +0,0 @@
package com.hermesandroid.relay.viewmodel
import android.os.Handler
import android.os.Looper
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.InMemoryChatActivityStore
import com.hermesandroid.relay.data.projectChatActivityReceipts
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.HermesApiClient
import com.hermesandroid.relay.ui.components.ChatActivityReceipt
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessSheet
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.OkHttpClient
import okhttp3.WebSocket
import org.junit.After
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
/** Real Gateway callbacks drive production activity surfaces through Android lifecycle changes. */
class ChatActivityReceiptInstrumentedTest {
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
private lateinit var fixture: AndroidGatewayContractFixture
private lateinit var gatewayScope: CoroutineScope
private lateinit var gateway: GatewayChatClient
private lateinit var handler: ChatHandler
private lateinit var viewModel: ChatViewModel
private lateinit var socket: WebSocket
private val owner = AgentDisplay.profileContextKey("fixture-connection", "research")
@Before
fun setUp() {
fixture = AndroidGatewayContractFixture().also { it.profileName = "research" }
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val http = OkHttpClient()
gateway = GatewayChatClient(
initialDashboardClient = DashboardApiClient(fixture.server.url("/").toString().trimEnd('/'), okHttpClient = http),
okHttpClient = http,
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
reconnectJitterUnit = { 0.0 },
)
handler = ChatHandler().also { it.setSessionId(STORED_SESSION_ID) }
viewModel = ChatViewModel().also {
it.initialize(HermesApiClient(fixture.server.url("/").toString(), "fixture-key"), handler)
it.streamingEndpoint = "gateway"
it.setSessionProfileNameProvider { "research" }
it.setProfileMessageLoader { Result.success(emptyList()) }
it.setChatActivityStore(InMemoryChatActivityStore())
it.switchProfileContext(owner, STORED_SESSION_ID)
it.updateGatewayClient(gateway)
it.setChatVisible(true)
}
compose.setContent {
val messages by viewModel.messages.collectAsStateWithLifecycle()
val records by viewModel.activityRecords.collectAsStateWithLifecycle()
val children by viewModel.subagentActivities.collectAsStateWithLifecycle()
val retained by viewModel.retainedActivityPreview.collectAsStateWithLifecycle()
val childPreview by viewModel.subagentChildPreview.collectAsStateWithLifecycle()
val session by viewModel.currentSessionId.collectAsStateWithLifecycle()
var sheetOpen by remember { mutableStateOf(false) }
MaterialTheme {
Column {
GatewayBackgroundProcessStrip(
processes = emptyList(), subagentActivities = children,
subagentPreviewVisibility = SubagentPreviewVisibility(), loading = false,
onClick = { viewModel.openCurrentActivityPreview(); sheetOpen = true },
modifier = Modifier.testTag("active-activity"),
)
projectChatActivityReceipts(messages, records, owner, session).forEach { message ->
message.activityRecord?.let { record ->
ChatActivityReceipt(
record = record,
onClick = { sheetOpen = viewModel.openRetainedActivity(record) },
modifier = Modifier.testTag("activity-receipt"),
)
}
}
}
if (sheetOpen) {
GatewayBackgroundProcessSheet(
processes = retained?.processes.orEmpty(),
subagentActivities = retained?.record?.previewActivities() ?: children,
subagentChildPreview = childPreview,
subagentPreviewVisibility = SubagentPreviewVisibility(),
loading = false, stoppingProcessIds = emptySet(),
onRefresh = viewModel::refreshBackgroundProcesses,
onStop = viewModel::stopBackgroundProcess,
onDismissProcess = viewModel::dismissBackgroundProcess,
onOpenSubagentChild = viewModel::openSubagentChildPreview,
onDismiss = { viewModel.closeActivityPreview(); sheetOpen = false },
readOnlyHistory = retained != null,
historyNotice = "Recorded activity. Available child history is read-only.",
)
}
}
}
assertTrue(runBlocking { gateway.prewarmAwait(STORED_SESSION_ID) })
socket = fixture.awaitServerSocket()
fixture.awaitRpc("session.resume")
}
@After
fun tearDown() {
viewModel.updateGatewayClient(null)
gateway.shutdown()
gatewayScope.cancel()
fixture.shutdown()
}
@Test
fun detachedCompletionLeavesReopenableReceiptAcrossActivityResume() {
viewModel.sendMessage("Delegate a background task")
fixture.awaitRpc("prompt.submit")
socket.send(fixture.event("message.start", null, LIVE_SESSION_ID))
socket.send(fixture.event("subagent.start", buildJsonObject {
put("subagent_id", "receipt-child")
put("delegation_id", "receipt-delegation")
put("task_count", 1)
put("goal", "Inspect activity lifecycle")
}, LIVE_SESSION_ID))
compose.waitUntil(5_000) { viewModel.subagentActivities.value.size == 1 }
compose.onNodeWithTag("active-activity").assertIsDisplayed()
socket.send(fixture.event("message.complete", buildJsonObject { put("text", "Launched") }, LIVE_SESSION_ID))
compose.waitUntil(5_000) { !handler.isStreaming.value }
compose.onNodeWithTag("active-activity").assertIsDisplayed()
socket.send(fixture.event("subagent.complete", buildJsonObject {
put("subagent_id", "receipt-child")
put("delegation_id", "receipt-delegation")
put("status", "completed")
}, LIVE_SESSION_ID))
compose.waitUntil(5_000) { viewModel.activityRecords.value.singleOrNull()?.phase == ChatActivityPhase.COMPLETE }
compose.onNodeWithTag("active-activity").assertDoesNotExist()
compose.onNodeWithTag("activity-receipt").assertIsDisplayed().performClick()
compose.onNodeWithText("Chat activity").assertIsDisplayed()
compose.onNodeWithText("Recorded activity. Available child history is read-only.").assertIsDisplayed()
compose.onNodeWithText("Stop").assertDoesNotExist()
compose.onNodeWithContentDescription("Close activity preview").performClick()
compose.onNodeWithTag("activity-receipt").assertIsDisplayed()
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.onNodeWithTag("active-activity").assertDoesNotExist()
compose.onNodeWithTag("activity-receipt").assertIsDisplayed().performClick()
compose.onNodeWithText("Chat activity").assertIsDisplayed()
}
private companion object {
const val STORED_SESSION_ID = "20260821_120000_fixture"
const val LIVE_SESSION_ID = "fixture-live-1"
}
}
@@ -28,8 +28,6 @@ import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.HermesApiClient import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.network.upstream.models.MessageItem import com.hermesandroid.relay.network.upstream.models.MessageItem
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -117,18 +115,9 @@ class GatewayForegroundRecoveryInstrumentedTest {
compose.setContent { compose.setContent {
val messages by viewModel.messages.collectAsStateWithLifecycle() val messages by viewModel.messages.collectAsStateWithLifecycle()
val streaming by viewModel.isStreaming.collectAsStateWithLifecycle() val streaming by viewModel.isStreaming.collectAsStateWithLifecycle()
val children by viewModel.subagentActivities.collectAsStateWithLifecycle()
val signInRequired by historySignInRequired.collectAsStateWithLifecycle() val signInRequired by historySignInRequired.collectAsStateWithLifecycle()
MaterialTheme { MaterialTheme {
Column(Modifier.testTag("contract-transcript")) { Column(Modifier.testTag("contract-transcript")) {
GatewayBackgroundProcessStrip(
processes = emptyList(),
subagentActivities = children,
subagentPreviewVisibility = SubagentPreviewVisibility(),
loading = false,
onClick = {},
modifier = Modifier.testTag("child-activity"),
)
Text( Text(
text = if (streaming) "STREAMING" else "IDLE", text = if (streaming) "STREAMING" else "IDLE",
modifier = Modifier.testTag("stream-state"), modifier = Modifier.testTag("stream-state"),
@@ -164,37 +153,6 @@ class GatewayForegroundRecoveryInstrumentedTest {
fixture.shutdown() fixture.shutdown()
} }
@Test
fun detachedChildActivity_survivesParentTerminalAndActivityResume() {
viewModel.sendMessage("Delegate a background task")
fixture.awaitRpc("prompt.submit")
serverSocket.send(fixture.event("message.start", null, LIVE_SESSION_ID))
serverSocket.send(fixture.event("subagent.start", buildJsonObject {
put("subagent_id", "detached-child")
put("goal", "Inspect")
}, LIVE_SESSION_ID))
compose.waitUntil(5_000) { viewModel.subagentActivities.value.size == 1 }
compose.onNodeWithTag("child-activity").assertIsDisplayed()
serverSocket.send(fixture.event("message.complete", buildJsonObject { put("text", "Launched") }, LIVE_SESSION_ID))
compose.waitUntil(5_000) { !handler.isStreaming.value }
compose.activityRule.scenario.moveToState(Lifecycle.State.STARTED)
serverSocket.send(fixture.event("subagent.progress", buildJsonObject {
put("subagent_id", "detached-child")
put("text", "Still working")
}, LIVE_SESSION_ID))
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.waitUntil(5_000) { viewModel.subagentActivities.value.single().events.last().text == "Still working" }
compose.onNodeWithTag("child-activity").assertIsDisplayed()
assertFalse(viewModel.subagentActivities.value.single().isTerminal)
compose.onNodeWithTag("stream-state").assertTextEquals("IDLE")
serverSocket.send(fixture.event("subagent.complete", buildJsonObject {
put("subagent_id", "detached-child")
put("status", "completed")
}, LIVE_SESSION_ID))
compose.waitUntil(5_000) { viewModel.subagentActivities.value.single().isTerminal }
compose.onNodeWithTag("child-activity").assertDoesNotExist()
}
@Test @Test
fun terminalGapActivate_recoversForegroundTurnWithoutNavigationOrCrossSessionLeak() { fun terminalGapActivate_recoversForegroundTurnWithoutNavigationOrCrossSessionLeak() {
viewModel.sendMessage("Run a long foreground task") viewModel.sendMessage("Run a long foreground task")
@@ -1,102 +0,0 @@
package com.hermesandroid.relay.data
/** Presentation only: canonical rows retain their wire identity, role and content. */
internal fun projectChatActivityReceipts(
messages: List<ChatMessage>,
records: List<ChatActivityRecord>,
scopeKey: String?,
sessionId: String?,
): List<ChatMessage> {
val originals = messages.filterNot {
it.clientOnly && it.id.startsWith("activity:") && it.activityRecord != null
}
if (scopeKey.isNullOrBlank() || sessionId.isNullOrBlank()) {
return originals.map { it.copy(activityRecord = null) }
}
val owned = records.filter { it.scopeKey == scopeKey && it.sessionId == sessionId }
.sortedByDescending { it.updatedAt }
.distinctBy { it.id }
val represented = mutableSetOf<String>()
val canonical = originals.map { message ->
val process = message.hermesProcessNotificationOrNull()
val delegation = message.activitySourceId?.takeIf { it.startsWith("delegation:") }
?.removePrefix("delegation:")?.takeIf { it.isNotBlank() }
val kind = when {
message.activitySourceId != null -> ChatActivityKind.SUBAGENTS
process != null -> ChatActivityKind.PROCESS
else -> return@map message.copy(activityRecord = null)
}
val sourceId = delegation ?: process?.processId
val processTerminal = process?.let { notice ->
PROCESS_TERMINAL_HEADLINE.matchEntire(notice.headline)?.let { match ->
match.groupValues[2].toIntOrNull()?.let { code ->
val phase = when {
match.groupValues[1].startsWith("terminated by ") -> ChatActivityPhase.CANCELLED
code == 0 -> ChatActivityPhase.COMPLETE
else -> ChatActivityPhase.FAILED
}
phase to code
}
}
}
// A process id can be reused after a registry restart. A canonical row has
// no start-generation field, so multiple generations must remain unmatched.
val matching = if (sourceId == null) emptyList() else owned.filter {
it.kind == kind && it.sourceId == sourceId
}
val record = matching.singleOrNull()?.also { represented += it.id }
?: ChatActivityRecord(
id = "canonical:${message.id}",
scopeKey = scopeKey,
sessionId = sessionId,
kind = kind,
sourceId = sourceId ?: "unavailable:${message.id}",
title = process?.headline ?: message.content,
phase = when {
kind == ChatActivityKind.PROCESS -> processTerminal?.first ?: ChatActivityPhase.UNKNOWN
(message.activityFailedCount ?: 0) > 0 ->
if (message.activityFailedCount == message.activityTaskCount) {
ChatActivityPhase.FAILED
} else ChatActivityPhase.UNKNOWN
(message.activityTaskCount ?: 0) > 0 -> ChatActivityPhase.COMPLETE
else -> ChatActivityPhase.UNKNOWN
},
createdAt = message.timestamp,
updatedAt = message.timestamp,
taskCount = message.activityTaskCount?.coerceAtLeast(0) ?: 0,
processId = process?.processId,
exitCode = processTerminal?.second,
)
// Aggregate completion metadata never rewrites captured child phases.
message.copy(activityRecord = record)
}
val pending = owned.filter { it.phase != ChatActivityPhase.RUNNING && it.id !in represented }
.sortedWith(compareBy<ChatActivityRecord> { it.updatedAt }.thenBy { it.id })
.map { record ->
ChatMessage(
id = "activity:${record.id}",
role = MessageRole.SYSTEM,
content = record.title,
timestamp = record.updatedAt,
clientOnly = true,
activityRecord = record,
)
}
// Stable merge: history order is authoritative even if server timestamps
// regress. Only insert local receipts; never sort canonical messages.
var next = 0
return buildList {
canonical.forEach { message ->
while (next < pending.size && pending[next].timestamp < message.timestamp) {
add(pending[next++])
}
add(message)
}
while (next < pending.size) add(pending[next++])
}
}
/** Upstream completion envelope only; never search arbitrary command/output text. */
private val PROCESS_TERMINAL_HEADLINE = Regex(
"""Background process \S+ (completed normally|exited|terminated by [^\r\n]+|marked lost because the process backend disappeared|failed to start) \(exit code (-?\d+)(?:, SIGTERM)?\)\.""",
)
@@ -1,222 +0,0 @@
package com.hermesandroid.relay.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import java.io.IOException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.intOrNull
@Serializable
enum class ChatActivityKind { SUBAGENTS, PROCESS }
@Serializable
enum class ChatActivityPhase { RUNNING, COMPLETE, FAILED, CANCELLED, UNKNOWN }
@Serializable
data class ChatActivityChild(
val id: String,
val childSessionId: String? = null,
val goal: String = "",
val phase: ChatActivityPhase = ChatActivityPhase.UNKNOWN,
val summary: String? = null,
)
/** Local presentation metadata and exact references, never transcripts or process output. */
@Serializable
data class ChatActivityRecord(
val id: String,
val scopeKey: String,
val sessionId: String,
val kind: ChatActivityKind,
val sourceId: String,
val title: String,
val phase: ChatActivityPhase,
val createdAt: Long,
val updatedAt: Long,
val children: List<ChatActivityChild> = emptyList(),
val taskCount: Int = 0,
val processId: String? = null,
val processStartedAt: String? = null,
val exitCode: Int? = null,
)
interface ChatActivityStore {
/** Recovery is not live evidence: RUNNING becomes UNKNOWN, including child phases. */
suspend fun read(scopeKey: String, sessionId: String): List<ChatActivityRecord>
suspend fun upsert(record: ChatActivityRecord)
suspend fun removeRecord(scopeKey: String, sessionId: String, id: String)
suspend fun removeSession(scopeKey: String, sessionId: String)
}
/**
* Bounded app-private history references in the shared settings DataStore.
* Retains 30 days, 128 records overall, 32 per exact owner/session, and 32 children
* per record. Titles are 160 characters; goals/summaries 512. Identity fields are
* rejected above 512 characters (scope 2048), never truncated into another owner.
* The complete encoded envelope is capped at 1 MiB, evicting oldest records first.
* Five minutes of future skew allows monotonic local revisions within one clock tick.
* All read/modify/write operations occur inside DataStore's serialized edit.
*/
class DataStoreChatActivityStore(
private val dataStore: DataStore<Preferences>,
private val now: () -> Long = System::currentTimeMillis,
) : ChatActivityStore {
constructor(context: Context) : this(context.applicationContext.relayDataStore)
override suspend fun read(scopeKey: String, sessionId: String): List<ChatActivityRecord> {
val raw = try {
dataStore.data.first()[CHAT_ACTIVITY_KEY]
} catch (_: IOException) {
return emptyList()
}
return boundChatActivities(decodeChatActivities(raw), now())
.filter { it.scopeKey == scopeKey && it.sessionId == sessionId }
.map(ChatActivityRecord::recovered)
}
override suspend fun upsert(record: ChatActivityRecord) {
dataStore.edit { preferences ->
val records = mergeChatActivity(decodeChatActivities(preferences[CHAT_ACTIVITY_KEY]), record, now())
preferences[CHAT_ACTIVITY_KEY] = encodeChatActivities(records)
}
}
override suspend fun removeSession(scopeKey: String, sessionId: String) {
dataStore.edit { preferences ->
val remaining = boundChatActivities(decodeChatActivities(preferences[CHAT_ACTIVITY_KEY]), now())
.filterNot { it.scopeKey == scopeKey && it.sessionId == sessionId }
if (remaining.isEmpty()) preferences.remove(CHAT_ACTIVITY_KEY)
else preferences[CHAT_ACTIVITY_KEY] = encodeChatActivities(remaining)
}
}
override suspend fun removeRecord(scopeKey: String, sessionId: String, id: String) {
dataStore.edit { preferences ->
val remaining = boundChatActivities(decodeChatActivities(preferences[CHAT_ACTIVITY_KEY]), now())
.filterNot { it.scopeKey == scopeKey && it.sessionId == sessionId && it.id == id }
if (remaining.isEmpty()) preferences.remove(CHAT_ACTIVITY_KEY)
else preferences[CHAT_ACTIVITY_KEY] = encodeChatActivities(remaining)
}
}
}
/** Test/ephemeral implementation with the same bounds and recovery semantics. */
class InMemoryChatActivityStore(
private val now: () -> Long = System::currentTimeMillis,
) : ChatActivityStore {
private val mutex = Mutex()
private var records = emptyList<ChatActivityRecord>()
override suspend fun read(scopeKey: String, sessionId: String): List<ChatActivityRecord> = mutex.withLock {
records = boundChatActivities(records, now())
records.filter { it.scopeKey == scopeKey && it.sessionId == sessionId }
.map(ChatActivityRecord::recovered)
}
override suspend fun upsert(record: ChatActivityRecord) = mutex.withLock {
records = mergeChatActivity(records, record, now())
}
override suspend fun removeSession(scopeKey: String, sessionId: String) = mutex.withLock {
records = boundChatActivities(records, now())
.filterNot { it.scopeKey == scopeKey && it.sessionId == sessionId }
}
override suspend fun removeRecord(scopeKey: String, sessionId: String, id: String) = mutex.withLock {
records = boundChatActivities(records, now())
.filterNot { it.scopeKey == scopeKey && it.sessionId == sessionId && it.id == id }
}
}
internal const val CHAT_ACTIVITY_MAX_AGE_MS = 30L * 24L * 60L * 60L * 1_000L
private const val MAX_RECORDS = 128
private const val MAX_SESSION_RECORDS = 32
private const val MAX_PAYLOAD_BYTES = 1_048_576
private val CHAT_ACTIVITY_KEY = stringPreferencesKey("chat_activity_records_v1")
private val activityJson = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@Serializable
private data class ChatActivityEnvelope(val version: Int = 1, val records: List<ChatActivityRecord>)
private fun encodeChatActivities(records: List<ChatActivityRecord>): String =
activityJson.encodeToString(ChatActivityEnvelope(records = records))
internal fun decodeChatActivities(raw: String?): List<ChatActivityRecord> {
if (raw == null || raw.length > MAX_PAYLOAD_BYTES || raw.toByteArray().size > MAX_PAYLOAD_BYTES) {
return emptyList()
}
val envelope = runCatching { activityJson.parseToJsonElement(raw) as? JsonObject }.getOrNull()
?: return emptyList()
val version = runCatching { envelope["version"]?.jsonPrimitive?.intOrNull }.getOrNull()
if (version != 1) return emptyList()
val rows = envelope["records"] as? JsonArray ?: return emptyList()
// One corrupt or newer row must not hide independently valid records.
return rows.mapNotNull { row ->
runCatching { activityJson.decodeFromJsonElement(ChatActivityRecord.serializer(), row) }.getOrNull()
}
}
private fun ChatActivityRecord.identity() = Triple(scopeKey, sessionId, id)
private fun mergeChatActivity(
existing: List<ChatActivityRecord>,
record: ChatActivityRecord,
now: Long,
): List<ChatActivityRecord> {
// Sorting first also rejects a late write for an older generation of the same record.
return boundChatActivities(listOf(record) + existing, now)
}
internal fun boundChatActivities(records: List<ChatActivityRecord>, now: Long): List<ChatActivityRecord> {
val counts = mutableMapOf<Pair<String, String>, Int>()
val bounded = records.mapNotNull { it.bounded(now) }
.sortedByDescending(ChatActivityRecord::updatedAt)
.distinctBy { it.identity() }
.filter {
val owner = it.scopeKey to it.sessionId
val count = counts.getOrDefault(owner, 0)
counts[owner] = count + 1
count < MAX_SESSION_RECORDS
}.take(MAX_RECORDS).toMutableList()
while (bounded.isNotEmpty() && encodeChatActivities(bounded).toByteArray().size > MAX_PAYLOAD_BYTES) {
bounded.removeAt(bounded.lastIndex)
}
return bounded
}
private fun validIdentity(value: String, max: Int = 512) = value.isNotBlank() && value.length <= max
private fun ChatActivityRecord.bounded(now: Long): ChatActivityRecord? {
if (!validIdentity(scopeKey, 2048) || !validIdentity(sessionId) || !validIdentity(id) ||
!validIdentity(sourceId) || (processId != null && !validIdentity(processId)) ||
(processStartedAt != null && !validIdentity(processStartedAt)) ||
createdAt < 0 || updatedAt < createdAt || updatedAt > now + 300_000L ||
now - updatedAt > CHAT_ACTIVITY_MAX_AGE_MS
) return null
return copy(
title = title.take(160),
taskCount = taskCount.coerceIn(0, 10_000),
children = children.asSequence().filter {
validIdentity(it.id) && (it.childSessionId == null || validIdentity(it.childSessionId))
}.distinctBy(ChatActivityChild::id).take(32)
.map { it.copy(goal = it.goal.take(512), summary = it.summary?.take(512)) }.toList(),
)
}
private fun ChatActivityRecord.recovered() = copy(
phase = if (phase == ChatActivityPhase.RUNNING) ChatActivityPhase.UNKNOWN else phase,
children = children.map {
if (it.phase == ChatActivityPhase.RUNNING) it.copy(phase = ChatActivityPhase.UNKNOWN) else it
},
)
@@ -170,12 +170,6 @@ data class ChatMessage(
* but server history never owns these presentation blocks. * but server history never owns these presentation blocks.
*/ */
val moaReferences: List<MoaReference> = emptyList(), val moaReferences: List<MoaReference> = emptyList(),
/** Exact upstream identity on a persisted activity-completion marker. */
val activitySourceId: String? = null,
val activityTaskCount: Int? = null,
val activityFailedCount: Int? = null,
/** Read-only UI projection; never sent as model history or voice input. */
val activityRecord: ChatActivityRecord? = null,
) )
data class MessageReaction( data class MessageReaction(
@@ -1451,12 +1451,6 @@ class ChatHandler {
val loaded = renderedItems.mapNotNull { item -> val loaded = renderedItems.mapNotNull { item ->
val displayKind = item.displayKind?.trim()?.lowercase() val displayKind = item.displayKind?.trim()?.lowercase()
if (displayKind == "hidden") return@mapNotNull null if (displayKind == "hidden") return@mapNotNull null
val activitySourceId = if (displayKind == "async_delegation_complete") {
item.displayMetadata.stringField("delegation_id")?.let { "delegation:$it" }
?: "unavailable:${item.id}"
} else null
val activityTaskCount = if (activitySourceId != null) item.displayMetadata.intField("task_count") else null
val activityFailedCount = if (activitySourceId != null) item.displayMetadata.intField("failed_count") else null
val role = when { val role = when {
displayKind == "model_switch" || displayKind == "model_switch" ||
displayKind == "async_delegation_complete" || displayKind == "async_delegation_complete" ||
@@ -1617,9 +1611,6 @@ class ChatHandler {
// this as the same visible row across the post-turn reload. // this as the same visible row across the post-turn reload.
prior.copy( prior.copy(
id = messageId, id = messageId,
activitySourceId = activitySourceId,
activityTaskCount = activityTaskCount,
activityFailedCount = activityFailedCount,
rowId = item.resolvedRowId, rowId = item.resolvedRowId,
reactions = item.reactions, reactions = item.reactions,
role = role, role = role,
@@ -1652,9 +1643,6 @@ class ChatHandler {
// nothing local to carry). // nothing local to carry).
ChatMessage( ChatMessage(
id = messageId, id = messageId,
activitySourceId = activitySourceId,
activityTaskCount = activityTaskCount,
activityFailedCount = activityFailedCount,
rowId = item.resolvedRowId, rowId = item.resolvedRowId,
reactions = item.reactions, reactions = item.reactions,
role = role, role = role,
@@ -1525,14 +1525,10 @@ class DashboardApiClient(
password: String, password: String,
next: String = "/", next: String = "/",
): Result<DashboardLoginResponse> = withContext(Dispatchers.IO) { ): Result<DashboardLoginResponse> = withContext(Dispatchers.IO) {
// Match the Dashboard's single-line HTML username/password controls:
// remove only forbidden line breaks and preserve every other code point.
val normalizedUsername = username.replace("\r", "").replace("\n", "")
val normalizedPassword = password.replace("\r", "").replace("\n", "")
val payload = buildJsonObject { val payload = buildJsonObject {
put("provider", provider) put("provider", provider)
put("username", normalizedUsername) put("username", username)
put("password", normalizedPassword) put("password", password)
put("next", next) put("next", next)
} }
val httpUrl = resolveUrl("/auth/password-login") val httpUrl = resolveUrl("/auth/password-login")
@@ -431,9 +431,6 @@ class GatewayChatClient(
private val lazyLiveSessions = ConcurrentHashMap.newKeySet<String>() private val lazyLiveSessions = ConcurrentHashMap.newKeySet<String>()
private val readyLiveSessions = ConcurrentHashMap.newKeySet<String>() private val readyLiveSessions = ConcurrentHashMap.newKeySet<String>()
private val sessionReadyWaiters = ConcurrentHashMap<String, CompletableDeferred<Unit>>() private val sessionReadyWaiters = ConcurrentHashMap<String, CompletableDeferred<Unit>>()
private val sessionReadyFailures = ConcurrentHashMap<String, String>()
private val _preparingSessionId = MutableStateFlow<String?>(null)
val preparingSessionId: StateFlow<String?> = _preparingSessionId.asStateFlow()
/** Monotonic client-local fence for lazy child watch open/close races. */ /** Monotonic client-local fence for lazy child watch open/close races. */
private val childWatchGeneration = AtomicLong(0) private val childWatchGeneration = AtomicLong(0)
@@ -636,13 +633,6 @@ class GatewayChatClient(
@Volatile @Volatile
private var processEventListener: ((GatewayProcessEvent) -> Unit)? = null private var processEventListener: ((GatewayProcessEvent) -> Unit)? = null
@Volatile
private var subagentEventListener: ((String, String?, GatewaySubagentEvent) -> Unit)? = null
fun setSubagentEventListener(listener: ((String, String?, GatewaySubagentEvent) -> Unit)?) {
subagentEventListener = listener
}
/** Process-wide durable-session invalidation/liveness edge. */ /** Process-wide durable-session invalidation/liveness edge. */
@Volatile @Volatile
private var sessionDirectoryInvalidationListener: (() -> Unit)? = null private var sessionDirectoryInvalidationListener: (() -> Unit)? = null
@@ -1573,10 +1563,7 @@ class GatewayChatClient(
.orEmpty() .orEmpty()
.also { recoveryEvents = null } .also { recoveryEvents = null }
} }
buffered.forEach { event -> buffered.forEach { event -> boundTurn?.onEvent(event.type, event.payload) }
dispatchSubagentEvent(event.type, event.payload, event.sessionId)
boundTurn.onEvent(event.type, event.payload)
}
queued?.let { queuedTurn -> queued?.let { queuedTurn ->
queuedTurnProvider?.invoke(queuedTurn)?.let { registration -> queuedTurnProvider?.invoke(queuedTurn)?.let { registration ->
boundTurn.installQueuedSuccessor(registration) boundTurn.installQueuedSuccessor(registration)
@@ -2963,7 +2950,6 @@ class GatewayChatClient(
unmatchedTurnCompleteListener = null unmatchedTurnCompleteListener = null
backgroundInteractionListener = null backgroundInteractionListener = null
processEventListener = null processEventListener = null
subagentEventListener = null
sessionDirectoryInvalidationListener = null sessionDirectoryInvalidationListener = null
closeSocket("client shutdown") closeSocket("client shutdown")
backgroundCloseJob?.cancel() backgroundCloseJob?.cancel()
@@ -3499,7 +3485,6 @@ class GatewayChatClient(
liveId: String, liveId: String,
sessionResult: JsonObject? = null, sessionResult: JsonObject? = null,
) { ) {
sessionReadyFailures[liveId]?.let { throw GatewayPreflightException(it) }
val lazy = (sessionResult?.get("info") as? JsonObject)?.booleanField("lazy") == true val lazy = (sessionResult?.get("info") as? JsonObject)?.booleanField("lazy") == true
if (lazy) lazyLiveSessions += liveId if (lazy) lazyLiveSessions += liveId
if (liveId !in lazyLiveSessions) return if (liveId !in lazyLiveSessions) return
@@ -3509,9 +3494,6 @@ class GatewayChatClient(
} }
val waiter = sessionReadyWaiters.computeIfAbsent(liveId) { CompletableDeferred() } val waiter = sessionReadyWaiters.computeIfAbsent(liveId) { CompletableDeferred() }
if (readyLiveSessions.remove(liveId)) waiter.complete(Unit) if (readyLiveSessions.remove(liveId)) waiter.complete(Unit)
sessionReadyFailures[liveId]?.let { waiter.completeExceptionally(GatewayRpcException(it)) }
val preparingStoredId = storedSessionId
_preparingSessionId.value = preparingStoredId
try { try {
withTimeout(sessionReadyTimeoutMs) { waiter.await() } withTimeout(sessionReadyTimeoutMs) { waiter.await() }
lazyLiveSessions.remove(liveId) lazyLiveSessions.remove(liveId)
@@ -3520,14 +3502,11 @@ class GatewayChatClient(
error.message ?: "Hermes session initialization timed out", error.message ?: "Hermes session initialization timed out",
) )
} finally { } finally {
if (_preparingSessionId.value == preparingStoredId) _preparingSessionId.value = null
sessionReadyWaiters.remove(liveId, waiter) sessionReadyWaiters.remove(liveId, waiter)
} }
} }
private fun failSessionReadyWaiters(message: String) { private fun failSessionReadyWaiters(message: String) {
_preparingSessionId.value = null
sessionReadyFailures.clear()
sessionReadyWaiters.values.forEach { sessionReadyWaiters.values.forEach {
it.completeExceptionally(GatewayRpcException(message)) it.completeExceptionally(GatewayRpcException(message))
} }
@@ -3717,7 +3696,6 @@ class GatewayChatClient(
if (type == "session.info" && !eventSessionId.isNullOrBlank() && if (type == "session.info" && !eventSessionId.isNullOrBlank() &&
payload?.booleanField("lazy") != true payload?.booleanField("lazy") != true
) { ) {
sessionReadyFailures.remove(eventSessionId)
readyLiveSessions += eventSessionId readyLiveSessions += eventSessionId
sessionReadyWaiters.remove(eventSessionId)?.complete(Unit) sessionReadyWaiters.remove(eventSessionId)?.complete(Unit)
} }
@@ -3727,16 +3705,10 @@ class GatewayChatClient(
// immediately so the optimistic prompt remains retryable/Not sent // immediately so the optimistic prompt remains retryable/Not sent
// instead of waiting for the five-minute readiness timeout. // instead of waiting for the five-minute readiness timeout.
if (type == "error" && !eventSessionId.isNullOrBlank() && if (type == "error" && !eventSessionId.isNullOrBlank() &&
(eventSessionId in lazyLiveSessions || sessionReadyWaiters.containsKey(eventSessionId) || (eventSessionId in lazyLiveSessions || sessionReadyWaiters.containsKey(eventSessionId))
payload?.stringField("message")?.startsWith("agent init failed:") == true)
) { ) {
val message = payload?.stringField("message") val message = payload?.stringField("message")
?: "Hermes session initialization failed" ?: "Hermes session initialization failed"
// A fast deferred build can fail before the lazy RPC acknowledgement.
// Retain the exact runtime failure so the subsequent readiness wait
// cannot lose that edge and hang until its deadline.
if (sessionReadyFailures.size >= 64) sessionReadyFailures.keys.firstOrNull()?.let(sessionReadyFailures::remove)
sessionReadyFailures[eventSessionId] = message.take(2_000)
lazyLiveSessions.remove(eventSessionId) lazyLiveSessions.remove(eventSessionId)
readyLiveSessions.remove(eventSessionId) readyLiveSessions.remove(eventSessionId)
sessionReadyWaiters.remove(eventSessionId) sessionReadyWaiters.remove(eventSessionId)
@@ -3943,7 +3915,6 @@ class GatewayChatClient(
dispatchProcessEvent(type, payload, eventSessionId) dispatchProcessEvent(type, payload, eventSessionId)
if (consumeCancelledTurnEvent(type, eventSessionId)) return if (consumeCancelledTurnEvent(type, eventSessionId)) return
if (consumeSettledTurnTerminal(type, eventSessionId)) return if (consumeSettledTurnTerminal(type, eventSessionId)) return
dispatchSubagentEvent(type, payload, eventSessionId)
var turn = activeTurn var turn = activeTurn
if (turn == null && type == "message.start") { if (turn == null && type == "message.start") {
// Unsolicited turns are accepted only with an explicit exact live- // Unsolicited turns are accepted only with an explicit exact live-
@@ -4005,25 +3976,10 @@ class GatewayChatClient(
} }
/** /**
* Detached child updates belong to the session even between parent turns. * Deliver session-scoped process events before the active-turn gate. The
* Recheck socket, session, profile and listener ownership on UI dispatch. * gateway socket is process-wide, so an exact non-blank live id match is
* required; missing/foreign ids must never leak another window's process.
*/ */
private fun dispatchSubagentEvent(type: String, payload: JsonObject?, eventSessionId: String?) {
val liveId = liveSessionId ?: return
val storedId = storedSessionId ?: return
if (eventSessionId.isNullOrBlank() || eventSessionId != liveId) return
val event = GatewayEventMapper.parseSubagentEvent(type, payload) ?: return
val profile = liveSessionProfile
val socket = webSocket
val listener = subagentEventListener ?: return
callbackDispatcher {
if (webSocket === socket && liveSessionId == liveId && storedSessionId == storedId &&
liveSessionProfile == profile && subagentEventListener === listener
) listener(storedId, profile, event)
}
}
/** Process updates likewise require an exact live-session match before turn admission. */
private fun dispatchProcessEvent(type: String, payload: JsonObject?, eventSessionId: String?) { private fun dispatchProcessEvent(type: String, payload: JsonObject?, eventSessionId: String?) {
val liveId = liveSessionId ?: return val liveId = liveSessionId ?: return
if (eventSessionId.isNullOrBlank() || eventSessionId != liveId) return if (eventSessionId.isNullOrBlank() || eventSessionId != liveId) return
@@ -292,7 +292,34 @@ class GatewayEventMapper(
"subagent.progress", "subagent.complete", "subagent.progress", "subagent.complete",
-> { -> {
clearActivityStatuses() clearActivityStatuses()
parseSubagentEvent(type, payload)?.let(callbacks.onSubagentEvent) val phase = when (type) {
"subagent.spawn_requested" -> GatewaySubagentEvent.Phase.SPAWN_REQUESTED
"subagent.start" -> GatewaySubagentEvent.Phase.START
"subagent.thinking" -> GatewaySubagentEvent.Phase.THINKING
"subagent.tool" -> GatewaySubagentEvent.Phase.TOOL
"subagent.progress" -> GatewaySubagentEvent.Phase.PROGRESS
else -> GatewaySubagentEvent.Phase.COMPLETE
}
callbacks.onSubagentEvent(
GatewaySubagentEvent(
phase = phase,
taskIndex = payload.int("task_index") ?: 0,
taskCount = payload.int("task_count") ?: 1,
goal = payload.string("goal") ?: "",
status = payload.string("status"),
summary = payload.string("summary"),
toolName = payload.string("tool_name"),
// subagent.tool sets tool_preview AND mirrors it into
// text; thinking/progress carry text only.
preview = payload.string("tool_preview") ?: payload.string("text"),
durationSeconds = payload.double("duration_seconds"),
subagentId = payload.string("subagent_id"),
childSessionId = payload.string("child_session_id"),
parentId = payload.string("parent_id"),
depth = payload.int("depth"),
model = payload.string("model"),
),
)
} }
"tool.output_risk" -> { "tool.output_risk" -> {
@@ -440,42 +467,6 @@ class GatewayEventMapper(
} }
companion object { companion object {
/** Shared by turn transcripts and the session-owned activity stream. */
fun parseSubagentEvent(type: String, payload: JsonObject?): GatewaySubagentEvent? {
if (type !in setOf(
"subagent.spawn_requested", "subagent.start", "subagent.thinking",
"subagent.tool", "subagent.progress", "subagent.complete",
)) return null
val phase = when (type) {
"subagent.spawn_requested" -> GatewaySubagentEvent.Phase.SPAWN_REQUESTED
"subagent.start" -> GatewaySubagentEvent.Phase.START
"subagent.thinking" -> GatewaySubagentEvent.Phase.THINKING
"subagent.tool" -> GatewaySubagentEvent.Phase.TOOL
"subagent.progress" -> GatewaySubagentEvent.Phase.PROGRESS
else -> GatewaySubagentEvent.Phase.COMPLETE
}
return GatewaySubagentEvent(
phase = phase,
taskIndex = payload.int("task_index") ?: 0,
taskCount = payload.int("task_count") ?: 1,
goal = payload.string("goal") ?: "",
status = payload.string("status"),
summary = payload.string("summary"),
toolName = payload.string("tool_name"),
// subagent.tool sets tool_preview AND mirrors it into
// text; thinking/progress carry text only.
preview = payload.string("tool_preview") ?: payload.string("text"),
durationSeconds = payload.double("duration_seconds"),
subagentId = payload.string("subagent_id"),
childSessionId = payload.string("child_session_id"),
parentId = payload.string("parent_id"),
depth = payload.int("depth"),
model = payload.string("model"),
delegationId = payload.string("delegation_id"),
)
}
const val PROVIDER_WAIT_STATUS_KIND = "provider_wait" const val PROVIDER_WAIT_STATUS_KIND = "provider_wait"
const val COMPACTION_STATUS_KIND = "compacting" const val COMPACTION_STATUS_KIND = "compacting"
const val ERROR_STATUS_KIND = "error" const val ERROR_STATUS_KIND = "error"
@@ -293,8 +293,6 @@ data class GatewaySubagentEvent(
val depth: Int? = null, val depth: Int? = null,
/** Effective child model, when the emitter exposes it. */ /** Effective child model, when the emitter exposes it. */
val model: String? = null, val model: String? = null,
/** Exact delegation group id shared with persisted async completion metadata. */
val delegationId: String? = null,
) { ) {
enum class Phase { SPAWN_REQUESTED, START, THINKING, TOOL, PROGRESS, COMPLETE } enum class Phase { SPAWN_REQUESTED, START, THINKING, TOOL, PROGRESS, COMPLETE }
} }
@@ -36,7 +36,7 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import com.hermesandroid.relay.ui.components.ThemedMessageHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -230,7 +230,11 @@ val LocalSnackbarHost = staticCompositionLocalOf<SnackbarHostState> {
// Short-lived snackbar by default; retryable errors get Long so users have // Short-lived snackbar by default; retryable errors get Long so users have
// time to tap the action before it auto-dismisses. // time to tap the action before it auto-dismisses.
suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult { suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult {
return showSnackbar(com.hermesandroid.relay.ui.components.HumanErrorVisuals(err)) return showSnackbar(
message = err.body,
actionLabel = err.actionLabel,
duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short,
)
} }
/** Startup chrome should wait for either standard chat surface, not Relay. */ /** Startup chrome should wait for either standard chat surface, not Relay. */
@@ -1948,8 +1952,6 @@ fun RelayApp() {
// child TopAppBar doesn't double-pad when this banner owns the top edge. // child TopAppBar doesn't double-pad when this banner owns the top edge.
val activeMessageCount by UiMessageBus.activeCount.collectAsState() val activeMessageCount by UiMessageBus.activeCount.collectAsState()
val showMessageBanner = activeMessageCount > 0 val showMessageBanner = activeMessageCount > 0
val modalMessageHostActive by UiMessageBus.modalHostActive.collectAsState()
val showActionMessage = snackbarHostState.currentSnackbarData != null && !modalMessageHostActive
// Update availability (unified): googlePlay = Play In-App Update FLEXIBLE, // Update availability (unified): googlePlay = Play In-App Update FLEXIBLE,
// sideload = GitHub releases. The handle filters dismissed versions + // sideload = GitHub releases. The handle filters dismissed versions +
// throttles checks internally, exposing a surfaceable status for the // throttles checks internally, exposing a surfaceable status for the
@@ -2075,14 +2077,6 @@ fun RelayApp() {
includeStatusBarPadding = includeStatusBarPadding =
!showUnattendedBanner && !showDemoBanner && !showHostResourcePressure, !showUnattendedBanner && !showDemoBanner && !showHostResourcePressure,
) )
// Action feedback owns layout space at the top, never the composer's
// touch area. Modal windows keep their own scoped host.
ThemedMessageHost(
snackbarHostState,
modifier = if (showActionMessage && !showMessageBanner &&
!showUnattendedBanner && !showDemoBanner && !showHostResourcePressure
) Modifier.windowInsetsPadding(WindowInsets.statusBars) else Modifier,
)
// The update banner AND the connection-status indicator now render as // The update banner AND the connection-status indicator now render as
// floating overlay TOASTS in the Box below (see the top-overlay Column // floating overlay TOASTS in the Box below (see the top-overlay Column
@@ -2123,7 +2117,7 @@ fun RelayApp() {
// participates in the top-inset accounting. // participates in the top-inset accounting.
if (showUnattendedBanner || showDemoBanner || showHostResourcePressure || if (showUnattendedBanner || showDemoBanner || showHostResourcePressure ||
connectionChipVisible || connectionChipVisible ||
showMessageBanner || showActionMessage showMessageBanner
) { ) {
Modifier.consumeWindowInsets(WindowInsets.statusBars) Modifier.consumeWindowInsets(WindowInsets.statusBars)
} else { } else {
@@ -2131,6 +2125,7 @@ fun RelayApp() {
} }
), ),
contentWindowInsets = WindowInsets(0), contentWindowInsets = WindowInsets(0),
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = { bottomBar = {
if ( if (
!suppressGlobalChrome && !suppressGlobalChrome &&
@@ -2217,10 +2212,7 @@ fun RelayApp() {
} }
} }
) { innerPadding -> ) { innerPadding ->
CompositionLocalProvider( CompositionLocalProvider(LocalSnackbarHost provides snackbarHostState) {
LocalSnackbarHost provides snackbarHostState,
com.hermesandroid.relay.ui.components.LocalMessageActionHost provides snackbarHostState,
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
/** Visual tone of a transient banner message. */ /** Visual tone of a transient banner message. */
enum class UiMessageSeverity { Info, Success, Status, Warning, Error } enum class UiMessageSeverity { Info, Success, Status, Warning }
data class UiMessage( data class UiMessage(
val id: Long, val id: Long,
@@ -23,7 +23,6 @@ data class UiMessage(
sealed interface UiMessageEvent { sealed interface UiMessageEvent {
data class Show(val message: UiMessage) : UiMessageEvent data class Show(val message: UiMessage) : UiMessageEvent
data class Clear(val key: String) : UiMessageEvent data class Clear(val key: String) : UiMessageEvent
data object ClearAll : UiMessageEvent
} }
internal fun reduceUiMessages( internal fun reduceUiMessages(
@@ -31,7 +30,6 @@ internal fun reduceUiMessages(
event: UiMessageEvent, event: UiMessageEvent,
maxRetained: Int, maxRetained: Int,
): List<UiMessage> = when (event) { ): List<UiMessage> = when (event) {
UiMessageEvent.ClearAll -> emptyList()
is UiMessageEvent.Clear -> current.filterNot { it.key == event.key } is UiMessageEvent.Clear -> current.filterNot { it.key == event.key }
is UiMessageEvent.Show -> { is UiMessageEvent.Show -> {
val incoming = event.message val incoming = event.message
@@ -43,14 +41,15 @@ internal fun reduceUiMessages(
} }
/** /**
* App-wide bus for transient status, confirmation, and error messages that * App-wide bus for transient, non-error status/confirmation messages that
* surface in the top [com.hermesandroid.relay.ui.components.MessageBannerHost] * surface in the top [com.hermesandroid.relay.ui.components.MessageBannerHost]
* — a thin banner that takes its own space (content slides down, no overlay), * — a thin banner that takes its own space (content slides down, no overlay),
* shows the newest line collapsed, expands to a few recent lines, auto-dismisses * shows the newest line collapsed, expands to a few recent lines, auto-dismisses
* and coalesces duplicates. * and coalesces duplicates.
* *
* Messages requiring Retry or Undo use ThemedMessageHost and retain their * App-owned errors and persistent/actionable messages keep going to the
* action-result contract. Upstream keyed AgentNotices own their sticky/clear lifecycle. * snackbar so they demand acknowledgement. Upstream keyed AgentNotices may use
* the warning tone here because their own sticky/clear lifecycle owns them.
* Migrate frequent * Migrate frequent
* `snackbarHostState.showSnackbar("…")` confirmations/status to [info] / * `snackbarHostState.showSnackbar("…")` confirmations/status to [info] /
* [success] / [status] here. * [success] / [status] here.
@@ -63,23 +62,6 @@ object UiMessageBus {
const val STATUS_TTL_MS = 6_000L const val STATUS_TTL_MS = 6_000L
private val counter = AtomicLong(0L) private val counter = AtomicLong(0L)
private val hosts = linkedMapOf<Long, Boolean>()
private val _activeHost = MutableStateFlow<Long?>(null)
internal val activeHost = _activeHost.asStateFlow()
private val _modalHostActive = MutableStateFlow(false)
internal val modalHostActive = _modalHostActive.asStateFlow()
internal fun registerHost(primary: Boolean): Long = synchronized(hosts) {
counter.incrementAndGet().also {
hosts[it] = primary
_activeHost.value = hosts.keys.lastOrNull()
_modalHostActive.value = hosts.values.any { primaryHost -> !primaryHost }
}
}
internal fun unregisterHost(id: Long) { synchronized(hosts) {
hosts.remove(id)
_activeHost.value = hosts.keys.lastOrNull()
_modalHostActive.value = hosts.values.any { !it }
} }
private val _events = MutableSharedFlow<UiMessageEvent>(extraBufferCapacity = 24) private val _events = MutableSharedFlow<UiMessageEvent>(extraBufferCapacity = 24)
val events: SharedFlow<UiMessageEvent> = _events.asSharedFlow() val events: SharedFlow<UiMessageEvent> = _events.asSharedFlow()
@@ -115,11 +97,6 @@ object UiMessageBus {
key.trim().takeIf(String::isNotEmpty)?.let { _events.tryEmit(UiMessageEvent.Clear(it)) } key.trim().takeIf(String::isNotEmpty)?.let { _events.tryEmit(UiMessageEvent.Clear(it)) }
} }
fun clearAll() { _events.tryEmit(UiMessageEvent.ClearAll) }
fun warning(text: String, ttlMillis: Long = STATUS_TTL_MS) = post(text, UiMessageSeverity.Warning, ttlMillis)
fun error(text: String, ttlMillis: Long = 10_000L) = post(text, UiMessageSeverity.Error, ttlMillis)
/** Neutral confirmation/info (e.g. "Pairing code copied"). */ /** Neutral confirmation/info (e.g. "Pairing code copied"). */
fun info(text: String, ttlMillis: Long = DEFAULT_TTL_MS) = fun info(text: String, ttlMillis: Long = DEFAULT_TTL_MS) =
post(text, UiMessageSeverity.Info, ttlMillis) post(text, UiMessageSeverity.Info, ttlMillis)
@@ -1,6 +1,6 @@
package com.hermesandroid.relay.ui.components package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.ui.UiMessageBus import android.widget.Toast
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -209,7 +209,7 @@ fun ActiveCardRelayStatusSection(
val relayUiState by connectionViewModel.relayUiState.collectAsState() val relayUiState by connectionViewModel.relayUiState.collectAsState()
val relayRowState by connectionViewModel.relayRowState.collectAsState() val relayRowState by connectionViewModel.relayRowState.collectAsState()
// Pre-resolve strings for action callbacks (non-composable context). // Pre-resolve strings for Toast (non-composable context)
val reconnectingRelayToast = stringResource(R.string.active_section_reconnecting_relay) val reconnectingRelayToast = stringResource(R.string.active_section_reconnecting_relay)
val relayStatusText = when (relayRowState.phase) { val relayStatusText = when (relayRowState.phase) {
RelayUiState.NotConfigured -> stringResource(R.string.relay_state_optional) RelayUiState.NotConfigured -> stringResource(R.string.relay_state_optional)
@@ -250,7 +250,11 @@ fun ActiveCardRelayStatusSection(
onClick = { onClick = {
if (relayUiState == RelayUiState.Stale) { if (relayUiState == RelayUiState.Stale) {
connectionViewModel.reconnectIfStale() connectionViewModel.reconnectIfStale()
UiMessageBus.status(reconnectingRelayToast) Toast.makeText(
context,
reconnectingRelayToast,
Toast.LENGTH_SHORT,
).show()
} else { } else {
onOpenRelayInfo() onOpenRelayInfo()
} }
@@ -813,7 +817,7 @@ private fun ManualUrlSubsection(
} }
val autoRelayUrl = RelayUrlDeriver.deriveFromApiUrl(apiUrlInput) val autoRelayUrl = RelayUrlDeriver.deriveFromApiUrl(apiUrlInput)
// Pre-resolve strings for action callbacks (non-composable context). // Pre-resolve strings for Toast (non-composable context)
val apiHermesVoiceReachableToast = stringResource(R.string.active_section_api_hermes_voice_reachable) val apiHermesVoiceReachableToast = stringResource(R.string.active_section_api_hermes_voice_reachable)
val apiRelayVoiceReachableToast = stringResource(R.string.active_section_api_relay_voice_reachable) val apiRelayVoiceReachableToast = stringResource(R.string.active_section_api_relay_voice_reachable)
val apiReachableVoiceReviewToast = stringResource(R.string.active_section_api_reachable_voice_review) val apiReachableVoiceReviewToast = stringResource(R.string.active_section_api_reachable_voice_review)
@@ -922,21 +926,21 @@ private fun ManualUrlSubsection(
relayOverrideVisible = true relayOverrideVisible = true
result.relayUrl?.let { relayUrlInput = it } result.relayUrl?.let { relayUrlInput = it }
} }
val feedback = when { Toast.makeText(
result.apiReachable && result.voiceConfigReachable -> context,
if (result.voiceRoute == "standard") { when {
apiHermesVoiceReachableToast result.apiReachable && result.voiceConfigReachable ->
} else { if (result.voiceRoute == "standard") {
apiRelayVoiceReachableToast apiHermesVoiceReachableToast
} } else {
result.apiReachable -> apiReachableVoiceReviewToast apiRelayVoiceReachableToast
else -> cannotReachApiToast }
} result.apiReachable ->
when { apiReachableVoiceReviewToast
result.apiReachable && result.voiceConfigReachable -> UiMessageBus.success(feedback) else -> cannotReachApiToast
result.apiReachable -> UiMessageBus.warning(feedback) },
else -> UiMessageBus.error(feedback) Toast.LENGTH_SHORT,
} ).show()
} }
}, },
enabled = apiUrlInput.isNotBlank() && !isTestingApi && inputApiKeyError == null, enabled = apiUrlInput.isNotBlank() && !isTestingApi && inputApiKeyError == null,
@@ -9,7 +9,7 @@ import android.media.audiofx.Visualizer
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import com.hermesandroid.relay.ui.UiMessageBus import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -312,112 +312,110 @@ fun AttachmentViewer(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false),
) { ) {
MessageOverlayScope { val context = LocalContext.current
val context = LocalContext.current val exportAllowed = LocalImageExportAllowed.current ||
val exportAllowed = LocalImageExportAllowed.current || attachment.renderMode != AttachmentRenderMode.IMAGE
attachment.renderMode != AttachmentRenderMode.IMAGE AllowDeviceRotation()
AllowDeviceRotation() val scope = rememberCoroutineScope()
val scope = rememberCoroutineScope() var busy by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
val blurMode = LocalMediaBlurMode.current val blurMode = LocalMediaBlurMode.current
var revealed by remember(attachment.cachedUri, attachment.relayToken) { var revealed by remember(attachment.cachedUri, attachment.relayToken) {
mutableStateOf(initiallyRevealed) mutableStateOf(initiallyRevealed)
} }
val blurred = !revealed && val blurred = !revealed &&
attachment.renderMode == AttachmentRenderMode.IMAGE && attachment.renderMode == AttachmentRenderMode.IMAGE &&
shouldBlurImage(blurMode, attachment.sensitive) shouldBlurImage(blurMode, attachment.sensitive)
val title = attachment.fileName val title = attachment.fileName
?: attachment.contentType.substringBefore(';').ifBlank { stringResource(R.string.attachment_title) } ?: attachment.contentType.substringBefore(';').ifBlank { stringResource(R.string.attachment_title) }
// --- One shared Share / Save / Open-externally action set ---------- // --- One shared Share / Save / Open-externally action set ----------
fun runWithBytes(action: suspend (ByteArray) -> Unit) { fun runWithBytes(action: suspend (ByteArray) -> Unit) {
scope.launch { scope.launch {
busy = true busy = true
val bytes = attachmentBytes(context, attachment) val bytes = attachmentBytes(context, attachment)
if (bytes == null) { if (bytes == null) {
busy = false
UiMessageBus.error(context.getString(R.string.inbound_attach_share_failed))
return@launch
}
action(bytes)
busy = false busy = false
viewerToast(context, context.getString(R.string.inbound_attach_share_failed))
return@launch
}
action(bytes)
busy = false
}
}
val onShare = {
runWithBytes { bytes ->
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
}
val onSave = {
runWithBytes { bytes ->
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) {
MediaSaver.saveImage(context, bytes, attachment.fileName, attachment.contentType)
} else {
MediaSaver.saveFile(context, bytes, attachment.fileName, attachment.contentType)
}
when (result) {
is MediaSaver.SaveResult.Saved ->
viewerToast(context, context.getString(R.string.inbound_attach_saved, result.location))
MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType)
}
is MediaSaver.SaveResult.Failed ->
viewerToast(context, context.getString(R.string.inbound_attach_save_failed, result.message))
} }
} }
}
val onShare = { val onOpenExternal = {
val cached = attachment.cachedUri
if (!cached.isNullOrBlank()) {
MediaSaver.open(context, Uri.parse(cached), attachment.contentType)
} else {
runWithBytes { bytes -> runWithBytes { bytes ->
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType) val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType) MediaSaver.open(context, uri, attachment.contentType)
} }
} }
val onSave = { }
runWithBytes { bytes ->
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) { Box(
MediaSaver.saveImage(context, bytes, attachment.fileName, attachment.contentType) modifier = modifier
} else { .fillMaxSize()
MediaSaver.saveFile(context, bytes, attachment.fileName, attachment.contentType) .background(Color.Black.copy(alpha = 0.96f))
} .testTag("attachment-viewer"),
when (result) { ) {
is MediaSaver.SaveResult.Saved -> // Body fills; toolbar floats on top. PDF/TEXT add their own top
UiMessageBus.success(context.getString(R.string.inbound_attach_saved, result.location)) // inset so the first line clears the toolbar.
MediaSaver.SaveResult.UseShareInstead -> { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType) when (attachment.renderMode) {
MediaSaver.share(context, uri, attachment.contentType) AttachmentRenderMode.IMAGE -> ImageBody(
} attachment = attachment,
is MediaSaver.SaveResult.Failed -> blurred = blurred,
UiMessageBus.error(context.getString(R.string.inbound_attach_save_failed, result.message)) onReveal = { revealed = true },
} )
} AttachmentRenderMode.VIDEO -> VideoBody(attachment)
} AttachmentRenderMode.AUDIO -> AudioBody(attachment)
val onOpenExternal = { AttachmentRenderMode.PDF -> PdfBody(attachment)
val cached = attachment.cachedUri AttachmentRenderMode.TEXT -> TextBody(attachment)
if (!cached.isNullOrBlank()) { AttachmentRenderMode.GENERIC -> GenericBody(attachment, onOpenExternal)
MediaSaver.open(context, Uri.parse(cached), attachment.contentType)
} else {
runWithBytes { bytes ->
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.open(context, uri, attachment.contentType)
}
} }
} }
Box( MediaViewerToolbar(
modifier = modifier title = title,
.fillMaxSize() busy = busy,
.background(Color.Black.copy(alpha = 0.96f)) actionsEnabled = !blurred,
.testTag("attachment-viewer"), exportAllowed = exportAllowed,
) { onShare = onShare,
// Body fills; toolbar floats on top. PDF/TEXT add their own top onSave = onSave,
// inset so the first line clears the toolbar. onOpenExternal = onOpenExternal,
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { onClose = onDismiss,
when (attachment.renderMode) { modifier = Modifier.align(Alignment.TopCenter),
AttachmentRenderMode.IMAGE -> ImageBody( )
attachment = attachment,
blurred = blurred,
onReveal = { revealed = true },
)
AttachmentRenderMode.VIDEO -> VideoBody(attachment)
AttachmentRenderMode.AUDIO -> AudioBody(attachment)
AttachmentRenderMode.PDF -> PdfBody(attachment)
AttachmentRenderMode.TEXT -> TextBody(attachment)
AttachmentRenderMode.GENERIC -> GenericBody(attachment, onOpenExternal)
}
}
MediaViewerToolbar(
title = title,
busy = busy,
actionsEnabled = !blurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
)
}
} }
} }
} }
@@ -454,165 +452,164 @@ internal fun AttachmentGalleryViewer(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false),
) { ) {
MessageOverlayScope { val context = LocalContext.current
val context = LocalContext.current val exportAllowed = LocalImageExportAllowed.current
val exportAllowed = LocalImageExportAllowed.current AllowDeviceRotation()
AllowDeviceRotation() val scope = rememberCoroutineScope()
val scope = rememberCoroutineScope() var busy by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) } val revealed = remember { mutableStateMapOf<String, Boolean>() }
val revealed = remember { mutableStateMapOf<String, Boolean>() } LaunchedEffect(initiallyRevealedKeys) {
LaunchedEffect(initiallyRevealedKeys) { initiallyRevealedKeys.forEach { revealed[it] = true }
initiallyRevealedKeys.forEach { revealed[it] = true } }
} val pagerState = rememberPagerState(
val pagerState = rememberPagerState( initialPage = initialIndex.coerceIn(attachments.indices),
initialPage = initialIndex.coerceIn(attachments.indices), pageCount = { attachments.size },
pageCount = { attachments.size }, )
)
val currentIndex = pagerState.currentPage.coerceIn(attachments.indices) val currentIndex = pagerState.currentPage.coerceIn(attachments.indices)
val attachment = attachments[currentIndex] val attachment = attachments[currentIndex]
val currentKey = galleryAttachmentKey(attachment, currentIndex) val currentKey = galleryAttachmentKey(attachment, currentIndex)
val blurMode = LocalMediaBlurMode.current val blurMode = LocalMediaBlurMode.current
val currentBlurred = revealed[currentKey] != true && val currentBlurred = revealed[currentKey] != true &&
shouldBlurImage(blurMode, attachment.sensitive) shouldBlurImage(blurMode, attachment.sensitive)
val title = attachment.fileName val title = attachment.fileName
?: attachment.contentType.substringBefore(';').ifBlank { "Image" } ?: attachment.contentType.substringBefore(';').ifBlank { "Image" }
val toolbarTitle = "$title · ${currentIndex + 1} of ${attachments.size}" val toolbarTitle = "$title · ${currentIndex + 1} of ${attachments.size}"
// Capture the currently visible attachment in each click lambda. A // Capture the currently visible attachment in each click lambda. A
// swipe while IO is running must not redirect Save/Share to a new page. // swipe while IO is running must not redirect Save/Share to a new page.
fun runWithBytes(action: suspend (Attachment, ByteArray) -> Unit) { fun runWithBytes(action: suspend (Attachment, ByteArray) -> Unit) {
if (currentBlurred || busy) return if (currentBlurred || busy) return
val target = attachment val target = attachment
scope.launch { scope.launch {
busy = true busy = true
try { try {
val bytes = attachmentBytes(context, target) val bytes = attachmentBytes(context, target)
if (bytes == null) { if (bytes == null) {
UiMessageBus.error("Couldn't read this image") viewerToast(context, "Couldn't read this image")
return@launch return@launch
}
action(target, bytes)
} catch (error: Exception) {
UiMessageBus.error(
error.message?.takeIf { it.isNotBlank() }
?: "Couldn't complete that image action",
)
} finally {
busy = false
} }
} action(target, bytes)
} } catch (error: Exception) {
viewerToast(
val onShare = {
runWithBytes { target, bytes ->
val uri = MediaSaver.stageForShare(
context, context,
bytes, error.message?.takeIf { it.isNotBlank() }
target.fileName, ?: "Couldn't complete that image action",
target.contentType,
) )
MediaSaver.share(context, uri, target.contentType) } finally {
busy = false
} }
} }
val onSave = { }
runWithBytes { target, bytes ->
when (val result = MediaSaver.saveImage( val onShare = {
context, runWithBytes { target, bytes ->
bytes, val uri = MediaSaver.stageForShare(
target.fileName, context,
target.contentType, bytes,
)) { target.fileName,
is MediaSaver.SaveResult.Saved -> target.contentType,
UiMessageBus.success("Saved to ${result.location}") )
MediaSaver.SaveResult.UseShareInstead -> { MediaSaver.share(context, uri, target.contentType)
val uri = MediaSaver.stageForShare(
context,
bytes,
target.fileName,
target.contentType,
)
MediaSaver.share(context, uri, target.contentType)
}
is MediaSaver.SaveResult.Failed ->
UiMessageBus.error("Save failed: ${result.message}")
}
}
} }
val onOpenExternal: () -> Unit = openExternal@{ }
if (currentBlurred || busy) return@openExternal val onSave = {
val target = attachment runWithBytes { target, bytes ->
val cached = target.cachedUri when (val result = MediaSaver.saveImage(
if (!cached.isNullOrBlank()) { context,
runCatching { bytes,
MediaSaver.open(context, Uri.parse(cached), target.contentType) target.fileName,
}.onFailure { target.contentType,
UiMessageBus.error("Couldn't open this image") )) {
} is MediaSaver.SaveResult.Saved ->
} else { viewerToast(context, "Saved to ${result.location}")
runWithBytes { item, bytes -> MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare( val uri = MediaSaver.stageForShare(
context, context,
bytes, bytes,
item.fileName, target.fileName,
item.contentType, target.contentType,
) )
MediaSaver.open(context, uri, item.contentType) MediaSaver.share(context, uri, target.contentType)
} }
is MediaSaver.SaveResult.Failed ->
viewerToast(context, "Save failed: ${result.message}")
} }
} }
}
val onOpenExternal: () -> Unit = openExternal@{
if (currentBlurred || busy) return@openExternal
val target = attachment
val cached = target.cachedUri
if (!cached.isNullOrBlank()) {
runCatching {
MediaSaver.open(context, Uri.parse(cached), target.contentType)
}.onFailure {
viewerToast(context, "Couldn't open this image")
}
} else {
runWithBytes { item, bytes ->
val uri = MediaSaver.stageForShare(
context,
bytes,
item.fileName,
item.contentType,
)
MediaSaver.open(context, uri, item.contentType)
}
}
}
Box( Box(
modifier = modifier modifier = modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.96f)),
) {
HorizontalPager(
state = pagerState,
beyondViewportPageCount = 0,
pageSpacing = 12.dp,
modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black.copy(alpha = 0.96f)), .testTag("attachment-gallery-pager"),
) { ) { page ->
HorizontalPager( val pageAttachment = attachments[page]
state = pagerState, val pageKey = galleryAttachmentKey(pageAttachment, page)
beyondViewportPageCount = 0, val blurred = revealed[pageKey] != true && shouldBlurImage(
pageSpacing = 12.dp, blurMode,
modifier = Modifier pageAttachment.sensitive,
.fillMaxSize()
.testTag("attachment-gallery-pager"),
) { page ->
val pageAttachment = attachments[page]
val pageKey = galleryAttachmentKey(pageAttachment, page)
val blurred = revealed[pageKey] != true && shouldBlurImage(
blurMode,
pageAttachment.sensitive,
)
ImageBody(
attachment = pageAttachment,
blurred = blurred,
onReveal = { revealed[pageKey] = true },
)
}
MediaViewerToolbar(
title = toolbarTitle,
busy = busy,
actionsEnabled = !currentBlurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
) )
ImageBody(
Text( attachment = pageAttachment,
text = "${currentIndex + 1} / ${attachments.size}", blurred = blurred,
style = MaterialTheme.typography.labelMedium, onReveal = { revealed[pageKey] = true },
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(bottom = 12.dp)
.clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.55f))
.padding(horizontal = 12.dp, vertical = 6.dp),
) )
} }
MediaViewerToolbar(
title = toolbarTitle,
busy = busy,
actionsEnabled = !currentBlurred,
exportAllowed = exportAllowed,
onShare = onShare,
onSave = onSave,
onOpenExternal = onOpenExternal,
onClose = onDismiss,
modifier = Modifier.align(Alignment.TopCenter),
)
Text(
text = "${currentIndex + 1} / ${attachments.size}",
style = MaterialTheme.typography.labelMedium,
color = Color.White,
modifier = Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(bottom = 12.dp)
.clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.55f))
.padding(horizontal = 12.dp, vertical = 6.dp),
)
} }
} }
} }
@@ -1194,6 +1191,10 @@ private fun rememberPlayableUri(attachment: Attachment): Uri? {
return uri return uri
} }
private fun viewerToast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
private const val MAX_TEXT_BYTES = 2 * 1024 * 1024 private const val MAX_TEXT_BYTES = 2 * 1024 * 1024
// --- Non-composable IO helpers --------------------------------------------- // --- Non-composable IO helpers ---------------------------------------------
@@ -1,118 +0,0 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ChatActivityKind
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.ChatActivityRecord
/** Stable transcript entry into the same read-only preview used by active work. */
@Composable
internal fun ChatActivityReceipt(
record: ChatActivityRecord,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val subagents = record.kind == ChatActivityKind.SUBAGENTS
val title = stringResource(
if (subagents) R.string.chat_activity_receipt_subagents else R.string.chat_activity_receipt_process,
)
val action = stringResource(
if (subagents) R.string.chat_activity_receipt_view_activity else R.string.chat_activity_receipt_view_output,
)
val status = if (subagents && record.children.isNotEmpty()) {
val groups = record.children.groupingBy { it.phase }.eachCount().toMutableMap()
val missing = (record.taskCount - record.children.size).coerceAtLeast(0)
if (missing > 0) groups[ChatActivityPhase.UNKNOWN] = (groups[ChatActivityPhase.UNKNOWN] ?: 0) + missing
val labels = groups.map { (phase, count) ->
stringResource(R.string.chat_activity_receipt_count_phase, count, activityPhaseLabel(phase))
}
labels.joinToString(" · ")
} else if (subagents && record.taskCount > 0) {
stringResource(R.string.chat_activity_receipt_count_phase, record.taskCount, activityPhaseLabel(record.phase))
} else {
activityPhaseLabel(record.phase)
}
Surface(
modifier = modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.semantics(mergeDescendants = true) { stateDescription = status }
.clickable(role = Role.Button, onClickLabel = action, onClick = onClick),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f),
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (subagents) Icons.Filled.AccountTree else Icons.Filled.Terminal,
contentDescription = null,
tint = if (record.phase == ChatActivityPhase.FAILED) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "$title · $status",
style = MaterialTheme.typography.labelMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = action,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(8.dp))
Icon(
imageVector = Icons.Filled.ChevronRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
}
@Composable
private fun activityPhaseLabel(phase: ChatActivityPhase): String = stringResource(
when (phase) {
ChatActivityPhase.RUNNING -> R.string.bg_processes_running
ChatActivityPhase.COMPLETE -> R.string.agent_activity_status_completed
ChatActivityPhase.FAILED -> R.string.agent_activity_status_failed
ChatActivityPhase.CANCELLED -> R.string.task_status_cancelled
ChatActivityPhase.UNKNOWN -> R.string.agent_activity_status_unavailable
},
)
@@ -1,197 +0,0 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.activity.compose.BackHandler
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.unit.Dp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.viewmodel.ConnectionStepState
@Composable
internal fun Modifier.chatDebugHeaderGesture(enabled: Boolean, onClick: () -> Unit, onHold: () -> Unit): Modifier =
combinedClickable(enabled = enabled, onClick = onClick, onLongClick = onHold,
onLongClickLabel = stringResource(R.string.chat_debug_open))
@Composable
internal fun BoxScope.ChatDebugOverlay(
visible: Boolean,
headerHeight: Dp,
onClose: () -> Unit,
content: @Composable () -> Unit,
) {
BackHandler(enabled = visible, onBack = onClose)
if (visible) {
Box(Modifier.fillMaxSize().padding(top = headerHeight)
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.28f))
.clickable(onClick = onClose))
}
AnimatedVisibility(
visible = visible,
enter = slideInVertically { -it } + fadeIn(),
exit = slideOutVertically { -it } + fadeOut(),
modifier = Modifier.align(Alignment.TopCenter).padding(top = headerHeight).fillMaxWidth().clipToBounds(),
) { content() }
}
/** Read-only snapshot of the visible conversation; opening it sends no RPCs. */
@Composable
internal fun ChatDebugDrawer(
profile: String,
model: String,
sessionId: String?,
gateway: Boolean,
signedIn: Boolean,
signInRequired: Boolean,
socketState: GatewayConnectionState,
preparing: Boolean,
streaming: Boolean,
loadingHistory: Boolean,
directoryUnavailable: Boolean,
failure: String?,
onClose: () -> Unit,
onConnections: () -> Unit,
modifier: Modifier = Modifier,
) {
val title = stringResource(R.string.chat_debug_title)
val safeFailure = DiagnosticsLog.redactReportText(failure)?.take(600)
val ready = socketState == GatewayConnectionState.Ready
val steps = buildList {
if (gateway) {
add(ConnectionSetupTimelineStep(
stringResource(R.string.chat_debug_sign_in),
stringResource(when {
signInRequired -> R.string.chat_debug_sign_in_needed
signedIn -> R.string.chat_debug_authenticated
else -> R.string.chat_debug_auth_unknown
}),
when {
signInRequired -> ConnectionStepState.Failed
signedIn -> ConnectionStepState.Done
else -> ConnectionStepState.Pending
},
))
add(ConnectionSetupTimelineStep(
stringResource(R.string.chat_debug_gateway),
stringResource(when (socketState) {
GatewayConnectionState.Ready -> R.string.chat_debug_socket_ready
GatewayConnectionState.MintingTicket -> R.string.chat_debug_ticket
GatewayConnectionState.Connecting -> R.string.chat_debug_socket_connecting
GatewayConnectionState.AwaitingReady -> R.string.chat_debug_socket_waiting
GatewayConnectionState.Idle -> R.string.chat_debug_socket_idle
}),
when {
ready -> ConnectionStepState.Done
signInRequired -> ConnectionStepState.Failed
socketState != GatewayConnectionState.Idle -> ConnectionStepState.Active
else -> ConnectionStepState.Pending
},
))
}
add(ConnectionSetupTimelineStep(
stringResource(R.string.chat_debug_session),
stringResource(when {
preparing -> R.string.chat_debug_preparing_detail
loadingHistory -> R.string.chat_debug_history_loading
directoryUnavailable -> R.string.chat_debug_history_failed
sessionId != null -> R.string.chat_debug_session_selected
else -> R.string.chat_debug_session_new
}),
when {
preparing || loadingHistory -> ConnectionStepState.Active
directoryUnavailable -> ConnectionStepState.Failed
sessionId != null -> ConnectionStepState.Done
else -> ConnectionStepState.Pending
},
))
add(ConnectionSetupTimelineStep(
stringResource(R.string.chat_debug_response),
stringResource(when {
safeFailure != null -> R.string.chat_debug_response_failed
preparing -> R.string.chat_debug_response_waiting
streaming -> R.string.chat_debug_response_active
else -> R.string.chat_debug_response_idle
}),
when {
safeFailure != null -> ConnectionStepState.Failed
preparing -> ConnectionStepState.Pending
streaming -> ConnectionStepState.Active
else -> ConnectionStepState.Pending
},
))
}
Surface(
modifier = modifier.fillMaxWidth().semantics { paneTitle = title },
shape = RoundedCornerShape(bottomStart = 24.dp, bottomEnd = 24.dp),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 8.dp,
) {
Column(Modifier.heightIn(max = 560.dp).verticalScroll(rememberScrollState()).padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleLarge)
Text(listOf(if (gateway) "Gateway" else "Direct API", profile, model)
.filter(String::isNotBlank).joinToString(" · "), style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, stringResource(R.string.chat_debug_close))
}
}
ConnectionSetupTimeline(steps)
if (safeFailure != null) {
Surface(color = MaterialTheme.colorScheme.errorContainer, shape = RoundedCornerShape(12.dp)) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(safeFailure, style = MaterialTheme.typography.bodySmall)
if (safeFailure.contains("agent init failed", ignoreCase = true)) {
Text(stringResource(R.string.chat_debug_init_recovery), style = MaterialTheme.typography.bodySmall)
}
}
}
}
HorizontalDivider()
Text(stringResource(R.string.chat_debug_session_id, sessionId ?: "—"),
style = MaterialTheme.typography.labelSmall, fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant)
TextButton(onClick = onConnections) { Text(stringResource(R.string.chat_debug_connections)) }
}
}
}
@@ -2,7 +2,7 @@
package com.hermesandroid.relay.ui.components package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.ui.UiMessageBus import android.widget.Toast
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -107,141 +107,143 @@ fun ChatImageViewer(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false),
) { ) {
MessageOverlayScope { val context = LocalContext.current
val context = LocalContext.current val exportAllowed = LocalImageExportAllowed.current
val exportAllowed = LocalImageExportAllowed.current AllowDeviceRotation()
AllowDeviceRotation() val scope = rememberCoroutineScope()
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
val blurMode = LocalMediaBlurMode.current val blurMode = LocalMediaBlurMode.current
var revealed by remember(source) { mutableStateOf(initiallyRevealed) } var revealed by remember(source) { mutableStateOf(initiallyRevealed) }
val blurred = !revealed && shouldBlurImage(blurMode, sensitive) val blurred = !revealed && shouldBlurImage(blurMode, sensitive)
val gestureModifier = Modifier.fillMaxSize().zoomable() val gestureModifier = Modifier.fillMaxSize().zoomable()
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black.copy(alpha = 0.94f)), .background(Color.Black.copy(alpha = 0.94f)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) {
BlurredMedia(
blurred = blurred,
onReveal = { revealed = true },
modifier = Modifier.fillMaxSize(),
) { ) {
BlurredMedia( when (source) {
blurred = blurred, is ChatImageViewerSource.Coil -> AsyncImage(
onReveal = { revealed = true }, model = source.model,
modifier = Modifier.fillMaxSize(), contentDescription = source.displayName,
) { contentScale = ContentScale.Fit,
when (source) { modifier = gestureModifier,
is ChatImageViewerSource.Coil -> AsyncImage( )
model = source.model,
contentDescription = source.displayName,
contentScale = ContentScale.Fit,
modifier = gestureModifier,
)
is ChatImageViewerSource.Bitmap -> Image( is ChatImageViewerSource.Bitmap -> Image(
bitmap = source.bitmap, bitmap = source.bitmap,
contentDescription = source.displayName, contentDescription = source.displayName,
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
modifier = gestureModifier, modifier = gestureModifier,
) )
}
}
if (busy) {
CircularProgressIndicator(color = Color.White)
}
// Control bar — top-right, inset past the status bar / notch.
Row(
modifier = Modifier
.align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
val cdClose = stringResource(R.string.cd_close_viewer)
val errorMsg = context.getString(R.string.image_viewer_error)
if (exportAllowed) {
val cdShare = stringResource(R.string.cd_share)
val cdSave = stringResource(R.string.cd_save)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = try {
source.bytesProvider()
} catch (_: Exception) {
null
}
busy = false
if (bytes == null) {
toast(context, errorMsg)
return@launch
}
val uri = MediaSaver.stageForShare(
context,
bytes,
source.displayName,
source.mime,
)
MediaSaver.share(context, uri, source.mime)
}
},
colors = tint,
) {
Icon(Icons.Filled.Share, contentDescription = cdShare)
} }
} val savedFmt = context.getString(R.string.image_viewer_saved)
val failedFmt = context.getString(R.string.image_viewer_failed)
if (busy) { IconButton(
CircularProgressIndicator(color = Color.White) onClick = {
} scope.launch {
busy = true
// Control bar — top-right, inset past the status bar / notch. val bytes = try {
Row( source.bytesProvider()
modifier = Modifier } catch (_: Exception) {
.align(Alignment.TopEnd) null
.windowInsetsPadding(WindowInsets.safeDrawing) }
.padding(8.dp), if (bytes == null) {
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
val cdClose = stringResource(R.string.cd_close_viewer)
val errorMsg = context.getString(R.string.image_viewer_error)
if (exportAllowed) {
val cdShare = stringResource(R.string.cd_share)
val cdSave = stringResource(R.string.cd_save)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = try {
source.bytesProvider()
} catch (_: Exception) {
null
}
busy = false busy = false
if (bytes == null) { toast(context, errorMsg)
UiMessageBus.error(errorMsg) return@launch
return@launch
}
val uri = MediaSaver.stageForShare(
context,
bytes,
source.displayName,
source.mime,
)
MediaSaver.share(context, uri, source.mime)
} }
}, when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
colors = tint, is MediaSaver.SaveResult.Saved -> {
) {
Icon(Icons.Filled.Share, contentDescription = cdShare)
}
val savedFmt = context.getString(R.string.image_viewer_saved)
val failedFmt = context.getString(R.string.image_viewer_failed)
IconButton(
onClick = {
scope.launch {
busy = true
val bytes = try {
source.bytesProvider()
} catch (_: Exception) {
null
}
if (bytes == null) {
busy = false busy = false
UiMessageBus.error(errorMsg) toast(context, savedFmt.format(result.location))
return@launch
} }
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) { MediaSaver.SaveResult.UseShareInstead -> {
is MediaSaver.SaveResult.Saved -> { busy = false
busy = false val uri = MediaSaver.stageForShare(
UiMessageBus.success(savedFmt.format(result.location)) context,
} bytes,
MediaSaver.SaveResult.UseShareInstead -> { source.displayName,
busy = false source.mime,
val uri = MediaSaver.stageForShare( )
context, MediaSaver.share(context, uri, source.mime)
bytes, }
source.displayName, is MediaSaver.SaveResult.Failed -> {
source.mime, busy = false
) toast(context, failedFmt.format(result.message))
MediaSaver.share(context, uri, source.mime)
}
is MediaSaver.SaveResult.Failed -> {
busy = false
UiMessageBus.error(failedFmt.format(result.message))
}
} }
} }
}, }
colors = tint, },
) { colors = tint,
Icon(Icons.Filled.Download, contentDescription = cdSave) ) {
} Icon(Icons.Filled.Download, contentDescription = cdSave)
}
IconButton(onClick = onDismiss, colors = tint) {
Icon(Icons.Filled.Close, contentDescription = cdClose)
} }
} }
IconButton(onClick = onDismiss, colors = tint) {
Icon(Icons.Filled.Close, contentDescription = cdClose)
}
} }
} }
} }
} }
private fun toast(context: android.content.Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
@@ -1,6 +1,6 @@
package com.hermesandroid.relay.ui.components package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.ui.UiMessageBus import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -12,6 +12,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.network.upstream.GatewayAvailability import com.hermesandroid.relay.network.upstream.GatewayAvailability
@@ -139,13 +140,18 @@ fun ChatTransportStatusBadge(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
) { ) {
val context = LocalContext.current
val textColor = status.textColor() val textColor = status.textColor()
val background = status.backgroundColor() val background = status.backgroundColor()
Surface( Surface(
modifier = modifier.combinedClickable( modifier = modifier.combinedClickable(
onClick = { onClick?.invoke() }, onClick = { onClick?.invoke() },
onLongClick = { onLongClick = {
UiMessageBus.info("${status.reason}: ${status.detail}") Toast.makeText(
context,
"${status.reason}: ${status.detail}",
Toast.LENGTH_LONG,
).show()
}, },
), ),
shape = RoundedCornerShape(999.dp), shape = RoundedCornerShape(999.dp),
@@ -13,8 +13,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -22,12 +20,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.viewmodel.ConnectionStepState
data class ConnectionSetupTimelineStep( data class ConnectionSetupTimelineStep(
val title: String, val title: String,
val detail: String, val detail: String,
val state: ConnectionStepState = ConnectionStepState.Done,
) )
/** Compact completed-state timeline shared by connection and auth flows. */ /** Compact completed-state timeline shared by connection and auth flows. */
@@ -47,21 +43,13 @@ fun ConnectionSetupTimeline(
Box( Box(
modifier = Modifier modifier = Modifier
.size(28.dp) .size(28.dp)
.background(when (step.state) { .background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
ConnectionStepState.Failed -> MaterialTheme.colorScheme.errorContainer
ConnectionStepState.Pending -> MaterialTheme.colorScheme.surfaceVariant
else -> MaterialTheme.colorScheme.primaryContainer
}, CircleShape),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (step.state == ConnectionStepState.Active) { Icon(
CircularProgressIndicator(Modifier.size(17.dp), strokeWidth = 2.dp) imageVector = Icons.Filled.Check,
} else if (step.state == ConnectionStepState.Pending) {
Text("·", color = MaterialTheme.colorScheme.onSurfaceVariant)
} else Icon(
imageVector = if (step.state == ConnectionStepState.Failed) Icons.Filled.Close else Icons.Filled.Check,
contentDescription = null, contentDescription = null,
tint = if (step.state == ConnectionStepState.Failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onPrimaryContainer, tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(17.dp), modifier = Modifier.size(17.dp),
) )
} }
@@ -6,7 +6,7 @@ import android.Manifest
import android.content.ClipData import android.content.ClipData
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import com.hermesandroid.relay.ui.UiMessageBus import android.widget.Toast
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -343,7 +343,11 @@ fun ConnectionWizard(
// user at the manual pairing paths (URL entry / 6-char code) // user at the manual pairing paths (URL entry / 6-char code)
// instead of leaving them on a vanishing toast with no scanner. // instead of leaving them on a vanishing toast with no scanner.
step = WizardStep.Nearby step = WizardStep.Nearby
UiMessageBus.warning(context.getString(R.string.cw_camera_denied)) Toast.makeText(
context,
context.getString(R.string.cw_camera_denied),
Toast.LENGTH_LONG
).show()
} }
} }
@@ -1,6 +1,7 @@
package com.hermesandroid.relay.ui.components package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.ui.UiMessageBus import android.content.Context
import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -103,119 +104,120 @@ private fun CrashReportDialog(report: ReliabilityReport, onDismiss: () -> Unit)
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false),
) { ) {
MessageOverlayScope { Surface(
Surface( modifier = Modifier.fillMaxWidth(0.94f),
modifier = Modifier.fillMaxWidth(0.94f), shape = appearanceRoundedCornerShape(24.dp),
shape = appearanceRoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surface,
color = MaterialTheme.colorScheme.surface, tonalElevation = 6.dp,
tonalElevation = 6.dp, ) {
) { Column(modifier = Modifier.padding(20.dp)) {
Column(modifier = Modifier.padding(20.dp)) { Row(verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically) { Icon(
Icon( imageVector = Icons.Filled.WarningAmber,
imageVector = Icons.Filled.WarningAmber, contentDescription = null,
contentDescription = null, tint = MaterialTheme.colorScheme.error,
tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(24.dp),
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(R.string.crash_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
}
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(R.string.crash_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(Modifier.width(12.dp))
Text( Text(
text = stringResource(R.string.crash_privacy), text = stringResource(R.string.crash_title),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 6.dp),
) )
}
if (showDetails) { Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(14.dp)) Text(
Box( text = stringResource(R.string.crash_body),
modifier = Modifier style = MaterialTheme.typography.bodyMedium,
.fillMaxWidth() color = MaterialTheme.colorScheme.onSurfaceVariant,
.heightIn(min = 120.dp, max = 300.dp) )
.clip(appearanceRoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
) {
SelectionContainer {
Text(
text = reportText,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
}
Spacer(Modifier.height(18.dp)) Text(
// FlowRow so the actions wrap instead of clipping on narrow / text = stringResource(R.string.crash_privacy),
// foldable cover screens now that a fourth (Share) action exists. style = MaterialTheme.typography.bodySmall,
FlowRow( color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.padding(top = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), )
verticalArrangement = Arrangement.spacedBy(4.dp),
if (showDetails) {
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 300.dp)
.clip(appearanceRoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f)),
) { ) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) } SelectionContainer {
if (!showDetails) { Text(
Button(onClick = { showDetails = true }) { text = reportText,
Text(stringResource(R.string.crash_review)) fontFamily = FontFamily.Monospace,
} fontSize = 11.sp,
} else { lineHeight = 15.sp,
OutlinedButton( color = MaterialTheme.colorScheme.onSurfaceVariant,
onClick = { modifier = Modifier
IssueReport.copyToClipboard(context, reportText) .verticalScroll(rememberScrollState())
UiMessageBus.success(copiedMessage) .padding(12.dp),
}, )
) { Text(stringResource(R.string.common_copy)) }
// Universal, GitHub-free path: hand the full report to the
// system share sheet (email, chat apps, notes, Drive…). The
// user picks the destination, so nothing leaves the device
// until they choose to send it — same privacy posture as Copy.
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
reportSubject,
reportText,
chooserTitle = shareTitle,
)
if (!shared) {
IssueReport.copyToClipboard(context, reportText)
UiMessageBus.warning(noShareMessage)
}
onDismiss()
},
) { Text(stringResource(R.string.common_share)) }
Button(
onClick = {
IssueReport.copyToClipboard(context, reportText)
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
if (opened) UiMessageBus.success(openedMessage)
else UiMessageBus.warning(noBrowserMessage)
onDismiss()
},
) { Text(stringResource(R.string.common_report)) }
} }
} }
} }
Spacer(Modifier.height(18.dp))
// FlowRow so the actions wrap instead of clipping on narrow /
// foldable cover screens now that a fourth (Share) action exists.
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_dismiss)) }
if (!showDetails) {
Button(onClick = { showDetails = true }) {
Text(stringResource(R.string.crash_review))
}
} else {
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, reportText)
toast(context, copiedMessage)
},
) { Text(stringResource(R.string.common_copy)) }
// Universal, GitHub-free path: hand the full report to the
// system share sheet (email, chat apps, notes, Drive…). The
// user picks the destination, so nothing leaves the device
// until they choose to send it — same privacy posture as Copy.
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
reportSubject,
reportText,
chooserTitle = shareTitle,
)
if (!shared) {
IssueReport.copyToClipboard(context, reportText)
toast(context, noShareMessage)
}
onDismiss()
},
) { Text(stringResource(R.string.common_share)) }
Button(
onClick = {
IssueReport.copyToClipboard(context, reportText)
val opened = IssueReport.openUrl(context, CrashReporter.buildGithubIssueUrl(report))
toast(context, if (opened) openedMessage else noBrowserMessage)
onDismiss()
},
) { Text(stringResource(R.string.common_report)) }
}
}
} }
} }
} }
} }
private fun toast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
@@ -1,7 +1,7 @@
package com.hermesandroid.relay.ui.components package com.hermesandroid.relay.ui.components
import android.text.format.DateFormat import android.text.format.DateFormat
import com.hermesandroid.relay.ui.UiMessageBus import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -78,158 +78,159 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false),
) { ) {
MessageOverlayScope { Surface(
Surface( modifier = Modifier.fillMaxWidth(0.94f),
modifier = Modifier.fillMaxWidth(0.94f), shape = appearanceRoundedCornerShape(24.dp),
shape = appearanceRoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surface,
color = MaterialTheme.colorScheme.surface, tonalElevation = 6.dp,
tonalElevation = 6.dp, ) {
) { Column(modifier = Modifier.padding(20.dp)) {
Column(modifier = Modifier.padding(20.dp)) { Row(verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically) { DiagnosticSeverityChip(entry.severity)
DiagnosticSeverityChip(entry.severity) Spacer(Modifier.width(10.dp))
Spacer(Modifier.width(10.dp)) Text(
Text( text = entry.category.label,
text = entry.category.label, style = MaterialTheme.typography.labelMedium,
style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurfaceVariant, )
) }
}
Spacer(Modifier.height(10.dp))
Text(
text = entry.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(10.dp))
// Metadata rows — only render the ones that are present.
MetaRow("When", DateFormat.format("yyyy-MM-dd HH:mm:ss", entry.timestampMs).toString())
MetaRow("Severity", severityName)
MetaRow("Category", entry.category.label)
entry.operation?.let { MetaRow("Operation", it) }
entry.endpointRole?.let { MetaRow("Route", it) }
entry.configuredUrl?.let { MetaRow("Configured URL", it) }
entry.requestUrl?.let { MetaRow("Request", it) }
if (entry.configuredUrl == null && entry.requestUrl == null) {
entry.url?.let { MetaRow("URL", it) }
}
entry.elapsedMs?.let { MetaRow("Elapsed", "${it}ms") }
entry.suggestion?.let { suggestion ->
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Text( Text(
text = entry.title, text = "Suggested next step",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Text(
text = suggestion,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(14.dp))
// Metadata rows — only render the ones that are present. val body = entry.stacktrace ?: entry.detail
MetaRow("When", DateFormat.format("yyyy-MM-dd HH:mm:ss", entry.timestampMs).toString()) if (body != null) {
MetaRow("Severity", severityName) Box(
MetaRow("Category", entry.category.label) modifier = Modifier
entry.operation?.let { MetaRow("Operation", it) } .fillMaxWidth()
entry.endpointRole?.let { MetaRow("Route", it) } .heightIn(min = 120.dp, max = 320.dp)
entry.configuredUrl?.let { MetaRow("Configured URL", it) } .background(
entry.requestUrl?.let { MetaRow("Request", it) } MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
if (entry.configuredUrl == null && entry.requestUrl == null) { appearanceRoundedCornerShape(12.dp),
entry.url?.let { MetaRow("URL", it) } ),
}
entry.elapsedMs?.let { MetaRow("Elapsed", "${it}ms") }
entry.suggestion?.let { suggestion ->
Spacer(Modifier.height(10.dp))
Text(
text = "Suggested next step",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = suggestion,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
Spacer(Modifier.height(14.dp))
val body = entry.stacktrace ?: entry.detail
if (body != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp, max = 320.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
appearanceRoundedCornerShape(12.dp),
),
) {
SelectionContainer {
Text(
text = body,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
lineHeight = 15.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
}
} else {
Text(
text = stringResource(R.string.diagnostic_no_detail),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (expectationVisible) {
Spacer(Modifier.height(14.dp))
OutlinedTextField(
value = expectation,
onValueChange = { expectation = it },
label = { Text("What were you expecting to happen?") },
supportingText = {
Text(stringResource(R.string.diagnostic_routine_hint))
},
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(18.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) } SelectionContainer {
OutlinedButton( Text(
onClick = { text = body,
IssueReport.copyToClipboard(context, plainText) fontFamily = FontFamily.Monospace,
UiMessageBus.success(copiedToast) fontSize = 11.sp,
}, lineHeight = 15.sp,
) { Text(stringResource(R.string.common_copy)) } color = MaterialTheme.colorScheme.onSurfaceVariant,
OutlinedButton( modifier = Modifier
onClick = { .verticalScroll(rememberScrollState())
val shared = IssueReport.share( .padding(12.dp),
context, )
subject = "Hermes-Relay diagnostic — ${entry.title}", }
text = plainText,
chooserTitle = exportChooserTitle,
)
if (!shared) {
IssueReport.copyToClipboard(context, plainText)
UiMessageBus.warning("Copied — no app found to share to")
}
},
) { Text(stringResource(R.string.common_export)) }
Button(
enabled = !expectationVisible || expectation.isNotBlank(),
onClick = {
if (needsExpectation && !expectationVisible) {
expectationVisible = true
return@Button
}
// Copy full text first; the GitHub URL only carries the
// head of long traces, so the user can paste the rest.
IssueReport.copyToClipboard(context, plainText)
val opened = IssueReport.openUrl(
context,
IssueReport.buildGithubIssueUrl(
title = DiagnosticIssuePrefill.issueTitle(entry),
bodyMarkdown = DiagnosticIssuePrefill.issueBody(
entry,
expectation = expectation.takeIf { expectationVisible },
),
labels = DiagnosticIssuePrefill.issueLabels(entry),
),
)
if (opened) UiMessageBus.success("Full diagnostic copied — paste it into the issue if truncated")
else UiMessageBus.warning("Copied — no browser found to open GitHub")
},
) { Text(stringResource(R.string.common_report)) }
} }
} else {
Text(
text = stringResource(R.string.diagnostic_no_detail),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (expectationVisible) {
Spacer(Modifier.height(14.dp))
OutlinedTextField(
value = expectation,
onValueChange = { expectation = it },
label = { Text("What were you expecting to happen?") },
supportingText = {
Text(stringResource(R.string.diagnostic_routine_hint))
},
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(18.dp))
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
OutlinedButton(
onClick = {
IssueReport.copyToClipboard(context, plainText)
toast(context, copiedToast)
},
) { Text(stringResource(R.string.common_copy)) }
OutlinedButton(
onClick = {
val shared = IssueReport.share(
context,
subject = "Hermes-Relay diagnostic — ${entry.title}",
text = plainText,
chooserTitle = exportChooserTitle,
)
if (!shared) {
IssueReport.copyToClipboard(context, plainText)
toast(context, "Copied — no app found to share to")
}
},
) { Text(stringResource(R.string.common_export)) }
Button(
enabled = !expectationVisible || expectation.isNotBlank(),
onClick = {
if (needsExpectation && !expectationVisible) {
expectationVisible = true
return@Button
}
// Copy full text first; the GitHub URL only carries the
// head of long traces, so the user can paste the rest.
IssueReport.copyToClipboard(context, plainText)
val opened = IssueReport.openUrl(
context,
IssueReport.buildGithubIssueUrl(
title = DiagnosticIssuePrefill.issueTitle(entry),
bodyMarkdown = DiagnosticIssuePrefill.issueBody(
entry,
expectation = expectation.takeIf { expectationVisible },
),
labels = DiagnosticIssuePrefill.issueLabels(entry),
),
)
toast(
context,
if (opened) "Full diagnostic copied — paste it into the issue if truncated"
else "Copied — no browser found to open GitHub",
)
},
) { Text(stringResource(R.string.common_report)) }
} }
} }
} }
@@ -275,6 +276,10 @@ internal fun DiagnosticSeverityChip(severity: DiagnosticSeverity) {
} }
} }
private fun toast(context: android.content.Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
}
/** Full, copy/share-ready plain-text rendering of a single diagnostic entry. */ /** Full, copy/share-ready plain-text rendering of a single diagnostic entry. */
private fun DiagnosticLogEntry.toPlainText(): String = buildString { private fun DiagnosticLogEntry.toPlainText(): String = buildString {
appendLine("Hermes-Relay diagnostic") appendLine("Hermes-Relay diagnostic")
@@ -24,12 +24,12 @@ import androidx.compose.material.icons.filled.AccountTree
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ErrorOutline import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material.icons.filled.HelpOutline
import androidx.compose.material.icons.filled.PauseCircleOutline
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.PauseCircleOutline
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -66,6 +66,7 @@ import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R import com.hermesandroid.relay.R
import com.hermesandroid.relay.network.upstream.GatewayProcess import com.hermesandroid.relay.network.upstream.GatewayProcess
import com.hermesandroid.relay.viewmodel.SubagentActivity import com.hermesandroid.relay.viewmodel.SubagentActivity
import com.hermesandroid.relay.viewmodel.SubagentActivityPhase
import com.hermesandroid.relay.viewmodel.SubagentChildPreview import com.hermesandroid.relay.viewmodel.SubagentChildPreview
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -83,18 +84,27 @@ internal fun GatewayBackgroundProcessStrip(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
// Completed work remains reachable from its transcript receipt. Only active // Initial/switch refreshes are silent. The strip appears only after the
// work owns composer space; refreshing history must not resurrect the strip. // session actually owns a process, avoiding a transient "Checking" row on
// every ordinary chat open.
val visibleActivities = subagentActivities.takeIf { subagentPreviewVisibility.showLifecycle }.orEmpty() val visibleActivities = subagentActivities.takeIf { subagentPreviewVisibility.showLifecycle }.orEmpty()
if (processes.isEmpty() && visibleActivities.isEmpty()) return
val running = processes.count { it.isRunning } val running = processes.count { it.isRunning }
val runningAgents = visibleActivities.count { !it.isTerminal } val runningAgents = visibleActivities.count { !it.isTerminal }
if (running == 0 && runningAgents == 0) return val failed = processes.count { !it.isRunning && (it.exitCode ?: 0) != 0 }
val failedAgents = visibleActivities.count { it.phase == SubagentActivityPhase.FAILED }
val interruptedAgents = visibleActivities.count {
it.phase == SubagentActivityPhase.INTERRUPTED ||
it.phase == SubagentActivityPhase.ENDED_WITH_PARENT
}
val failureCount = failed + failedAgents
val status = when { val status = when {
runningAgents > 0 && running > 0 -> stringResource(
R.string.current_chat_activity_summary, runningAgents, running,
)
runningAgents > 0 -> stringResource(R.string.subagent_lane_running_count, runningAgents) runningAgents > 0 -> stringResource(R.string.subagent_lane_running_count, runningAgents)
else -> "$running ${stringResource(R.string.bg_processes_running)}" running > 0 -> "$running ${stringResource(R.string.bg_processes_running)}"
failureCount > 0 -> "$failureCount ${stringResource(R.string.task_status_failed)}"
interruptedAgents > 0 -> stringResource(R.string.agent_activity_status_interrupted)
else -> stringResource(R.string.task_status_complete)
} }
val openDescription = stringResource(R.string.current_chat_activity_open) val openDescription = stringResource(R.string.current_chat_activity_open)
@@ -120,7 +130,24 @@ internal fun GatewayBackgroundProcessStrip(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) if (running > 0 || runningAgents > 0 || loading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Icon(
imageVector = when {
failureCount > 0 -> Icons.Filled.ErrorOutline
interruptedAgents > 0 -> Icons.Filled.PauseCircleOutline
else -> Icons.Filled.CheckCircle
},
contentDescription = null,
modifier = Modifier.size(17.dp),
tint = when {
failureCount > 0 -> MaterialTheme.colorScheme.error
interruptedAgents > 0 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.primary
},
)
}
Spacer(Modifier.width(9.dp)) Spacer(Modifier.width(9.dp))
Text( Text(
text = stringResource(R.string.current_chat_activity_title), text = stringResource(R.string.current_chat_activity_title),
@@ -130,7 +157,11 @@ internal fun GatewayBackgroundProcessStrip(
Text( Text(
text = status, text = status,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = if (failureCount > 0 && running == 0) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
) )
Icon( Icon(
imageVector = Icons.Filled.Visibility, imageVector = Icons.Filled.Visibility,
@@ -159,8 +190,6 @@ internal fun GatewayBackgroundProcessSheet(
onDismissProcess: (String) -> Unit, onDismissProcess: (String) -> Unit,
onOpenSubagentChild: (String) -> Unit, onOpenSubagentChild: (String) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
readOnlyHistory: Boolean = false,
historyNotice: String? = null,
) { ) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
val listState = androidx.compose.foundation.lazy.rememberLazyListState() val listState = androidx.compose.foundation.lazy.rememberLazyListState()
@@ -217,19 +246,17 @@ internal fun GatewayBackgroundProcessSheet(
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
stringResource( stringResource(R.string.current_chat_activity_title),
if (readOnlyHistory) R.string.chat_activity_history_title else R.string.current_chat_activity_title,
),
modifier = Modifier.semantics { heading() }, modifier = Modifier.semantics { heading() },
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
) )
Text( Text(
historyNotice ?: stringResource(R.string.current_chat_activity_subtitle), stringResource(R.string.current_chat_activity_subtitle),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
if (processes.isNotEmpty() && !readOnlyHistory) IconButton(onClick = onRefresh, enabled = !loading) { if (processes.isNotEmpty()) IconButton(onClick = onRefresh, enabled = !loading) {
if (loading) { if (loading) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
} else { } else {
@@ -273,9 +300,7 @@ internal fun GatewayBackgroundProcessSheet(
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Text( Text(
stringResource( stringResource(R.string.current_chat_activity_empty),
if (readOnlyHistory) R.string.chat_activity_history_empty else R.string.current_chat_activity_empty,
),
modifier = Modifier.padding(top = 12.dp), modifier = Modifier.padding(top = 12.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -316,7 +341,7 @@ internal fun GatewayBackgroundProcessSheet(
GatewayProcessRow( GatewayProcessRow(
process = process, process = process,
stopping = process.id in stoppingProcessIds, stopping = process.id in stoppingProcessIds,
onStop = if (readOnlyHistory) null else ({ onStop(process.id) }), onStop = { onStop(process.id) },
onDismiss = null, onDismiss = null,
) )
} }
@@ -331,7 +356,7 @@ internal fun GatewayBackgroundProcessSheet(
process = process, process = process,
stopping = false, stopping = false,
onStop = null, onStop = null,
onDismiss = if (readOnlyHistory) null else ({ onDismissProcess(process.id) }), onDismiss = { onDismissProcess(process.id) },
) )
} }
} }
@@ -359,7 +384,7 @@ private fun GatewayProcessRow(
onDismiss: (() -> Unit)?, onDismiss: (() -> Unit)?,
) { ) {
var expanded by remember(process.id) { mutableStateOf(false) } var expanded by remember(process.id) { mutableStateOf(false) }
val failed = processDisplayPhase(process) == ProcessDisplayPhase.FAILED val failed = !process.isRunning && (process.exitCode ?: 0) != 0
val output = sanitizeTerminalText( val output = sanitizeTerminalText(
process.outputTail.orEmpty().ifBlank { process.outputPreview.orEmpty() }, process.outputTail.orEmpty().ifBlank { process.outputPreview.orEmpty() },
).trimEnd() ).trimEnd()
@@ -453,11 +478,9 @@ private fun GatewayProcessRow(
@Composable @Composable
private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping: Boolean) { private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping: Boolean) {
val phase = processDisplayPhase(process)
val tint: Color = when { val tint: Color = when {
failed -> MaterialTheme.colorScheme.error failed -> MaterialTheme.colorScheme.error
process.isRunning || stopping -> MaterialTheme.colorScheme.primary process.isRunning || stopping -> MaterialTheme.colorScheme.primary
phase == ProcessDisplayPhase.UNKNOWN || phase == ProcessDisplayPhase.CANCELLED -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.tertiary else -> MaterialTheme.colorScheme.tertiary
} }
Surface( Surface(
@@ -478,14 +501,6 @@ private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping:
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
tint = tint, tint = tint,
) )
phase == ProcessDisplayPhase.UNKNOWN || phase == ProcessDisplayPhase.CANCELLED -> Icon(
imageVector = if (phase == ProcessDisplayPhase.UNKNOWN) Icons.Filled.HelpOutline else Icons.Filled.PauseCircleOutline,
contentDescription = stringResource(
if (phase == ProcessDisplayPhase.UNKNOWN) R.string.agent_activity_status_unavailable else R.string.task_status_cancelled,
),
modifier = Modifier.size(18.dp),
tint = tint,
)
else -> Icon( else -> Icon(
Icons.Filled.CheckCircle, Icons.Filled.CheckCircle,
contentDescription = stringResource(R.string.tool_completed_a11y), contentDescription = stringResource(R.string.tool_completed_a11y),
@@ -499,11 +514,8 @@ private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping:
@Composable @Composable
private fun processMetadata(process: GatewayProcess, failed: Boolean): String { private fun processMetadata(process: GatewayProcess, failed: Boolean): String {
val phase = processDisplayPhase(process)
val state = when { val state = when {
process.isRunning -> stringResource(R.string.bg_processes_running) process.isRunning -> stringResource(R.string.bg_processes_running)
phase == ProcessDisplayPhase.UNKNOWN -> stringResource(R.string.agent_activity_status_unavailable)
phase == ProcessDisplayPhase.CANCELLED -> stringResource(R.string.task_status_cancelled)
failed -> stringResource(R.string.task_status_failed) + failed -> stringResource(R.string.task_status_failed) +
process.exitCode?.let { " · exit $it" }.orEmpty() process.exitCode?.let { " · exit $it" }.orEmpty()
else -> stringResource(R.string.task_status_complete) + else -> stringResource(R.string.task_status_complete) +
@@ -513,19 +525,6 @@ private fun processMetadata(process: GatewayProcess, failed: Boolean): String {
if (process.detached) " · recovered" else "" if (process.detached) " · recovered" else ""
} }
internal enum class ProcessDisplayPhase { RUNNING, COMPLETE, FAILED, CANCELLED, UNKNOWN }
/** A recovered or unfamiliar state is not evidence of successful completion. */
internal fun processDisplayPhase(process: GatewayProcess): ProcessDisplayPhase = when {
process.isRunning -> ProcessDisplayPhase.RUNNING
process.status.equals("unknown", ignoreCase = true) -> ProcessDisplayPhase.UNKNOWN
process.status.equals("cancelled", ignoreCase = true) ||
process.status.equals("canceled", ignoreCase = true) -> ProcessDisplayPhase.CANCELLED
process.status.equals("failed", ignoreCase = true) || (process.exitCode ?: 0) != 0 -> ProcessDisplayPhase.FAILED
process.exitCode == 0 || process.status.lowercase() in setOf("complete", "completed", "exited") -> ProcessDisplayPhase.COMPLETE
else -> ProcessDisplayPhase.UNKNOWN
}
private val ansiTerminalEscape = Regex( private val ansiTerminalEscape = Regex(
"\u001B(?:\\].*?(?:\u0007|\u001B\\\\)|\\[[0-?]*[ -/]*[@-~]|[ -/]*[@-~])", "\u001B(?:\\].*?(?:\u0007|\u001B\\\\)|\\[[0-?]*[ -/]*[@-~]|[ -/]*[@-~])",
RegexOption.DOT_MATCHES_ALL, RegexOption.DOT_MATCHES_ALL,
@@ -5,7 +5,7 @@ import android.graphics.Bitmap
import android.graphics.pdf.PdfRenderer import android.graphics.pdf.PdfRenderer
import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import com.hermesandroid.relay.ui.UiMessageBus import android.widget.Toast
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import com.hermesandroid.relay.R import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
@@ -616,7 +616,7 @@ fun SaveOverlayButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
private suspend fun shareAttachment(context: Context, attachment: Attachment) { private suspend fun shareAttachment(context: Context, attachment: Attachment) {
val bytes = attachmentBytes(context, attachment) val bytes = attachmentBytes(context, attachment)
if (bytes == null) { if (bytes == null) {
UiMessageBus.error(context.getString(R.string.inbound_attach_share_failed)) attachmentToast(context, context.getString(R.string.inbound_attach_share_failed))
return return
} }
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType) val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
@@ -626,7 +626,7 @@ private suspend fun shareAttachment(context: Context, attachment: Attachment) {
suspend fun saveAttachment(context: Context, attachment: Attachment) { suspend fun saveAttachment(context: Context, attachment: Attachment) {
val bytes = attachmentBytes(context, attachment) val bytes = attachmentBytes(context, attachment)
if (bytes == null) { if (bytes == null) {
UiMessageBus.error(context.getString(R.string.inbound_attach_share_failed)) attachmentToast(context, context.getString(R.string.inbound_attach_share_failed))
return return
} }
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) { val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) {
@@ -636,13 +636,13 @@ suspend fun saveAttachment(context: Context, attachment: Attachment) {
} }
when (result) { when (result) {
is MediaSaver.SaveResult.Saved -> is MediaSaver.SaveResult.Saved ->
UiMessageBus.success(context.getString(R.string.inbound_attach_saved, result.location)) attachmentToast(context, context.getString(R.string.inbound_attach_saved, result.location))
MediaSaver.SaveResult.UseShareInstead -> { MediaSaver.SaveResult.UseShareInstead -> {
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType) val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
MediaSaver.share(context, uri, attachment.contentType) MediaSaver.share(context, uri, attachment.contentType)
} }
is MediaSaver.SaveResult.Failed -> is MediaSaver.SaveResult.Failed ->
UiMessageBus.error(context.getString(R.string.inbound_attach_save_failed, result.message)) attachmentToast(context, context.getString(R.string.inbound_attach_save_failed, result.message))
} }
} }
@@ -658,7 +658,7 @@ private suspend fun openAttachmentExternally(context: Context, attachment: Attac
if (uri != null) { if (uri != null) {
MediaSaver.open(context, uri, attachment.contentType) MediaSaver.open(context, uri, attachment.contentType)
} else { } else {
UiMessageBus.error(context.getString(R.string.inbound_attach_open_failed)) attachmentToast(context, context.getString(R.string.inbound_attach_open_failed))
} }
} }
@@ -774,6 +774,10 @@ internal suspend fun attachmentBytes(context: Context, attachment: Attachment):
} }
} }
private fun attachmentToast(context: Context, message: String) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
@Composable @Composable
private fun emojiAndLabelFor( private fun emojiAndLabelFor(
mode: AttachmentRenderMode, mode: AttachmentRenderMode,
@@ -32,7 +32,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -48,7 +47,6 @@ import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -66,26 +64,21 @@ private const val MAX_VISIBLE_EXPANDED = 3
private const val ROW_MIN_HEIGHT_DP = 34 private const val ROW_MIN_HEIGHT_DP = 34
/** /**
* Top, thin, themed banner host. Collects [UiMessageBus] and renders the * Top, thin, info-only banner host. Collects [UiMessageBus] and renders the
* newest transient message on one line; tapping expands to the recent few * newest transient message on one line; tapping expands to the recent few
* (scrolling past three). It takes its own vertical space — the Scaffold below * (scrolling past three). It takes its own vertical space — the Scaffold below
* reflows, so content slides down smoothly instead of being covered by an * reflows, so content slides down smoothly instead of being covered by an
* overlay. Auto-dismisses (paused while expanded) and coalesces duplicates so a * overlay. Auto-dismisses (paused while expanded) and coalesces duplicates so a
* burst of the same status collapses to one refreshed row. * burst of the same status collapses to one refreshed row.
* *
* Actionable messages use ThemedMessageHost. Modal hosts render above their * App-owned errors stay on the snackbar. Keyed upstream AgentNotices may also
* dialog while the primary host retains messages for the return to the app. * use the warning tone because their sticky/clear lifecycle is server-owned.
*/ */
@Composable @Composable
fun MessageBannerHost( fun MessageBannerHost(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
includeStatusBarPadding: Boolean = true, includeStatusBarPadding: Boolean = true,
primary: Boolean = true,
) { ) {
val hostId = remember { UiMessageBus.registerHost(primary) }
val activeHost by UiMessageBus.activeHost.collectAsState()
val active = activeHost == hostId
DisposableEffect(hostId) { onDispose { UiMessageBus.unregisterHost(hostId) } }
// Backing queue (oldest first; newest is last). expiresAt is kept in a // Backing queue (oldest first; newest is last). expiresAt is kept in a
// parallel map so coalescing/auto-dismiss can address rows by id. // parallel map so coalescing/auto-dismiss can address rows by id.
val shown = remember { mutableStateListOf<UiMessage>() } val shown = remember { mutableStateListOf<UiMessage>() }
@@ -129,12 +122,12 @@ fun MessageBannerHost(
} }
// Collapse + report count to the scaffold (for inset accounting). // Collapse + report count to the scaffold (for inset accounting).
LaunchedEffect(shown.size, active) { LaunchedEffect(shown.size) {
if (shown.isEmpty()) expanded = false if (shown.isEmpty()) expanded = false
if (primary) UiMessageBus.reportActiveCount(if (active) shown.size else 0) UiMessageBus.reportActiveCount(shown.size)
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { if (primary) UiMessageBus.reportActiveCount(0) } onDispose { UiMessageBus.reportActiveCount(0) }
} }
// Mirror the live queue into a retained copy so the exit animation still // Mirror the live queue into a retained copy so the exit animation still
@@ -154,7 +147,7 @@ fun MessageBannerHost(
// under the notch. The smooth "slide" lives in animateContentSize below // under the notch. The smooth "slide" lives in animateContentSize below
// (collapsed↔expanded and message-count changes). // (collapsed↔expanded and message-count changes).
AnimatedVisibility( AnimatedVisibility(
visible = active && shown.isNotEmpty(), visible = shown.isNotEmpty(),
enter = fadeIn(tween(180)), enter = fadeIn(tween(180)),
exit = fadeOut(tween(160)), exit = fadeOut(tween(160)),
modifier = modifier, modifier = modifier,
@@ -162,7 +155,7 @@ fun MessageBannerHost(
MessageBannerContent( MessageBannerContent(
messages = rendered, messages = rendered,
expanded = expanded, expanded = expanded,
onToggle = { expanded = !expanded }, onToggle = { if (rendered.size > 1) expanded = !expanded },
includeStatusBarPadding = includeStatusBarPadding, includeStatusBarPadding = includeStatusBarPadding,
) )
} }
@@ -177,7 +170,6 @@ private fun MessageBannerContent(
) { ) {
val newest = messages.lastOrNull() ?: return val newest = messages.lastOrNull() ?: return
val multiple = messages.size > 1 val multiple = messages.size > 1
val expandable = multiple || newest.text.length > 80 || newest.severity in setOf(UiMessageSeverity.Warning, UiMessageSeverity.Error)
val insetModifier = if (includeStatusBarPadding) { val insetModifier = if (includeStatusBarPadding) {
Modifier.windowInsetsPadding(WindowInsets.statusBars) Modifier.windowInsetsPadding(WindowInsets.statusBars)
} else { } else {
@@ -198,14 +190,14 @@ private fun MessageBannerContent(
tonalElevation = 0.dp, tonalElevation = 0.dp,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(if (expandable) Modifier.clickable(onClick = onToggle) else Modifier) .then(if (multiple) Modifier.clickable(onClick = onToggle) else Modifier)
.animateContentSize(animationSpec = tween(durationMillis = 180)), .animateContentSize(animationSpec = tween(durationMillis = 180)),
) { ) {
if (!expanded) { if (!expanded) {
MessageRow( MessageRow(
message = newest, message = newest,
trailing = { trailing = {
if (expandable) { if (multiple) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -244,7 +236,6 @@ private fun MessageBannerContent(
ordered.forEachIndexed { index, message -> ordered.forEachIndexed { index, message ->
MessageRow( MessageRow(
message = message, message = message,
expanded = true,
trailing = { trailing = {
if (index == 0) { if (index == 0) {
Icon( Icon(
@@ -265,7 +256,6 @@ private fun MessageBannerContent(
@Composable @Composable
private fun MessageRow( private fun MessageRow(
message: UiMessage, message: UiMessage,
expanded: Boolean = false,
trailing: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null,
) { ) {
Row( Row(
@@ -285,7 +275,7 @@ private fun MessageRow(
Text( Text(
text = message.text, text = message.text,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
maxLines = if (expanded) 12 else 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@@ -294,25 +284,22 @@ private fun MessageRow(
} }
@Composable @Composable
internal fun severityContainer(severity: UiMessageSeverity): Color = (when (severity) { private fun severityContainer(severity: UiMessageSeverity): Color = when (severity) {
UiMessageSeverity.Error -> MaterialTheme.colorScheme.errorContainer
UiMessageSeverity.Success -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.58f) UiMessageSeverity.Success -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.58f)
UiMessageSeverity.Status -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.74f) UiMessageSeverity.Status -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.74f)
UiMessageSeverity.Warning -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.72f) UiMessageSeverity.Warning -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.72f)
UiMessageSeverity.Info -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.90f) UiMessageSeverity.Info -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.90f)
}).compositeOver(MaterialTheme.colorScheme.surface) }
@Composable @Composable
internal fun severityOnContainer(severity: UiMessageSeverity): Color = when (severity) { private fun severityOnContainer(severity: UiMessageSeverity): Color = when (severity) {
UiMessageSeverity.Error -> MaterialTheme.colorScheme.onErrorContainer
UiMessageSeverity.Success -> MaterialTheme.colorScheme.onTertiaryContainer UiMessageSeverity.Success -> MaterialTheme.colorScheme.onTertiaryContainer
UiMessageSeverity.Status -> MaterialTheme.colorScheme.onSecondaryContainer UiMessageSeverity.Status -> MaterialTheme.colorScheme.onSecondaryContainer
UiMessageSeverity.Warning -> MaterialTheme.colorScheme.onErrorContainer UiMessageSeverity.Warning -> MaterialTheme.colorScheme.onErrorContainer
UiMessageSeverity.Info -> MaterialTheme.colorScheme.onSurfaceVariant UiMessageSeverity.Info -> MaterialTheme.colorScheme.onSurfaceVariant
} }
internal fun severityIcon(severity: UiMessageSeverity): ImageVector = when (severity) { private fun severityIcon(severity: UiMessageSeverity): ImageVector = when (severity) {
UiMessageSeverity.Error -> Icons.Filled.Warning
UiMessageSeverity.Success -> Icons.Filled.CheckCircle UiMessageSeverity.Success -> Icons.Filled.CheckCircle
UiMessageSeverity.Status -> Icons.Filled.Sync UiMessageSeverity.Status -> Icons.Filled.Sync
UiMessageSeverity.Warning -> Icons.Filled.Warning UiMessageSeverity.Warning -> Icons.Filled.Warning
@@ -1,6 +1,6 @@
package com.hermesandroid.relay.ui.components package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.ui.UiMessageBus import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -92,70 +92,68 @@ fun SupportBundleDialog(state: SupportReviewState, onDismiss: () -> Unit) {
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false),
) { ) {
MessageOverlayScope { Surface(
Surface( modifier = Modifier.fillMaxWidth(0.94f),
modifier = Modifier.fillMaxWidth(0.94f), shape = appearanceRoundedCornerShape(24.dp),
shape = appearanceRoundedCornerShape(24.dp), tonalElevation = 6.dp,
tonalElevation = 6.dp, ) {
) { Column(modifier = Modifier.padding(20.dp)) {
Column(modifier = Modifier.padding(20.dp)) { Text(stringResource(R.string.support_bundle_title), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.support_bundle_title), style = MaterialTheme.typography.titleMedium) Spacer(Modifier.height(6.dp))
Spacer(Modifier.height(6.dp)) Text(
Text( stringResource(R.string.support_bundle_privacy, state.recordCount),
stringResource(R.string.support_bundle_privacy, state.recordCount), style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurfaceVariant, )
) Spacer(Modifier.height(14.dp))
Spacer(Modifier.height(14.dp)) Box(
Box( modifier = Modifier
modifier = Modifier .fillMaxWidth()
.fillMaxWidth() .heightIn(min = 160.dp, max = 420.dp)
.heightIn(min = 160.dp, max = 420.dp) .background(
.background( MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), appearanceRoundedCornerShape(12.dp),
appearanceRoundedCornerShape(12.dp), ),
), ) {
) { SelectionContainer {
SelectionContainer { Text(
Text( text = state.text,
text = state.text, fontFamily = FontFamily.Monospace,
fontFamily = FontFamily.Monospace, fontSize = 11.sp,
fontSize = 11.sp, lineHeight = 15.sp,
lineHeight = 15.sp, modifier = Modifier.verticalScroll(rememberScrollState()).padding(12.dp),
modifier = Modifier.verticalScroll(rememberScrollState()).padding(12.dp), )
)
}
} }
Spacer(Modifier.height(18.dp)) }
FlowRow( Spacer(Modifier.height(18.dp))
modifier = Modifier.fillMaxWidth(), FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) { verticalArrangement = Arrangement.spacedBy(4.dp),
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) } ) {
OutlinedButton( TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
enabled = state.shareEnabled, OutlinedButton(
onClick = { enabled = state.shareEnabled,
onClick = {
IssueReport.copyToClipboard(context, state.text)
Toast.makeText(context, copied, Toast.LENGTH_LONG).show()
},
) { Text(stringResource(R.string.common_copy)) }
Button(
enabled = state.shareEnabled,
onClick = {
if (!IssueReport.share(
context,
subject = chooser,
text = state.text,
chooserTitle = chooser,
)
) {
IssueReport.copyToClipboard(context, state.text) IssueReport.copyToClipboard(context, state.text)
UiMessageBus.success(copied) Toast.makeText(context, noShare, Toast.LENGTH_LONG).show()
}, }
) { Text(stringResource(R.string.common_copy)) } },
Button( ) { Text(stringResource(R.string.common_share)) }
enabled = state.shareEnabled,
onClick = {
if (!IssueReport.share(
context,
subject = chooser,
text = state.text,
chooserTitle = chooser,
)
) {
IssueReport.copyToClipboard(context, state.text)
UiMessageBus.warning(noShare)
}
},
) { Text(stringResource(R.string.common_share)) }
}
} }
} }
} }
@@ -1,88 +0,0 @@
package com.hermesandroid.relay.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarVisuals
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.UiMessageSeverity
import com.hermesandroid.relay.util.HumanError
val LocalMessageActionHost = staticCompositionLocalOf<SnackbarHostState?> { null }
data class HumanErrorVisuals(val error: HumanError) : SnackbarVisuals {
override val message get() = error.body
override val actionLabel get() = error.actionLabel
override val withDismissAction = true
override val duration get() = if (error.retryable) SnackbarDuration.Long else SnackbarDuration.Short
}
/** Uses the app banner's appearance while preserving existing suspend/action contracts. */
@Composable
fun ThemedMessageHost(hostState: SnackbarHostState, modifier: Modifier = Modifier, scoped: Boolean = false) {
val modalActive by UiMessageBus.modalHostActive.collectAsState()
if (!scoped && modalActive) return
SnackbarHost(hostState = hostState, modifier = modifier) { data ->
val error = (data.visuals as? HumanErrorVisuals)?.error
val severity = if (error != null) UiMessageSeverity.Error else UiMessageSeverity.Info
Surface(
modifier = Modifier.padding(12.dp).fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
color = severityContainer(severity), contentColor = severityOnContainer(severity),
) {
Column(Modifier.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(severityIcon(severity), null, Modifier.size(18.dp))
Column(Modifier.weight(1f).padding(10.dp).heightIn(max = 200.dp).verticalScroll(rememberScrollState())) {
error?.title?.let { Text(it, style = MaterialTheme.typography.titleSmall) }
Text(data.visuals.message, style = MaterialTheme.typography.bodyMedium)
}
IconButton(onClick = data::dismiss) {
Icon(Icons.Filled.Close, stringResource(R.string.common_dismiss))
}
}
data.visuals.actionLabel?.let { label ->
TextButton(onClick = data::performAction, modifier = Modifier.align(Alignment.End)) { Text(label) }
}
}
}
}
}
/** Modal-window host; the root retains the same bus events until their normal expiry. */
@Composable
fun MessageOverlayScope(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
val actions = LocalMessageActionHost.current
Box(modifier) {
content()
MessageBannerHost(Modifier.align(Alignment.TopCenter), includeStatusBarPadding = false, primary = false)
if (actions != null) ThemedMessageHost(actions, Modifier.align(Alignment.BottomCenter), scoped = true)
}
}
@@ -57,18 +57,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.ToolCall import com.hermesandroid.relay.data.ToolCall
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/** A completed dispatch call is not evidence that its detached child finished. */
internal fun isDetachedDelegationDispatch(toolCall: ToolCall): Boolean {
if (toolCall.name != "delegate_task" || !toolCall.isComplete || toolCall.success != true) return false
val result = toolCall.result ?: return false
val payload = runCatching { Json.parseToJsonElement(result) as? JsonObject }.getOrNull() ?: return false
return (payload["status"] as? JsonPrimitive)?.content == "dispatched" ||
(payload["mode"] as? JsonPrimitive)?.content == "background"
}
@Composable @Composable
fun ToolProgressCard( fun ToolProgressCard(
@@ -128,9 +116,7 @@ fun ToolProgressCard(
} }
val toolIcon = toolIcon(toolCall.name) val toolIcon = toolIcon(toolCall.name)
val detachedDispatch = isDetachedDelegationDispatch(toolCall)
val statusText = when { val statusText = when {
detachedDispatch -> stringResource(R.string.tool_progress_status_dispatched)
toolCall.isComplete && toolCall.success == true -> stringResource(R.string.tool_progress_status_completed) toolCall.isComplete && toolCall.success == true -> stringResource(R.string.tool_progress_status_completed)
toolCall.isComplete && toolCall.success == false -> stringResource(R.string.tool_progress_status_failed) toolCall.isComplete && toolCall.success == false -> stringResource(R.string.tool_progress_status_failed)
isPreparing -> stringResource(R.string.tool_preparing_a11y) isPreparing -> stringResource(R.string.tool_preparing_a11y)
@@ -201,7 +187,7 @@ fun ToolProgressCard(
) )
// Duration + completion time ("3.1s · 5:32 PM") // Duration + completion time ("3.1s · 5:32 PM")
val metaLabel = if (detachedDispatch) statusText else listOfNotNull(duration, timeLabel).joinToString(" · ") val metaLabel = listOfNotNull(duration, timeLabel).joinToString(" · ")
if (metaLabel.isNotEmpty()) { if (metaLabel.isNotEmpty()) {
Text( Text(
text = metaLabel, text = metaLabel,
@@ -4,7 +4,7 @@ package com.hermesandroid.relay.ui.screens
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import com.hermesandroid.relay.ui.UiMessageBus import android.widget.Toast
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -94,7 +94,7 @@ fun AboutScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val isDarkTheme = LocalBrand.current.isDark val isDarkTheme = LocalBrand.current.isDark
// Pre-resolved messages for non-composable action callbacks. // Pre-resolved Toast messages (Toast is not a composable scope)
val devUnlockedMsg = stringResource(R.string.about_dev_options_unlocked) val devUnlockedMsg = stringResource(R.string.about_dev_options_unlocked)
val updateCopiedMsg = stringResource(R.string.about_update_copied) val updateCopiedMsg = stringResource(R.string.about_update_copied)
val emDash = stringResource(R.string.about_em_dash) val emDash = stringResource(R.string.about_em_dash)
@@ -235,13 +235,17 @@ fun AboutScreen(
versionTapCount = 0 versionTapCount = 0
scope.launch { scope.launch {
FeatureFlags.unlockDevOptions(context) FeatureFlags.unlockDevOptions(context)
UiMessageBus.success(devUnlockedMsg) Toast.makeText(
context,
devUnlockedMsg,
Toast.LENGTH_SHORT,
).show()
onUnlockDeveloperOptions() onUnlockDeveloperOptions()
} }
} }
remaining <= 3 -> { remaining <= 3 -> {
val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining) val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining)
UiMessageBus.info(tapsMsg) Toast.makeText(context, tapsMsg, Toast.LENGTH_SHORT).show()
} }
} }
}, },
@@ -322,7 +326,11 @@ fun AboutScreen(
if (ru.updateAvailable && !ru.updateCommand.isNullOrBlank()) { if (ru.updateAvailable && !ru.updateCommand.isNullOrBlank()) {
TextButton(onClick = { TextButton(onClick = {
clipboard.setText(AnnotatedString(ru.updateCommand)) clipboard.setText(AnnotatedString(ru.updateCommand))
UiMessageBus.success(updateCopiedMsg) Toast.makeText(
context,
updateCopiedMsg,
Toast.LENGTH_SHORT,
).show()
}) { }) {
Text(stringResource(R.string.about_copy_fix)) Text(stringResource(R.string.about_copy_fix))
} }
@@ -59,7 +59,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import com.hermesandroid.relay.ui.components.ThemedMessageHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -268,7 +268,7 @@ fun AppearanceSettingsScreen(
} }
} }
}, },
snackbarHost = { ThemedMessageHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding -> ) { innerPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -41,7 +41,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import com.hermesandroid.relay.ui.components.ThemedMessageHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -73,7 +73,6 @@ import com.hermesandroid.relay.R
import com.hermesandroid.relay.data.BotGroupMessage import com.hermesandroid.relay.data.BotGroupMessage
import com.hermesandroid.relay.data.BotGroupRoom import com.hermesandroid.relay.data.BotGroupRoom
import com.hermesandroid.relay.data.BotGatewayRoute import com.hermesandroid.relay.data.BotGatewayRoute
import com.hermesandroid.relay.data.BotGatewayRouteKey
import com.hermesandroid.relay.data.BotModeState import com.hermesandroid.relay.data.BotModeState
import com.hermesandroid.relay.data.BotRosterEntry import com.hermesandroid.relay.data.BotRosterEntry
import com.hermesandroid.relay.data.Connection import com.hermesandroid.relay.data.Connection
@@ -85,13 +84,6 @@ import kotlinx.coroutines.launch
internal enum class BotModeFilter { All, Bots, Groups } internal enum class BotModeFilter { All, Bots, Groups }
/** Bundle-compatible key; length-prefix the connection so delimiters cannot alias owners. */
internal val BotRosterEntry.lazyItemKey: String
get() {
val owner = route?.key ?: return "bot:unbound:${profile.name}"
return "bot:route:${owner.connectionId.length}:${owner.connectionId}:${owner.profileName}"
}
private sealed interface BotModeRow { private sealed interface BotModeRow {
val activityAtMs: Long val activityAtMs: Long
@@ -117,7 +109,7 @@ fun BotModeScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val resources = LocalResources.current val resources = LocalResources.current
val snackbar = remember { SnackbarHostState() } val snackbar = remember { SnackbarHostState() }
var openingRoute by remember { mutableStateOf<BotGatewayRouteKey?>(null) } var openingProfile by remember { mutableStateOf<String?>(null) }
var showCreateBot by remember { mutableStateOf(false) } var showCreateBot by remember { mutableStateOf(false) }
var creatingBot by remember { mutableStateOf(false) } var creatingBot by remember { mutableStateOf(false) }
var selectedGatewayId by rememberSaveable { mutableStateOf<String?>(null) } var selectedGatewayId by rememberSaveable { mutableStateOf<String?>(null) }
@@ -144,11 +136,10 @@ fun BotModeScreen(
onBack = onBack, onBack = onBack,
onRefresh = connectionViewModel::refreshBotMode, onRefresh = connectionViewModel::refreshBotMode,
onSelectGateway = { selectedGatewayId = it }, onSelectGateway = { selectedGatewayId = it },
openingRoute = openingRoute, openingProfile = openingProfile,
onOpenBot = { bot -> onOpenBot = { bot ->
val route = bot.route ?: return@BotModeContent val route = bot.route ?: return@BotModeContent
if (openingRoute != null) return@BotModeContent openingProfile = bot.profile.name
openingRoute = route.key
scope.launch { scope.launch {
val result = connectionViewModel.ensureCanonicalBotChat(route) val result = connectionViewModel.ensureCanonicalBotChat(route)
.map { it.resolvedSessionId } .map { it.resolvedSessionId }
@@ -156,12 +147,12 @@ fun BotModeScreen(
onSuccess = { onOpenBotChat(route, it) }, onSuccess = { onOpenBotChat(route, it) },
onFailure = { snackbar.showSnackbar(it.message ?: chatOpenFailed) }, onFailure = { snackbar.showSnackbar(it.message ?: chatOpenFailed) },
) )
openingRoute = null openingProfile = null
} }
}, },
onOpenGroup = { onOpenGroup(it.key) }, onOpenGroup = { onOpenGroup(it.key) },
onNewBot = { showCreateBot = true }, onNewBot = { showCreateBot = true },
snackbarHost = { ThemedMessageHost(snackbar) }, snackbarHost = { SnackbarHost(snackbar) },
botAvatar = { bot, size -> botAvatar = { bot, size ->
BotProfileAvatar( BotProfileAvatar(
connectionViewModel = connectionViewModel, connectionViewModel = connectionViewModel,
@@ -215,7 +206,7 @@ internal fun BotModeContent(
onBack: () -> Unit, onBack: () -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onSelectGateway: (String?) -> Unit, onSelectGateway: (String?) -> Unit,
openingRoute: BotGatewayRouteKey? = null, openingProfile: String? = null,
onOpenBot: (BotRosterEntry) -> Unit, onOpenBot: (BotRosterEntry) -> Unit,
onOpenGroup: (BotGroupRoom) -> Unit, onOpenGroup: (BotGroupRoom) -> Unit,
onNewBot: () -> Unit, onNewBot: () -> Unit,
@@ -413,12 +404,12 @@ internal fun BotModeContent(
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp), horizontalArrangement = Arrangement.spacedBy(18.dp),
) { ) {
items(activeBots, key = { it.lazyItemKey }) { bot -> items(activeBots, key = { it.profile.name }) { bot ->
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier = Modifier
.width(78.dp) .width(78.dp)
.clickable(enabled = openingRoute == null) { onOpenBot(bot) }, .clickable(enabled = openingProfile == null) { onOpenBot(bot) },
) { ) {
Box { Box {
botAvatar(bot, 56.dp) botAvatar(bot, 56.dp)
@@ -464,7 +455,7 @@ internal fun BotModeContent(
itemsIndexed( itemsIndexed(
items = rows, items = rows,
key = { _, row -> when (row) { key = { _, row -> when (row) {
is BotModeRow.Bot -> row.value.lazyItemKey is BotModeRow.Bot -> "bot:${row.value.profile.name}"
is BotModeRow.Group -> "group:${row.value.key}" is BotModeRow.Group -> "group:${row.value.key}"
} }, } },
) { index, row -> ) { index, row ->
@@ -472,7 +463,7 @@ internal fun BotModeContent(
is BotModeRow.Bot -> BotConversationRow( is BotModeRow.Bot -> BotConversationRow(
bot = row.value, bot = row.value,
connectionLabel = row.value.route?.connectionLabel, connectionLabel = row.value.route?.connectionLabel,
opening = openingRoute != null && openingRoute == row.value.route?.key, opening = openingProfile == row.value.profile.name,
onClick = { onOpenBot(row.value) }, onClick = { onOpenBot(row.value) },
avatar = { botAvatar(row.value, 56.dp) }, avatar = { botAvatar(row.value, 56.dp) },
nowMs = nowMs, nowMs = nowMs,
@@ -12,14 +12,6 @@ import androidx.compose.foundation.MutatePriority
import com.hermesandroid.relay.ui.theme.LocalBrand import com.hermesandroid.relay.ui.theme.LocalBrand
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.testTag
import com.hermesandroid.relay.ui.components.ChatDebugDrawer
import com.hermesandroid.relay.ui.components.ChatActivityReceipt
import com.hermesandroid.relay.data.projectChatActivityReceipts
import com.hermesandroid.relay.viewmodel.previewActivities
import com.hermesandroid.relay.ui.components.ChatDebugOverlay
import com.hermesandroid.relay.ui.components.chatDebugHeaderGesture
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -158,7 +150,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.SmallFloatingActionButton
import com.hermesandroid.relay.ui.components.ThemedMessageHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
@@ -167,7 +159,7 @@ import android.content.ClipData
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import com.hermesandroid.relay.ui.UiMessageBus import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -878,17 +870,9 @@ fun ChatScreen(
val rawMessages by chatViewModel.messages.collectAsState() val rawMessages by chatViewModel.messages.collectAsState()
val activityRecords by chatViewModel.activityRecords.collectAsState() val messages = remember(rawMessages, supervised, supervisedPolicy.capabilities.generatedImages) {
val activityOwner by chatViewModel.conversationBinding.collectAsState() if (!supervised) rawMessages
val activitySessionId by chatViewModel.currentSessionId.collectAsState() else rawMessages.map { message ->
val receiptMessages = remember(rawMessages, activityRecords, activityOwner, activitySessionId, supervised, supervisedVisibility) {
if (activityOwner.transport == com.hermesandroid.relay.data.SessionTransport.SSE ||
(supervised && !supervisedVisibility.showWorkingStatus)
) rawMessages else projectChatActivityReceipts(rawMessages, activityRecords, activityOwner.contextKey, activitySessionId)
}
val messages = remember(receiptMessages, supervised, supervisedPolicy.capabilities.generatedImages) {
if (!supervised) receiptMessages
else receiptMessages.map { message ->
if (message.role == MessageRole.ASSISTANT) { if (message.role == MessageRole.ASSISTANT) {
message.copy( message.copy(
attachments = if (supervisedPolicy.capabilities.generatedImages) { attachments = if (supervisedPolicy.capabilities.generatedImages) {
@@ -951,10 +935,6 @@ fun ChatScreen(
val sessionArchivingSupported by chatViewModel.sessionArchivingSupported.collectAsState() val sessionArchivingSupported by chatViewModel.sessionArchivingSupported.collectAsState()
val currentSessionId by chatViewModel.currentSessionId.collectAsState() val currentSessionId by chatViewModel.currentSessionId.collectAsState()
val structuredChatFailure by chatViewModel.chatFailure.collectAsState() val structuredChatFailure by chatViewModel.chatFailure.collectAsState()
val preparingGatewaySessionId by chatViewModel.gatewayPreparingSessionId.collectAsState()
val gatewaySocketState by chatViewModel.gatewaySocketState.collectAsState()
val preparingGatewaySession = isStreaming && currentSessionId != null &&
preparingGatewaySessionId == currentSessionId
val visibleChatFailure = scopedChatFailure( val visibleChatFailure = scopedChatFailure(
structuredChatFailure, structuredChatFailure,
currentSessionId, currentSessionId,
@@ -981,7 +961,6 @@ fun ChatScreen(
val backgroundProcessesLoading by chatViewModel.backgroundProcessesLoading.collectAsState() val backgroundProcessesLoading by chatViewModel.backgroundProcessesLoading.collectAsState()
val stoppingProcessIds by chatViewModel.stoppingProcessIds.collectAsState() val stoppingProcessIds by chatViewModel.stoppingProcessIds.collectAsState()
val subagentActivities by chatViewModel.subagentActivities.collectAsState() val subagentActivities by chatViewModel.subagentActivities.collectAsState()
val retainedActivityPreview by chatViewModel.retainedActivityPreview.collectAsState()
val subagentChildPreview by chatViewModel.subagentChildPreview.collectAsState() val subagentChildPreview by chatViewModel.subagentChildPreview.collectAsState()
val isLoadingHistory by chatViewModel.isLoadingHistory.collectAsState() val isLoadingHistory by chatViewModel.isLoadingHistory.collectAsState()
val isLoadingSessions by chatViewModel.isLoadingSessions.collectAsState() val isLoadingSessions by chatViewModel.isLoadingSessions.collectAsState()
@@ -1220,10 +1199,7 @@ fun ChatScreen(
// RPC burst while the session directory was also trying to hydrate. // RPC burst while the session directory was also trying to hydrate.
} }
DisposableEffect(chatViewModel) { DisposableEffect(chatViewModel) {
onDispose { onDispose { chatViewModel.setChatVisible(false) }
chatViewModel.setChatVisible(false)
chatViewModel.closeActivityPreview()
}
} }
// Cold-open recovery: the dashboard probe that flips gatewayAvailability to // Cold-open recovery: the dashboard probe that flips gatewayAvailability to
@@ -1394,10 +1370,14 @@ fun ChatScreen(
} }
} }
if (request.payload.omittedUriCount > 0) { if (request.payload.omittedUriCount > 0) {
UiMessageBus.warning(context.getString( Toast.makeText(
context,
context.getString(
R.string.chat_shared_files_limited, R.string.chat_shared_files_limited,
com.hermesandroid.relay.util.MAX_SHARED_CONTENT_ATTACHMENTS, com.hermesandroid.relay.util.MAX_SHARED_CONTENT_ATTACHMENTS,
)) ),
Toast.LENGTH_LONG,
).show()
} }
com.hermesandroid.relay.util.SharedContentRequest.consume(request.id) com.hermesandroid.relay.util.SharedContentRequest.consume(request.id)
} }
@@ -1436,9 +1416,6 @@ fun ChatScreen(
var showEffortSheet by remember { mutableStateOf(false) } var showEffortSheet by remember { mutableStateOf(false) }
var showAgentInfo by remember { mutableStateOf(false) } var showAgentInfo by remember { mutableStateOf(false) }
var showProfileShelf by remember { mutableStateOf(false) } var showProfileShelf by remember { mutableStateOf(false) }
var showChatDebug by remember { mutableStateOf(false) }
var chatHeaderHeightPx by remember { mutableStateOf(0) }
LaunchedEffect(currentSessionId, activeConnection?.id, supervised) { showChatDebug = false }
var showProfileSwitcher by remember { mutableStateOf(false) } var showProfileSwitcher by remember { mutableStateOf(false) }
var showProfileManager by remember { mutableStateOf(false) } var showProfileManager by remember { mutableStateOf(false) }
var showBackgroundProcesses by remember { mutableStateOf(false) } var showBackgroundProcesses by remember { mutableStateOf(false) }
@@ -1455,7 +1432,7 @@ fun ChatScreen(
// A process inventory is scoped to one gateway session. Never leave a // A process inventory is scoped to one gateway session. Never leave a
// sheet opened onto a different chat after a drawer/profile switch. // sheet opened onto a different chat after a drawer/profile switch.
LaunchedEffect(currentSessionId, selectedProfile?.name, activeConnection?.id) { LaunchedEffect(currentSessionId, selectedProfile?.name, activeConnection?.id) {
chatViewModel.closeActivityPreview() chatViewModel.closeSubagentChildPreview()
showBackgroundProcesses = false showBackgroundProcesses = false
} }
@@ -1822,7 +1799,7 @@ fun ChatScreen(
cameraLauncher.launch(uri) cameraLauncher.launch(uri)
}.onFailure { }.onFailure {
pendingCameraUri = null pendingCameraUri = null
UiMessageBus.error(context.getString(R.string.chat_camera_open_failed)) Toast.makeText(context, context.getString(R.string.chat_camera_open_failed), Toast.LENGTH_SHORT).show()
} }
} }
var pendingCameraAfterPermission by remember { mutableStateOf(false) } var pendingCameraAfterPermission by remember { mutableStateOf(false) }
@@ -1834,7 +1811,11 @@ fun ChatScreen(
if (granted && wanted) { if (granted && wanted) {
launchCamera() launchCamera()
} else if (!granted) { } else if (!granted) {
UiMessageBus.warning(context.getString(R.string.chat_camera_perm_needed)) Toast.makeText(
context,
context.getString(R.string.chat_camera_perm_needed),
Toast.LENGTH_SHORT,
).show()
} }
} }
val requestCameraCapture: () -> Unit = { val requestCameraCapture: () -> Unit = {
@@ -2632,7 +2613,11 @@ fun ChatScreen(
clipboard.setClipEntry( clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(copySessionIdLabel, sessionId)) ClipEntry(ClipData.newPlainText(copySessionIdLabel, sessionId))
) )
UiMessageBus.success(copiedToClipboardMsg) Toast.makeText(
context,
copiedToClipboardMsg,
Toast.LENGTH_SHORT,
).show()
} }
}, },
threadsCapabilityActive = threadsCapabilityActive, threadsCapabilityActive = threadsCapabilityActive,
@@ -2778,7 +2763,6 @@ fun ChatScreen(
) { ) {
// Top bar — messaging app style with avatar, name, model subtitle // Top bar — messaging app style with avatar, name, model subtitle
TopAppBar( TopAppBar(
modifier = Modifier.onSizeChanged { chatHeaderHeightPx = it.height },
navigationIcon = { navigationIcon = {
if (!supervised || supervisedPolicy.capabilities.conversationHistory) { if (!supervised || supervisedPolicy.capabilities.conversationHistory) {
IconButton(onClick = { scope.launch { drawerState.open() } }) { IconButton(onClick = { scope.launch { drawerState.open() } }) {
@@ -2822,7 +2806,6 @@ fun ChatScreen(
val showStreamingState = isStreaming && val showStreamingState = isStreaming &&
(!supervised || supervisedVisibility.showWorkingStatus) (!supervised || supervisedVisibility.showWorkingStatus)
val statusText = when { val statusText = when {
preparingGatewaySession -> stringResource(R.string.chat_debug_preparing)
headerChatReady -> if (showStreamingState) { headerChatReady -> if (showStreamingState) {
stringResource(R.string.chat_streaming) stringResource(R.string.chat_streaming)
} else { } else {
@@ -2899,21 +2882,13 @@ fun ChatScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier modifier = Modifier
.chatDebugHeaderGesture(enabled = !supervised, .clickable(enabled = !supervised) {
onHold = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
showProfileShelf = false
showChatDebug = !showChatDebug
},
onClick = {
showChatDebug = false
if (profileShelfAvailable) { if (profileShelfAvailable) {
showProfileShelf = !showProfileShelf showProfileShelf = !showProfileShelf
} else { } else {
showAgentInfo = true showAgentInfo = true
} }
}) }
.testTag("chat-agent-header")
.semantics { .semantics {
contentDescription = if (profileShelfAvailable) { contentDescription = if (profileShelfAvailable) {
context.getString( context.getString(
@@ -3694,15 +3669,7 @@ fun ChatScreen(
) )
} }
if (message.activityRecord != null && (!supervised || supervisedVisibility.showWorkingStatus)) { if (processNotification != null) {
ChatActivityReceipt(
record = message.activityRecord,
onClick = {
showBackgroundProcesses = chatViewModel.openRetainedActivity(message.activityRecord, processNotification?.detail)
},
modifier = Modifier.padding(vertical = 4.dp),
)
} else if (processNotification != null) {
val notificationModifier = Modifier.padding( val notificationModifier = Modifier.padding(
top = if (isFirstInGroup) 6.dp else 2.dp, top = if (isFirstInGroup) 6.dp else 2.dp,
) )
@@ -3944,9 +3911,6 @@ fun ChatScreen(
// optional diagnostic scaffolding. Keep lanes // optional diagnostic scaffolding. Keep lanes
// visible in Off, Compact, and Detailed modes. // visible in Off, Compact, and Detailed modes.
laneGroups.keys.filterNotNull().sorted().forEach { taskIndex -> laneGroups.keys.filterNotNull().sorted().forEach { taskIndex ->
val laneCalls = laneGroups.getValue(taskIndex)
val retainedIds = activityRecords.flatMap { it.children }.mapTo(HashSet()) { it.id }
if (laneCalls.all { it.subagentId != null && it.subagentId in retainedIds }) return@forEach
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
SubagentLane( SubagentLane(
taskIndex = taskIndex, taskIndex = taskIndex,
@@ -4082,7 +4046,7 @@ fun ChatScreen(
} }
// Copy feedback snackbar // Copy feedback snackbar
ThemedMessageHost( SnackbarHost(
hostState = snackbarHostState, hostState = snackbarHostState,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
@@ -4097,7 +4061,7 @@ fun ChatScreen(
subagentActivities = subagentActivities, subagentActivities = subagentActivities,
subagentPreviewVisibility = subagentPreviewVisibility, subagentPreviewVisibility = subagentPreviewVisibility,
loading = backgroundProcessesLoading, loading = backgroundProcessesLoading,
onClick = { chatViewModel.openCurrentActivityPreview(); showBackgroundProcesses = true }, onClick = { showBackgroundProcesses = true },
) )
} }
@@ -4605,11 +4569,17 @@ fun ChatScreen(
isDemoMode = isDemoMode, isDemoMode = isDemoMode,
voiceReady = voiceReady, voiceReady = voiceReady,
onDemoNotice = { onDemoNotice = {
UiMessageBus.warning("Voice is unavailable in the offline demo — connect to Hermes to use it") Toast.makeText(
context,
"Voice is unavailable in the offline demo — connect to Hermes to use it",
Toast.LENGTH_LONG,
).show()
}, },
onStartVoice = requestVoiceMode, onStartVoice = requestVoiceMode,
onSetupNotice = { onSetupNotice = {
UiMessageBus.warning(when (standardVoiceAvailability) { Toast.makeText(
context,
when (standardVoiceAvailability) {
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.SignInRequired -> com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.SignInRequired ->
standardVoiceSignInRouteHint?.let { route -> standardVoiceSignInRouteHint?.let { route ->
"Voice needs a one-time sign-in on the $route route — open Manage" "Voice needs a one-time sign-in on the $route route — open Manage"
@@ -4618,7 +4588,9 @@ fun ChatScreen(
"This Hermes build has no voice routes — update hermes-agent or pair Relay" "This Hermes build has no voice routes — update hermes-agent or pair Relay"
else -> else ->
context.getString(R.string.chat_voice_needs_route) context.getString(R.string.chat_voice_needs_route)
}) },
Toast.LENGTH_SHORT,
).show()
}, },
) )
}, },
@@ -4842,30 +4814,6 @@ fun ChatScreen(
} }
} // end Column } // end Column
ChatDebugOverlay(
visible = showChatDebug && !supervised,
headerHeight = with(density) { chatHeaderHeightPx.toDp() },
onClose = { showChatDebug = false },
) {
ChatDebugDrawer(
profile = AgentDisplay.profileDisplayName(conversationProfile)
?: stringResource(R.string.chat_server_default),
model = AgentDisplay.displayModelName(sessionModelState.model).orEmpty(),
sessionId = currentSessionId,
gateway = isGatewayTransport,
signedIn = chatGatewayAvailability == GatewayAvailability.Ready,
signInRequired = chatGatewayAvailability == GatewayAvailability.SignInRequired,
socketState = gatewaySocketState,
preparing = preparingGatewaySession,
streaming = isStreaming,
loadingHistory = isLoadingHistory,
directoryUnavailable = sessionListUnavailable,
failure = visibleChatFailure?.rawError,
onClose = { showChatDebug = false },
onConnections = { showChatDebug = false; onNavigateToConnections() },
)
}
// Mic permission denied banner — title + body + Open Settings action. // Mic permission denied banner — title + body + Open Settings action.
// System "Don't ask again" gives no callback, so a toast would leave // System "Don't ask again" gives no callback, so a toast would leave
// the user stranded. Banner + direct-to-app-details deep link is the // the user stranded. Banner + direct-to-app-details deep link is the
@@ -5005,8 +4953,8 @@ fun ChatScreen(
if (showBackgroundProcesses) { if (showBackgroundProcesses) {
GatewayBackgroundProcessSheet( GatewayBackgroundProcessSheet(
processes = retainedActivityPreview?.processes ?: backgroundProcesses, processes = backgroundProcesses,
subagentActivities = retainedActivityPreview?.record?.previewActivities() ?: subagentActivities, subagentActivities = subagentActivities,
subagentChildPreview = subagentChildPreview, subagentChildPreview = subagentChildPreview,
subagentPreviewVisibility = subagentPreviewVisibility, subagentPreviewVisibility = subagentPreviewVisibility,
loading = backgroundProcessesLoading, loading = backgroundProcessesLoading,
@@ -5015,10 +4963,8 @@ fun ChatScreen(
onStop = chatViewModel::stopBackgroundProcess, onStop = chatViewModel::stopBackgroundProcess,
onDismissProcess = chatViewModel::dismissBackgroundProcess, onDismissProcess = chatViewModel::dismissBackgroundProcess,
onOpenSubagentChild = chatViewModel::openSubagentChildPreview, onOpenSubagentChild = chatViewModel::openSubagentChildPreview,
readOnlyHistory = retainedActivityPreview != null,
historyNotice = if (retainedActivityPreview != null) stringResource(R.string.chat_activity_history_notice) else null,
onDismiss = { onDismiss = {
chatViewModel.closeActivityPreview() chatViewModel.closeSubagentChildPreview()
showBackgroundProcesses = false showBackgroundProcesses = false
}, },
) )
@@ -5559,9 +5505,13 @@ private suspend fun ingestAttachmentFromUri(
} catch (cancelled: CancellationException) { } catch (cancelled: CancellationException) {
throw cancelled throw cancelled
} catch (_: AttachmentTooLargeException) { } catch (_: AttachmentTooLargeException) {
UiMessageBus.warning(context.getString(R.string.chat_file_too_large, maxAttachmentMb)) Toast.makeText(
context,
context.getString(R.string.chat_file_too_large, maxAttachmentMb),
Toast.LENGTH_SHORT,
).show()
} catch (e: Exception) { } catch (e: Exception) {
UiMessageBus.error(context.getString(R.string.chat_failed_read_file)) Toast.makeText(context, context.getString(R.string.chat_failed_read_file), Toast.LENGTH_SHORT).show()
} }
} }
@@ -30,7 +30,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import com.hermesandroid.relay.ui.components.ThemedMessageHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@@ -124,7 +124,7 @@ fun CustomPetGuideScreen(
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface), colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
) )
}, },
snackbarHost = { ThemedMessageHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding -> ) { innerPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -2,7 +2,7 @@
package com.hermesandroid.relay.ui.screens package com.hermesandroid.relay.ui.screens
import com.hermesandroid.relay.ui.UiMessageBus import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import com.hermesandroid.relay.ui.theme.LocalBrand import com.hermesandroid.relay.ui.theme.LocalBrand
@@ -92,7 +92,11 @@ fun DeveloperSettingsScreen(
) { uri -> ) { uri ->
if (uri != null && backupJson != null) { if (uri != null && backupJson != null) {
connectionViewModel.writeBackupToUri(uri, backupJson!!) { success -> connectionViewModel.writeBackupToUri(uri, backupJson!!) { success ->
UiMessageBus.post(if (success) context.getString(R.string.dev_settings_exported) else context.getString(R.string.dev_settings_export_failed), severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error) Toast.makeText(
context,
if (success) context.getString(R.string.dev_settings_exported) else context.getString(R.string.dev_settings_export_failed),
Toast.LENGTH_SHORT
).show()
backupJson = null backupJson = null
} }
} }
@@ -104,7 +108,11 @@ fun DeveloperSettingsScreen(
) { uri -> ) { uri ->
if (uri != null) { if (uri != null) {
connectionViewModel.importFromUri(uri) { success -> connectionViewModel.importFromUri(uri) { success ->
UiMessageBus.post(if (success) context.getString(R.string.dev_settings_imported) else context.getString(R.string.dev_settings_import_failed), severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error) Toast.makeText(
context,
if (success) context.getString(R.string.dev_settings_imported) else context.getString(R.string.dev_settings_import_failed),
Toast.LENGTH_SHORT
).show()
} }
} }
} }
@@ -176,11 +184,15 @@ fun DeveloperSettingsScreen(
} }
IconButton(onClick = { IconButton(onClick = {
connectionViewModel.resetOnboarding { success -> connectionViewModel.resetOnboarding { success ->
UiMessageBus.post(if (success) { Toast.makeText(
context,
if (success) {
context.getString(R.string.dev_settings_onboarding_reset_toast) context.getString(R.string.dev_settings_onboarding_reset_toast)
} else { } else {
context.getString(R.string.dev_settings_reset_failed) context.getString(R.string.dev_settings_reset_failed)
}, severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error) },
Toast.LENGTH_SHORT,
).show()
} }
}) { }) {
Icon( Icon(
@@ -421,7 +433,11 @@ fun DeveloperSettingsScreen(
IconButton(onClick = { IconButton(onClick = {
scope.launch { scope.launch {
FeatureFlags.lockDevOptions(context) FeatureFlags.lockDevOptions(context)
UiMessageBus.info(context.getString(R.string.dev_settings_locked_toast)) Toast.makeText(
context,
context.getString(R.string.dev_settings_locked_toast),
Toast.LENGTH_SHORT,
).show()
onBack() onBack()
} }
}) { }) {
@@ -439,7 +455,6 @@ fun DeveloperSettingsScreen(
// logged error, a live update). Gated by isDevBuild so it never // logged error, a live update). Gated by isDevBuild so it never
// ships in a release APK. // ships in a release APK.
if (FeatureFlags.isDevBuild) { if (FeatureFlags.isDevBuild) {
MessagePreviewControls()
Text( Text(
text = stringResource(R.string.dev_settings_test_harness), text = stringResource(R.string.dev_settings_test_harness),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@@ -486,7 +501,7 @@ fun DeveloperSettingsScreen(
context.getString(R.string.dev_settings_sample_stacktrace), context.getString(R.string.dev_settings_sample_stacktrace),
), ),
) )
UiMessageBus.info(context.getString(R.string.dev_settings_diagnostics_emitted_toast)) Toast.makeText(context, context.getString(R.string.dev_settings_diagnostics_emitted_toast), Toast.LENGTH_SHORT).show()
}, },
) )
@@ -503,7 +518,7 @@ fun DeveloperSettingsScreen(
is UpdateStatus.Downloaded -> context.getString(R.string.dev_settings_update_state_downloaded) is UpdateStatus.Downloaded -> context.getString(R.string.dev_settings_update_state_downloaded)
else -> context.getString(R.string.dev_settings_update_state_off) else -> context.getString(R.string.dev_settings_update_state_off)
} }
UiMessageBus.info(context.getString(R.string.dev_settings_update_banner_preview_toast, state)) Toast.makeText(context, context.getString(R.string.dev_settings_update_banner_preview_toast, state), Toast.LENGTH_SHORT).show()
}, },
) )
@@ -553,7 +568,11 @@ fun DeveloperSettingsScreen(
showExportDialog = false showExportDialog = false
connectionViewModel.exportSettings { json -> connectionViewModel.exportSettings { json ->
if (json == null) { if (json == null) {
UiMessageBus.error(context.getString(R.string.dev_settings_export_failed)) Toast.makeText(
context,
context.getString(R.string.dev_settings_export_failed),
Toast.LENGTH_SHORT,
).show()
} else { } else {
backupJson = json backupJson = json
exportLauncher.launch("hermes-relay-sensitive-backup.json") exportLauncher.launch("hermes-relay-sensitive-backup.json")
@@ -614,11 +633,15 @@ fun DeveloperSettingsScreen(
onClick = { onClick = {
showResetDialog = false showResetDialog = false
connectionViewModel.resetAppData { success -> connectionViewModel.resetAppData { success ->
UiMessageBus.post(if (success) { Toast.makeText(
context,
if (success) {
context.getString(R.string.dev_settings_app_data_reset_toast) context.getString(R.string.dev_settings_app_data_reset_toast)
} else { } else {
context.getString(R.string.dev_settings_reset_failed) context.getString(R.string.dev_settings_reset_failed)
}, severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error) },
Toast.LENGTH_SHORT,
).show()
} }
} }
) { ) {
@@ -635,7 +658,7 @@ fun DeveloperSettingsScreen(
} }
@Composable @Composable
internal fun TestHarnessRow( private fun TestHarnessRow(
title: String, title: String,
subtitle: String, subtitle: String,
icon: androidx.compose.ui.graphics.vector.ImageVector, icon: androidx.compose.ui.graphics.vector.ImageVector,
@@ -3,7 +3,7 @@
package com.hermesandroid.relay.ui.screens package com.hermesandroid.relay.ui.screens
import android.content.Context import android.content.Context
import com.hermesandroid.relay.ui.UiMessageBus import android.widget.Toast
import com.hermesandroid.relay.ui.theme.LocalBrand import com.hermesandroid.relay.ui.theme.LocalBrand
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -285,7 +285,11 @@ fun MediaSettingsScreen(
R.string.media_clear_cache_freed, R.string.media_clear_cache_freed,
formatBytesHuman(context, freed) formatBytesHuman(context, freed)
) )
UiMessageBus.info(freedMsg) Toast.makeText(
context,
freedMsg,
Toast.LENGTH_SHORT
).show()
} }
} }
) { ) {
@@ -1,83 +0,0 @@
package com.hermesandroid.relay.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Science
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.hermesandroid.relay.R
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.UiMessageSeverity
import com.hermesandroid.relay.ui.components.HumanErrorVisuals
import com.hermesandroid.relay.ui.components.LocalMessageActionHost
import com.hermesandroid.relay.util.HumanError
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
/** Local-only samples; no network, diagnostics, or connection-state mutations. */
@Composable
internal fun MessagePreviewControls() {
val scope = rememberCoroutineScope()
val actionHost = LocalMessageActionHost.current
val jobs = remember { arrayOfNulls<Job>(1) }
val key = "developer-message-preview"
fun reset() {
jobs[0]?.cancel()
jobs[0] = null
UiMessageBus.clear(key)
}
DisposableEffect(Unit) { onDispose { reset() } }
Text(stringResource(R.string.dev_message_previews), style = MaterialTheme.typography.titleMedium)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.dev_message_previews_desc), style = MaterialTheme.typography.bodySmall)
val samples = listOf(
R.string.dev_message_info to UiMessageSeverity.Info,
R.string.dev_message_success to UiMessageSeverity.Success,
R.string.dev_message_progress to UiMessageSeverity.Status,
R.string.dev_message_warning to UiMessageSeverity.Warning,
R.string.dev_message_error to UiMessageSeverity.Error,
)
samples.forEach { (label, severity) ->
val text = stringResource(label)
TestHarnessRow(text, stringResource(R.string.dev_message_preview_sample), Icons.Filled.Science, onClick = {
reset()
UiMessageBus.post(text, severity, ttlMillis = if (severity == UiMessageSeverity.Status) 0L else 10_000L, key = key)
})
}
val errorTitle = stringResource(R.string.dev_message_action)
val errorBody = stringResource(R.string.dev_message_action_body)
val retryLabel = stringResource(R.string.dev_message_retry)
val retryResult = stringResource(R.string.dev_message_retry_result)
TestHarnessRow(errorTitle, errorBody, Icons.Filled.Science, onClick = {
reset()
jobs[0] = scope.launch {
val result = actionHost?.showSnackbar(HumanErrorVisuals(HumanError(
title = errorTitle, body = errorBody, retryable = true, actionLabel = retryLabel,
)))
if (result == SnackbarResult.ActionPerformed) {
UiMessageBus.post(retryResult, UiMessageSeverity.Success, key = key)
}
}
})
TestHarnessRow(
stringResource(R.string.dev_message_clear),
stringResource(R.string.dev_message_clear_desc),
Icons.Filled.Science,
onClick = { reset() },
)
}
}
}
@@ -2,7 +2,7 @@
package com.hermesandroid.relay.ui.screens package com.hermesandroid.relay.ui.screens
import com.hermesandroid.relay.ui.UiMessageBus import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -170,7 +170,7 @@ fun PairScreen(
ConnectionWizard( ConnectionWizard(
connectionViewModel = connectionViewModel, connectionViewModel = connectionViewModel,
onComplete = { onComplete = {
UiMessageBus.success(context.getString(R.string.pair_connection_updated)) Toast.makeText(context, context.getString(R.string.pair_connection_updated), Toast.LENGTH_SHORT).show()
onComplete() onComplete()
}, },
onCancel = onCancel, onCancel = onCancel,
@@ -38,7 +38,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import com.hermesandroid.relay.ui.components.ThemedMessageHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -150,7 +150,7 @@ fun PairedDevicesScreen(
) )
) )
}, },
snackbarHost = { ThemedMessageHost(snackbarHostState) } snackbarHost = { SnackbarHost(snackbarHostState) }
) { inner -> ) { inner ->
Box( Box(
modifier = Modifier modifier = Modifier
@@ -36,7 +36,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import com.hermesandroid.relay.ui.components.ThemedMessageHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -189,7 +189,7 @@ fun PetdexBrowseScreen(
), ),
) )
}, },
snackbarHost = { ThemedMessageHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding -> ) { innerPadding ->
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(156.dp), columns = GridCells.Adaptive(156.dp),
@@ -5,7 +5,6 @@ import com.hermesandroid.relay.R
import com.hermesandroid.relay.diagnostics.DiagnosticCategory import com.hermesandroid.relay.diagnostics.DiagnosticCategory
import com.hermesandroid.relay.diagnostics.DiagnosticsLog import com.hermesandroid.relay.diagnostics.DiagnosticsLog
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
import com.hermesandroid.relay.network.upstream.DashboardHttpException
import java.io.IOException import java.io.IOException
import java.net.ConnectException import java.net.ConnectException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
@@ -87,8 +86,6 @@ private fun nullFallback(context: String?, ctx: Context?): HumanError {
return HumanError(title = titlePrefix(context, ctx), body = base, retryable = false) return HumanError(title = titlePrefix(context, ctx), body = base, retryable = false)
} }
private val LEGACY_HTTP_STATUS = Regex("""^(?:http(?: error)?|api error|relay responded http) ([1-5]\d{2})(?=\s|:|$)""")
private fun classifyIoMessage(msg: String, context: String?, ctx: Context?): HumanError? { private fun classifyIoMessage(msg: String, context: String?, ctx: Context?): HumanError? {
// Ordered most-specific-first; callers have already handled the typed // Ordered most-specific-first; callers have already handled the typed
// SSL / timeout / connect exceptions so this only runs on generic IOs. // SSL / timeout / connect exceptions so this only runs on generic IOs.
@@ -152,9 +149,11 @@ private fun classifyIoMessage(msg: String, context: String?, ctx: Context?): Hum
"server. Update the relay, then try again.", "server. Update the relay, then try again.",
retryable = false, retryable = false,
) )
LEGACY_HTTP_STATUS.find(msg.trim())?.groupValues?.get(1) == "404" -> HumanError( "404" in msg -> HumanError(
title = ctx?.getString(R.string.error_classify_endpoint) ?: "Endpoint not found", title = ctx?.getString(R.string.error_classify_endpoint) ?: "Endpoint not found",
body = "The requested resource or endpoint is unavailable on this server.", body = if (context == "voice_config")
"The relay doesn't have voice endpoints — it may be an older version"
else "The relay doesn't have this endpoint — it may be an older version",
retryable = false, retryable = false,
) )
"413" in msg -> HumanError( "413" in msg -> HumanError(
@@ -256,30 +255,6 @@ private fun String.diagnosticOperation(): String =
private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?): HumanError { private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?): HumanError {
if (t == null) return nullFallback(context, ctx) if (t == null) return nullFallback(context, ctx)
// Preserve the actual transport and status instead of inferring them from
// a response body, resource identifier, or unrelated number in an error.
if (t is DashboardHttpException) {
return when (t.statusCode) {
401 -> HumanError(
title = ctx?.getString(R.string.power_feature_dashboard_signin_label) ?: "Dashboard sign-in required",
body = "Sign in to the Hermes Dashboard to continue this action.",
)
403 -> HumanError(
title = ctx?.getString(R.string.error_classify_not_allowed) ?: "Not allowed",
body = "The Dashboard refused this action.",
)
404 -> HumanError(
title = ctx?.getString(R.string.error_classify_endpoint) ?: "Endpoint not found",
body = "The requested Dashboard resource or endpoint is unavailable.",
)
else -> HumanError(
title = titlePrefix(context, ctx),
body = "The Dashboard request failed (HTTP ${t.statusCode}).",
retryable = t.statusCode == 408 || t.statusCode == 429 || t.statusCode >= 500,
)
}
}
val msg = t.message.orEmpty().lowercase() val msg = t.message.orEmpty().lowercase()
if ("cannot create audiorecord" in msg || if ("cannot create audiorecord" in msg ||
@@ -1,286 +0,0 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.ChatActivityChild
import com.hermesandroid.relay.data.ChatActivityKind
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.ChatActivityRecord
import com.hermesandroid.relay.data.ChatActivityStore
import com.hermesandroid.relay.data.InMemoryChatActivityStore
import com.hermesandroid.relay.data.boundChatActivities
import com.hermesandroid.relay.network.upstream.GatewayProcess
import java.io.IOException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/** Session-owned history metadata. Call capture only with events/snapshots owned by the selected session. */
internal class ChatActivityController(
private val scope: CoroutineScope,
private var store: ChatActivityStore = InMemoryChatActivityStore(),
private val clock: () -> Long = System::currentTimeMillis,
) {
private val mutableRecords = MutableStateFlow<List<ChatActivityRecord>>(emptyList())
val records: StateFlow<List<ChatActivityRecord>> = mutableRecords.asStateFlow()
private var owner: Pair<String, String>? = null
private var generation = 0L
private var deletedOwner: Pair<String, String>? = null
private val retiredIds = mutableSetOf<String>()
private val writes = Mutex()
fun bindStore(store: ChatActivityStore) {
if (this.store === store) return
this.store = store
generation++
load(migrate = true)
}
fun selectSession(scopeKey: String?, sessionId: String?) {
val next = if (!scopeKey.isNullOrBlank() && !sessionId.isNullOrBlank()) scopeKey to sessionId else null
if (next != null && next == deletedOwner) return
if (next != null && next != deletedOwner) deletedOwner = null
if (next == owner) return
owner = next
generation++
mutableRecords.value = emptyList()
retiredIds.clear()
load()
}
private fun load(migrate: Boolean = false) {
val selected = owner ?: return
val selectedStore = store
val selectedGeneration = generation
scope.launch {
val loaded = try {
selectedStore.read(selected.first, selected.second)
} catch (_: IOException) {
emptyList()
}
if (selectedGeneration != generation || owner != selected || selectedStore !== store) return@launch
val fresh = mutableRecords.value.associateBy(ChatActivityRecord::id)
val merged = loaded.filter { it.scopeKey == selected.first && it.sessionId == selected.second && it.id !in retiredIds }
.map { restored -> fresh[restored.id]?.let { live -> mergeRestored(restored, live) } ?: restored }
.associateByTo(linkedMapOf(), ChatActivityRecord::id)
fresh.forEach { (id, record) -> if (id !in merged) merged[id] = record }
// A lazy disk read can reveal a fallback only after its live event supplied delegation identity.
merged.values.filter { it.kind == ChatActivityKind.SUBAGENTS && !it.isFallback() }.toList()
.forEach { target ->
val fallbacks = merged.values.filter { candidate ->
candidate.isFallback() && candidate.children.any { old ->
target.children.any { child -> old.id == child.id ||
(child.childSessionId != null && old.childSessionId == child.childSessionId) }
}
}
var consolidated = target
fallbacks.forEach { fallback ->
consolidated = mergeRestored(fallback, consolidated)
merged.remove(fallback.id)
retire(fallback)
}
merged[target.id] = consolidated
}
publish(merged.values.toList())
mutableRecords.value.filter { it.id in fresh }.forEach { record ->
if (migrate || fresh[record.id] != record) persist(record)
}
}
}
private fun mergeRestored(restored: ChatActivityRecord, fresh: ChatActivityRecord): ChatActivityRecord {
if (fresh.kind != ChatActivityKind.SUBAGENTS) return fresh.copy(createdAt = minOf(restored.createdAt, fresh.createdAt))
val children = mergeChildren(restored.children, fresh.children)
val count = maxOf(restored.taskCount, fresh.taskCount, children.size)
return fresh.copy(
createdAt = minOf(restored.createdAt, fresh.createdAt), children = children,
phase = aggregate(children, count), taskCount = count,
)
}
fun captureSubagents(activities: List<SubagentActivity>) {
val selected = owner ?: return
val identifiable = activities.mapNotNull { activity ->
val childId = activity.subagentId?.takeIf(String::isNotBlank)
?: activity.childSessionId?.takeIf(String::isNotBlank) ?: return@mapNotNull null
val delegation = activity.delegationId?.takeIf(String::isNotBlank)
val identityKind = when {
delegation != null -> "delegation"
!activity.subagentId.isNullOrBlank() -> "subagent"
else -> "session"
}
val existing = if (delegation == null) mutableRecords.value.firstOrNull { record ->
record.kind == ChatActivityKind.SUBAGENTS && record.children.any { it.matches(activity) }
} else null
val id = existing?.id ?: identity("subagents", identityKind, delegation ?: childId)
Triple(id, existing?.sourceId ?: delegation ?: childId, activity)
}
identifiable.groupBy { it.first }.forEach { (id, group) ->
val previous = mutableRecords.value.firstOrNull { it.id == id }
val migrated = mutableRecords.value.filter { record ->
record.id != id && record.isFallback() && group.any { (_, _, activity) ->
record.children.any { it.matches(activity) }
}
}
val inherited = mergeChildren(migrated.flatMap { it.children }, previous?.children.orEmpty())
val children = inherited.associateByTo(linkedMapOf(), ChatActivityChild::id)
group.forEach { (_, _, activity) ->
val childId = activity.subagentId?.takeIf(String::isNotBlank) ?: activity.childSessionId!!
val old = children.values.firstOrNull { it.matches(activity) }
if (old != null && old.id != childId) children.remove(old.id)
children[childId] = ChatActivityChild(
id = childId,
childSessionId = activity.childSessionId ?: old?.childSessionId,
goal = activity.goal.take(512).ifBlank { old?.goal.orEmpty() },
phase = activity.phase.toHistoryPhase(),
// Terminal summary is bounded metadata; progress/tool-event bodies are never copied.
summary = if (activity.isTerminal) activity.summary?.take(512) ?: old?.summary else old?.summary,
)
}
val now = clock()
val values = children.values.toList()
val count = maxOf(previous?.taskCount ?: 0, migrated.maxOfOrNull { it.taskCount } ?: 0,
group.maxOf { it.third.taskCount }, values.size).coerceIn(0, 10_000)
migrated.forEach(::retire)
update(ChatActivityRecord(
id = id, scopeKey = selected.first, sessionId = selected.second,
kind = ChatActivityKind.SUBAGENTS, sourceId = group.first().second,
title = "Subagents", phase = aggregate(values, count),
createdAt = minOf(previous?.createdAt ?: now, migrated.minOfOrNull { it.createdAt } ?: now),
updatedAt = previous?.updatedAt ?: now,
children = values, taskCount = count,
))
}
}
/** An authoritative session-scoped process.list snapshot, including an empty successful snapshot. */
fun captureProcesses(processes: List<GatewayProcess>) {
val selected = owner ?: return
val seen = mutableSetOf<String>()
processes.filter { it.id.isNotBlank() }.forEach { process ->
val id = identity("process", process.id, process.startedAt)
seen += id
val previous = mutableRecords.value.firstOrNull { it.id == id }
val now = clock()
update(ChatActivityRecord(
id = id, scopeKey = selected.first, sessionId = selected.second, kind = ChatActivityKind.PROCESS,
sourceId = process.id, title = "Background command", phase = process.toHistoryPhase(),
createdAt = previous?.createdAt ?: now, updatedAt = previous?.updatedAt ?: now,
processId = process.id, processStartedAt = process.startedAt, exitCode = process.exitCode,
))
}
mutableRecords.value.filter {
it.kind == ChatActivityKind.PROCESS && it.phase == ChatActivityPhase.RUNNING && it.id !in seen
}.forEach { update(it.copy(phase = ChatActivityPhase.UNKNOWN)) }
}
fun removeSession(scopeKey: String, sessionId: String) {
if (owner == (scopeKey to sessionId)) {
deletedOwner = owner
owner = null
generation++
mutableRecords.value = emptyList()
}
val selectedStore = store
scope.launch { writes.withLock { selectedStore.removeSession(scopeKey, sessionId) } }
}
fun markUnavailable() {
mutableRecords.value.toList().forEach { record ->
val children = record.children.map {
if (it.phase == ChatActivityPhase.RUNNING) it.copy(phase = ChatActivityPhase.UNKNOWN) else it
}
val phase = if (record.phase == ChatActivityPhase.RUNNING) ChatActivityPhase.UNKNOWN else record.phase
update(record.copy(phase = phase, children = children))
}
}
private fun retire(record: ChatActivityRecord) {
retiredIds += record.id
mutableRecords.value = mutableRecords.value.filterNot { it.id == record.id }
val selectedStore = store
scope.launch {
try {
writes.withLock { selectedStore.removeRecord(record.scopeKey, record.sessionId, record.id) }
} catch (_: IOException) {
// The live selection still suppresses the superseded entry.
}
}
}
private fun update(candidate: ChatActivityRecord) {
val previous = mutableRecords.value.firstOrNull { it.id == candidate.id }
if (previous == candidate) return
val updated = candidate.copy(updatedAt = maxOf(clock(), (previous?.updatedAt ?: -1L) + 1L))
publish(mutableRecords.value.filterNot { it.id == updated.id } + updated)
mutableRecords.value.firstOrNull { it.id == updated.id }?.let(::persist)
}
private fun publish(records: List<ChatActivityRecord>) {
// Logical timestamps can advance within one wall-clock millisecond.
mutableRecords.value = boundChatActivities(records, maxOf(clock(), records.maxOfOrNull { it.updatedAt } ?: 0L))
.sortedWith(compareBy<ChatActivityRecord> { it.createdAt }.thenBy { it.id })
}
private fun persist(record: ChatActivityRecord) {
val selectedStore = store
scope.launch {
try {
writes.withLock { selectedStore.upsert(record) }
} catch (_: IOException) {
// Keep the session's visible metadata when local persistence is temporarily unavailable.
}
}
}
}
private fun identity(vararg parts: String?): String = parts.joinToString("") {
if (it == null) "-1:" else "${it.length}:$it"
}
private fun SubagentActivityPhase.toHistoryPhase(): ChatActivityPhase = when (this) {
SubagentActivityPhase.STARTED, SubagentActivityPhase.THINKING, SubagentActivityPhase.TOOL,
SubagentActivityPhase.PROGRESS -> ChatActivityPhase.RUNNING
SubagentActivityPhase.COMPLETED -> ChatActivityPhase.COMPLETE
SubagentActivityPhase.FAILED -> ChatActivityPhase.FAILED
SubagentActivityPhase.INTERRUPTED -> ChatActivityPhase.CANCELLED
SubagentActivityPhase.ENDED_WITH_PARENT -> ChatActivityPhase.UNKNOWN
}
private fun aggregate(children: List<ChatActivityChild>, taskCount: Int): ChatActivityPhase = when {
children.any { it.phase == ChatActivityPhase.RUNNING } -> ChatActivityPhase.RUNNING
children.isEmpty() || children.size < taskCount || children.any { it.phase == ChatActivityPhase.UNKNOWN } -> ChatActivityPhase.UNKNOWN
children.any { it.phase == ChatActivityPhase.FAILED } -> ChatActivityPhase.FAILED
children.any { it.phase == ChatActivityPhase.CANCELLED } -> ChatActivityPhase.CANCELLED
else -> ChatActivityPhase.COMPLETE
}
private fun ChatActivityChild.matches(activity: SubagentActivity): Boolean =
(!activity.subagentId.isNullOrBlank() && id == activity.subagentId) ||
(!activity.childSessionId.isNullOrBlank() && childSessionId == activity.childSessionId)
private fun ChatActivityRecord.isFallback(): Boolean = kind == ChatActivityKind.SUBAGENTS &&
(id == identity("subagents", "subagent", sourceId) || id == identity("subagents", "session", sourceId))
private fun mergeChildren(old: List<ChatActivityChild>, fresh: List<ChatActivityChild>): List<ChatActivityChild> {
val merged = old.associateByTo(linkedMapOf(), ChatActivityChild::id)
fresh.forEach { child ->
val alias = merged.values.firstOrNull {
it.id == child.id || (child.childSessionId != null && it.childSessionId == child.childSessionId)
}
if (alias != null && alias.id != child.id) merged.remove(alias.id)
merged[child.id] = child
}
return merged.values.toList()
}
private fun GatewayProcess.toHistoryPhase(): ChatActivityPhase = when {
isRunning -> ChatActivityPhase.RUNNING
status.lowercase() in setOf("cancelled", "canceled", "killed", "interrupted") -> ChatActivityPhase.CANCELLED
status.equals("failed", ignoreCase = true) || (exitCode != null && exitCode != 0) -> ChatActivityPhase.FAILED
exitCode == 0 -> ChatActivityPhase.COMPLETE
status.lowercase() in setOf("completed", "complete", "exited", "finished", "done") -> ChatActivityPhase.COMPLETE
else -> ChatActivityPhase.UNKNOWN
}
@@ -1,43 +0,0 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.ChatActivityRecord
import com.hermesandroid.relay.network.upstream.GatewayProcess
internal data class RetainedChatActivityPreview(
val record: ChatActivityRecord,
val processes: List<GatewayProcess> = emptyList(),
)
/** Historical rows contain references and bounded summaries, never an invented live transcript. */
internal fun ChatActivityRecord.previewActivities(): List<SubagentActivity> = children.mapIndexed { index, child ->
val phase = when (child.phase) {
ChatActivityPhase.RUNNING -> SubagentActivityPhase.STARTED
ChatActivityPhase.COMPLETE -> SubagentActivityPhase.COMPLETED
ChatActivityPhase.FAILED -> SubagentActivityPhase.FAILED
ChatActivityPhase.CANCELLED -> SubagentActivityPhase.INTERRUPTED
ChatActivityPhase.UNKNOWN -> SubagentActivityPhase.ENDED_WITH_PARENT
}
SubagentActivity(
laneId = 0,
turnId = "record:$id:${child.id}",
taskIndex = index,
taskCount = maxOf(taskCount, children.size),
goal = child.goal,
subagentId = child.id,
childSessionId = child.childSessionId,
profile = AgentDisplay.parseProfileContextKey(scopeKey)?.requestProfileName,
phase = phase,
summary = child.summary,
events = child.summary?.let { summary -> listOf(SubagentActivityEvent(
sequence = 0,
kind = SubagentActivityEventKind.COMPLETED,
text = summary,
phase = phase,
observedAtMillis = updatedAt,
)) }.orEmpty(),
partialAfterGap = true,
revision = updatedAt,
)
}
@@ -94,10 +94,6 @@ import com.hermesandroid.relay.network.upstream.GatewayModelOptions
import com.hermesandroid.relay.network.upstream.GatewayProcess import com.hermesandroid.relay.network.upstream.GatewayProcess
import com.hermesandroid.relay.network.upstream.GatewayProcessCapability import com.hermesandroid.relay.network.upstream.GatewayProcessCapability
import com.hermesandroid.relay.network.upstream.GatewayProcessEvent import com.hermesandroid.relay.network.upstream.GatewayProcessEvent
import com.hermesandroid.relay.data.ChatActivityRecord
import com.hermesandroid.relay.data.ChatActivityKind
import com.hermesandroid.relay.data.ChatActivityStore
import com.hermesandroid.relay.data.DataStoreChatActivityStore
import com.hermesandroid.relay.network.upstream.GatewaySessionModel import com.hermesandroid.relay.network.upstream.GatewaySessionModel
import com.hermesandroid.relay.network.upstream.ReasoningEffortAvailability import com.hermesandroid.relay.network.upstream.ReasoningEffortAvailability
import com.hermesandroid.relay.network.upstream.ReasoningEffortIdentity import com.hermesandroid.relay.network.upstream.ReasoningEffortIdentity
@@ -2239,11 +2235,6 @@ class ChatViewModel : ViewModel() {
private var gatewayProcessSource: GatewayProcessSource? = null private var gatewayProcessSource: GatewayProcessSource? = null
private val gatewayProcessController = GatewayProcessController(viewModelScope) private val gatewayProcessController = GatewayProcessController(viewModelScope)
private val subagentActivityController = SubagentActivityController() private val subagentActivityController = SubagentActivityController()
private val chatActivityController = ChatActivityController(viewModelScope)
internal val activityRecords = chatActivityController.records
private var activityStoreInitialized = false
private val _retainedActivityPreview = MutableStateFlow<RetainedChatActivityPreview?>(null)
internal val retainedActivityPreview = _retainedActivityPreview.asStateFlow()
private val subagentChildPreviewController = SubagentChildPreviewController(viewModelScope) private val subagentChildPreviewController = SubagentChildPreviewController(viewModelScope)
internal val subagentChildPreview: StateFlow<SubagentChildPreview?> = internal val subagentChildPreview: StateFlow<SubagentChildPreview?> =
subagentChildPreviewController.state subagentChildPreviewController.state
@@ -2263,10 +2254,7 @@ class ChatViewModel : ViewModel() {
subagentActivityController.activities subagentActivityController.activities
fun openSubagentChildPreview(activityKey: String) { fun openSubagentChildPreview(activityKey: String) {
if (supervisedModePolicy.enabled) return val activity = subagentActivities.value.firstOrNull { it.stableKey == activityKey } ?: return
val history = _retainedActivityPreview.value
val candidates = history?.record?.previewActivities() ?: subagentActivities.value
val activity = candidates.firstOrNull { it.stableKey == activityKey } ?: return
val parentSessionId = chatHandler?.currentSessionId?.value ?: return val parentSessionId = chatHandler?.currentSessionId?.value ?: return
val parentScopeKey = activeProfileContextKey val parentScopeKey = activeProfileContextKey
val client = gatewayClient val client = gatewayClient
@@ -2288,49 +2276,6 @@ class ChatViewModel : ViewModel() {
subagentChildPreviewController.close() subagentChildPreviewController.close()
} }
internal fun setChatActivityStore(store: ChatActivityStore) {
activityStoreInitialized = true
chatActivityController.bindStore(store)
}
fun openCurrentActivityPreview() {
_retainedActivityPreview.value = null
closeSubagentChildPreview()
subagentActivityController.setPreviewOpen(true)
}
internal fun openRetainedActivity(record: ChatActivityRecord, processDetail: String? = null): Boolean {
if (record.scopeKey != activeProfileContextKey || record.sessionId != chatHandler?.currentSessionId?.value) return false
if (supervisedModePolicy.enabled && !supervisedModePolicy.visibility.resolved().showWorkingStatus) return false
closeSubagentChildPreview()
subagentActivityController.setPreviewOpen(false)
val process = if (record.kind == ChatActivityKind.PROCESS) {
val exact = backgroundProcesses.value.singleOrNull {
!supervisedModePolicy.enabled && record.processStartedAt != null &&
it.id == record.processId && it.startedAt == record.processStartedAt
}
exact ?: GatewayProcess(
id = record.processId ?: record.sourceId,
command = if (supervisedModePolicy.enabled) {
appContext?.getString(R.string.chat_activity_receipt_process) ?: "Background command"
} else record.title,
status = record.phase.name.lowercase(),
outputTail = if (supervisedModePolicy.enabled) null else processDetail?.take(4_000)
?: appContext?.getString(R.string.chat_activity_output_unavailable)
?: "Output is no longer available. This entry preserves the recorded process status.",
exitCode = record.exitCode,
)
} else null
_retainedActivityPreview.value = RetainedChatActivityPreview(record, listOfNotNull(process))
return true
}
fun closeActivityPreview() {
closeSubagentChildPreview()
subagentActivityController.setPreviewOpen(false)
_retainedActivityPreview.value = null
}
private val _messageReactionsSupported = MutableStateFlow(true) private val _messageReactionsSupported = MutableStateFlow(true)
val messageReactionsSupported: StateFlow<Boolean> = _messageReactionsSupported.asStateFlow() val messageReactionsSupported: StateFlow<Boolean> = _messageReactionsSupported.asStateFlow()
@@ -2746,12 +2691,10 @@ class ChatViewModel : ViewModel() {
previousClient?.setColdPrewarmSessionReadyListener(null) previousClient?.setColdPrewarmSessionReadyListener(null)
previousClient?.setUnmatchedTurnCompleteListener(null) previousClient?.setUnmatchedTurnCompleteListener(null)
previousClient?.setBackgroundInteractionListener(null) previousClient?.setBackgroundInteractionListener(null)
previousClient?.setSubagentEventListener(null)
previousClient?.setSessionDirectoryInvalidationListener(null) previousClient?.setSessionDirectoryInvalidationListener(null)
} }
gatewayClient = client gatewayClient = client
if (changed) { if (changed) {
chatActivityController.markUnavailable()
dismissChatFailure() dismissChatFailure()
resetApprovalModeState() resetApprovalModeState()
_messageReactionsSupported.value = true _messageReactionsSupported.value = true
@@ -2794,29 +2737,6 @@ class ChatViewModel : ViewModel() {
client?.setUnsolicitedTurnProvider { storedSessionId -> client?.setUnsolicitedTurnProvider { storedSessionId ->
createGatewayInboundTurnRegistration(client, storedSessionId) createGatewayInboundTurnRegistration(client, storedSessionId)
} }
client?.setSubagentEventListener { sessionId, profile, event ->
if (gatewayClient === client && streamingEndpoint == "gateway" &&
chatHandler?.currentSessionId?.value == sessionId &&
currentSessionProfileName() == profile
) {
val owner = AgentDisplay.parseProfileContextKey(activeProfileContextKey)
val checkpointProfileKey = activeTurnCheckpointSeed?.takeIf {
it.sessionId == sessionId && it.contextKey == activeProfileContextKey
}?.profileKey
val previousRevisions = subagentActivities.value.associate { it.stableKey to it.revision }
subagentActivityController.onSessionEvent(
sessionId, activeProfileContextKey, event,
when {
checkpointProfileKey != null -> AgentDisplay.profileRequestName(checkpointProfileKey)
owner != null -> owner.requestProfileName
else -> profile
},
)
chatActivityController.captureSubagents(subagentActivities.value.filter {
previousRevisions[it.stableKey] != it.revision
})
}
}
client?.setSessionDirectoryInvalidationListener { client?.setSessionDirectoryInvalidationListener {
// Gateway emits this only after durable session state changes. It // Gateway emits this only after durable session state changes. It
// is also a useful liveness edge after a Dashboard timeout: retry // is also a useful liveness edge after a Dashboard timeout: retry
@@ -2906,8 +2826,6 @@ class ChatViewModel : ViewModel() {
if (changed) { if (changed) {
gatewayStateSyncJob?.cancel() gatewayStateSyncJob?.cancel()
gatewayStateSyncJob = null gatewayStateSyncJob = null
_gatewayPreparingSessionId.value = null
_gatewaySocketState.value = GatewayConnectionState.Idle
client?.let { startGatewayStateSync(it) } client?.let { startGatewayStateSync(it) }
if (client != null) { if (client != null) {
gatewayVisibleReattachJob = viewModelScope.launch { gatewayVisibleReattachJob = viewModelScope.launch {
@@ -3051,6 +2969,7 @@ class ChatViewModel : ViewModel() {
): GatewayInboundTurnRegistration? { ): GatewayInboundTurnRegistration? {
val handler = chatHandler ?: return null val handler = chatHandler ?: return null
val eventScopeKey = activeProfileContextKey val eventScopeKey = activeProfileContextKey
val eventProfile = currentSessionProfileName()
fun matchesAdmissionContext(): Boolean = fun matchesAdmissionContext(): Boolean =
gatewayClient === client && gatewayClient === client &&
streamingEndpoint == "gateway" && streamingEndpoint == "gateway" &&
@@ -3209,6 +3128,13 @@ class ChatViewModel : ViewModel() {
}, },
onSubagentEvent = { event -> onSubagentEvent = { event ->
if (acceptsEvent()) { if (acceptsEvent()) {
subagentActivityController.onEvent(
sessionId = storedSessionId,
eventScopeKey = eventScopeKey,
turnId = messageId,
event = event,
profile = eventProfile,
)
handler.onSubagentEvent(messageId, event) handler.onSubagentEvent(messageId, event)
} }
}, },
@@ -3947,10 +3873,6 @@ class ChatViewModel : ViewModel() {
sessionId: String?, sessionId: String?,
scopeKey: String? = activeProfileContextKey, scopeKey: String? = activeProfileContextKey,
) { ) {
_retainedActivityPreview.value?.record?.let {
if (it.sessionId != sessionId || it.scopeKey != scopeKey) closeActivityPreview()
}
chatActivityController.selectSession(scopeKey, sessionId)
subagentChildPreview.value?.let { preview -> subagentChildPreview.value?.let { preview ->
if (preview.parentSessionId != sessionId || preview.parentScopeKey != scopeKey) { if (preview.parentSessionId != sessionId || preview.parentScopeKey != scopeKey) {
closeSubagentChildPreview() closeSubagentChildPreview()
@@ -4214,10 +4136,6 @@ class ChatViewModel : ViewModel() {
} }
private var gatewayStateSyncJob: Job? = null private var gatewayStateSyncJob: Job? = null
private val _gatewayPreparingSessionId = MutableStateFlow<String?>(null)
val gatewayPreparingSessionId = _gatewayPreparingSessionId.asStateFlow()
private val _gatewaySocketState = MutableStateFlow(GatewayConnectionState.Idle)
val gatewaySocketState = _gatewaySocketState.asStateFlow()
/** /**
* Last credential_warning already surfaced as a system notice, so the * Last credential_warning already surfaced as a system notice, so the
@@ -4240,18 +4158,7 @@ class ChatViewModel : ViewModel() {
private fun startGatewayStateSync(client: GatewayChatClient) { private fun startGatewayStateSync(client: GatewayChatClient) {
gatewayStateSyncJob?.cancel() gatewayStateSyncJob?.cancel()
lastSurfacedCredentialWarning = null lastSurfacedCredentialWarning = null
gatewayProcessController.setSnapshotListener { processes ->
val sessionId = chatHandler?.currentSessionId?.value
if (gatewayClient === client && sessionId != null &&
gatewayProcessController.ownsSnapshot(sessionId, activeProfileContextKey)
) chatActivityController.captureProcesses(processes)
}
gatewayStateSyncJob = viewModelScope.launch { gatewayStateSyncJob = viewModelScope.launch {
launch {
client.preparingSessionId.collect {
if (gatewayClient === client) _gatewayPreparingSessionId.value = it
}
}
launch { launch {
backgroundProcesses.collect { backgroundProcesses.collect {
if (gatewayClient !== client) return@collect if (gatewayClient !== client) return@collect
@@ -4265,10 +4172,6 @@ class ChatViewModel : ViewModel() {
launch { launch {
client.connectionState.collect { state -> client.connectionState.collect { state ->
if (gatewayClient !== client) return@collect if (gatewayClient !== client) return@collect
if (_gatewaySocketState.value == GatewayConnectionState.Ready && state != GatewayConnectionState.Ready) {
chatActivityController.markUnavailable()
}
_gatewaySocketState.value = state
subagentActivityController.onConnectionReady( subagentActivityController.onConnectionReady(
state == com.hermesandroid.relay.network.upstream.GatewayConnectionState.Ready, state == com.hermesandroid.relay.network.upstream.GatewayConnectionState.Ready,
) )
@@ -4759,7 +4662,6 @@ class ChatViewModel : ViewModel() {
dashboardMediaClientProvider: () -> DashboardApiClient? = { null }, dashboardMediaClientProvider: () -> DashboardApiClient? = { null },
) { ) {
this.appContext = context.applicationContext this.appContext = context.applicationContext
if (!activityStoreInitialized) setChatActivityStore(DataStoreChatActivityStore(context.applicationContext))
if (chatTurnCheckpointStore == null) { if (chatTurnCheckpointStore == null) {
chatTurnCheckpointStore = DataStoreChatTurnCheckpointStore(context.applicationContext) chatTurnCheckpointStore = DataStoreChatTurnCheckpointStore(context.applicationContext)
} }
@@ -4787,7 +4689,6 @@ class ChatViewModel : ViewModel() {
/** Route-owned Gateway chat setup without borrowing the active connection's Relay/media clients. */ /** Route-owned Gateway chat setup without borrowing the active connection's Relay/media clients. */
fun initializeGatewayOnly(context: Context) { fun initializeGatewayOnly(context: Context) {
appContext = context.applicationContext appContext = context.applicationContext
if (!activityStoreInitialized) setChatActivityStore(DataStoreChatActivityStore(context.applicationContext))
if (chatTurnCheckpointStore == null) { if (chatTurnCheckpointStore == null) {
chatTurnCheckpointStore = DataStoreChatTurnCheckpointStore(context.applicationContext) chatTurnCheckpointStore = DataStoreChatTurnCheckpointStore(context.applicationContext)
} }
@@ -6258,7 +6159,6 @@ class ChatViewModel : ViewModel() {
} else { } else {
client?.deleteSession(sessionId) == true client?.deleteSession(sessionId) == true
} }
if (success && contextKey != null) chatActivityController.removeSession(contextKey, sessionId)
if ( if (
activeProfileContextKey != contextKey || activeProfileContextKey != contextKey ||
currentSessionProfileName() != profileName currentSessionProfileName() != profileName
@@ -8221,6 +8121,18 @@ class ChatViewModel : ViewModel() {
}, },
onSubagentEvent = { event -> onSubagentEvent = { event ->
if (owns()) { if (owns()) {
subagentActivityController.onEvent(
sessionId = checkpoint.sessionId,
eventScopeKey = checkpoint.contextKey,
turnId = messageId,
event = event,
profile = if (checkpoint.profileKey != null) {
AgentDisplay.profileRequestName(checkpoint.profileKey)
} else {
AgentDisplay.parseProfileContextKey(checkpoint.contextKey)
?.requestProfileName
},
)
handler.onSubagentEvent(messageId, event) handler.onSubagentEvent(messageId, event)
scheduleCheckpointWrite(immediate = true) scheduleCheckpointWrite(immediate = true)
} }
@@ -10752,6 +10664,13 @@ class ChatViewModel : ViewModel() {
onSubagentEvent = { event -> onSubagentEvent = { event ->
ensurePostInterimMessage() ensurePostInterimMessage()
streamDeltas.flushNow() streamDeltas.flushNow()
subagentActivityController.onEvent(
sessionId = handler.currentSessionId.value,
eventScopeKey = activeProfileContextKey,
turnId = currentMessageId,
event = event,
profile = currentSessionProfileName(),
)
handler.onSubagentEvent(currentMessageId, event) handler.onSubagentEvent(currentMessageId, event)
scheduleCheckpointWrite(immediate = true) scheduleCheckpointWrite(immediate = true)
}, },
@@ -10912,10 +10831,6 @@ class ChatViewModel : ViewModel() {
fun cancelStream() { fun cancelStream() {
intentionallyCancelled = true intentionallyCancelled = true
if (activeStream != null) {
subagentActivityController.interrupt()
chatActivityController.captureSubagents(subagentActivities.value)
}
currentQueueDestination()?.let { destination -> currentQueueDestination()?.let { destination ->
if (queuedMessageItems.any { it.contextKey to it.sessionId == destination }) { if (queuedMessageItems.any { it.contextKey to it.sessionId == destination }) {
pausedQueueDestinations += destination pausedQueueDestinations += destination
@@ -11451,8 +11366,9 @@ class ChatViewModel : ViewModel() {
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
// Attachment owns failure and retry. History hydration can // Classifier produces a specific label (disk full, bad
// fetch many files; never queue a global popup per file. // URI, permission, …) for both the in-card text and the
// global snackbar — same event, two surfaces.
val human = classifyError(e, context = "media_fetch", ctx = appContext) val human = classifyError(e, context = "media_fetch", ctx = appContext)
updateAttachmentByToken(handler, messageId, fetchKey, expectedRole = expectedRole) { att -> updateAttachmentByToken(handler, messageId, fetchKey, expectedRole = expectedRole) { att ->
att.copy( att.copy(
@@ -11460,6 +11376,7 @@ class ChatViewModel : ViewModel() {
errorMessage = human.body errorMessage = human.body
) )
} }
_errorEvents.tryEmit(human)
} }
}, },
onFailure = { err -> onFailure = { err ->
@@ -11479,6 +11396,7 @@ class ChatViewModel : ViewModel() {
errorMessage = human.body errorMessage = human.body
) )
} }
_errorEvents.tryEmit(human)
} }
) )
} }
@@ -11743,7 +11661,6 @@ class ChatViewModel : ViewModel() {
gatewayClient?.setColdPrewarmSessionReadyListener(null) gatewayClient?.setColdPrewarmSessionReadyListener(null)
gatewayClient?.setUnmatchedTurnCompleteListener(null) gatewayClient?.setUnmatchedTurnCompleteListener(null)
gatewayClient?.setBackgroundInteractionListener(null) gatewayClient?.setBackgroundInteractionListener(null)
gatewayClient?.setSubagentEventListener(null)
backgroundPendingInteractions.clear() backgroundPendingInteractions.clear()
backgroundNeedsInputKeys.clear() backgroundNeedsInputKeys.clear()
publishBackgroundSessionActivity() publishBackgroundSessionActivity()
@@ -517,22 +517,11 @@ internal fun resolveEffectiveDashboardUrl(
connection.authenticatedDashboardOrigin connection.authenticatedDashboardOrigin
?.let(::normalizeCredentialFreeAuthenticatedDashboardOrigin) ?.let(::normalizeCredentialFreeAuthenticatedDashboardOrigin)
?.let { return it } ?.let { return it }
// The resolver publishes independently of the active connection. During a endpoint?.pluginProxyRoutesOrNull()?.dashboardBaseUrl?.let { return it }
// switch its last winner can still belong to the outgoing installation. endpoint?.dashboard?.url
// Never use that winner as authority for the incoming connection's bearer.
val routes = connection.routeCandidates.ifEmpty {
Connection.buildRouteCandidates(
apiServerUrl = connection.apiServerUrl,
relayUrl = connection.relayUrl,
dashboardUrl = connection.configuredDashboardUrl,
)
}
val ownedEndpoint = endpoint?.takeIf { it in routes }
ownedEndpoint?.pluginProxyRoutesOrNull()?.dashboardBaseUrl?.let { return it }
ownedEndpoint?.dashboard?.url
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?.let { return it } ?.let { return it }
ownedEndpoint?.api?.url?.let { apiUrl -> endpoint?.api?.url?.let { apiUrl ->
connection.dashboardUrl connection.dashboardUrl
?.takeIf { it.isNotBlank() && Connection.urlsShareHost(it, apiUrl) } ?.takeIf { it.isNotBlank() && Connection.urlsShareHost(it, apiUrl) }
?.let { return it } ?.let { return it }
@@ -1758,20 +1747,15 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
/** /**
* Dashboard URL for the active connection **on the currently-resolved * Dashboard URL for the active connection **on the currently-resolved
* route**. Read the authoritative id/list synchronously, not the combined * route** — snapshot twin of [effectiveDashboardUrl], which it delegates
* [effectiveDashboardUrl] StateFlow: its previous emission can outlive a * to. Standard voice and the availability probe read this per call, so
* connection switch and must not authorize the new owner's credentials.
* Standard voice and the availability probe read this per call, so
* an auto-managed dashboard URL follows LAN/Tailscale handoffs the same * an auto-managed dashboard URL follows LAN/Tailscale handoffs the same
* way Manage does; an explicit dashboard override stays pinned. (This * way Manage does; an explicit dashboard override stays pinned. (This
* used to read the persisted `resolvedDashboardUrl`, which kept voice * used to read the persisted `resolvedDashboardUrl`, which kept voice
* aimed at the LAN host after the resolver had moved chat to Tailscale.) * aimed at the LAN host after the resolver had moved chat to Tailscale.)
*/ */
fun activeDashboardUrl(): String? = fun activeDashboardUrl(): String? =
resolveEffectiveDashboardUrl( effectiveDashboardUrl.value.takeIf { it.isNotBlank() }
connection = activeConnectionSnapshot(),
endpoint = connectionManager.activeEndpoint.value,
).takeIf { it.isNotBlank() }
/** /**
* Promote the exact reviewed Dashboard origin that completed cookie/OIDC * Promote the exact reviewed Dashboard origin that completed cookie/OIDC
@@ -62,10 +62,6 @@ internal class GatewayProcessController(
private var generation = 0L private var generation = 0L
private var refreshSequence = 0L private var refreshSequence = 0L
private var allProcesses: List<GatewayProcess> = emptyList() private var allProcesses: List<GatewayProcess> = emptyList()
private var snapshotListener: ((List<GatewayProcess>) -> Unit)? = null
/** Only successful process.list results, never cached output-tail changes. */
fun setSnapshotListener(listener: ((List<GatewayProcess>) -> Unit)?) { snapshotListener = listener }
/** A dismissal applies only to this concrete process identity. */ /** A dismissal applies only to this concrete process identity. */
private val dismissedIdentities = mutableMapOf<String, ProcessIdentity>() private val dismissedIdentities = mutableMapOf<String, ProcessIdentity>()
@@ -183,7 +179,6 @@ internal class GatewayProcessController(
} }
fun close() { fun close() {
snapshotListener = null
source?.setEventListener(null) source?.setEventListener(null)
source = null source = null
capabilityJob?.cancel() capabilityJob?.cancel()
@@ -234,7 +229,6 @@ internal class GatewayProcessController(
allProcesses = coalesced allProcesses = coalesced
publishVisibleSnapshot() publishVisibleSnapshot()
updatePoller() updatePoller()
snapshotListener?.invoke(coalesced)
} }
private fun publishVisibleSnapshot() { private fun publishVisibleSnapshot() {
@@ -30,8 +30,9 @@ internal data class SubagentActivityEvent(
/** /**
* A bounded, ephemeral projection of parent-session `subagent.*` events. * A bounded, ephemeral projection of parent-session `subagent.*` events.
* *
* The owning profile/session outlives individual parent turns. Child identity * This is intentionally not a child transcript. Upstream currently exposes no
* keeps detached work visible until its own terminal event arrives. * durable child-session key or child-history route, so the projection is owned
* by the exact profile-scoped parent session and parent turn that emitted it.
*/ */
internal data class SubagentActivity( internal data class SubagentActivity(
val laneId: Long, val laneId: Long,
@@ -40,7 +41,6 @@ internal data class SubagentActivity(
val taskCount: Int, val taskCount: Int,
val goal: String, val goal: String,
val subagentId: String? = null, val subagentId: String? = null,
val delegationId: String? = null,
val childSessionId: String? = null, val childSessionId: String? = null,
val parentId: String? = null, val parentId: String? = null,
val depth: Int? = null, val depth: Int? = null,
@@ -91,9 +91,6 @@ internal class SubagentActivityController(
private var laneSequence = 0L private var laneSequence = 0L
private var connectionWasReady = false private var connectionWasReady = false
private var pendingGap = false private var pendingGap = false
private var interrupted = false
private var previewOpen = false
private val retiredChildIds = linkedSetOf<String>()
fun selectSession(sessionId: String?, newScopeKey: String?) { fun selectSession(sessionId: String?, newScopeKey: String?) {
if (storedSessionId == sessionId && scopeKey == newScopeKey) return if (storedSessionId == sessionId && scopeKey == newScopeKey) return
@@ -104,16 +101,10 @@ internal class SubagentActivityController(
laneSequence = 0L laneSequence = 0L
connectionWasReady = false connectionWasReady = false
pendingGap = false pendingGap = false
interrupted = false
previewOpen = false
retiredChildIds.clear()
_activities.value = emptyList() _activities.value = emptyList()
} }
fun resetConnection() { fun resetConnection() {
interrupted = false
previewOpen = false
retiredChildIds.clear()
activeTurnId = null activeTurnId = null
sequence = 0L sequence = 0L
laneSequence = 0L laneSequence = 0L
@@ -142,34 +133,9 @@ internal class SubagentActivityController(
if (sessionId == null || sessionId != storedSessionId || eventScopeKey != scopeKey) return if (sessionId == null || sessionId != storedSessionId || eventScopeKey != scopeKey) return
if (activeTurnId == turnId) return if (activeTurnId == turnId) return
activeTurnId = turnId activeTurnId = turnId
interrupted = false sequence = 0L
_activities.value.filter { it.isTerminal }.forEach { activity -> laneSequence = 0L
activity.subagentId?.let(retiredChildIds::add) _activities.value = emptyList()
activity.childSessionId?.let(retiredChildIds::add)
}
while (retiredChildIds.size > 256) retiredChildIds.remove(retiredChildIds.first())
val visibleTerminalKeys = _activities.value.filter { it.isTerminal }.takeLast(32)
.mapTo(HashSet()) { it.stableKey }
_activities.value = _activities.value.filter {
(previewOpen && it.stableKey in visibleTerminalKeys) ||
(!it.isTerminal && (!it.subagentId.isNullOrBlank() || !it.childSessionId.isNullOrBlank()))
}
}
fun setPreviewOpen(open: Boolean) {
previewOpen = open
if (!open) _activities.value = _activities.value.filterNot { it.isTerminal }
}
fun onSessionEvent(sessionId: String, eventScopeKey: String?, event: GatewaySubagentEvent, profile: String?) {
onEvent(sessionId, eventScopeKey, activeTurnId ?: "session", event, profile)
}
fun interrupt() {
interrupted = true
_activities.value = _activities.value.map {
if (it.isTerminal) it else it.copy(phase = SubagentActivityPhase.INTERRUPTED, revision = it.revision + 1)
}
} }
fun onEvent( fun onEvent(
@@ -180,21 +146,18 @@ internal class SubagentActivityController(
profile: String? = null, profile: String? = null,
) { ) {
if (sessionId == null || sessionId != storedSessionId || eventScopeKey != scopeKey) return if (sessionId == null || sessionId != storedSessionId || eventScopeKey != scopeKey) return
if (interrupted) return if (activeTurnId != turnId) return
val taskIndex = event.taskIndex.coerceAtLeast(0) val taskIndex = event.taskIndex.coerceAtLeast(0)
val eventIdentity = event.subagentId?.takeIf(String::isNotBlank) val eventIdentity = event.subagentId?.takeIf(String::isNotBlank)
?: event.childSessionId?.takeIf(String::isNotBlank) ?: event.childSessionId?.takeIf(String::isNotBlank)
if (eventIdentity != null && eventIdentity in retiredChildIds) return
val identityMatch = eventIdentity?.let { identity -> val identityMatch = eventIdentity?.let { identity ->
_activities.value.firstOrNull { _activities.value.firstOrNull {
it.subagentId == identity || it.childSessionId == identity it.subagentId == identity || it.childSessionId == identity
} }
} }
val compatibleIndexMatches = _activities.value.filter { activity -> val compatibleIndexMatches = _activities.value.filter { activity ->
activity.turnId == turnId && activity.taskIndex == taskIndex && activity.taskIndex == taskIndex &&
(event.delegationId.isNullOrBlank() || activity.delegationId.isNullOrBlank() ||
event.delegationId == activity.delegationId) &&
(event.subagentId.isNullOrBlank() || activity.subagentId.isNullOrBlank() || (event.subagentId.isNullOrBlank() || activity.subagentId.isNullOrBlank() ||
event.subagentId == activity.subagentId) && event.subagentId == activity.subagentId) &&
(event.childSessionId.isNullOrBlank() || activity.childSessionId.isNullOrBlank() || (event.childSessionId.isNullOrBlank() || activity.childSessionId.isNullOrBlank() ||
@@ -204,14 +167,13 @@ internal class SubagentActivityController(
(event.depth == null || activity.depth == null || event.depth == activity.depth) (event.depth == null || activity.depth == null || event.depth == activity.depth)
} }
val current = identityMatch ?: compatibleIndexMatches.singleOrNull() val current = identityMatch ?: compatibleIndexMatches.singleOrNull()
if (activeTurnId != turnId && identityMatch == null && eventIdentity == null) return if (
if (current?.isTerminal == true) return current?.isTerminal == true &&
// Progress and completion cannot invent children when their start was missed. event.phase != GatewaySubagentEvent.Phase.SPAWN_REQUESTED &&
if (current == null && event.phase != GatewaySubagentEvent.Phase.SPAWN_REQUESTED &&
event.phase != GatewaySubagentEvent.Phase.START event.phase != GatewaySubagentEvent.Phase.START
) return ) return
val base = current val base = if (current?.isTerminal == true) null else current
val phase = event.toActivityPhase() val phase = event.toActivityPhase()
val goal = sanitize(event.goal, MAX_GOAL_CHARS) val goal = sanitize(event.goal, MAX_GOAL_CHARS)
val preview = sanitize(event.preview, MAX_EVENT_TEXT_CHARS).ifBlank { null } val preview = sanitize(event.preview, MAX_EVENT_TEXT_CHARS).ifBlank { null }
@@ -244,17 +206,16 @@ internal class SubagentActivityController(
val (boundedEvents, truncated) = boundEvents(appended) val (boundedEvents, truncated) = boundEvents(appended)
val next = SubagentActivity( val next = SubagentActivity(
laneId = base?.laneId ?: laneSequence++, laneId = base?.laneId ?: laneSequence++,
turnId = base?.turnId ?: turnId, turnId = turnId,
taskIndex = taskIndex, taskIndex = taskIndex,
taskCount = maxOf(1, event.taskCount, base?.taskCount ?: 1), taskCount = maxOf(1, event.taskCount, base?.taskCount ?: 1),
goal = goal.ifBlank { base?.goal.orEmpty() }, goal = goal.ifBlank { base?.goal.orEmpty() },
subagentId = event.subagentId?.takeIf(String::isNotBlank) ?: base?.subagentId, subagentId = event.subagentId?.takeIf(String::isNotBlank) ?: base?.subagentId,
delegationId = event.delegationId?.takeIf(String::isNotBlank) ?: base?.delegationId,
childSessionId = event.childSessionId?.takeIf(String::isNotBlank) ?: base?.childSessionId, childSessionId = event.childSessionId?.takeIf(String::isNotBlank) ?: base?.childSessionId,
parentId = event.parentId?.takeIf(String::isNotBlank) ?: base?.parentId, parentId = event.parentId?.takeIf(String::isNotBlank) ?: base?.parentId,
depth = event.depth ?: base?.depth, depth = event.depth ?: base?.depth,
model = event.model?.takeIf(String::isNotBlank) ?: base?.model, model = event.model?.takeIf(String::isNotBlank) ?: base?.model,
profile = if (base != null) base.profile else profile?.takeIf(String::isNotBlank), profile = profile?.takeIf(String::isNotBlank) ?: base?.profile,
phase = phase, phase = phase,
summary = summary ?: base?.summary, summary = summary ?: base?.summary,
durationSeconds = event.durationSeconds ?: base?.durationSeconds, durationSeconds = event.durationSeconds ?: base?.durationSeconds,
@@ -270,9 +231,7 @@ internal class SubagentActivityController(
fun endTurn(turnId: String) { fun endTurn(turnId: String) {
if (activeTurnId != turnId) return if (activeTurnId != turnId) return
_activities.value = _activities.value.map { activity -> _activities.value = _activities.value.map { activity ->
if (activity.isTerminal || !activity.subagentId.isNullOrBlank() || if (activity.isTerminal) activity else activity.copy(
!activity.childSessionId.isNullOrBlank()
) activity else activity.copy(
phase = SubagentActivityPhase.ENDED_WITH_PARENT, phase = SubagentActivityPhase.ENDED_WITH_PARENT,
partialAfterGap = true, partialAfterGap = true,
revision = activity.revision + 1, revision = activity.revision + 1,
@@ -302,9 +261,9 @@ private fun GatewaySubagentEvent.toActivityPhase(): SubagentActivityPhase = when
GatewaySubagentEvent.Phase.TOOL -> SubagentActivityPhase.TOOL GatewaySubagentEvent.Phase.TOOL -> SubagentActivityPhase.TOOL
GatewaySubagentEvent.Phase.PROGRESS -> SubagentActivityPhase.PROGRESS GatewaySubagentEvent.Phase.PROGRESS -> SubagentActivityPhase.PROGRESS
GatewaySubagentEvent.Phase.COMPLETE -> when (status?.trim()?.lowercase()) { GatewaySubagentEvent.Phase.COMPLETE -> when (status?.trim()?.lowercase()) {
"completed", "complete" -> SubagentActivityPhase.COMPLETED "failed", "error" -> SubagentActivityPhase.FAILED
"interrupted", "cancelled", "canceled" -> SubagentActivityPhase.INTERRUPTED "interrupted", "cancelled", "canceled" -> SubagentActivityPhase.INTERRUPTED
else -> SubagentActivityPhase.FAILED else -> SubagentActivityPhase.COMPLETED
} }
} }
@@ -4,8 +4,7 @@ import android.app.Application
import android.content.Context import android.content.Context
import android.os.SystemClock import android.os.SystemClock
import android.util.Log import android.util.Log
import com.hermesandroid.relay.ui.UiMessageBus import android.widget.Toast
import com.hermesandroid.relay.ui.UiMessageSeverity
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.hermesandroid.relay.R import com.hermesandroid.relay.R
@@ -2640,23 +2639,28 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
// route is unavailable. Runs outside the turn state machine so it // route is unavailable. Runs outside the turn state machine so it
// doesn't disturb uiState. // doesn't disturb uiState.
// //
// Keyed progress feedback is cleared before an outcome replaces it. // Three toasts so the user knows what's happening: "Testing voice…" on
// Classified failures retain their existing error event/overlay flow. // trigger, "Voice test successful" on completion, "Voice test failed" on
// any error. The trigger toast is held in [triggerToast] so it can be
// cancelled the moment the result toast fires — without that the two
// would briefly overlap on screen. viewModelScope.launch defaults to
// Main.immediate so Toast.show() is safe inline without a dispatcher
// switch.
fun testVoice( fun testVoice(
sample: String = "Hello, this is Hermes. Voice mode is working.", sample: String = "Hello, this is Hermes. Voice mode is working.",
onResult: (Result<Unit>) -> Unit = {}, onResult: (Result<Unit>) -> Unit = {},
) { ) {
val app = getApplication<Application>()
val audioClient = voiceAudioClient val audioClient = voiceAudioClient
val relayClient = voiceClient val relayClient = voiceClient
val p = player val p = player
if (audioClient == null || p == null) { if (audioClient == null || p == null) {
onResult(Result.failure(IllegalStateException("Voice pipeline not initialized"))) onResult(Result.failure(IllegalStateException("Voice pipeline not initialized")))
UiMessageBus.error("Voice test failed: pipeline not initialized") Toast.makeText(app, "Voice test failed: pipeline not initialized", Toast.LENGTH_SHORT).show()
setError("Voice pipeline not initialized") setError("Voice pipeline not initialized")
return return
} }
val feedbackKey = "voice-test-${System.nanoTime()}" val triggerToast = Toast.makeText(app, "Testing voice…", Toast.LENGTH_SHORT).also { it.show() }
UiMessageBus.post("Testing voice…", severity = UiMessageSeverity.Status, ttlMillis = 0L, key = feedbackKey)
viewModelScope.launch { viewModelScope.launch {
val profileAwareResult = if (audioClient.route == VoiceAudioRoute.Relay && relayClient != null) { val profileAwareResult = if (audioClient.route == VoiceAudioRoute.Relay && relayClient != null) {
testVoiceViaVoiceOutput(relayClient, sample) testVoiceViaVoiceOutput(relayClient, sample)
@@ -2665,9 +2669,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
} }
val result = if (profileAwareResult != null) { val result = if (profileAwareResult != null) {
if (profileAwareResult.isSuccess) { if (profileAwareResult.isSuccess) {
UiMessageBus.clear(feedbackKey) triggerToast.cancel()
onResult(Result.success(Unit)) onResult(Result.success(Unit))
UiMessageBus.success("Voice test successful") Toast.makeText(app, "Voice test successful", Toast.LENGTH_SHORT).show()
return@launch return@launch
} }
Log.w(TAG, "profile-aware voice test failed; falling back to legacy synthesize: ${profileAwareResult.exceptionOrNull()?.message}") Log.w(TAG, "profile-aware voice test failed; falling back to legacy synthesize: ${profileAwareResult.exceptionOrNull()?.message}")
@@ -2676,35 +2680,34 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
audioClient.synthesize(sample) audioClient.synthesize(sample)
} }
if (result.isFailure) { if (result.isFailure) {
UiMessageBus.clear(feedbackKey) triggerToast.cancel()
val msg = result.exceptionOrNull()?.message ?: "synthesize failed" val msg = result.exceptionOrNull()?.message ?: "synthesize failed"
onResult(Result.failure(result.exceptionOrNull() ?: IllegalStateException(msg))) onResult(Result.failure(result.exceptionOrNull() ?: IllegalStateException(msg)))
Toast.makeText(app, "Voice test failed: $msg", Toast.LENGTH_LONG).show()
surfaceError(result.exceptionOrNull(), context = "synthesize") surfaceError(result.exceptionOrNull(), context = "synthesize")
return@launch return@launch
} }
val file = result.getOrNull() val file = result.getOrNull()
if (file == null) { if (file == null) {
UiMessageBus.clear(feedbackKey) triggerToast.cancel()
onResult(Result.failure(IllegalStateException("No audio returned"))) onResult(Result.failure(IllegalStateException("No audio returned")))
UiMessageBus.error("Voice test failed: no audio returned") Toast.makeText(app, "Voice test failed: no audio returned", Toast.LENGTH_LONG).show()
return@launch return@launch
} }
trackTtsFile(file) trackTtsFile(file)
try { try {
p.play(file) p.play(file)
p.awaitCompletion() p.awaitCompletion()
UiMessageBus.clear(feedbackKey) triggerToast.cancel()
onResult(Result.success(Unit)) onResult(Result.success(Unit))
UiMessageBus.success("Voice test successful") Toast.makeText(app, "Voice test successful", Toast.LENGTH_SHORT).show()
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "test playback failed: ${e.message}") Log.w(TAG, "test playback failed: ${e.message}")
UiMessageBus.clear(feedbackKey) triggerToast.cancel()
onResult(Result.failure(e)) onResult(Result.failure(e))
UiMessageBus.error("Voice test failed: ${e.message ?: "playback error"}") Toast.makeText(app, "Voice test failed: ${e.message ?: "playback error"}", Toast.LENGTH_LONG).show()
} }
}.invokeOnCompletion { UiMessageBus.clear(feedbackKey) } }
} }
/** /**
@@ -2927,16 +2930,17 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
sample: String = "Say a short confirmation that Hermes Realtime Agent is working.", sample: String = "Say a short confirmation that Hermes Realtime Agent is working.",
onResult: (Result<Unit>) -> Unit = {}, onResult: (Result<Unit>) -> Unit = {},
) { ) {
val app = getApplication<Application>()
val client = voiceClient val client = voiceClient
val pcmPlayer = realtimePcmPlayer val pcmPlayer = realtimePcmPlayer
if (client == null || pcmPlayer == null) { if (client == null || pcmPlayer == null) {
onResult(Result.failure(IllegalStateException("Voice pipeline not initialized"))) onResult(Result.failure(IllegalStateException("Voice pipeline not initialized")))
UiMessageBus.error("Realtime test failed: pipeline not initialized") Toast.makeText(app, "Realtime test failed: pipeline not initialized", Toast.LENGTH_SHORT).show()
setError("Voice pipeline not initialized") setError("Voice pipeline not initialized")
return return
} }
val feedbackKey = "realtime-test-${System.nanoTime()}" val triggerToast = Toast.makeText(app, "Testing Realtime Agent...", Toast.LENGTH_SHORT)
UiMessageBus.post("Testing Realtime Agent...", severity = UiMessageSeverity.Status, ttlMillis = 0L, key = feedbackKey) .also { it.show() }
viewModelScope.launch { viewModelScope.launch {
DiagnosticsLog.record( DiagnosticsLog.record(
category = DiagnosticCategory.Voice, category = DiagnosticCategory.Voice,
@@ -2966,11 +2970,12 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
audioBytes.addAndGet(audio.size) audioBytes.addAndGet(audio.size)
pcmPlayer.write(audio, rate) pcmPlayer.write(audio, rate)
} }
UiMessageBus.clear(feedbackKey) triggerToast.cancel()
if (result.isFailure) { if (result.isFailure) {
pcmPlayer.stop() pcmPlayer.stop()
val msg = result.exceptionOrNull()?.message ?: "realtime agent failed" val msg = result.exceptionOrNull()?.message ?: "realtime agent failed"
onResult(Result.failure(result.exceptionOrNull() ?: IllegalStateException(msg))) onResult(Result.failure(result.exceptionOrNull() ?: IllegalStateException(msg)))
Toast.makeText(app, "Realtime test failed: $msg", Toast.LENGTH_LONG).show()
DiagnosticsLog.record( DiagnosticsLog.record(
category = DiagnosticCategory.Voice, category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Error, severity = DiagnosticSeverity.Error,
@@ -2983,7 +2988,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
if (audioBytes.get() <= 0) { if (audioBytes.get() <= 0) {
pcmPlayer.stop() pcmPlayer.stop()
onResult(Result.failure(IllegalStateException("Provider returned no audio"))) onResult(Result.failure(IllegalStateException("Provider returned no audio")))
UiMessageBus.error("Realtime test failed: no audio returned") Toast.makeText(app, "Realtime test failed: no audio returned", Toast.LENGTH_LONG).show()
DiagnosticsLog.record( DiagnosticsLog.record(
category = DiagnosticCategory.Voice, category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Error, severity = DiagnosticSeverity.Error,
@@ -2997,14 +3002,14 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
delay(drainMs) delay(drainMs)
pcmPlayer.stop() pcmPlayer.stop()
onResult(Result.success(Unit)) onResult(Result.success(Unit))
UiMessageBus.success("Realtime test successful") Toast.makeText(app, "Realtime test successful", Toast.LENGTH_SHORT).show()
DiagnosticsLog.record( DiagnosticsLog.record(
category = DiagnosticCategory.Voice, category = DiagnosticCategory.Voice,
severity = DiagnosticSeverity.Info, severity = DiagnosticSeverity.Info,
title = getApplication<Application>().getString(R.string.voice_status_test_complete), title = getApplication<Application>().getString(R.string.voice_status_test_complete),
detail = "${audioBytes.get()} bytes streamed", detail = "${audioBytes.get()} bytes streamed",
) )
}.invokeOnCompletion { UiMessageBus.clear(feedbackKey) } }
} }
private suspend fun testVoiceViaVoiceOutput( private suspend fun testVoiceViaVoiceOutput(
@@ -6668,7 +6673,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
_uiState.update { _uiState.update {
voiceNoSpeechState(it) voiceNoSpeechState(it)
} }
UiMessageBus.warning(message) Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
} }
/** /**
@@ -4429,14 +4429,4 @@
<string name="chat_queue_paused">Fila pausada</string> <string name="chat_queue_paused">Fila pausada</string>
<string name="chat_queue_resume">Retomar</string> <string name="chat_queue_resume">Retomar</string>
<string name="chat_queue_remove">Remover mensagem da fila</string> <string name="chat_queue_remove">Remover mensagem da fila</string>
<string name="chat_activity_receipt_subagents">Subagentes</string>
<string name="chat_activity_receipt_process">Comando em segundo plano</string>
<string name="chat_activity_receipt_view_activity">Ver atividade</string>
<string name="chat_activity_receipt_view_output">Ver saída</string>
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
<string name="chat_activity_history_title">Atividade do chat</string>
<string name="chat_activity_history_empty">A atividade detalhada não foi mantida neste dispositivo. O registro de conclusão permanece no chat.</string>
<string name="tool_progress_status_dispatched">Enviado</string>
<string name="chat_activity_history_notice">Atividade registrada. As atualizações de progresso não são mantidas; o histórico disponível dos subagentes pode ser aberto somente para leitura.</string>
<string name="chat_activity_output_unavailable">A saída não está mais disponível. Este registro preserva o estado registrado do processo.</string>
</resources> </resources>
@@ -4510,14 +4510,4 @@
<string name="chat_queue_paused">队列已暂停</string> <string name="chat_queue_paused">队列已暂停</string>
<string name="chat_queue_resume">继续</string> <string name="chat_queue_resume">继续</string>
<string name="chat_queue_remove">移除队列消息</string> <string name="chat_queue_remove">移除队列消息</string>
<string name="chat_activity_receipt_subagents">子代理</string>
<string name="chat_activity_receipt_process">后台命令</string>
<string name="chat_activity_receipt_view_activity">查看活动</string>
<string name="chat_activity_receipt_view_output">查看输出</string>
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
<string name="chat_activity_history_title">聊天活动</string>
<string name="chat_activity_history_empty">此设备未保留详细活动。完成记录仍保留在聊天中。</string>
<string name="tool_progress_status_dispatched">已分派</string>
<string name="chat_activity_history_notice">已记录的活动。进度更新不会保留;可用的子代理历史记录可以以只读方式打开。</string>
<string name="chat_activity_output_unavailable">输出已不可用。此条目保留了记录的进程状态。</string>
</resources> </resources>
-10
View File
@@ -4586,14 +4586,4 @@
<string name="chat_queue_paused">Warteschlange pausiert</string> <string name="chat_queue_paused">Warteschlange pausiert</string>
<string name="chat_queue_resume">Fortsetzen</string> <string name="chat_queue_resume">Fortsetzen</string>
<string name="chat_queue_remove">Nachricht aus Warteschlange entfernen</string> <string name="chat_queue_remove">Nachricht aus Warteschlange entfernen</string>
<string name="chat_activity_receipt_subagents">Unteragenten</string>
<string name="chat_activity_receipt_process">Hintergrundbefehl</string>
<string name="chat_activity_receipt_view_activity">Aktivität anzeigen</string>
<string name="chat_activity_receipt_view_output">Ausgabe anzeigen</string>
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
<string name="chat_activity_history_title">Chat-Aktivität</string>
<string name="chat_activity_history_empty">Detaillierte Aktivitäten wurden auf diesem Gerät nicht gespeichert. Der Abschlusseintrag bleibt im Chat.</string>
<string name="tool_progress_status_dispatched">Übergeben</string>
<string name="chat_activity_history_notice">Gespeicherte Aktivität. Fortschrittsmeldungen werden nicht gespeichert; der verfügbare Verlauf der Unteragenten kann schreibgeschützt geöffnet werden.</string>
<string name="chat_activity_output_unavailable">Die Ausgabe ist nicht mehr verfügbar. Dieser Eintrag enthält den gespeicherten Prozessstatus.</string>
</resources> </resources>
-10
View File
@@ -4277,14 +4277,4 @@
<string name="chat_queue_paused">Cola pausada</string> <string name="chat_queue_paused">Cola pausada</string>
<string name="chat_queue_resume">Reanudar</string> <string name="chat_queue_resume">Reanudar</string>
<string name="chat_queue_remove">Quitar mensaje de la cola</string> <string name="chat_queue_remove">Quitar mensaje de la cola</string>
<string name="chat_activity_receipt_subagents">Subagentes</string>
<string name="chat_activity_receipt_process">Comando en segundo plano</string>
<string name="chat_activity_receipt_view_activity">Ver actividad</string>
<string name="chat_activity_receipt_view_output">Ver salida</string>
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
<string name="chat_activity_history_title">Actividad del chat</string>
<string name="chat_activity_history_empty">La actividad detallada no se guardó en este dispositivo. La entrada de finalización permanece en el chat.</string>
<string name="tool_progress_status_dispatched">Enviado</string>
<string name="chat_activity_history_notice">Actividad registrada. No se conservan las actualizaciones de progreso; el historial disponible de los subagentes se puede abrir en modo de solo lectura.</string>
<string name="chat_activity_output_unavailable">La salida ya no está disponible. Esta entrada conserva el estado registrado del proceso.</string>
</resources> </resources>
-10
View File
@@ -4581,14 +4581,4 @@
<string name="chat_queue_paused">キューを一時停止中</string> <string name="chat_queue_paused">キューを一時停止中</string>
<string name="chat_queue_resume">再開</string> <string name="chat_queue_resume">再開</string>
<string name="chat_queue_remove">キューからメッセージを削除</string> <string name="chat_queue_remove">キューからメッセージを削除</string>
<string name="chat_activity_receipt_subagents">サブエージェント</string>
<string name="chat_activity_receipt_process">バックグラウンドコマンド</string>
<string name="chat_activity_receipt_view_activity">アクティビティを表示</string>
<string name="chat_activity_receipt_view_output">出力を表示</string>
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
<string name="chat_activity_history_title">チャットのアクティビティ</string>
<string name="chat_activity_history_empty">詳細なアクティビティはこのデバイスに保存されていません。完了の記録はチャットに残っています。</string>
<string name="tool_progress_status_dispatched">ディスパッチ済み</string>
<string name="chat_activity_history_notice">記録されたアクティビティです。進捗の更新は保存されません。利用可能なサブエージェントの履歴は読み取り専用で開けます。</string>
<string name="chat_activity_output_unavailable">出力は利用できなくなりました。この項目には記録されたプロセスの状態が残っています。</string>
</resources> </resources>
-10
View File
@@ -4325,14 +4325,4 @@
<string name="chat_queue_paused">Очередь приостановлена</string> <string name="chat_queue_paused">Очередь приостановлена</string>
<string name="chat_queue_resume">Продолжить</string> <string name="chat_queue_resume">Продолжить</string>
<string name="chat_queue_remove">Удалить сообщение из очереди</string> <string name="chat_queue_remove">Удалить сообщение из очереди</string>
<string name="chat_activity_receipt_subagents">Субагенты</string>
<string name="chat_activity_receipt_process">Фоновая команда</string>
<string name="chat_activity_receipt_view_activity">Посмотреть активность</string>
<string name="chat_activity_receipt_view_output">Посмотреть вывод</string>
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
<string name="chat_activity_history_title">Активность чата</string>
<string name="chat_activity_history_empty">Подробная активность не сохранена на этом устройстве. Запись о завершении остаётся в чате.</string>
<string name="tool_progress_status_dispatched">Отправлено</string>
<string name="chat_activity_history_notice">Сохранённая активность. Обновления хода работы не сохраняются; доступную историю субагентов можно открыть только для чтения.</string>
<string name="chat_activity_output_unavailable">Вывод больше недоступен. Эта запись сохраняет зафиксированное состояние процесса.</string>
</resources> </resources>
-52
View File
@@ -1,33 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="chat_debug_title" translatable="false">Session diagnostics</string>
<string name="chat_debug_open" translatable="false">Open session diagnostics</string>
<string name="chat_debug_close" translatable="false">Close session diagnostics</string>
<string name="chat_debug_preparing" translatable="false">Preparing session…</string>
<string name="chat_debug_sign_in" translatable="false">Dashboard sign-in</string>
<string name="chat_debug_sign_in_needed" translatable="false">Sign in to continue this conversation.</string>
<string name="chat_debug_authenticated" translatable="false">Authenticated for this connection.</string>
<string name="chat_debug_auth_unknown" translatable="false">Sign-in has not been confirmed.</string>
<string name="chat_debug_gateway" translatable="false">Live connection</string>
<string name="chat_debug_socket_ready" translatable="false">Gateway is ready to receive requests.</string>
<string name="chat_debug_ticket" translatable="false">Requesting a secure connection ticket…</string>
<string name="chat_debug_socket_connecting" translatable="false">Opening the Gateway connection…</string>
<string name="chat_debug_socket_waiting" translatable="false">Waiting for the Gateway to become ready…</string>
<string name="chat_debug_socket_idle" translatable="false">No live Gateway connection.</string>
<string name="chat_debug_session" translatable="false">Conversation session</string>
<string name="chat_debug_preparing_detail" translatable="false">Hermes is initializing this session. The message has not been sent yet.</string>
<string name="chat_debug_history_loading" translatable="false">Loading saved messages…</string>
<string name="chat_debug_history_failed" translatable="false">Saved conversations could not be refreshed.</string>
<string name="chat_debug_session_selected" translatable="false">Session selected.</string>
<string name="chat_debug_session_new" translatable="false">A session will be created when you send.</string>
<string name="chat_debug_response" translatable="false">Agent response</string>
<string name="chat_debug_response_failed" translatable="false">The request failed. Details are below.</string>
<string name="chat_debug_response_waiting" translatable="false">Waiting for session initialization.</string>
<string name="chat_debug_response_active" translatable="false">Waiting for the current reply to finish.</string>
<string name="chat_debug_response_idle" translatable="false">No reply is currently in progress.</string>
<string name="chat_debug_init_recovery" translatable="false">Hermes could not initialize the agent. Check the server runtime and logs before retrying; signing in again may not resolve this.</string>
<string name="chat_debug_session_id" translatable="false">Session · %1$s</string>
<string name="chat_debug_connections" translatable="false">Connection settings</string>
<string name="app_name">Hermes-Relay</string> <string name="app_name">Hermes-Relay</string>
<string name="app_title">Hermes-Relay</string> <string name="app_title">Hermes-Relay</string>
<string name="agent_interface">agent interface</string> <string name="agent_interface">agent interface</string>
@@ -4630,28 +4602,4 @@
<string name="chat_queue_paused">Queue paused</string> <string name="chat_queue_paused">Queue paused</string>
<string name="chat_queue_resume">Resume</string> <string name="chat_queue_resume">Resume</string>
<string name="chat_queue_remove">Remove queued message</string> <string name="chat_queue_remove">Remove queued message</string>
<string name="chat_activity_receipt_subagents">Subagents</string>
<string name="chat_activity_receipt_process">Background command</string>
<string name="chat_activity_receipt_view_activity">View activity</string>
<string name="chat_activity_receipt_view_output">View output</string>
<string name="chat_activity_receipt_count_phase">%1$d %2$s</string>
<string name="chat_activity_history_title">Chat activity</string>
<string name="chat_activity_history_empty">Detailed activity was not retained on this device. The completion entry remains in chat.</string>
<string name="tool_progress_status_dispatched">Dispatched</string>
<string name="chat_activity_history_notice">Recorded activity. Progress updates are not retained; available child history can be opened read-only.</string>
<string name="chat_activity_output_unavailable">Output is no longer available. This entry preserves the recorded process status.</string>
<string name="dev_message_previews" translatable="false">Message previews</string>
<string name="dev_message_previews_desc" translatable="false">Local-only samples using the app’s real message surfaces. No requests are sent. Samples clear when you leave this screen.</string>
<string name="dev_message_info" translatable="false">Info preview</string>
<string name="dev_message_success" translatable="false">Success preview</string>
<string name="dev_message_progress" translatable="false">Progress preview</string>
<string name="dev_message_warning" translatable="false">Warning preview</string>
<string name="dev_message_error" translatable="false">Error preview</string>
<string name="dev_message_preview_sample" translatable="false">Show a themed sample. Progress stays until replaced or cleared.</string>
<string name="dev_message_action" translatable="false">Actionable error preview</string>
<string name="dev_message_action_body" translatable="false">This is a simulated error with a longer explanation. Expand or scroll to check readability. Retry only confirms the preview action; it does not contact a server.</string>
<string name="dev_message_retry" translatable="false">Retry</string>
<string name="dev_message_retry_result" translatable="false">Preview retry selected — no request sent.</string>
<string name="dev_message_clear" translatable="false">Clear previews</string>
<string name="dev_message_clear_desc" translatable="false">Dismiss only these samples; real app messages are preserved.</string>
</resources> </resources>
@@ -1,207 +0,0 @@
package com.hermesandroid.relay.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatActivityProjectionTest {
private fun record(
id: String = "record",
sourceId: String = "delegation-a",
phase: ChatActivityPhase = ChatActivityPhase.COMPLETE,
at: Long = 20,
) = ChatActivityRecord(
id, "connection::profile", "session", ChatActivityKind.SUBAGENTS,
sourceId, "Research", phase, 10, at,
children = listOf(ChatActivityChild("child-a", "child-session", phase = phase)),
taskCount = 1,
)
private fun message(id: String, at: Long = 30) =
ChatMessage(id, MessageRole.ASSISTANT, "Reply $id", at)
private fun completion(id: String = "server-row", source: String? = "delegation:delegation-a") =
message(id).copy(
role = MessageRole.SYSTEM,
content = "1 background task completed",
activitySourceId = source,
activityTaskCount = 1,
)
private fun project(messages: List<ChatMessage>, records: List<ChatActivityRecord>) =
projectChatActivityReceipts(messages, records, "connection::profile", "session")
@Test
fun `canonical completion preserves identity and original payload`() {
val row = completion().copy(uiKey = "live-key", rowId = 77)
val result = project(listOf(row), listOf(record())).single()
assertEquals(row, result.copy(activityRecord = null))
assertEquals("record", result.activityRecord?.id)
}
@Test
fun `completion wake replaces synthetic receipt without dropping ordinary messages`() {
val before = project(listOf(message("first", 1)), listOf(record()))
assertEquals(listOf("first", "activity:record"), before.map { it.id })
val after = project(before + completion() + message("wake", 40), listOf(record()))
assertEquals(listOf("first", "server-row", "wake"), after.map { it.id })
assertEquals(after, project(after, listOf(record())))
}
@Test
fun `duplicate group completion rows retain separate identities and live sibling state`() {
val running = record(phase = ChatActivityPhase.RUNNING).copy(
children = listOf(
ChatActivityChild("done", phase = ChatActivityPhase.COMPLETE),
ChatActivityChild("working", phase = ChatActivityPhase.RUNNING),
),
taskCount = 2,
)
val result = project(listOf(completion("group-one"), completion("group-two")), listOf(running))
assertEquals(listOf("group-one", "group-two"), result.map { it.id })
result.forEach {
assertEquals(ChatActivityPhase.RUNNING, it.activityRecord?.phase)
assertEquals(ChatActivityPhase.RUNNING, it.activityRecord?.children?.last()?.phase)
}
}
@Test
fun `missing and unknown delegation IDs never select archive by count or recency`() {
listOf("unavailable:server-row", "delegation:", "delegation:other").forEach { source ->
val result = project(listOf(completion(source = source)), listOf(record()))
val fallback = result.single { it.id == "server-row" }.activityRecord!!
assertTrue(fallback.children.isEmpty())
assertEquals("canonical:server-row", fallback.id)
assertEquals(1, fallback.taskCount)
assertEquals(2, result.size)
}
}
@Test
fun `incomplete metadata exposes unknown detail without fabricated task count`() {
val row = completion(source = "unavailable:server-row").copy(activityTaskCount = null)
val fallback = project(listOf(row), emptyList()).single().activityRecord!!
assertEquals(ChatActivityPhase.UNKNOWN, fallback.phase)
assertEquals(0, fallback.taskCount)
assertTrue(fallback.children.isEmpty())
}
@Test
fun `partial failure count never labels every missing child failed`() {
val row = completion().copy(activityTaskCount = 3, activityFailedCount = 1)
val result = project(listOf(row), emptyList()).single().activityRecord!!
assertEquals(ChatActivityPhase.UNKNOWN, result.phase)
assertEquals(3, result.taskCount)
assertTrue(result.children.isEmpty())
}
@Test
fun `archive ownership excludes sibling profiles and sessions`() {
val result = project(
listOf(completion()),
listOf(record().copy(scopeKey = "connection::other"), record().copy(sessionId = "other")),
)
assertEquals(1, result.size)
assertTrue(result.single().activityRecord!!.children.isEmpty())
}
@Test
fun `same snapshot identity chooses newest and restored unknown remains unknown`() {
val result = project(emptyList(), listOf(
record(phase = ChatActivityPhase.COMPLETE, at = 15),
record(phase = ChatActivityPhase.UNKNOWN, at = 20),
)).single()
assertEquals(ChatActivityPhase.UNKNOWN, result.activityRecord?.phase)
assertEquals(ChatActivityPhase.UNKNOWN, result.activityRecord?.children?.single()?.phase)
}
@Test
fun `running archive has no synthetic receipt and timestamp merge preserves history order`() {
val rows = listOf(message("first", 40), message("second", 5), message("third", 50))
val result = project(rows, listOf(record(), record(id = "running", phase = ChatActivityPhase.RUNNING)))
assertEquals(listOf("activity:record", "first", "second", "third"), result.map { it.id })
assertEquals(rows, result.filter { !it.clientOnly })
}
private fun processRow() = message("process-row").copy(
role = MessageRole.USER,
content = "[IMPORTANT: Background process proc-1 completed normally (exit code 0).\nCommand: build\nOutput:\nOK]",
)
private fun processRecord(id: String, startedAt: String) = record(id, "proc-1").copy(
kind = ChatActivityKind.PROCESS,
processId = "proc-1",
processStartedAt = startedAt,
children = emptyList(),
)
@Test
fun `exact process ID joins receipt and retains authoritative output in source row`() {
val row = processRow()
val result = project(listOf(row), listOf(processRecord("process", "start-1"))).single()
assertEquals(row, result.copy(activityRecord = null))
assertEquals("process", result.activityRecord?.id)
assertEquals("proc-1", result.activityRecord?.processId)
}
@Test
fun `reused process ID does not choose a generation by timestamps`() {
val result = project(listOf(processRow()), listOf(
processRecord("generation-1", "start-1"),
processRecord("generation-2", "start-2").copy(updatedAt = 25),
))
val fallback = result.single { it.id == "process-row" }.activityRecord!!
assertEquals("canonical:process-row", fallback.id)
assertNull(fallback.processStartedAt)
assertEquals(ChatActivityPhase.COMPLETE, fallback.phase)
assertEquals(0, fallback.exitCode)
}
@Test
fun `canonical process headline supplies terminal outcome without a retained process`() {
listOf(
Triple("completed normally (exit code 0).", ChatActivityPhase.COMPLETE, 0),
Triple("exited (exit code 1).", ChatActivityPhase.FAILED, 1),
Triple("terminated by Hermes (exit code -15, SIGTERM).", ChatActivityPhase.CANCELLED, -15),
).forEach { (status, phase, code) ->
val row = processRow().copy(content = "[IMPORTANT: Background process proc-1 $status\nOutput:\nresult]")
val receipt = project(listOf(row), emptyList()).single().activityRecord!!
assertEquals(phase, receipt.phase)
assertEquals(code, receipt.exitCode)
}
}
@Test
fun `watch and malformed headlines cannot borrow exit codes from output`() {
listOf(
"matched watch pattern \"done\".",
"exited (exit code ?).",
"exited (exit code 0). trailing text",
"exited (exit code 99999999999999999999).",
).forEach { status ->
val row = processRow().copy(
content = "[IMPORTANT: Background process proc-1 $status\nOutput:\nBackground process proc-1 completed normally (exit code 0).]",
)
val receipt = project(listOf(row), emptyList()).single().activityRecord!!
assertEquals(ChatActivityPhase.UNKNOWN, receipt.phase)
assertNull(receipt.exitCode)
}
}
@Test
fun `ordinary important messages never become activity receipts`() {
val row = message("important").copy(role = MessageRole.USER, content = "[IMPORTANT: Read this carefully]")
assertEquals(listOf(row), project(listOf(row), emptyList()))
}
@Test
fun `missing owner clears previously projected detail and synthetic rows`() {
val projected = project(listOf(completion()), listOf(record()))
val result = projectChatActivityReceipts(projected, listOf(record()), null, "session")
assertEquals(1, result.size)
assertNull(result.single().activityRecord)
assertFalse(result.single().clientOnly)
}
}
@@ -1,197 +0,0 @@
package com.hermesandroid.relay.data
import androidx.datastore.core.DataStore
import androidx.datastore.core.okio.OkioStorage
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferencesSerializer
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import okio.Path.Companion.toPath
import okio.FileSystem
class ChatActivityStoreTest {
@get:Rule val tempFolder = TemporaryFolder()
private lateinit var scope: CoroutineScope
private lateinit var dataStore: DataStore<Preferences>
private lateinit var store: DataStoreChatActivityStore
private var now = CHAT_ACTIVITY_MAX_AGE_MS * 2
@Before
fun setUp() {
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// Okio performs a real atomic replacement on Windows; File.renameTo cannot replace
// an existing destination there. Android production keeps its shared platform store.
dataStore = PreferenceDataStoreFactory.create(
storage = OkioStorage(FileSystem.SYSTEM, PreferencesSerializer) {
tempFolder.root.resolve("activity.preferences_pb").absolutePath.toPath()
},
scope = scope,
)
store = DataStoreChatActivityStore(dataStore) { now }
}
@After
fun tearDown() = runBlocking { scope.coroutineContext[Job]?.cancelAndJoin(); Unit }
@Test
fun completedMetadataAndReferencesSurviveStoreRecreation() = runTest {
val record = sample().copy(children = listOf(ChatActivityChild(
id = "child", childSessionId = "child-session", goal = "Review", phase = ChatActivityPhase.COMPLETE,
summary = "Reviewed",
)))
store.upsert(record)
val recreated = DataStoreChatActivityStore(dataStore) { now }
assertEquals(listOf(record), recreated.read("scope", "session"))
}
@Test
fun recoveredRunningStateNeverClaimsLiveness() = runTest {
store.upsert(sample().copy(
phase = ChatActivityPhase.RUNNING,
children = listOf(ChatActivityChild("child", phase = ChatActivityPhase.RUNNING)),
))
val recovered = store.read("scope", "session").single()
assertEquals(ChatActivityPhase.UNKNOWN, recovered.phase)
assertEquals(ChatActivityPhase.UNKNOWN, recovered.children.single().phase)
}
@Test
fun exactOwnerKeysDoNotAliasAndRemovalIsScoped() = runTest {
val first = sample().copy(scopeKey = "a::b", sessionId = "c")
val second = sample().copy(scopeKey = "a", sessionId = "b::c")
store.upsert(first)
store.upsert(second)
assertEquals(listOf(first), store.read("a::b", "c"))
store.removeSession("a::b", "c")
assertTrue(store.read("a::b", "c").isEmpty())
assertEquals(listOf(second), store.read("a", "b::c"))
}
@Test
fun concurrentUpsertsPreserveDistinctRecordsAndOlderUpdatesCannotRegressState() = runTest {
coroutineScope { repeat(20) { index -> launch { store.upsert(sample("$index")) } } }
assertEquals(20, store.read("scope", "session").size)
store.upsert(sample("0").copy(updatedAt = now - 1, createdAt = now - 2, phase = ChatActivityPhase.RUNNING))
assertEquals(ChatActivityPhase.COMPLETE, store.read("scope", "session").first { it.id == "0" }.phase)
}
@Test
fun invalidEnvelopeFailsClosedAndMalformedRowsDoNotHideValidSiblings() = runTest {
val key = stringPreferencesKey("chat_activity_records_v1")
for (raw in listOf("{broken", "{\"version\":2,\"records\":[]}", "[]")) {
dataStore.edit { it[key] = raw }
assertTrue(store.read("scope", "session").isEmpty())
}
dataStore.edit { it[key] = "{\"version\":1,\"records\":[{},${Json.encodeToString(sample())}]}" }
assertEquals(listOf(sample()), store.read("scope", "session"))
}
@Test
fun retentionExpiresWithoutRequiringAnotherWrite() = runTest {
store.upsert(sample())
now += CHAT_ACTIVITY_MAX_AGE_MS + 1
assertTrue(store.read("scope", "session").isEmpty())
}
@Test
fun boundedFieldsPreserveExactIdentifiersAndRejectOversizedOwners() = runTest {
store.upsert(sample().copy(title = "t".repeat(500), children = (0..40).map {
ChatActivityChild("child-$it", goal = "g".repeat(2000), summary = "s".repeat(2000))
}))
val record = store.read("scope", "session").single()
assertEquals(160, record.title.length)
assertEquals(32, record.children.size)
assertEquals(512, record.children.first().goal.length)
assertEquals(512, record.children.first().summary?.length)
store.upsert(sample("bad").copy(scopeKey = "x".repeat(2049)))
assertTrue(store.read("x".repeat(2048), "session").isEmpty())
}
@Test
fun totalAndSessionLimitsEvictOldest() {
val session = boundChatActivities((0..40).map {
sample("$it").copy(createdAt = now - 100, updatedAt = now - it)
}, now)
assertEquals(32, session.size)
assertEquals("0", session.first().id)
val total = boundChatActivities((0..150).map {
sample("$it").copy(sessionId = "session-$it", createdAt = now - 200, updatedAt = now - it)
}, now)
assertEquals(128, total.size)
}
@Test
fun inMemoryStoreUsesSameRecoveryAndOwnershipRules() = runTest {
val fake = InMemoryChatActivityStore { now }
fake.upsert(sample().copy(phase = ChatActivityPhase.RUNNING))
assertEquals(ChatActivityPhase.UNKNOWN, fake.read("scope", "session").single().phase)
fake.removeSession("other", "session")
assertEquals(1, fake.read("scope", "session").size)
fake.removeSession("scope", "session")
assertTrue(fake.read("scope", "session").isEmpty())
}
@Test
fun processGenerationReferenceRoundTrips() = runTest {
val process = sample().copy(
kind = ChatActivityKind.PROCESS, processId = "pid-1", processStartedAt = "2026-09-07T12:00:00Z",
exitCode = 0,
)
store.upsert(process)
assertEquals(listOf(process), store.read("scope", "session"))
}
@Test
fun aggregateCountDoesNotInventChildReferencesAndIsBounded() = runTest {
store.upsert(sample().copy(taskCount = 3))
assertEquals(3, store.read("scope", "session").single().taskCount)
assertTrue(store.read("scope", "session").single().children.isEmpty())
store.upsert(sample().copy(taskCount = Int.MAX_VALUE))
assertEquals(10_000, store.read("scope", "session").single().taskCount)
store.upsert(sample().copy(taskCount = -1))
assertEquals(0, store.read("scope", "session").single().taskCount)
}
@Test
fun shortLogicalClockSkewIsAcceptedButFarFutureRecordsAreRejected() = runTest {
store.upsert(sample().copy(updatedAt = now + 1))
assertEquals(now + 1, store.read("scope", "session").single().updatedAt)
store.upsert(sample("future").copy(updatedAt = now + 300_001))
assertEquals(1, store.read("scope", "session").size)
}
@Test
fun removeRecordKeepsSiblingAndSameIdOtherOwner() = runTest {
store.upsert(sample("a"))
store.upsert(sample("b"))
store.upsert(sample("a").copy(scopeKey = "other"))
store.removeRecord("scope", "session", "a")
assertEquals(listOf("b"), store.read("scope", "session").map { it.id })
assertEquals(listOf("a"), store.read("other", "session").map { it.id })
}
private fun sample(id: String = "activity") = ChatActivityRecord(
id = id, scopeKey = "scope", sessionId = "session", kind = ChatActivityKind.SUBAGENTS,
sourceId = "dispatch", title = "Subagents", phase = ChatActivityPhase.COMPLETE,
createdAt = now - 10, updatedAt = now,
)
}
@@ -12,7 +12,6 @@ import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.ResponseBody.Companion.toResponseBody
@@ -660,40 +659,6 @@ class DashboardApiClientTest {
assertEquals("basic", session.provider) assertEquals("basic", session.provider)
} }
@Test
fun passwordLogin_stripsOnlyBrowserForbiddenLineBreaksFromCredentials() = runTest {
val preservedCredential = " \t\u00A0påss\u200B "
val cases = listOf(
listOf("user", "line\rbreak", "user", "linebreak"),
listOf("user", "line\nbreak", "user", "linebreak"),
listOf("user", "line\r\nbreak", "user", "linebreak"),
listOf("us\r\ner", "secret", "user", "secret"),
listOf(
preservedCredential,
preservedCredential,
preservedCredential,
preservedCredential,
),
)
repeat(cases.size) {
server.enqueue(
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"ok": true, "next": "/"}"""),
)
}
val client = DashboardApiClient(baseUrl = server.url("/").toString())
cases.forEach { (username, password, expectedUsername, expectedPassword) ->
client.loginPassword(username = username, password = password).getOrThrow()
val body = Json.parseToJsonElement(server.takeRequest().body.readUtf8()).jsonObject
assertEquals(expectedUsername, body["username"]?.jsonPrimitive?.content)
assertEquals(expectedPassword, body["password"]?.jsonPrimitive?.content)
}
}
private fun storedCookie( private fun storedCookie(
name: String, name: String,
value: String, value: String,
@@ -2408,27 +2408,6 @@ class GatewayChatClientTest {
assertEquals("focus on Android", (params["text"] as? JsonPrimitive)?.contentOrNull) assertEquals("focus on Android", (params["text"] as? JsonPrimitive)?.contentOrNull)
} }
@Test
fun `session child listener receives detached updates after parent terminal and rejects foreign frames`() = runBlocking {
val events = LinkedBlockingQueue<GatewaySubagentEvent>()
client.setSubagentEventListener { _, _, event -> events.add(event) }
val recorder = Recorder()
client.sendTurn(null, "delegate", null, recorder.callbacks) { recorder.preflightFailures += it }
val socket = harness.awaitServerSocket()
harness.awaitRpc("prompt.submit")
val payload = buildJsonObject { put("subagent_id", "child"); put("status", "completed") }
socket.send(harness.eventFrame("subagent.start", payload, "live-1"))
assertEquals(GatewaySubagentEvent.Phase.START, events.poll(5, TimeUnit.SECONDS)?.phase)
socket.send(harness.eventFrame("message.complete", buildJsonObject { put("text", "Launched") }, "live-1"))
socket.send(harness.eventFrame("subagent.tool", payload, "foreign"))
socket.send(harness.eventFrame("subagent.tool", payload, null))
socket.send(harness.eventFrame("subagent.progress", payload, "live-1"))
socket.send(harness.eventFrame("subagent.complete", payload, "live-1"))
assertEquals(GatewaySubagentEvent.Phase.PROGRESS, events.poll(5, TimeUnit.SECONDS)?.phase)
assertEquals(GatewaySubagentEvent.Phase.COMPLETE, events.poll(5, TimeUnit.SECONDS)?.phase)
assertTrue(events.isEmpty())
}
@Test @Test
fun `child watch is profile pinned bounded and isolated from main session`() = runBlocking { fun `child watch is profile pinned bounded and isolated from main session`() = runBlocking {
harness.sessionProfileOverride = "operator" harness.sessionProfileOverride = "operator"
@@ -4013,27 +3992,6 @@ class GatewayChatClientTest {
assertTrue(r.preflightFailures.isEmpty()) assertTrue(r.preflightFailures.isEmpty())
} }
@Test
fun `agent init failure before lazy acknowledgement remains authoritative`() {
harness.suppressAckMethods += "session.create"
val r = Recorder()
client.sendTurn(null, "hello", null, r.callbacks) { r.preflightFailures += it }
val ack = harness.awaitPendingAck()
ack.ws.send(harness.eventFrame("error", buildJsonObject {
put("message", "agent init failed: incompatible runtime helper")
}, "live-1"))
harness.releaseAck(ack, buildJsonObject {
put("session_id", "live-1")
put("stored_session_id", "stored-1")
put("profile", "default")
put("info", buildJsonObject { put("lazy", true) })
})
waitUntil { r.preflightFailures.isNotEmpty() }
assertTrue(r.preflightFailures.single().contains("incompatible runtime helper"))
assertTrue(harness.rpcLog.none { it.first == "prompt.submit" })
assertNull(client.preparingSessionId.value)
}
@Test @Test
fun `lazy fresh session readiness timeout never submits`() { fun `lazy fresh session readiness timeout never submits`() {
rebuildClient(sessionReadyTimeoutMs = 50L) rebuildClient(sessionReadyTimeoutMs = 50L)
@@ -741,29 +741,6 @@ class GatewayEventMapperTest {
assertNull(event.durationSeconds) assertNull(event.durationSeconds)
} }
@Test
fun `subagent events preserve delegation identity independently of child identity`() {
val phases = listOf("spawn_requested", "start", "thinking", "tool", "progress", "complete")
phases.forEach { phase ->
val event = GatewayEventMapper.parseSubagentEvent(
"subagent.$phase",
obj("""{"delegation_id":"delegation-9","subagent_id":"child-17","child_session_id":"session-17"}"""),
)!!
assertEquals("delegation-9", event.delegationId)
assertEquals("child-17", event.subagentId)
assertEquals("session-17", event.childSessionId)
}
}
@Test
fun `older subagent emitters do not invent a delegation identity`() {
val event = GatewayEventMapper.parseSubagentEvent(
"subagent.complete",
obj("""{"subagent_id":"child-17","task_index":0,"task_count":1}"""),
)!!
assertNull(event.delegationId)
}
// --- Usage translation (tui_gateway key names, not SSE names) --- // --- Usage translation (tui_gateway key names, not SSE names) ---
@Test @Test
@@ -1,141 +0,0 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Surface
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.data.ChatActivityChild
import com.hermesandroid.relay.data.ChatActivityKind
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.ChatActivityRecord
import com.hermesandroid.relay.data.ToolCall
import com.hermesandroid.relay.network.upstream.GatewayProcess
import com.hermesandroid.relay.ui.components.ChatActivityReceipt
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessStrip
import com.hermesandroid.relay.ui.components.GatewayBackgroundProcessSheet
import com.hermesandroid.relay.ui.components.SubagentPreviewVisibility
import com.hermesandroid.relay.ui.components.ToolProgressCard
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h720dp-xhdpi")
class ChatActivityReceiptScreenshotTest {
@get:Rule val compose = createComposeRule()
@Test
fun completedActivityStaysCompactAndOpensOnlyOnTap() {
var opened = 0
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
Surface {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
ToolProgressCard(ToolCall(
name = "delegate_task", args = null, result = "{\"status\":\"dispatched\"}",
success = true, isComplete = true,
))
ChatActivityReceipt(
record(ChatActivityKind.SUBAGENTS, ChatActivityPhase.COMPLETE).copy(
children = listOf(
ChatActivityChild("child-one", phase = ChatActivityPhase.COMPLETE, summary = "Private result"),
ChatActivityChild("child-two", phase = ChatActivityPhase.FAILED),
),
),
onClick = { opened++ },
)
ChatActivityReceipt(record(ChatActivityKind.PROCESS, ChatActivityPhase.UNKNOWN), {})
}
}
}
}
assertEquals(0, opened)
compose.onNodeWithText("Dispatched").assertExists()
compose.onNodeWithText("Subagents · 1 Completed · 1 Failed").assertExists()
compose.onNodeWithText("Private result").assertDoesNotExist()
compose.onNodeWithText("Background command · Final state unavailable").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/chat-activity-receipts.png")
compose.onNodeWithText("View activity").performClick()
assertEquals(1, opened)
}
@Test
fun completionRemovesActiveStripButLeavesTranscriptEntry() {
val running = mutableStateOf(true)
compose.setContent {
HermesRelayTheme {
Column {
GatewayBackgroundProcessStrip(
processes = listOf(GatewayProcess("process", "command", status = if (running.value) "running" else "completed")),
subagentActivities = emptyList(),
subagentPreviewVisibility = SubagentPreviewVisibility(),
loading = true,
onClick = {},
)
ChatActivityReceipt(
record(ChatActivityKind.PROCESS, if (running.value) ChatActivityPhase.RUNNING else ChatActivityPhase.COMPLETE),
{},
)
}
}
}
compose.onNodeWithText("Current chat activity").assertExists()
compose.runOnIdle { running.value = false }
compose.onNodeWithText("Current chat activity").assertDoesNotExist()
compose.onNodeWithText("Background command · Completed").assertExists()
}
@Test
fun recordedProcessPreviewOffersNoMutationControls() {
compose.setContent {
HermesRelayTheme {
GatewayBackgroundProcessSheet(
processes = listOf(GatewayProcess("process", "Saved command", status = "completed", exitCode = 0)),
subagentActivities = emptyList(),
subagentChildPreview = null,
subagentPreviewVisibility = SubagentPreviewVisibility(),
loading = false,
stoppingProcessIds = emptySet(),
onRefresh = {},
onStop = {},
onDismissProcess = {},
onOpenSubagentChild = {},
onDismiss = {},
readOnlyHistory = true,
historyNotice = "Recorded activity. Output is unavailable.",
)
}
}
compose.onNodeWithText("Chat activity").assertExists()
compose.onNodeWithText("Recorded activity. Output is unavailable.").assertExists()
compose.onNodeWithText("Saved command").assertExists()
compose.onNodeWithText("Stop").assertDoesNotExist()
compose.onNodeWithText("Dismiss").assertDoesNotExist()
}
private fun record(kind: ChatActivityKind, phase: ChatActivityPhase) = ChatActivityRecord(
id = "receipt-${kind.name}",
scopeKey = "scope",
sessionId = "session",
kind = kind,
sourceId = "source",
title = "Activity",
phase = phase,
createdAt = 1L,
updatedAt = 2L,
)
}
@@ -1,47 +0,0 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.network.upstream.GatewayConnectionState
import com.hermesandroid.relay.ui.components.ChatDebugDrawer
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h720dp-xhdpi")
class ChatDebugDrawerScreenshotTest {
@get:Rule val compose = createComposeRule()
@Test
fun initializationFailureShowsConfirmedConnectionAndActionableError() {
var closed = false
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
ChatDebugDrawer(
profile = "Server Default", model = "gpt-5.6-sol", sessionId = "session-example",
gateway = true, signedIn = true, signInRequired = false,
socketState = GatewayConnectionState.Ready, preparing = false,
streaming = false, loadingHistory = false, directoryUnavailable = false,
failure = "agent init failed: incompatible runtime helper",
onClose = { closed = true }, onConnections = {},
)
}
}
compose.onNodeWithText("Authenticated for this connection.").assertExists()
compose.onNodeWithText("The request failed. Details are below.").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/chat-debug-drawer.png")
compose.onNodeWithContentDescription("Close session diagnostics").performClick()
assertTrue(closed)
}
}
@@ -1,56 +0,0 @@
package com.hermesandroid.relay.screenshots
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.github.takahirom.roborazzi.captureRoboImage
import com.hermesandroid.relay.ui.UiMessageBus
import com.hermesandroid.relay.ui.components.LocalMessageActionHost
import com.hermesandroid.relay.ui.components.MessageBannerHost
import com.hermesandroid.relay.ui.components.ThemedMessageHost
import com.hermesandroid.relay.ui.screens.MessagePreviewControls
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(qualifiers = "w360dp-h1200dp-xhdpi")
class MessagePreviewControlsTest {
@get:Rule val compose = createComposeRule()
@Test
fun previewRetryIsLocalAndClearPreservesRealMessages() {
val host = SnackbarHostState()
compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
CompositionLocalProvider(LocalMessageActionHost provides host) {
Surface { Column {
MessageBannerHost(includeStatusBarPadding = false)
MessagePreviewControls()
ThemedMessageHost(host)
} }
}
}
}
compose.onNodeWithContentDescription("Actionable error preview").performClick()
compose.onNodeWithText("Retry").assertExists()
compose.onRoot().captureRoboImage("build/ui-regression/message-previews.png")
compose.onNodeWithText("Retry").performClick()
compose.onNodeWithText("Preview retry selected — no request sent.").assertExists()
compose.runOnIdle { UiMessageBus.post("Real app message", ttlMillis = 0L, key = "real-test-message") }
compose.onNodeWithContentDescription("Clear previews").performClick()
compose.onNodeWithText("Real app message").assertExists()
compose.runOnIdle { UiMessageBus.clear("real-test-message") }
}
}
@@ -1,44 +0,0 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.data.ToolCall
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DetachedDelegationDispatchTest {
@Test
fun completedSuccessfulStructuredDispatchIsRecognized() {
assertTrue(isDetachedDelegationDispatch(call("""{"status":"dispatched"}""")))
assertTrue(isDetachedDelegationDispatch(call("""{"mode":"background"}""")))
}
@Test
fun synchronousDelegationAndUnstructuredTextRemainOrdinaryCompletion() {
listOf(
"""{"status":"completed","results":["done"]}""",
"""{"result":"status=dispatched mode=background"}""",
"dispatched in background",
"""{"status":{"status":"dispatched"}}""",
"""[{"status":"dispatched"}]""",
"{broken",
).forEach { assertFalse(isDetachedDelegationDispatch(call(it))) }
}
@Test
fun failedRunningAndOtherToolCallsAreNeverDispatchSuccess() {
val dispatch = call("""{"status":"dispatched"}""")
assertFalse(isDetachedDelegationDispatch(dispatch.copy(success = false)))
assertFalse(isDetachedDelegationDispatch(dispatch.copy(success = null)))
assertFalse(isDetachedDelegationDispatch(dispatch.copy(isComplete = false)))
assertFalse(isDetachedDelegationDispatch(dispatch.copy(name = "terminal")))
assertFalse(isDetachedDelegationDispatch(dispatch.copy(result = null)))
}
private fun call(result: String) = ToolCall(
name = "delegate_task",
args = null,
result = result,
success = true,
isComplete = true,
)
}
@@ -1,29 +0,0 @@
package com.hermesandroid.relay.ui.components
import com.hermesandroid.relay.network.upstream.GatewayProcess
import org.junit.Assert.assertEquals
import org.junit.Test
class ProcessDisplayPhaseTest {
@Test
fun unavailableAndCancelledDoNotBecomeSuccessfulWithoutExitCode() {
assertEquals(ProcessDisplayPhase.UNKNOWN, phase("unknown"))
assertEquals(ProcessDisplayPhase.UNKNOWN, phase("unrecognized"))
assertEquals(ProcessDisplayPhase.CANCELLED, phase("cancelled"))
assertEquals(ProcessDisplayPhase.FAILED, phase("failed"))
}
@Test
fun knownLiveAndTerminalStatesRetainTheirMeaning() {
assertEquals(ProcessDisplayPhase.RUNNING, phase("running"))
assertEquals(ProcessDisplayPhase.COMPLETE, phase("complete"))
assertEquals(ProcessDisplayPhase.COMPLETE, phase("completed"))
assertEquals(ProcessDisplayPhase.COMPLETE, phase("exited", 0))
assertEquals(ProcessDisplayPhase.FAILED, phase("exited", 1))
assertEquals(ProcessDisplayPhase.UNKNOWN, phase("unknown", 0))
}
private fun phase(status: String, exitCode: Int? = null) = processDisplayPhase(
GatewayProcess("process", "command", status = status, exitCode = exitCode),
)
}
@@ -2,11 +2,8 @@ package com.hermesandroid.relay.ui.screens
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import com.hermesandroid.relay.data.BotGroupMessage import com.hermesandroid.relay.data.BotGroupMessage
import com.hermesandroid.relay.data.BotGatewayRoute
import com.hermesandroid.relay.data.BotGatewayRouteKey
import com.hermesandroid.relay.data.BotGroupRoom import com.hermesandroid.relay.data.BotGroupRoom
import com.hermesandroid.relay.data.BotModeRoster import com.hermesandroid.relay.data.BotModeRoster
import com.hermesandroid.relay.data.BotModeState import com.hermesandroid.relay.data.BotModeState
@@ -16,7 +13,6 @@ import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.Profile import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.ui.theme.HermesRelayTheme import com.hermesandroid.relay.ui.theme.HermesRelayTheme
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -69,64 +65,10 @@ class BotModeScreenTest {
compose.onNodeWithText("Lucy").assertDoesNotExist() compose.onNodeWithText("Lucy").assertDoesNotExist()
} }
@Test
fun `same named bots on different gateways render and open their own conversation`() {
val opened = mutableListOf<String>()
render(onOpenBot = { opened += it.route!!.connectionId })
compose.onNodeWithText("Lucy").performClick()
compose.onNodeWithText("Researcher").performClick()
assertEquals(listOf("home", "lab"), opened)
}
@Test
fun `same named active bots on different gateways render and open their own owner`() {
val opened = mutableListOf<String>()
render(nowMs = NOW, onOpenBot = { opened += it.route!!.connectionId })
compose.onAllNodesWithText("Lucy")[0].performClick()
compose.onAllNodesWithText("Researcher")[0].performClick()
assertEquals(listOf("home", "lab"), opened)
}
@Test
fun `opening progress only replaces the selected owners preview`() {
render(openingRoute = BotGatewayRouteKey("home", "default"))
compose.onNodeWithText("Drafted a rollout plan").assertDoesNotExist()
compose.onNodeWithText("Findings ready").assertExists()
}
@Test
fun `item identity survives presentation refresh and separates delimiter containing owners`() {
val bot = state().roster.bots.first()
val refreshed = bot.copy(
displayName = "Renamed",
handle = "new-handle",
stale = true,
route = BotGatewayRoute(bot.route!!.key, "New label", "new-install-metadata"),
canonicalSession = BotSessionSummary(id = "compressed-tip"),
)
assertEquals(bot.lazyItemKey, refreshed.lazyItemKey)
assertNotEquals(
bot.copy(route = BotGatewayRoute(BotGatewayRouteKey("a:b", "c"), "Same label")).lazyItemKey,
bot.copy(route = BotGatewayRoute(BotGatewayRouteKey("a", "b:c"), "Same label")).lazyItemKey,
)
assertNotEquals(
bot.lazyItemKey,
bot.copy(route = BotGatewayRoute(BotGatewayRouteKey("home", "other"), "Hermes")).lazyItemKey,
)
assertNotEquals(bot.lazyItemKey, bot.copy(route = null).lazyItemKey)
}
private fun render( private fun render(
onOpenBot: (BotRosterEntry) -> Unit = {}, onOpenBot: (BotRosterEntry) -> Unit = {},
onOpenGroup: (BotGroupRoom) -> Unit = {}, onOpenGroup: (BotGroupRoom) -> Unit = {},
selectedGatewayId: String? = null, selectedGatewayId: String? = null,
nowMs: Long = NOW + 200_000L,
openingRoute: BotGatewayRouteKey? = null,
) { ) {
compose.setContent { compose.setContent {
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") { HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
@@ -135,14 +77,13 @@ class BotModeScreenTest {
connections = listOf(connection(), labConnection()), connections = listOf(connection(), labConnection()),
activeConnection = connection(), activeConnection = connection(),
selectedGatewayId = selectedGatewayId, selectedGatewayId = selectedGatewayId,
openingRoute = openingRoute,
onBack = {}, onBack = {},
onRefresh = {}, onRefresh = {},
onSelectGateway = {}, onSelectGateway = {},
onOpenBot = onOpenBot, onOpenBot = onOpenBot,
onOpenGroup = onOpenGroup, onOpenGroup = onOpenGroup,
onNewBot = {}, onNewBot = {},
nowMs = nowMs, nowMs = NOW + 200_000L,
) )
} }
} }
@@ -165,10 +106,10 @@ class BotModeScreenTest {
), ),
), ),
BotRosterEntry( BotRosterEntry(
profile = Profile(name = "default", model = "gpt-5.6", description = "Research"), profile = Profile(name = "researcher", model = "gpt-5.6", description = "Research"),
displayName = "Researcher", displayName = "Researcher",
route = com.hermesandroid.relay.data.BotGatewayRoute( route = com.hermesandroid.relay.data.BotGatewayRoute(
key = com.hermesandroid.relay.data.BotGatewayRouteKey("lab", "default"), key = com.hermesandroid.relay.data.BotGatewayRouteKey("lab", "researcher"),
connectionLabel = "Lab server", connectionLabel = "Lab server",
), ),
canonicalSession = BotSessionSummary( canonicalSession = BotSessionSummary(
@@ -1,7 +1,5 @@
package com.hermesandroid.relay.util package com.hermesandroid.relay.util
import com.hermesandroid.relay.network.upstream.DashboardHttpException
import java.io.IOException import java.io.IOException
import java.net.ConnectException import java.net.ConnectException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
@@ -13,54 +11,6 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class RelayErrorClassifierTest { class RelayErrorClassifierTest {
@Test
fun dashboard404UsesTypedStatusAndNamesItsOwner() {
val error = classifyError(DashboardHttpException(404, "request failed"), context = "send_message")
assertEquals("Endpoint not found", error.title)
assertTrue(error.body.contains("Dashboard"))
assertFalse(error.body.contains("relay", ignoreCase = true))
assertFalse(error.retryable)
}
@Test
fun typedDashboardStatusWinsOverMisleadingResponseBody() {
val error = classifyError(DashboardHttpException(500, "HTTP 404 missing"))
assertFalse(error.title == "Endpoint not found")
assertTrue(error.body.contains("HTTP 500"))
assertTrue(error.retryable)
}
@Test
fun explicitLegacy404IsNeutralForVoiceAndChat() {
listOf("HTTP 404", "API error 404: Not found", "Relay responded HTTP 404").forEach { text ->
listOf("voice_config", "send_message").forEach { context ->
val error = classifyError(IOException(text), context)
assertEquals("Endpoint not found", error.title)
assertFalse(error.body.contains("relay", ignoreCase = true))
assertFalse(error.body.contains("older", ignoreCase = true))
}
}
}
@Test
fun incidental404NeverBecomesMissingEndpoint() {
listOf("file 404 not readable", "HTTP 4040", "request id=404", "See https://example.test/404", "Response body: HTTP 404").forEach { text ->
val error = classifyError(IOException(text))
assertEquals("Network error", error.title)
assertEquals(text, error.body)
}
}
@Test
fun typedDashboardAuthDoesNotSuggestRelayRepair() {
listOf(401, 403).forEach { code ->
val error = classifyError(DashboardHttpException(code, "request failed"), "media_fetch")
assertEquals(if (code == 401) "Dashboard sign-in required" else "Not allowed", error.title)
assertEquals(null, error.action)
assertFalse(error.body.contains("re-pair", ignoreCase = true))
}
}
@Test @Test
fun gatewayDrainIsNotMisclassifiedAsProviderOutage() { fun gatewayDrainIsNotMisclassifiedAsProviderOutage() {
val err = classifyError( val err = classifyError(
@@ -1,285 +0,0 @@
package com.hermesandroid.relay.viewmodel
import com.hermesandroid.relay.data.ChatActivityKind
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.ChatActivityRecord
import com.hermesandroid.relay.data.ChatActivityStore
import com.hermesandroid.relay.data.InMemoryChatActivityStore
import com.hermesandroid.relay.network.upstream.GatewayProcess
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ChatActivityControllerTest {
private val now = 100_000L
@Test
fun completedChildrenRemainWhenNewParentTurnPrunesActiveList() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("first", SubagentActivityPhase.COMPLETED)))
val original = controller.records.value.single()
controller.captureSubagents(emptyList())
controller.captureSubagents(listOf(child("second", SubagentActivityPhase.THINKING).copy(turnId = "next-turn")))
val record = controller.records.value.single()
assertEquals(original.id, record.id)
assertEquals(original.createdAt, record.createdAt)
assertEquals(listOf("first", "second"), record.children.map { it.id })
assertEquals(ChatActivityPhase.RUNNING, record.phase)
controller.captureSubagents(listOf(child("second", SubagentActivityPhase.COMPLETED)))
assertEquals(ChatActivityPhase.COMPLETE, controller.records.value.single().phase)
}
@Test
fun groupsOnlyByExactDelegationAndSkipsAnonymousChildren() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(
child("a").copy(delegationId = null), child("b").copy(delegationId = null),
child("c").copy(delegationId = "different"),
child("anonymous").copy(subagentId = null, childSessionId = null),
))
assertEquals(3, controller.records.value.size)
assertEquals(3, controller.records.value.map { it.id }.distinct().size)
}
@Test
fun uncertaintyAndExactFailuresDriveAggregateWithoutPersistingEventBodies() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(
child("a", SubagentActivityPhase.FAILED), child("b", SubagentActivityPhase.ENDED_WITH_PARENT),
))
assertEquals(ChatActivityPhase.UNKNOWN, controller.records.value.single().phase)
controller.captureSubagents(listOf(child("b", SubagentActivityPhase.COMPLETED)))
assertEquals(ChatActivityPhase.FAILED, controller.records.value.single().phase)
}
@Test
fun processIdReuseCreatesSeparateHistoryAndDisappearanceBecomesUnknown() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureProcesses(listOf(process("old")))
val original = controller.records.value.single()
controller.captureProcesses(listOf(process("new")))
val records = controller.records.value
assertEquals(2, records.size)
assertEquals(ChatActivityPhase.UNKNOWN, records.first { it.id == original.id }.phase)
assertEquals(ChatActivityPhase.RUNNING, records.first { it.processStartedAt == "new" }.phase)
controller.captureProcesses(listOf(process("new").copy(status = "exited", exitCode = 0)))
assertEquals(ChatActivityPhase.COMPLETE, controller.records.value.first { it.processStartedAt == "new" }.phase)
assertTrue(controller.records.value.none { it.title.contains("private") })
}
@Test
fun identityEncodingSeparatesNullAndLiteralStartValues() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureProcesses(listOf(process(null), process("null")))
assertEquals(2, controller.records.value.size)
assertNotEquals(controller.records.value[0].id, controller.records.value[1].id)
}
@Test
fun staleReadCannotPublishIntoAnotherSession() = runTest {
val gate = CompletableDeferred<List<ChatActivityRecord>>()
val store = RecordingStore { _, session -> if (session == "old") gate.await() else emptyList() }
val controller = ChatActivityController(this, store) { now }
controller.selectSession("scope", "old")
runCurrent()
controller.selectSession("scope", "new")
controller.captureSubagents(listOf(child("new-child")))
gate.complete(listOf(record("old")))
advanceUntilIdle()
assertEquals(listOf("new"), controller.records.value.map { it.sessionId })
}
@Test
fun delayedRestoreDoesNotOverwriteFreshLiveState() = runTest {
val gate = CompletableDeferred<List<ChatActivityRecord>>()
val store = RecordingStore { _, _ -> gate.await() }
val controller = ChatActivityController(this, store) { now }
controller.selectSession("scope", "session")
runCurrent()
controller.captureProcesses(listOf(process("same")))
val fresh = controller.records.value.single()
gate.complete(listOf(fresh.copy(phase = ChatActivityPhase.UNKNOWN)))
advanceUntilIdle()
assertEquals(ChatActivityPhase.RUNNING, controller.records.value.single().phase)
}
@Test
fun storeBindingMigratesMemoryAndFencesPreviousLoads() = runTest {
val memory = InMemoryChatActivityStore { now }
val controller = ChatActivityController(this, memory) { now }
controller.selectSession("scope", "session")
advanceUntilIdle()
controller.captureSubagents(listOf(child("a", SubagentActivityPhase.COMPLETED)))
advanceUntilIdle()
val durable = RecordingStore { _, _ -> emptyList() }
controller.bindStore(durable)
advanceUntilIdle()
assertEquals(1, controller.records.value.size)
assertEquals(controller.records.value, durable.written)
}
@Test
fun unchangedSnapshotsDoNotWriteAndChangedMetadataUsesMonotonicRevision() = runTest {
val store = RecordingStore { _, _ -> emptyList() }
val controller = ChatActivityController(this, store) { now }
controller.selectSession("scope", "session")
advanceUntilIdle()
controller.captureSubagents(listOf(child("a")))
advanceUntilIdle()
controller.captureSubagents(listOf(child("a")))
advanceUntilIdle()
assertEquals(1, store.written.size)
controller.captureSubagents(listOf(child("a", SubagentActivityPhase.COMPLETED)))
advanceUntilIdle()
assertEquals(2, store.written.size)
assertTrue(store.written.last().updatedAt > store.written.first().updatedAt)
}
@Test
fun removeFencesPendingLoadAndRemovesOnlyRequestedOwner() = runTest {
val gate = CompletableDeferred<List<ChatActivityRecord>>()
val store = RecordingStore { _, _ -> gate.await() }
val controller = ChatActivityController(this, store) { now }
controller.selectSession("scope", "session")
runCurrent()
controller.removeSession("scope", "session")
gate.complete(listOf(record("session")))
advanceUntilIdle()
assertTrue(controller.records.value.isEmpty())
assertEquals(listOf("scope" to "session"), store.removed)
}
@Test
fun lateDelegationMigratesExactFallbackAndRemovesDurableDuplicate() = runTest {
val store = RecordingStore { _, _ -> emptyList() }
val controller = ChatActivityController(this, store) { now }
controller.selectSession("scope", "session")
advanceUntilIdle()
controller.captureSubagents(listOf(child("a").copy(delegationId = null)))
val fallback = controller.records.value.single()
controller.captureSubagents(listOf(child("a")))
advanceUntilIdle()
val grouped = controller.records.value.single()
assertNotEquals(fallback.id, grouped.id)
assertEquals(fallback.createdAt, grouped.createdAt)
assertEquals(1, grouped.children.size)
assertEquals(listOf(fallback.id), store.removedRecords)
}
@Test
fun childSessionIdentityEnrichesWithoutDuplicatingChild() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("a").copy(subagentId = null)))
controller.captureSubagents(listOf(child("a")))
val children = controller.records.value.single().children
assertEquals(1, children.size)
assertEquals("a", children.single().id)
}
@Test
fun declaredUnobservedChildrenPreventFalseCompletion() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("a", SubagentActivityPhase.COMPLETED).copy(taskCount = 3)))
assertEquals(3, controller.records.value.single().taskCount)
assertEquals(ChatActivityPhase.UNKNOWN, controller.records.value.single().phase)
}
@Test
fun deletionRejectsLateCapturesAndSameOwnerReselectionUntilSwitchAway() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("a")))
controller.removeSession("scope", "session")
controller.captureSubagents(listOf(child("late")))
controller.selectSession("scope", "session")
controller.captureProcesses(listOf(process("late")))
assertTrue(controller.records.value.isEmpty())
controller.selectSession("scope", "other")
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("new")))
assertEquals(1, controller.records.value.size)
}
@Test
fun connectionLossDowngradesOnlyRunningRecordsAndChildren() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("a"), child("b", SubagentActivityPhase.COMPLETED)))
controller.captureProcesses(listOf(process("now")))
controller.markUnavailable()
assertTrue(controller.records.value.all { it.phase == ChatActivityPhase.UNKNOWN })
val children = controller.records.value.first { it.kind == ChatActivityKind.SUBAGENTS }.children
assertEquals(ChatActivityPhase.UNKNOWN, children.first { it.id == "a" }.phase)
assertEquals(ChatActivityPhase.COMPLETE, children.first { it.id == "b" }.phase)
}
@Test
fun freshChildAfterConnectionLossDoesNotReviveUnobservedSibling() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureSubagents(listOf(child("a"), child("b")))
controller.markUnavailable()
controller.captureSubagents(listOf(child("a", SubagentActivityPhase.PROGRESS)))
val record = controller.records.value.single()
assertEquals(ChatActivityPhase.RUNNING, record.phase)
assertEquals(ChatActivityPhase.RUNNING, record.children.first { it.id == "a" }.phase)
assertEquals(ChatActivityPhase.UNKNOWN, record.children.first { it.id == "b" }.phase)
}
@Test
fun explicitZeroProcessExitCompletesUnknownStatusButDoesNotOverrideCancellationOrRunning() = runTest {
val controller = ChatActivityController(this, clock = { now })
controller.selectSession("scope", "session")
controller.captureProcesses(listOf(
process("zero").copy(status = "unknown", exitCode = 0),
process("cancelled").copy(status = "cancelled", exitCode = 0),
process("running").copy(status = "running", exitCode = 0),
))
val phases = controller.records.value.associate { it.processStartedAt to it.phase }
assertEquals(ChatActivityPhase.COMPLETE, phases["zero"])
assertEquals(ChatActivityPhase.CANCELLED, phases["cancelled"])
assertEquals(ChatActivityPhase.RUNNING, phases["running"])
}
private fun child(id: String, phase: SubagentActivityPhase = SubagentActivityPhase.THINKING) = SubagentActivity(
laneId = 1, turnId = "turn", taskIndex = 1, taskCount = 2, goal = "Review", subagentId = id,
childSessionId = "session-$id", phase = phase, delegationId = "delegation",
)
private fun process(start: String?) = GatewayProcess(
id = "process", command = "private command", cwd = "/private/path", startedAt = start,
status = "running", outputTail = "private output", sessionScoped = true,
)
private fun record(session: String) = ChatActivityRecord(
id = "old-record", scopeKey = "scope", sessionId = session, kind = ChatActivityKind.SUBAGENTS,
sourceId = "source", title = "Subagents", phase = ChatActivityPhase.COMPLETE,
createdAt = now, updatedAt = now,
)
private class RecordingStore(
val reader: suspend (String, String) -> List<ChatActivityRecord>,
) : ChatActivityStore {
val written = mutableListOf<ChatActivityRecord>()
val removed = mutableListOf<Pair<String, String>>()
val removedRecords = mutableListOf<String>()
override suspend fun read(scopeKey: String, sessionId: String) = reader(scopeKey, sessionId)
override suspend fun upsert(record: ChatActivityRecord) { written += record }
override suspend fun removeRecord(scopeKey: String, sessionId: String, id: String) { removedRecords += id }
override suspend fun removeSession(scopeKey: String, sessionId: String) { removed += scopeKey to sessionId }
}
}
@@ -1,203 +0,0 @@
package com.hermesandroid.relay.viewmodel
import android.os.Handler
import android.os.Looper
import com.hermesandroid.relay.data.AgentDisplay
import com.hermesandroid.relay.data.ChatActivityPhase
import com.hermesandroid.relay.data.InMemoryChatActivityStore
import com.hermesandroid.relay.data.Profile
import com.hermesandroid.relay.data.projectChatActivityReceipts
import com.hermesandroid.relay.network.upstream.ChatHandler
import com.hermesandroid.relay.network.upstream.DashboardApiClient
import com.hermesandroid.relay.network.upstream.GatewayChatClient
import com.hermesandroid.relay.network.upstream.GatewayClientHarness
import com.hermesandroid.relay.network.upstream.HermesApiClient
import com.hermesandroid.relay.network.upstream.models.MessageItem
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.OkHttpClient
import okhttp3.WebSocket
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ChatActivityIntegrationTest {
private lateinit var harness: GatewayClientHarness
private lateinit var gatewayScope: CoroutineScope
private lateinit var client: GatewayChatClient
private lateinit var socket: WebSocket
private lateinit var handler: ChatHandler
private lateinit var viewModel: ChatViewModel
private val owner = AgentDisplay.profileContextKey("connection-a", "research")
@Volatile private var history: List<MessageItem> = emptyList()
@Before
fun setUp() {
harness = GatewayClientHarness()
// This harness has a fixed process list for process-controller tests.
// Keep this delegation-only lane independent of those unrelated rows.
harness.methodNotFound.add("process.list")
harness.resumeLiveSessionIds["child-session"] = "child-live"
gatewayScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
client = GatewayChatClient(
initialDashboardClient = DashboardApiClient(
harness.server.url("/").toString().trimEnd('/'), OkHttpClient(),
),
okHttpClient = OkHttpClient(),
callbackDispatcher = { block -> Handler(Looper.getMainLooper()).post(block) },
scope = gatewayScope,
)
handler = ChatHandler().also { it.setSessionId(SESSION) }
viewModel = ChatViewModel().also {
it.initialize(HermesApiClient(harness.server.url("/").toString(), "test-key"), handler)
it.streamingEndpoint = "gateway"
it.setSelectedProfileProvider { Profile(name = "research", model = "model") }
it.setSessionProfileNameProvider { "research" }
it.setProfileMessageLoader { Result.success(history) }
it.setChatActivityStore(InMemoryChatActivityStore())
it.switchProfileContext(owner, SESSION)
it.updateGatewayClient(client)
}
assertTrue(runBlocking { client.prewarmAwait(SESSION) })
socket = harness.awaitServerSocket()
shadowOf(Looper.getMainLooper()).idle()
}
@After
fun tearDown() {
viewModel.updateGatewayClient(null)
client.shutdown()
gatewayScope.cancel()
harness.shutdown()
}
@Test
fun detachedCompletionKeepsHistoryEntryThroughWakeAndClosesOnProfileSwitch() {
viewModel.sendMessage("Inspect the project")
harness.awaitRpc("prompt.submit")
emit("message.start")
emit("subagent.start", childPayload())
awaitCondition { viewModel.activityRecords.value.singleOrNull()?.phase == ChatActivityPhase.RUNNING }
history = listOf(row("prompt", "user", "Inspect the project"), row("parent", "assistant", "Children launched."))
emit("message.complete", buildJsonObject {
put("text", "Children launched.")
put("status", "complete")
})
awaitCondition { !handler.isStreaming.value }
assertEquals(ChatActivityPhase.RUNNING, viewModel.activityRecords.value.single().phase)
emit("subagent.complete", childPayload(completed = true))
awaitCondition { viewModel.activityRecords.value.singleOrNull()?.phase == ChatActivityPhase.COMPLETE }
val completed = viewModel.activityRecords.value.single()
assertEquals("delegation-a", completed.sourceId)
assertEquals("child-session", completed.children.single().childSessionId)
assertTrue(viewModel.openRetainedActivity(completed))
assertEquals(completed.id, viewModel.retainedActivityPreview.value?.record?.id)
val promptCount = harness.rpcLog.count { it.first == "prompt.submit" }
viewModel.openSubagentChildPreview(completed.previewActivities().single().stableKey)
awaitCondition { viewModel.subagentChildPreview.value?.childWatchAvailable == true }
val childResume = harness.rpcLog.single {
it.first == "session.resume" && it.second["session_id"] == JsonPrimitive("child-session")
}.second
assertEquals(JsonPrimitive(true), childResume["lazy"])
assertEquals(JsonPrimitive("research"), childResume["profile"])
assertEquals("live-resumed", client.currentLiveSessionId(SESSION))
assertEquals(promptCount, harness.rpcLog.count { it.first == "prompt.submit" })
val receipt = row("receipt", "user", "[ASYNC DELEGATION COMPLETE — delegation-a]").copy(
rowId = 73,
displayKind = "async_delegation_complete",
displayMetadata = buildJsonObject {
put("delegation_id", "delegation-a")
put("task_count", 1)
put("completed_count", 1)
put("failed_count", 0)
},
)
history = history + receipt + row("wake", "assistant", "Inspection complete.")
emit("message.start")
emit("message.complete", buildJsonObject {
put("text", "Inspection complete.")
put("status", "complete")
})
awaitCondition { handler.messages.value.any { it.id == "receipt" } }
assertEquals(completed.id, viewModel.activityRecords.value.single().id)
val raw = handler.messages.value
val canonical = raw.single { it.id == "receipt" }
val projected = projectChatActivityReceipts(raw, viewModel.activityRecords.value, owner, SESSION)
val displayed = projected.single { it.activityRecord != null }
assertEquals(canonical.id, displayed.id)
assertEquals(canonical.uiKey, displayed.uiKey)
assertEquals(73L, displayed.rowId)
assertEquals(canonical.content, displayed.content)
assertEquals(completed.id, displayed.activityRecord?.id)
assertEquals(raw, handler.messages.value)
assertTrue(handler.messages.value.none { it.activityRecord != null || it.id.startsWith("activity:") })
assertFalse(viewModel.openRetainedActivity(completed.copy(scopeKey = "foreign-owner")))
assertFalse(viewModel.openRetainedActivity(completed.copy(sessionId = "foreign-session")))
assertNotNull(viewModel.retainedActivityPreview.value)
viewModel.switchProfileContext(AgentDisplay.profileContextKey("connection-a", "other"), SESSION)
shadowOf(Looper.getMainLooper()).idle()
assertTrue(viewModel.activityRecords.value.isEmpty())
assertNull(viewModel.retainedActivityPreview.value)
assertNull(viewModel.subagentChildPreview.value)
assertFalse(viewModel.openRetainedActivity(completed))
}
private fun row(id: String, role: String, content: String) = MessageItem(
id = id, sessionId = SESSION, role = role, content = JsonPrimitive(content),
)
private fun childPayload(completed: Boolean = false) = buildJsonObject {
put("delegation_id", "delegation-a")
put("subagent_id", "child-a")
put("child_session_id", "child-session")
put("task_index", 0)
put("task_count", 1)
put("goal", "Inspect the project")
if (completed) {
put("status", "completed")
put("summary", "Inspected the project")
}
}
private fun emit(type: String, payload: JsonObject? = null) {
assertTrue(socket.send(harness.eventFrame(type, payload, "live-resumed")))
}
private fun awaitCondition(condition: () -> Boolean) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (System.nanoTime() < deadline) {
shadowOf(Looper.getMainLooper()).idleFor(20, TimeUnit.MILLISECONDS)
if (condition()) return
Thread.sleep(20)
}
assertTrue("Activity=${viewModel.activityRecords.value}; messages=${handler.messages.value}", condition())
}
companion object {
private const val SESSION = "stored-session"
}
}
@@ -2475,15 +2475,6 @@ class ChatViewModelGatewayInboundTurnTest {
) )
} }
@Test
fun recoveredLegacyContextKeepsExplicitPersistedSubagentProfile() {
assertRecoveredSubagentWatchProfile(
contextKey = "connection-a/profile-default",
persistedProfileKey = "default",
expectedProfile = "default",
)
}
@Test @Test
fun currentServerDefaultCheckpointPersistsExplicitSentinel() { fun currentServerDefaultCheckpointPersistsExplicitSentinel() {
assertCurrentCheckpointProfileKey( assertCurrentCheckpointProfileKey(
@@ -2562,37 +2553,6 @@ class ChatViewModelGatewayInboundTurnTest {
assertEquals(JsonPrimitive("default"), resume["profile"]) assertEquals(JsonPrimitive("default"), resume["profile"])
} }
@Test
fun detachedChildRemainsPreviewableAfterParentCompletesAndAcceptsLateProgress() {
viewModel.sendMessage("Delegate work")
gatewayHarness.awaitRpc("prompt.submit")
val child = buildJsonObject {
put("subagent_id", "detached-child")
put("child_session_id", "detached-session")
put("goal", "Inspect")
}
serverWs.send(gatewayHarness.eventFrame("message.start", null, "live-resumed"))
serverWs.send(gatewayHarness.eventFrame("message.interim", buildJsonObject { put("text", "Delegating now") }, "live-resumed"))
serverWs.send(gatewayHarness.eventFrame("subagent.start", child, "live-resumed"))
awaitCondition { viewModel.subagentActivities.value.size == 1 }
val key = viewModel.subagentActivities.value.single().stableKey
serverWs.send(gatewayHarness.eventFrame("message.complete", buildJsonObject { put("text", "Launched") }, "live-resumed"))
awaitCondition { !handler.isStreaming.value }
assertFalse(viewModel.subagentActivities.value.single().isTerminal)
serverWs.send(gatewayHarness.eventFrame("subagent.progress", buildJsonObject {
put("subagent_id", "detached-child")
put("text", "Still inspecting")
}, "live-resumed"))
awaitCondition { viewModel.subagentActivities.value.single().events.last().text == "Still inspecting" }
assertEquals(key, viewModel.subagentActivities.value.single().stableKey)
assertFalse(handler.isStreaming.value)
serverWs.send(gatewayHarness.eventFrame("subagent.complete", buildJsonObject {
put("subagent_id", "detached-child")
put("status", "completed")
}, "live-resumed"))
awaitCondition { viewModel.subagentActivities.value.single().phase == SubagentActivityPhase.COMPLETED }
}
@Test @Test
fun explicitApprovalActionAloneEmitsResponseAndCollapsesCard() { fun explicitApprovalActionAloneEmitsResponseAndCollapsesCard() {
viewModel.sendMessage("Run the guarded command") viewModel.sendMessage("Run the guarded command")
@@ -14,10 +14,6 @@ import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.mockk import io.mockk.mockk
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import com.hermesandroid.relay.util.HumanError
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer import okhttp3.mockwebserver.MockWebServer
@@ -289,35 +285,6 @@ class ChatViewModelMediaStateTest {
assertEquals("/media/by-path", server.takeRequest().requestUrl?.encodedPath) assertEquals("/media/by-path", server.takeRequest().requestUrl?.encodedPath)
} }
@Test
fun missingDashboardMediaStaysInAttachmentWithoutGlobalError() {
dashboardServer.enqueue(MockResponse().setResponseCode(404).setBody("{\"detail\":\"Path not found\"}"))
viewModel.cellularNetworkOverride = false
viewModel.initializeMedia(
context = RuntimeEnvironment.getApplication(),
relayHttpClient = RelayHttpClient(
okHttpClient = OkHttpClient(), relayUrlProvider = { null },
sessionTokenProvider = { null }, pairedTokenSnapshot = { null },
),
mediaSettingsRepo = MediaSettingsRepository(RuntimeEnvironment.getApplication()),
mediaCacheWriter = cache,
dashboardMediaClientProvider = { DashboardApiClient(baseUrl = dashboardServer.url("/").toString()) },
)
val errors = mutableListOf<HumanError>()
val collector = CoroutineScope(Dispatchers.Unconfined).launch { viewModel.errorEvents.collect { errors.add(it) } }
try {
handler.loadMessageHistory(listOf(MessageItem(
id = "missing-image", role = "assistant", content = JsonPrimitive("MEDIA:/tmp/missing-image.png"),
)))
val failed = awaitMessage { it.attachments.singleOrNull()?.state == AttachmentState.FAILED }
shadowOf(Looper.getMainLooper()).idle()
assertEquals(AttachmentState.FAILED, failed.attachments.single().state)
assertEquals(1, dashboardServer.requestCount)
assertEquals(0, server.requestCount)
assertEquals(emptyList<HumanError>(), errors)
} finally { collector.cancel() }
}
@Test @Test
fun assistantBarePathDoesNotBypassUpstreamSensitiveFileDenial() { fun assistantBarePathDoesNotBypassUpstreamSensitiveFileDenial() {
val path = "/home/user/.ssh/id_ed25519" val path = "/home/user/.ssh/id_ed25519"
@@ -13,31 +13,6 @@ import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
class EffectiveDashboardRouteTest { class EffectiveDashboardRouteTest {
@Test
fun `outgoing resolver endpoint cannot retarget incoming connection credentials`() {
val incomingRoute = EndpointCandidate(
role = "public",
dashboard = DashboardEndpoint("https://b.example.invalid"),
)
val outgoingRoute = EndpointCandidate(
role = "public",
dashboard = DashboardEndpoint("https://a.example.invalid"),
)
val incoming = connection(
dashboardUrl = "https://b.example.invalid",
apiServerUrl = "",
).copy(routeCandidates = listOf(incomingRoute))
assertEquals(
"https://b.example.invalid",
resolveEffectiveDashboardUrl(incoming, outgoingRoute),
)
assertEquals(
"https://b.example.invalid",
resolveEffectiveDashboardUrl(incoming, incomingRoute),
)
}
@Test @Test
fun `late dashboard probe cannot publish across connection or route change`() { fun `late dashboard probe cannot publish across connection or route change`() {
assertTrue( assertTrue(
@@ -107,7 +82,7 @@ class EffectiveDashboardRouteTest {
assertEquals( assertEquals(
"http://100.71.8.56:9119", "http://100.71.8.56:9119",
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(tailscale)), tailscale), resolveEffectiveDashboardUrl(connection, tailscale),
) )
} }
@@ -127,7 +102,7 @@ class EffectiveDashboardRouteTest {
assertEquals( assertEquals(
"https://hermes.example.com", "https://hermes.example.com",
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(tailscale)), tailscale), resolveEffectiveDashboardUrl(connection, tailscale),
) )
assertEquals("http://100.71.8.56:8642", resolveEffectiveApiServerUrl(connection.apiServerUrl, tailscale)) assertEquals("http://100.71.8.56:8642", resolveEffectiveApiServerUrl(connection.apiServerUrl, tailscale))
} }
@@ -163,7 +138,7 @@ class EffectiveDashboardRouteTest {
assertEquals( assertEquals(
"https://hermes.example.com:443", "https://hermes.example.com:443",
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(fallback)), fallback), resolveEffectiveDashboardUrl(connection, fallback),
) )
} }
@@ -181,7 +156,7 @@ class EffectiveDashboardRouteTest {
assertEquals( assertEquals(
"http://100.71.8.56:9119", "http://100.71.8.56:9119",
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(tailscale)), tailscale), resolveEffectiveDashboardUrl(connection, tailscale),
) )
} }
@@ -19,29 +19,6 @@ import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class GatewayProcessControllerTest { class GatewayProcessControllerTest {
@Test
fun retainedStatusListenerOnlyReceivesAuthoritativeSnapshots() = runTest {
val source = FakeProcessSource().apply {
snapshot = listOf(process(id = "p1", status = "running"))
}
val snapshots = mutableListOf<List<GatewayProcess>>()
val controller = GatewayProcessController(this)
controller.setSnapshotListener { snapshots += it }
controller.bind(source, "chat")
controller.sessionReady("chat")
runCurrent()
assertEquals(1, snapshots.size)
source.emit(GatewayProcessEvent.Output("p1", "new output"))
runCurrent()
assertEquals(1, snapshots.size)
source.snapshot = emptyList()
controller.refresh()
runCurrent()
assertEquals(2, snapshots.size)
assertTrue(snapshots.last().isEmpty())
controller.close()
}
@Test @Test
fun duplicateProcessSnapshotsPublishOneComposeIdentityWithLatestState() = runTest { fun duplicateProcessSnapshotsPublishOneComposeIdentityWithLatestState() = runTest {
val source = FakeProcessSource().apply { val source = FakeProcessSource().apply {
@@ -3,7 +3,6 @@ package com.hermesandroid.relay.viewmodel
import android.app.Application import android.app.Application
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import com.hermesandroid.relay.network.upstream.DashboardApiClient import com.hermesandroid.relay.network.upstream.DashboardApiClient
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
@@ -148,14 +147,12 @@ class GitStateWriteViewModelTest {
val vm = viewModel() val vm = viewModel()
selectAlpha(vm) selectAlpha(vm)
enqueuePostSuccess("abc") enqueuePostSuccess("abc")
val committedTarget = CompletableDeferred<GitTarget>() var committedTarget: GitTarget? = null
vm.commit("add feature") { committedTarget.complete(it) } vm.commit("add feature") { committedTarget = it }
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() } withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
// Success is published before detail refresh and the callback complete. assertEquals("alpha", committedTarget?.repoId)
val target = withTimeout(5_000) { committedTarget.await() }
assertEquals("alpha", target.repoId)
} }
@Test @Test
@@ -1,359 +0,0 @@
package com.hermesandroid.relay.viewmodel
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import com.hermesandroid.relay.auth.AuthManager
import com.hermesandroid.relay.auth.AuthState
import com.hermesandroid.relay.auth.SecureStoreCache
import com.hermesandroid.relay.auth.SessionTokenStore
import com.hermesandroid.relay.data.Connection
import com.hermesandroid.relay.data.ConnectionStore
import com.hermesandroid.relay.data.DashboardConnectionStatus
import com.hermesandroid.relay.data.EndpointCandidate
import com.hermesandroid.relay.network.relay.ConnectionManager
import com.hermesandroid.relay.network.upstream.EncryptedNativeDashboardTokenStore
import com.hermesandroid.relay.network.upstream.InMemoryDashboardCookieStore
import com.hermesandroid.relay.network.upstream.NativeDashboardAuthClient
import com.hermesandroid.relay.network.upstream.NativeDashboardTokenStore
import com.hermesandroid.relay.viewmodel.connection.UpstreamTransportController
import io.mockk.every
import io.mockk.mockk
import java.io.File
import java.util.Properties
import java.util.UUID
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
/**
* Extends the switch-coordinator/route-pool seams with real HTTP and production
* token JSON. Only Android Keystore is substituted: its raw string backend is
* file-backed and rereads the file on every access. No NativeDashboardTokens
* object survives a store reload. ConnectionStore owns its real JSON encoding.
* Loopback HTTP is the documented fixture exception, not a production TLS bypass.
*/
class MultiGatewayAuthPersistenceTest {
@get:Rule
val files = TemporaryFolder()
@Test
fun switchingWithOutgoingResolverSnapshotPreservesBothSerializedSessions() = runTest {
Fixture(backgroundScope).use { fixture ->
fixture.initialize()
fixture.signInBoth()
fixture.assertAuthenticated("a")
fixture.assertAuthenticated("b")
// The coordinator publishes B's id before the endpoint resolver
// finishes. Keep A's endpoint deliberately stale through the probe.
fixture.switchTo("b")
fixture.switchTo("a")
fixture.switchTo("b")
fixture.assertBothStored()
assertEquals(0, fixture.a.foreignBearerRequests.get())
assertEquals(0, fixture.b.foreignBearerRequests.get())
assertEquals(0, fixture.a.rejectedRefreshes.get())
assertEquals(0, fixture.b.rejectedRefreshes.get())
fixture.reload()
fixture.assertBothStored()
fixture.assertAuthenticated("a")
fixture.assertAuthenticated("b")
}
}
@Test
fun coldReloadPreservesBothSessionsWithoutAnySwitch() = runTest {
Fixture(backgroundScope).use { fixture ->
fixture.initialize()
fixture.signInBoth()
fixture.reload()
fixture.assertBothStored()
fixture.assertAuthenticated("a")
fixture.assertAuthenticated("b")
}
}
@Test
fun rotatedCredentialsSurviveClientAndStoreRecreation() = runTest {
Fixture(backgroundScope).use { fixture ->
fixture.initialize()
fixture.signInBoth()
fixture.a.rejectCurrentAccess = true
fixture.assertAuthenticated("a")
assertEquals(1, fixture.a.refreshes.get())
assertEquals(0, fixture.b.refreshes.get())
fixture.reload()
fixture.assertAuthenticated("a")
fixture.assertAuthenticated("b")
assertEquals(1, fixture.a.refreshes.get())
}
}
@Test
fun mismatchedAndIneligibleRoutesNeverAttachOrClearStoredBearer() = runTest {
Fixture(backgroundScope).use { fixture ->
fixture.initialize()
fixture.signInBoth()
val wrongRoute = fixture.transport.dashboardClientFor("a", fixture.b.url)
assertFalse(wrongRoute.currentSession().getOrThrow().authenticated)
wrongRoute.shutdown()
fixture.eligible = false
// A new controller ensures this assertion tests eligibility itself,
// independently of the separate cached-client policy transition.
fixture.replaceTransport()
val ineligible = fixture.transport.dashboardClientFor("a", fixture.a.url)
assertFalse(ineligible.currentSession().getOrThrow().authenticated)
ineligible.shutdown()
fixture.assertBothStored()
assertEquals(0, fixture.a.refreshes.get())
assertEquals(0, fixture.b.refreshes.get())
assertEquals(0, fixture.b.foreignBearerRequests.get())
}
}
private inner class Fixture(private val scope: CoroutineScope) : AutoCloseable {
val a = AuthPeer("a")
val b = AuthPeer("b")
private val context = mockk<Context>().also { every { it.applicationContext } returns it }
private val preferences = PreferencesBackend()
private val suffix = UUID.randomUUID().toString()
private val rawStores = listOf("a", "b").associateWith {
FileStrings(files.newFile("$suffix-$it.properties"))
}
private val definitions = listOf(a, b).map { peer ->
Connection(
id = peer.id,
label = peer.id,
apiServerUrl = "",
relayUrl = "",
dashboardUrl = peer.url,
routeCandidates = listOf(EndpointCandidate(
role = "lan",
dashboard = com.hermesandroid.relay.data.DashboardEndpoint(peer.url),
)),
tokenStoreKey = "fixture-$suffix-${peer.id}",
)
}
private var store = ConnectionStore(preferences, scope)
private var endpoint: EndpointCandidate? = null
private var tokenStores = emptyMap<String, NativeDashboardTokenStore>()
var eligible = true
lateinit var transport: UpstreamTransportController
private set
suspend fun initialize() {
store.isHydrated.first { it }
definitions.forEach { store.addConnection(it) }
store.setActiveConnection("a")
endpoint = connection("a").routeCandidates.single()
tokenStores = definitions.associate { connection ->
SecureStoreCache.getOrBuild(connection.tokenStoreKey) { rawStores.getValue(connection.id) }
connection.id to EncryptedNativeDashboardTokenStore(context, connection.tokenStoreKey)
}
replaceTransport()
}
private fun connection(id: String): Connection = store.connections.value.single { it.id == id }
// Same synchronous ownership inputs as ConnectionViewModel.activeDashboardUrl.
// Endpoint publication intentionally lags connection-id publication.
private fun activeUrl(): String = resolveEffectiveDashboardUrl(
store.connections.value.firstOrNull { it.id == store.activeConnectionId.value },
endpoint,
)
fun replaceTransport() {
if (::transport.isInitialized) definitions.forEach { transport.disposeConnectionRouteClients(it.id) }
transport = UpstreamTransportController(
context = context,
activeConnectionIdProvider = { store.activeConnectionId.value },
dashboardUrlProvider = { activeUrl() },
gatewayKeepAliveProvider = { false },
tokenStoreKeyProvider = { connection(it).tokenStoreKey },
trustedDashboardUrlProvider = { id ->
if (id == store.activeConnectionId.value) activeUrl() else connection(id).resolvedDashboardUrl
},
nativeDashboardBearerEligibleProvider = { id ->
eligible && nativeDashboardBearerCompatible(
connection(id).dashboardLastStatus?.authProviders
?.takeIf { it.isNotEmpty() }
?: connection(id).dashboardAuthProviders,
)
},
dashboardTokenStoreFactory = { key ->
tokenStores.getValue(definitions.single { it.tokenStoreKey == key }.id)
},
dashboardCookieStoreFactory = { _, _ -> InMemoryDashboardCookieStore() },
)
}
fun signInBoth() {
listOf(a, b).forEach { peer ->
val client = NativeDashboardAuthClient(peer.url, tokenStores.getValue(peer.id))
val authorization = client.beginAuthorization("http://127.0.0.1:43123/callback")
client.exchangeCallback(authorization, "/callback?code=fixture&state=${authorization.state}")
}
assertBothStored()
}
suspend fun assertAuthenticated(id: String) {
val client = transport.dashboardClientFor(id, connection(id).resolvedDashboardUrl)
try {
val status = client.getStatus().getOrThrow()
assertTrue(status.authRequired)
store.setDashboardStatus(
id,
DashboardConnectionStatus(authRequired = status.authRequired, authProviders = status.authProviders),
)
assertTrue("REST session must authenticate for its owner", client.currentSession().getOrThrow().authenticated)
assertTrue("WebSocket admission must authenticate for its owner", client.requestWsTicket().isSuccess)
} finally {
client.shutdown()
}
}
suspend fun switchTo(id: String) {
val manager = mockk<ConnectionManager>(relaxed = true)
val auth = mockk<AuthManager>(relaxed = true)
every { auth.authState } returns MutableStateFlow<AuthState>(AuthState.Unpaired)
every { auth.hasPairContext } returns false
val coordinator = ConnectionSwitchCoordinator(
connectionStore = store,
connectionManager = manager,
scope = scope,
authManagerFactory = { auth },
installAuthManager = {},
setApiServerUrl = {},
setRelayUrl = {},
persistUrls = { _, _ -> },
rebuildApiClient = {
val client = transport.dashboardClientFor(id, activeUrl())
try {
client.getStatus().getOrThrow()
client.currentSession().getOrThrow()
} finally {
client.shutdown()
}
},
)
transport.resetGatewayForConnectionSwitch()
coordinator.switchConnection(id).join()
endpoint = connection(id).routeCandidates.single()
assertBothStored()
assertAuthenticated(id)
}
fun assertBothStored() {
assertTrue("A's serialized token must remain readable", tokenStores.getValue("a").load() != null)
assertTrue("B's serialized token must remain readable", tokenStores.getValue("b").load() != null)
}
suspend fun reload() {
definitions.forEach { transport.disposeConnectionRouteClients(it.id) }
// ConnectionStore decodes its persisted connections_v1 JSON again;
// the native stores decode their saved JSON from files again.
store = ConnectionStore(preferences, scope)
store.isHydrated.first { it }
tokenStores = definitions.associate {
it.id to EncryptedNativeDashboardTokenStore(context, it.tokenStoreKey)
}
endpoint = connection(store.activeConnectionId.value!!).routeCandidates.single()
replaceTransport()
}
override fun close() {
if (::transport.isInitialized) definitions.forEach { transport.disposeConnectionRouteClients(it.id) }
a.close()
b.close()
}
}
private class AuthPeer(val id: String) : AutoCloseable {
private val server = MockWebServer()
private var generation = 0
@Volatile
var rejectCurrentAccess = false
val refreshes = AtomicInteger()
val rejectedRefreshes = AtomicInteger()
val foreignBearerRequests = AtomicInteger()
val url: String
init {
server.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
if (request.path == "/api/status") {
return MockResponse().setBody(
"""{"auth_required":true,"auth_providers":["basic"],"auth_flows":["cookie","native_pkce"]}""",
)
}
if (request.path == "/auth/native/token") return tokens()
if (request.path == "/auth/native/refresh") {
if (!request.body.readUtf8().contains("fixture-refresh-$id-$generation")) {
rejectedRefreshes.incrementAndGet()
return MockResponse().setResponseCode(401)
}
generation += 1
rejectCurrentAccess = false
refreshes.incrementAndGet()
return tokens()
}
val header = request.getHeader("Authorization")
if (header != null && !header.startsWith("Bearer fixture-access-$id-")) {
foreignBearerRequests.incrementAndGet()
}
if (header != "Bearer fixture-access-$id-$generation" || rejectCurrentAccess) {
return MockResponse().setResponseCode(401)
}
return when (request.path) {
"/api/auth/me" -> MockResponse().setBody("""{"authenticated":true,"provider":"basic"}""")
"/api/auth/ws-ticket" -> MockResponse().setBody("""{"ticket":"fixture-ticket"}""")
else -> MockResponse().setResponseCode(404)
}
}
}
server.start()
url = server.url("/").newBuilder().host("127.0.0.1").build().toString().trimEnd('/')
}
private fun tokens() = MockResponse().setBody(
"""{"access_token":"fixture-access-$id-$generation","refresh_token":"fixture-refresh-$id-$generation","expires_at":4102444800,"provider":"basic"}""",
)
override fun close() = server.shutdown()
}
/** Raw storage seam only: production NativeDashboardTokenStore owns JSON. */
private class FileStrings(private val file: File) : SessionTokenStore {
override val hasHardwareBackedStorage = false
private fun read() = Properties().apply { file.inputStream().use { load(it) } }
private fun write(values: Properties) = file.outputStream().use { values.store(it, null) }
@Synchronized override fun getString(key: String): String? = read().getProperty(key)
@Synchronized override fun putString(key: String, value: String) { write(read().apply { setProperty(key, value) }) }
@Synchronized override fun remove(key: String) { write(read().apply { remove(key) }) }
@Synchronized override fun contains(key: String): Boolean = read().containsKey(key)
@Synchronized override fun clearAll() { write(Properties()) }
}
private class PreferencesBackend : DataStore<Preferences> {
private val state = MutableStateFlow<Preferences>(emptyPreferences())
override val data: Flow<Preferences> = state
override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences =
transform(state.value).also { state.value = it }
}
}
@@ -10,86 +10,6 @@ class SubagentActivityControllerTest {
private var now = 1_000L private var now = 1_000L
private val controller = SubagentActivityController { now++ } private val controller = SubagentActivityController { now++ }
@Test
fun `open sheet keeps completed details across parent wake until dismissed`() {
controller.selectSession("parent", "scope")
controller.beginTurn("parent", "scope", "first")
controller.setPreviewOpen(true)
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child"), null)
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.COMPLETE, subagentId = "child", status = "completed"), null)
controller.beginTurn("parent", "scope", "wake")
assertEquals(SubagentActivityPhase.COMPLETED, controller.activities.value.single().phase)
controller.setPreviewOpen(false)
assertTrue(controller.activities.value.isEmpty())
}
@Test
fun `detached child survives parent completion and a new turn with stable preview identity`() {
controller.selectSession("parent", "scope")
controller.beginTurn("parent", "scope", "parent-turn")
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child"), null)
val key = controller.activities.value.single().stableKey
controller.endTurn("parent-turn")
assertFalse(controller.activities.value.single().isTerminal)
controller.beginTurn("parent", "scope", "next-turn")
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.PROGRESS, preview = "Still working", subagentId = "child"), null)
assertEquals(key, controller.activities.value.single().stableKey)
assertEquals("Still working", controller.activities.value.single().events.last().text)
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.COMPLETE, status = "completed", subagentId = "child"), null)
assertEquals(SubagentActivityPhase.COMPLETED, controller.activities.value.single().phase)
controller.beginTurn("parent", "scope", "completion-wake")
assertTrue(controller.activities.value.isEmpty())
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child"), null)
assertTrue(controller.activities.value.isEmpty())
}
@Test
fun `idle session accepts child start but not orphan progress or other profile`() {
controller.selectSession("parent", "scope")
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.PROGRESS, subagentId = "unknown"), null)
controller.onSessionEvent("parent", "foreign", event(0, GatewaySubagentEvent.Phase.START, subagentId = "foreign"), null)
assertTrue(controller.activities.value.isEmpty())
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child"), null)
assertFalse(controller.activities.value.single().isTerminal)
controller.interrupt()
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "late"), null)
assertEquals(SubagentActivityPhase.INTERRUPTED, controller.activities.value.single().phase)
}
@Test
fun `concurrent batches reusing task index remain separate across turns`() {
controller.selectSession("parent", "scope")
controller.beginTurn("parent", "scope", "first")
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child-a"), null)
controller.beginTurn("parent", "scope", "second")
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.START, subagentId = "child-b"), null)
controller.onSessionEvent("parent", "scope", event(0, GatewaySubagentEvent.Phase.COMPLETE, status = "completed", subagentId = "child-a"), null)
assertEquals(2, controller.activities.value.size)
assertFalse(controller.activities.value.single { it.subagentId == "child-b" }.isTerminal)
}
@Test
fun `timeouts and unknown terminal statuses never report success`() {
controller.selectSession("parent", "scope")
controller.beginTurn("parent", "scope", "turn")
listOf("timeout", "error", "unknown", "running").forEachIndexed { index, status ->
controller.onSessionEvent("parent", "scope", event(index, GatewaySubagentEvent.Phase.START, subagentId = "child-$index"), null)
controller.onSessionEvent("parent", "scope", event(index, GatewaySubagentEvent.Phase.COMPLETE, status = status, subagentId = "child-$index"), null)
}
assertTrue(controller.activities.value.all { it.phase == SubagentActivityPhase.FAILED })
}
@Test
fun `child preview keeps its admitted profile after parent checkpoint is cleared`() {
controller.selectSession("parent", "scope")
controller.beginTurn("parent", "scope", "turn")
val start = event(0, GatewaySubagentEvent.Phase.START, subagentId = "child")
controller.onSessionEvent("parent", "scope", start, "default")
controller.endTurn("turn")
controller.onSessionEvent("parent", "scope", start.copy(phase = GatewaySubagentEvent.Phase.PROGRESS), null)
assertEquals("default", controller.activities.value.single().profile)
}
@Test @Test
fun `interleaved children retain independent lifecycle previews`() { fun `interleaved children retain independent lifecycle previews`() {
controller.selectSession("parent", "connection::default") controller.selectSession("parent", "connection::default")
+2 -2
View File
@@ -1,6 +1,6 @@
plugins { plugins {
id("com.android.application") version "9.4.0" apply false id("com.android.application") version "9.3.2" apply false
id("com.android.library") version "9.4.0" apply false id("com.android.library") version "9.3.2" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.4.10" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10" apply false
} }
-10
View File
@@ -46,16 +46,6 @@ Android workflow from the selected `dev` ref.
## Local use ## Local use
### Message appearance previews
In a development build, open Developer settings → Message previews to exercise
the production info, success, progress, warning, error, and Retry/Dismiss surfaces.
These samples make no network requests and do not change connection state.
Progress remains visible until replaced or cleared. Clear previews and leaving
the screen remove only sample messages, preserving real app feedback. Use these
controls for on-device theme, text sizing, readability, and action checks; they
do not substitute for transport/error-routing tests.
The Windows `scripts/dev.bat` commands and `scripts/android-prepush.py` acquire The Windows `scripts/dev.bat` commands and `scripts/android-prepush.py` acquire
the lane automatically. For an ad hoc Gradle command, use: the lane automatically. For an ad hoc Gradle command, use:
+1 -7
View File
@@ -3982,13 +3982,7 @@ compression path instead of forking the relationship. New Bot creates an upstrea
only the small `hermes-bots` metadata marker; profile skills/model remain managed only the small `hermes-bots` metadata marker; profile skills/model remain managed
through the established Hermes surfaces. through the established Hermes surfaces.
Every Bot owner is the immutable `(connectionId, profile)` pair. Both the active Every Bot owner is the immutable `(connectionId, profile)` pair. A typed route
Bot strip and conversation list use that pair for stable Compose item identity;
opening progress belongs to the same exact owner. Profile names, display labels,
and handles alone are not unique across installations. Upstream
[`profiles.list`](https://github.com/NousResearch/hermes-agent/blob/2db0c7a2d8f29debe7d1cbfb4a72f4f98dc00808/tui_gateway/methods_profiles.py)
returns installation-local profile names and profile-local session summaries.
A typed route
pool holds separate clients for separate owners, validates bearer authority pool holds separate clients for separate owners, validates bearer authority
against that connection's exact trusted Dashboard base, adds the profile to the against that connection's exact trusted Dashboard base, adds the profile to the
WebSocket URL, mints a fresh one-use ticket on every dial, and uses request or WebSocket URL, mints a fresh one-use ticket on every dial, and uses request or
-10
View File
@@ -68,8 +68,6 @@ the upstream contract identifiers it depends on.
|---|---| |---|---|
| `initial_history_bind` | Durable, profile-scoped history is already available when the client resumes and first binds its rendered transcript | | `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 | | `ordinary_turn` | Normal message start, deltas, completion, and persisted history |
| `session_initialization_failure` | Exact-session initialization error arrives before a lazy create acknowledgement; Android must fail the pending send without waiting for the readiness deadline |
| `subagent_child_preview` | Child activity continues after the parent terminal, followed by child completion and a separate completion wake; preview ownership remains on the same profile/session |
| `ownership_rejection` | A submit acknowledged before the defense-in-depth ownership check emits the canonical terminal refusal; no user/model row is persisted and clients must not enter history recovery | | `ownership_rejection` | A submit acknowledged before the defense-in-depth ownership check emits the canonical terminal refusal; no user/model row is persisted and clients must not enter history recovery |
| `compaction_status` | Compaction status is client-visible before terminal completion and may repeat as a heartbeat | | `compaction_status` | Compaction status is client-visible before terminal completion and may repeat as a heartbeat |
| `rapid_tools_interims` | Rapid chunks, reasoning, tool activity, and interim assistant boundaries | | `rapid_tools_interims` | Rapid chunks, reasoning, tool activity, and interim assistant boundaries |
@@ -84,14 +82,6 @@ the upstream contract identifiers it depends on.
| `active_status_unsupported` | An older Gateway returns JSON-RPC method-not-found; the client retains Unknown rather than inventing Idle or Working | | `active_status_unsupported` | An older Gateway returns JSON-RPC method-not-found; the client retains Unknown rather than inventing Idle or Working |
| `cross_client_observation` | A second client observes a Desktop-owned working session through active status and history without resume, activate, submit, or interrupt; the producing client receives the terminal event | | `cross_client_observation` | A second client observes a Desktop-owned working session through active status and history without resume, activate, submit, or interrupt; the producing client receives the terminal event |
Activity receipts join canonical completion metadata by exact delegation identity
within the connection/profile/session owner. Process notices use the canonical
process ID; ambiguous reused process generations do not attach cached output.
Local metadata recovery never asserts live execution, and receipt projection
does not modify the transport/model/voice transcript. Controller/store tests
cover late identity enrichment, partial groups, stale loads, removal, and bounded
retention; rendered tests cover active-strip disappearance and historical controls.
Fixture evidence is a bounded metadata-only ring. It records sequence, Fixture evidence is a bounded metadata-only ring. It records sequence,
connection number, RPC method, event type, scope classification, and outcome. connection number, RPC method, event type, scope classification, and outcome.
It never records prompts, responses, RPC parameters, credentials, URLs, It never records prompts, responses, RPC parameters, credentials, URLs,
+6 -6
View File
@@ -13,7 +13,7 @@
"verification": "ai-translated", "verification": "ai-translated",
"review_refs": [], "review_refs": [],
"source_sha256": { "source_sha256": {
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc", "main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
}, },
"surfaces": { "surfaces": {
@@ -48,7 +48,7 @@
"verification": "ai-translated", "verification": "ai-translated",
"review_refs": [], "review_refs": [],
"source_sha256": { "source_sha256": {
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc", "main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
}, },
"surfaces": { "surfaces": {
@@ -72,7 +72,7 @@
"verification": "ai-translated", "verification": "ai-translated",
"review_refs": [], "review_refs": [],
"source_sha256": { "source_sha256": {
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc", "main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
}, },
"surfaces": { "surfaces": {
@@ -96,7 +96,7 @@
"verification": "ai-translated", "verification": "ai-translated",
"review_refs": [], "review_refs": [],
"source_sha256": { "source_sha256": {
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc", "main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
}, },
"surfaces": { "surfaces": {
@@ -120,7 +120,7 @@
"verification": "ai-translated", "verification": "ai-translated",
"review_refs": [], "review_refs": [],
"source_sha256": { "source_sha256": {
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc", "main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
}, },
"surfaces": { "surfaces": {
@@ -135,7 +135,7 @@
"verification": "ai-translated", "verification": "ai-translated",
"review_refs": [], "review_refs": [],
"source_sha256": { "source_sha256": {
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc", "main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2" "sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
}, },
"surfaces": { "surfaces": {
+1 -1
View File
@@ -496,7 +496,7 @@ Errors: relay connect-error / timeout / 5xx → `502 Bad Gateway` with a human-r
## Health Check ## Health Check
```bash ```bash
curl http://127.0.0.1:8767/health curl http://localhost:8767/health
``` ```
Returns JSON with server status and version. Returns JSON with server status and version.
+1 -1
View File
@@ -938,7 +938,7 @@ Uses `asyncio.create_subprocess_exec` with PTY for non-blocking I/O. tmux gives
Wraps the existing relay protocol. When the agent calls `android_*` tools, the tool handler routes through the relay server's bridge channel to the phone. Wraps the existing relay protocol. When the agent calls `android_*` tools, the tool handler routes through the relay server's bridge channel to the phone.
**Change from upstream:** The bridge channel is part of the multiplexed WSS connection instead of a separate `ws://` relay on port 8766. The legacy standalone `plugin/tools/android_relay.py` was retired in Phase 3 Wave 1 (2026-04-12) and its functionality migrated to two files in the unified relay: `plugin/tools/android_tool.py` (Hermes tools pointing at `http://127.0.0.1:8767` by default — baseline 14 plus v0.4 expansion) and `plugin/relay/channels/bridge.py` (the `BridgeHandler.handle_command(...)` dispatcher that mints request IDs, sends `bridge.command` envelopes over the shared WSS pipe, and awaits matching `bridge.response` envelopes with a 30s timeout). HTTP routes are registered on `plugin/relay/server.py` between `# === PHASE3-bridge-server ===` markers and delegate through the same handler. Wire protocol is frozen — envelopes match the legacy relay byte-for-byte. **Change from upstream:** The bridge channel is part of the multiplexed WSS connection instead of a separate `ws://` relay on port 8766. The legacy standalone `plugin/tools/android_relay.py` was retired in Phase 3 Wave 1 (2026-04-12) and its functionality migrated to two files in the unified relay: `plugin/tools/android_tool.py` (Hermes tools pointing at `http://localhost:8767` — baseline 14 plus v0.4 expansion) and `plugin/relay/channels/bridge.py` (the `BridgeHandler.handle_command(...)` dispatcher that mints request IDs, sends `bridge.command` envelopes over the shared WSS pipe, and awaits matching `bridge.response` envelopes with a 30s timeout). HTTP routes are registered on `plugin/relay/server.py` between `# === PHASE3-bridge-server ===` markers and delegate through the same handler. Wire protocol is frozen — envelopes match the legacy relay byte-for-byte.
#### 6.4.1 `android_*` tool surface #### 6.4.1 `android_*` tool surface
+2 -2
View File
@@ -1,7 +1,7 @@
[versions] [versions]
appVersionName = "1.15.1" appVersionName = "1.15.1"
appVersionCode = "54" appVersionCode = "54"
agp = "9.4.0" agp = "9.3.2"
kotlin = "2.4.10" kotlin = "2.4.10"
compose-bom = "2026.08.00" compose-bom = "2026.08.00"
navigation-compose = "2.10.0" navigation-compose = "2.10.0"
@@ -22,7 +22,7 @@ exifinterface = "1.4.2"
datastore = "1.2.1" datastore = "1.2.1"
splashscreen = "1.2.0" splashscreen = "1.2.0"
markdown-renderer = "0.45.0" markdown-renderer = "0.45.0"
coil = "3.6.2" coil = "3.6.0"
haze = "1.7.3" haze = "1.7.3"
mlkit-barcode = "17.3.0" mlkit-barcode = "17.3.0"
zxing-core = "3.5.4" zxing-core = "3.5.4"
+3 -3
View File
@@ -42,7 +42,7 @@ Off by default:
Environment variables (env wins over config.yaml ``extra``): Environment variables (env wins over config.yaml ``extra``):
PHONE_ENABLED "1"/"true"/"yes"/"on" enables the platform (required) PHONE_ENABLED "1"/"true"/"yes"/"on" enables the platform (required)
PHONE_RELAY_URL Relay base URL. Default: reuse ANDROID_BRIDGE_URL, PHONE_RELAY_URL Relay base URL. Default: reuse ANDROID_BRIDGE_URL,
else http://127.0.0.1:{ANDROID_RELAY_PORT|RELAY_PORT|8767} else http://localhost:{ANDROID_RELAY_PORT|RELAY_PORT|8767}
PHONE_RELAY_TOKEN Optional bearer for the relay POST (loopback is PHONE_RELAY_TOKEN Optional bearer for the relay POST (loopback is
unauthenticated by default; sent only if set) unauthenticated by default; sent only if set)
PHONE_HOME_CHANNEL Default chat_id for cron / home-channel delivery PHONE_HOME_CHANNEL Default chat_id for cron / home-channel delivery
@@ -156,7 +156,7 @@ def _relay_base_url() -> str:
Honors ``PHONE_RELAY_URL`` first, then reuses the same convention as Honors ``PHONE_RELAY_URL`` first, then reuses the same convention as
``plugin/tools/android_tool.py`` (``ANDROID_BRIDGE_URL`` / ``plugin/tools/android_tool.py`` (``ANDROID_BRIDGE_URL`` /
``ANDROID_RELAY_PORT`` / ``RELAY_PORT``) so a single override flips both ``ANDROID_RELAY_PORT`` / ``RELAY_PORT``) so a single override flips both
the android tools and this adapter. Defaults to ``http://127.0.0.1:8767``. the android tools and this adapter. Defaults to ``http://localhost:8767``.
""" """
explicit = os.getenv("PHONE_RELAY_URL", "").strip() explicit = os.getenv("PHONE_RELAY_URL", "").strip()
if explicit: if explicit:
@@ -165,7 +165,7 @@ def _relay_base_url() -> str:
if bridge: if bridge:
return bridge.rstrip("/") return bridge.rstrip("/")
port = os.getenv("ANDROID_RELAY_PORT", os.getenv("RELAY_PORT", "8767")).strip() or "8767" port = os.getenv("ANDROID_RELAY_PORT", os.getenv("RELAY_PORT", "8767")).strip() or "8767"
return f"http://127.0.0.1:{port}" return f"http://localhost:{port}"
def _home_channel() -> str: def _home_channel() -> str:
-41
View File
@@ -34,7 +34,6 @@ if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT)) sys.path.insert(0, str(REPO_ROOT))
from plugin.tools import android_navigate as nav # noqa: E402 from plugin.tools import android_navigate as nav # noqa: E402
from plugin.tools import android_tool # noqa: E402
from plugin.tools.android_navigate_prompt import ( # noqa: E402 from plugin.tools.android_navigate_prompt import ( # noqa: E402
ParsedAction, ParsedAction,
VALID_ACTIONS, VALID_ACTIONS,
@@ -242,46 +241,6 @@ def _mk_screenshot(token: str = "hermes-relay://fake-token") -> nav._Screenshot:
return nav._Screenshot(token=token, local_path="/tmp/fake.jpg") return nav._Screenshot(token=token, local_path="/tmp/fake.jpg")
class TestSharedBridgeTransport(unittest.TestCase):
def test_uses_the_canonical_unified_relay_config(self) -> None:
self.assertIs(nav._bridge_request, android_tool._bridge_request)
with mock.patch.dict(
os.environ,
{"ANDROID_BRIDGE_URL": "", "ANDROID_BRIDGE_TIMEOUT": "30"},
):
os.environ.pop("ANDROID_BRIDGE_URL")
self.assertEqual(android_tool._bridge_url(), "http://127.0.0.1:8767")
def test_preserves_explicit_loopback_overrides(self) -> None:
for override in ("http://localhost:8767", "http://[::1]:8767"):
with self.subTest(override=override), mock.patch.dict(
os.environ, {"ANDROID_BRIDGE_URL": override}
):
self.assertEqual(android_tool._bridge_url(), override)
def test_get_uses_android_tool_bridge_transport(self) -> None:
response = mock.Mock()
response.json.return_value = {"ok": True}
with mock.patch.object(
nav, "_bridge_request", return_value=response
) as request:
self.assertEqual(nav._get("/screen"), {"ok": True})
request.assert_called_once_with("GET", "/screen", timeout=5.0)
response.raise_for_status.assert_called_once_with()
def test_post_uses_android_tool_bridge_transport(self) -> None:
response = mock.Mock()
response.json.return_value = {"ok": True}
with mock.patch.object(
nav, "_bridge_request", return_value=response
) as request:
self.assertEqual(nav._post("/tap", {"x": 1, "y": 2}), {"ok": True})
request.assert_called_once_with(
"POST", "/tap", json={"x": 1, "y": 2}, timeout=5.0
)
response.raise_for_status.assert_called_once_with()
class TestNavigateLoop(unittest.TestCase): class TestNavigateLoop(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
# Belt-and-suspenders: make sure the stub env var never leaks # Belt-and-suspenders: make sure the stub env var never leaks
+1 -254
View File
@@ -1,14 +1,10 @@
import json import json
import os import os
import sys
import time
from pathlib import Path
from unittest import mock from unittest import mock
import responses import responses
import pytest import pytest
# Import tool functions directly (not via registry) # Import tool functions directly (not via registry)
from plugin.tools import android_tool
from plugin.tools.android_tool import ( from plugin.tools.android_tool import (
android_ping, android_ping,
android_read_screen, android_read_screen,
@@ -322,33 +318,6 @@ class TestCurrentApp:
class TestSetup: class TestSetup:
@pytest.fixture(autouse=True)
def _isolate_home(self, monkeypatch, tmp_path):
"""Keep android_setup away from the developer's real ~/.hermes/.env.
android_setup persists ANDROID_BRIDGE_* by writing the host env file
(via hermes_cli.config.save_env_value when importable, otherwise
Path.home()/".hermes"/".env"). Without this fixture the suite
overwrites the real paired session token on the machine running the
tests — which silently 401s every subsequent android_* call.
"""
fake_home = tmp_path / "home"
(fake_home / ".hermes").mkdir(parents=True)
monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home))
monkeypatch.setenv("HERMES_HOME", str(fake_home / ".hermes"))
monkeypatch.setitem(
sys.modules,
"hermes_constants",
mock.Mock(get_hermes_home=lambda: fake_home / ".hermes"),
)
monkeypatch.delenv("ANDROID_BRIDGE_TOKEN", raising=False)
monkeypatch.delenv("ANDROID_BRIDGE_URL", raising=False)
monkeypatch.delenv("ANDROID_RELAY_PORT", raising=False)
monkeypatch.delenv("RELAY_PORT", raising=False)
android_tool._reset_token_cache()
yield
android_tool._reset_token_cache()
@responses.activate @responses.activate
def test_setup_saves_config(self, monkeypatch): def test_setup_saves_config(self, monkeypatch):
"""android_setup saves pairing code and sets env vars.""" """android_setup saves pairing code and sets env vars."""
@@ -358,226 +327,4 @@ class TestSetup:
result = json.loads(android_setup("ABC123")) result = json.loads(android_setup("ABC123"))
# Config should be saved regardless of relay import # Config should be saved regardless of relay import
assert os.environ.get("ANDROID_BRIDGE_TOKEN") == "ABC123" assert os.environ.get("ANDROID_BRIDGE_TOKEN") == "ABC123"
assert os.environ.get("ANDROID_BRIDGE_URL") == "http://127.0.0.1:8767" assert "localhost" in os.environ.get("ANDROID_BRIDGE_URL", "")
assert any(
call.request.url == "http://127.0.0.1:8767/health"
for call in responses.calls
)
assert "ANDROID_BRIDGE_TOKEN=ABC123" in (
Path(os.environ["HERMES_HOME"]) / ".env"
).read_text()
assert "ANDROID_BRIDGE_URL=http://127.0.0.1:8767" in (
Path(os.environ["HERMES_HOME"]) / ".env"
).read_text()
@responses.activate
def test_setup_accepts_legacy_pairing_code_kwarg(self, monkeypatch):
"""The published schema key `pairing_code` must not raise TypeError.
Regression: the schema advertised a required `pairing_code` while the
function signature had been renamed to `bridge_session_token`, so
every schema-conformant call died with
`TypeError: unexpected keyword argument 'pairing_code'`.
"""
responses.add(responses.GET, "https://api.ipify.org", body="1.2.3.4")
result = json.loads(_HANDLERS["android_setup"]({"pairing_code": "LEGACY1"}))
assert result["status"] != "error" or "token" not in result.get("message", "")
assert os.environ.get("ANDROID_BRIDGE_TOKEN") == "LEGACY1"
@responses.activate
def test_setup_accepts_canonical_kwarg(self, monkeypatch):
responses.add(responses.GET, "https://api.ipify.org", body="1.2.3.4")
_HANDLERS["android_setup"]({"bridge_session_token": "CANON1"})
assert os.environ.get("ANDROID_BRIDGE_TOKEN") == "CANON1"
def test_setup_without_token_returns_structured_error(self):
result = json.loads(_HANDLERS["android_setup"]({}))
assert result["status"] == "error"
assert "bridge_session_token" in result["message"]
def test_setup_schema_has_no_required_key(self):
"""Either spelling is valid, so neither may be schema-required."""
params = _SCHEMAS["android_setup"]["parameters"]
assert params.get("required") == []
assert "bridge_session_token" in params["properties"]
assert "pairing_code" in params["properties"]
class TestBridgeTokenResolution:
"""`_bridge_token` must survive a token written after process start."""
def _clear(self, monkeypatch):
monkeypatch.delenv("ANDROID_BRIDGE_TOKEN", raising=False)
android_tool._reset_token_cache()
def test_env_var_wins(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("ANDROID_BRIDGE_TOKEN", "from-env")
android_tool._reset_token_cache()
(tmp_path / ".env").write_text("ANDROID_BRIDGE_TOKEN=from-file\n")
assert android_tool._bridge_token() == "from-env"
def test_falls_back_to_env_file(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
self._clear(monkeypatch)
(tmp_path / ".env").write_text(
"# comment\nOTHER=x\nANDROID_BRIDGE_TOKEN=\"from-file\"\n"
)
assert android_tool._bridge_token() == "from-file"
def test_falls_back_to_sessions_file(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
self._clear(monkeypatch)
(tmp_path / "hermes-relay-sessions.json").write_text(
json.dumps({
"sessions": [
{
"token": "older",
"last_seen": 1.0,
"expires_at": "never",
"grants": {"bridge": "never"},
},
{
"token": "newest",
"last_seen": 2.0,
"expires_at": "never",
"grants": {"bridge": "never"},
},
]
})
)
assert android_tool._bridge_token() == "newest"
def test_returns_none_when_nothing_configured(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
self._clear(monkeypatch)
assert android_tool._bridge_token() is None
def test_auth_header_uses_resolved_token(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
self._clear(monkeypatch)
(tmp_path / ".env").write_text("ANDROID_BRIDGE_TOKEN=hdr-token\n")
assert android_tool._auth_headers() == {
"Authorization": "Bearer hdr-token"
}
def test_uses_request_scoped_profile_home(self, monkeypatch, tmp_path):
launch_home = tmp_path / "launch"
profile_home = tmp_path / "profile"
launch_home.mkdir()
profile_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(launch_home))
monkeypatch.setenv("ANDROID_BRIDGE_TOKEN", "launch-token")
monkeypatch.setitem(
sys.modules,
"hermes_constants",
mock.Mock(get_hermes_home=lambda: profile_home),
)
(profile_home / ".env").write_text("ANDROID_BRIDGE_TOKEN=profile-token\n")
android_tool._reset_token_cache()
assert android_tool._bridge_token() == "profile-token"
def test_cache_is_scoped_to_profile_home(self, monkeypatch, tmp_path):
active_home = tmp_path / "one"
active_home.mkdir()
monkeypatch.delenv("ANDROID_BRIDGE_TOKEN", raising=False)
monkeypatch.setitem(
sys.modules,
"hermes_constants",
mock.Mock(get_hermes_home=lambda: active_home),
)
(active_home / ".env").write_text("ANDROID_BRIDGE_TOKEN=one\n")
android_tool._reset_token_cache()
accepted = mock.Mock(status_code=200)
with mock.patch.object(android_tool.requests, "get", return_value=accepted):
android_tool._bridge_request("GET", "/ping")
assert android_tool._token_cache[active_home][1] == "one"
active_home = tmp_path / "two"
active_home.mkdir()
(active_home / ".env").write_text("ANDROID_BRIDGE_TOKEN=two\n")
assert android_tool._bridge_token() == "two"
def test_skips_expired_or_ungranted_sessions(self, monkeypatch, tmp_path):
now = time.time()
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
self._clear(monkeypatch)
(tmp_path / "hermes-relay-sessions.json").write_text(json.dumps({
"sessions": [
{
"token": "valid",
"last_seen": 1.0,
"expires_at": now + 60,
"grants": {"bridge": now + 60},
},
{
"token": "newer-expired-session",
"last_seen": 2.0,
"expires_at": now - 1,
"grants": {"bridge": now + 60},
},
{
"token": "newer-expired-bridge",
"last_seen": 3.0,
"expires_at": now + 60,
"grants": {"bridge": now - 1},
},
{
"token": "newest-no-bridge",
"last_seen": 4.0,
"expires_at": now + 60,
"grants": {},
},
]
}))
assert android_tool._bridge_token() == "valid"
def test_auth_denial_retries_disk_token_and_caches_success(
self, monkeypatch, tmp_path
):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("ANDROID_BRIDGE_TOKEN", "startup-stale")
(tmp_path / ".env").write_text("ANDROID_BRIDGE_TOKEN=rotated-live\n")
android_tool._reset_token_cache()
denied = mock.Mock(status_code=401)
accepted = mock.Mock(status_code=200)
with mock.patch.object(
android_tool.requests,
"get",
side_effect=[denied, accepted],
) as request:
response = android_tool._bridge_request("GET", "/ping")
assert response.status_code == 200
assert request.call_args_list[0].kwargs["headers"]["Authorization"] == (
"Bearer startup-stale"
)
assert request.call_args_list[1].kwargs["headers"]["Authorization"] == (
"Bearer rotated-live"
)
assert android_tool._bridge_token() == "rotated-live"
def test_auth_denial_stops_after_distinct_candidates(
self, monkeypatch, tmp_path
):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("ANDROID_BRIDGE_TOKEN", "startup-stale")
(tmp_path / ".env").write_text("ANDROID_BRIDGE_TOKEN=file-stale\n")
now = time.time()
(tmp_path / "hermes-relay-sessions.json").write_text(json.dumps({
"sessions": [{
"token": "session-stale",
"last_seen": now,
"expires_at": now + 60,
"grants": {"bridge": now + 60},
}]
}))
android_tool._reset_token_cache()
denied = mock.Mock(status_code=401)
with mock.patch.object(
android_tool.requests,
"get",
return_value=denied,
) as request:
response = android_tool._bridge_request("GET", "/ping")
assert response is denied
assert request.call_count == 3
@@ -9,7 +9,6 @@ from plugin.tools import android_tool
class _FakeResponse: class _FakeResponse:
def __init__(self, payload): def __init__(self, payload):
self._payload = payload self._payload = payload
self.status_code = 200
def raise_for_status(self): def raise_for_status(self):
return None return None
@@ -1,253 +0,0 @@
"""Focused tests for shared Desktop tool availability snapshots."""
from __future__ import annotations
import os
import threading
import unittest
from unittest.mock import patch
import requests
from plugin.tools import desktop_tool
class _Response:
def __init__(self, body, status_code: int = 200) -> None:
self._body = body
self.status_code = status_code
def json(self):
if isinstance(self._body, Exception):
raise self._body
return self._body
def _health(*, connected=True, clients=None, advertised_tools=None):
body = {
"connected": connected,
"advertised_tools": advertised_tools or [],
}
if clients is not None:
body["clients"] = [
{"device_id": f"pc-{index}", "advertised_tools": tools}
for index, tools in enumerate(clients)
]
return body
class DesktopToolAvailabilityTests(unittest.TestCase):
def setUp(self) -> None:
desktop_tool._clear_availability_cache()
def tearDown(self) -> None:
desktop_tool._clear_availability_cache()
def test_default_relay_url_uses_ipv4_loopback_and_override_is_preserved(self) -> None:
with patch.dict(os.environ, {}, clear=True):
self.assertEqual(desktop_tool._relay_url(), "http://127.0.0.1:8767")
with patch.dict(os.environ, {"DESKTOP_RELAY_URL": "http://[::1]:9876"}, clear=True):
self.assertEqual(desktop_tool._relay_url(), "http://[::1]:9876")
def test_one_health_snapshot_serves_tools_and_desktop_health(self) -> None:
response = _Response(
_health(
clients=[["desktop_read_file", "desktop_health"]],
advertised_tools=["desktop_read_file", "desktop_health"],
)
)
with patch.object(desktop_tool.requests, "get", return_value=response) as get:
self.assertTrue(desktop_tool._check_tool("desktop_read_file"))
self.assertFalse(desktop_tool._check_tool("desktop_terminal"))
self.assertTrue(desktop_tool._check_relay())
get.assert_called_once()
self.assertTrue(get.call_args.args[0].endswith("/desktop/health"))
def test_multi_client_disjoint_tools_and_legacy_client_match_handler_semantics(self) -> None:
response = _Response(
_health(
clients=[["desktop_read_file"], ["desktop_terminal"], []],
advertised_tools=["desktop_terminal"],
)
)
with patch.object(desktop_tool.requests, "get", return_value=response):
self.assertTrue(desktop_tool._check_tool("desktop_read_file"))
self.assertTrue(desktop_tool._check_tool("desktop_terminal"))
self.assertTrue(desktop_tool._check_tool("desktop_checksum"))
self.assertFalse(desktop_tool._check_tool("desktop_computer_status"))
def test_old_health_response_without_clients_uses_legacy_rules(self) -> None:
with patch.object(
desktop_tool.requests,
"get",
return_value=_Response(_health(advertised_tools=["desktop_terminal"])),
):
self.assertTrue(desktop_tool._check_tool("desktop_terminal"))
self.assertFalse(desktop_tool._check_tool("desktop_read_file"))
desktop_tool._clear_availability_cache()
with patch.object(
desktop_tool.requests,
"get",
return_value=_Response(_health(advertised_tools=[])),
):
self.assertTrue(desktop_tool._check_tool("desktop_read_file"))
self.assertFalse(desktop_tool._check_tool("desktop_computer_status"))
def test_reachable_no_client_keeps_health_available_but_tools_unavailable(self) -> None:
with patch.object(
desktop_tool.requests,
"get",
return_value=_Response(_health(connected=False, clients=[])),
):
self.assertTrue(desktop_tool._check_relay())
self.assertFalse(desktop_tool._check_tool("desktop_read_file"))
def test_unreachable_relay_disables_health_and_tools_from_one_cached_miss(self) -> None:
with patch.object(
desktop_tool.requests,
"get",
side_effect=requests.ConnectionError("offline"),
) as get:
self.assertFalse(desktop_tool._check_relay())
self.assertFalse(desktop_tool._check_tool("desktop_read_file"))
get.assert_called_once()
def test_malformed_availability_fields_fail_closed(self) -> None:
malformed = [
{},
{"connected": 1, "advertised_tools": [], "clients": []},
{"connected": False, "advertised_tools": "desktop_read_file", "clients": []},
{"connected": True, "advertised_tools": [], "clients": "invalid"},
{"connected": True, "advertised_tools": [], "clients": []},
{"connected": False, "advertised_tools": ["desktop_read_file"], "clients": []},
{"connected": True, "advertised_tools": [], "clients": [{}]},
{"connected": True, "advertised_tools": [], "clients": [["desktop_read_file"]]},
{"connected": True, "advertised_tools": [], "clients": [{"advertised_tools": [1]}]},
]
for body in malformed:
with self.subTest(body=body):
desktop_tool._clear_availability_cache()
with patch.object(desktop_tool.requests, "get", return_value=_Response(body)):
self.assertFalse(desktop_tool._check_relay())
self.assertFalse(desktop_tool._check_tool("desktop_read_file"))
def test_slow_probe_receives_full_ttl_after_it_completes(self) -> None:
clock = [0.0]
def get(*args, **kwargs):
clock[0] = 10.0
return _Response(_health(connected=False, clients=[]))
with (
patch.object(desktop_tool.time, "monotonic", side_effect=lambda: clock[0]),
patch.object(desktop_tool.requests, "get", side_effect=get) as request,
):
self.assertTrue(desktop_tool._check_relay())
clock[0] = 12.9
self.assertTrue(desktop_tool._check_relay())
self.assertEqual(request.call_count, 1)
clock[0] = 13.0
self.assertTrue(desktop_tool._check_relay())
self.assertEqual(request.call_count, 2)
def test_endpoint_and_token_changes_do_not_reuse_a_snapshot(self) -> None:
response = _Response(_health(connected=False, clients=[]))
with patch.object(desktop_tool.requests, "get", return_value=response) as get:
with patch.dict(
os.environ,
{"DESKTOP_RELAY_URL": "http://127.0.0.1:8767", "DESKTOP_RELAY_TOKEN": "one"},
clear=True,
):
self.assertTrue(desktop_tool._check_relay())
self.assertTrue(desktop_tool._check_relay())
with patch.dict(
os.environ,
{"DESKTOP_RELAY_URL": "http://127.0.0.1:8767", "DESKTOP_RELAY_TOKEN": "two"},
clear=True,
):
self.assertTrue(desktop_tool._check_relay())
with patch.dict(
os.environ,
{"DESKTOP_RELAY_URL": "http://127.0.0.1:9999", "DESKTOP_RELAY_TOKEN": "two"},
clear=True,
):
self.assertTrue(desktop_tool._check_relay())
self.assertEqual(get.call_count, 3)
def test_endpoint_history_is_bounded_and_expired_entries_are_pruned(self) -> None:
clock = [0.0]
response = _Response(_health(connected=False, clients=[]))
with (
patch.object(desktop_tool.time, "monotonic", side_effect=lambda: clock[0]),
patch.object(desktop_tool.requests, "get", return_value=response),
):
for port in range(desktop_tool._AVAILABILITY_CACHE_MAX_ENTRIES + 5):
with patch.dict(
os.environ,
{"DESKTOP_RELAY_URL": f"http://127.0.0.1:{9000 + port}"},
clear=True,
):
self.assertTrue(desktop_tool._check_relay())
self.assertEqual(
len(desktop_tool._availability_cache),
desktop_tool._AVAILABILITY_CACHE_MAX_ENTRIES,
)
self.assertEqual(
len(desktop_tool._availability_probe_generation),
desktop_tool._AVAILABILITY_CACHE_MAX_ENTRIES,
)
clock[0] = desktop_tool._AVAILABILITY_CACHE_TTL_SECONDS
with patch.dict(
os.environ,
{"DESKTOP_RELAY_URL": "http://127.0.0.1:9999"},
clear=True,
):
self.assertTrue(desktop_tool._check_relay())
self.assertEqual(len(desktop_tool._availability_cache), 1)
self.assertEqual(len(desktop_tool._availability_probe_generation), 1)
def test_older_concurrent_probe_cannot_overwrite_newer_cache_entry(self) -> None:
first_started = threading.Event()
release_first = threading.Event()
call_count = 0
call_count_lock = threading.Lock()
def get(*args, **kwargs):
nonlocal call_count
with call_count_lock:
call_count += 1
call_number = call_count
if call_number == 1:
first_started.set()
release_first.wait(timeout=2)
return _Response(
_health(clients=[["desktop_read_file"]], advertised_tools=["desktop_read_file"])
)
release_first.set()
return _Response(_health(connected=False, clients=[]))
results: list[bool] = []
with patch.object(desktop_tool.requests, "get", side_effect=get):
first = threading.Thread(
target=lambda: results.append(desktop_tool._check_tool("desktop_read_file"))
)
first.start()
self.assertTrue(first_started.wait(timeout=2))
second = threading.Thread(
target=lambda: results.append(desktop_tool._check_tool("desktop_read_file"))
)
second.start()
first.join(timeout=2)
second.join(timeout=2)
self.assertFalse(first.is_alive())
self.assertFalse(second.is_alive())
self.assertEqual(call_count, 2) # Duplicate concurrent probes are allowed.
self.assertFalse(desktop_tool._check_tool("desktop_read_file"))
self.assertCountEqual(results, [True, False])
if __name__ == "__main__":
unittest.main()
+4 -12
View File
@@ -172,7 +172,7 @@ class GatingTests(_EnvIsolated):
class RelayUrlTests(_EnvIsolated): class RelayUrlTests(_EnvIsolated):
def test_default(self) -> None: def test_default(self) -> None:
self.assertEqual(pp._relay_base_url(), "http://127.0.0.1:8767") self.assertEqual(pp._relay_base_url(), "http://localhost:8767")
def test_explicit_phone_relay_url_wins(self) -> None: def test_explicit_phone_relay_url_wins(self) -> None:
os.environ["PHONE_RELAY_URL"] = "https://relay.example:9000/" os.environ["PHONE_RELAY_URL"] = "https://relay.example:9000/"
@@ -183,20 +183,12 @@ class RelayUrlTests(_EnvIsolated):
os.environ["ANDROID_BRIDGE_URL"] = "http://192.168.1.5:8767/" os.environ["ANDROID_BRIDGE_URL"] = "http://192.168.1.5:8767/"
self.assertEqual(pp._relay_base_url(), "http://192.168.1.5:8767") self.assertEqual(pp._relay_base_url(), "http://192.168.1.5:8767")
def test_preserves_explicit_loopback_overrides(self) -> None:
for key in ("PHONE_RELAY_URL", "ANDROID_BRIDGE_URL"):
for override in ("http://localhost:8767/", "http://[::1]:8767/"):
with self.subTest(key=key, override=override):
os.environ[key] = override
self.assertEqual(pp._relay_base_url(), override.rstrip("/"))
os.environ.pop(key)
def test_port_override(self) -> None: def test_port_override(self) -> None:
os.environ["ANDROID_RELAY_PORT"] = "8888" os.environ["ANDROID_RELAY_PORT"] = "8888"
self.assertEqual(pp._relay_base_url(), "http://127.0.0.1:8888") self.assertEqual(pp._relay_base_url(), "http://localhost:8888")
os.environ.pop("ANDROID_RELAY_PORT") os.environ.pop("ANDROID_RELAY_PORT")
os.environ["RELAY_PORT"] = "7777" os.environ["RELAY_PORT"] = "7777"
self.assertEqual(pp._relay_base_url(), "http://127.0.0.1:7777") self.assertEqual(pp._relay_base_url(), "http://localhost:7777")
def test_token_header_only_when_set(self) -> None: def test_token_header_only_when_set(self) -> None:
url, headers = pp._relay_url_and_headers() url, headers = pp._relay_url_and_headers()
@@ -262,7 +254,7 @@ class EnvEnablementTests(_EnvIsolated):
assert seed is not None assert seed is not None
self.assertTrue(seed["enabled"]) self.assertTrue(seed["enabled"])
self.assertEqual(seed["home_channel"], {"chat_id": "myphone", "name": "Phone"}) self.assertEqual(seed["home_channel"], {"chat_id": "myphone", "name": "Phone"})
self.assertEqual(seed["relay_url"], "http://127.0.0.1:8767") self.assertEqual(seed["relay_url"], "http://localhost:8767")
self.assertFalse(seed["typing_indicator"]) self.assertFalse(seed["typing_indicator"])
+31 -7
View File
@@ -31,19 +31,41 @@ import logging
import os import os
import time import time
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from typing import Any, Callable from typing import Any, Callable, Optional
import requests
from .android_navigate_prompt import ParsedAction, build_prompt, parse_response from .android_navigate_prompt import ParsedAction, build_prompt, parse_response
from .android_tool import _bridge_request, _timeout
logger = logging.getLogger("hermes_relay.tools.android_navigate") logger = logging.getLogger("hermes_relay.tools.android_navigate")
# ── Config (shared with android_tool.py conventions) ───────────────────────── # ── Config (shared with android_tool.py conventions) ─────────────────────────
def _bridge_url() -> str:
return os.getenv("ANDROID_BRIDGE_URL", "http://localhost:8766")
def _bridge_token() -> Optional[str]:
return os.getenv("ANDROID_BRIDGE_TOKEN")
def _timeout() -> float:
return float(os.getenv("ANDROID_BRIDGE_TIMEOUT", "30"))
def _auth_headers() -> dict:
token = _bridge_token()
if token:
return {"Authorization": f"Bearer {token}"}
return {}
def _check_requirements() -> bool: def _check_requirements() -> bool:
try: try:
r = _bridge_request("GET", "/ping", timeout=2) r = requests.get(
f"{_bridge_url()}/ping", headers=_auth_headers(), timeout=2
)
if r.status_code == 200: if r.status_code == 200:
data = r.json() data = r.json()
return data.get("phone_connected", False) or data.get( return data.get("phone_connected", False) or data.get(
@@ -55,16 +77,18 @@ def _check_requirements() -> bool:
def _get(path: str) -> dict: def _get(path: str) -> dict:
r = _bridge_request("GET", path, timeout=_timeout()) r = requests.get(
f"{_bridge_url()}{path}", headers=_auth_headers(), timeout=_timeout()
)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
def _post(path: str, payload: dict) -> dict: def _post(path: str, payload: dict) -> dict:
r = _bridge_request( r = requests.post(
"POST", f"{_bridge_url()}{path}",
path,
json=payload, json=payload,
headers=_auth_headers(),
timeout=_timeout(), timeout=_timeout(),
) )
r.raise_for_status() r.raise_for_status()
+32 -235
View File
@@ -76,194 +76,21 @@ except ImportError: # pragma: no cover - direct-script fallback
# ── Config ──────────────────────────────────────────────────────────────────── # ── Config ────────────────────────────────────────────────────────────────────
# #
# Architecture: Phone connects OUT to Hermes server via WebSocket (NAT-friendly). # Architecture: Phone connects OUT to Hermes server via WebSocket (NAT-friendly).
# The unified Hermes-Relay server runs on 127.0.0.1:8767 and multiplexes the # The unified Hermes-Relay server runs on localhost:8767 and multiplexes the
# bridge channel alongside chat, terminal, media, and voice. The legacy # bridge channel alongside chat, terminal, media, and voice. The legacy
# standalone bridge relay on port 8766 was retired in Phase 3 Wave 1. # standalone bridge relay on port 8766 was retired in Phase 3 Wave 1.
# #
# Tools ──HTTP──> Unified Relay (127.0.0.1:8767) ──WSS bridge channel──> Phone # Tools ──HTTP──> Unified Relay (localhost:8767) ──WSS bridge channel──> Phone
# #
# For local/USB dev, tools can also talk directly to the phone's HTTP server # For local/USB dev, tools can also talk directly to the phone's HTTP server
# by setting ANDROID_BRIDGE_URL to the phone's IP. # by setting ANDROID_BRIDGE_URL to the phone's IP.
def _bridge_url() -> str: def _bridge_url() -> str:
"""URL of the relay (default) or direct phone connection.""" """URL of the relay (default) or direct phone connection."""
return os.getenv("ANDROID_BRIDGE_URL", "http://127.0.0.1:8767") return os.getenv("ANDROID_BRIDGE_URL", "http://localhost:8767")
def _hermes_home() -> Path:
"""Return the request-scoped Hermes home when the host exposes one."""
try:
from hermes_constants import get_hermes_home
return Path(get_hermes_home())
except (ImportError, AttributeError, TypeError):
override = os.getenv("HERMES_HOME")
return Path(override) if override else Path.home() / ".hermes"
def _token_from_env_file() -> Optional[str]:
"""Read ``ANDROID_BRIDGE_TOKEN`` straight out of ``~/.hermes/.env``."""
try:
raw = (_hermes_home() / ".env").read_text(encoding="utf-8-sig")
except OSError:
return None
for line in raw.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, _, value = stripped.partition("=")
if key.strip() != "ANDROID_BRIDGE_TOKEN":
continue
value = value.strip().strip('"').strip("'")
if value:
return value
return None
def _token_from_sessions() -> Optional[str]:
"""Return the newest unexpired session with an active bridge grant."""
try:
raw = (_hermes_home() / "hermes-relay-sessions.json").read_text(
encoding="utf-8"
)
data = json.loads(raw)
except (OSError, ValueError):
return None
sessions = data.get("sessions") if isinstance(data, dict) else None
if not isinstance(sessions, list):
return None
def _timestamp_is_active(value: Any, now: float) -> bool:
if value == "never":
return True
return (
isinstance(value, (int, float))
and not isinstance(value, bool)
and float(value) > now
)
def _last_seen(entry: Any) -> float:
seen = entry.get("last_seen") if isinstance(entry, dict) else None
return (
float(seen)
if isinstance(seen, (int, float)) and not isinstance(seen, bool)
else 0.0
)
now = time.time()
for entry in sorted(sessions, key=_last_seen, reverse=True):
if not isinstance(entry, dict):
continue
if not _timestamp_is_active(entry.get("expires_at"), now):
continue
grants = entry.get("grants")
if not isinstance(grants, dict) or not _timestamp_is_active(
grants.get("bridge"), now
):
continue
token = entry.get("token")
if isinstance(token, str) and token.strip():
return token.strip()
return None
# Disk fallbacks are re-read at most once per TTL so a long-lived host does
# not stat two files on every single bridge call.
_TOKEN_CACHE_TTL = 30.0
_token_cache: dict[Path, tuple[float, str]] = {}
def _reset_token_cache() -> None:
_token_cache.clear()
def _process_token_for_home(home: Path) -> Optional[str]:
"""Use the process token only for the launch profile that owns it."""
launch_home = Path(os.getenv("HERMES_HOME") or (Path.home() / ".hermes"))
if home != launch_home:
return None
token = os.getenv("ANDROID_BRIDGE_TOKEN")
return token.strip() if token and token.strip() else None
def _bridge_token_candidates(*, refresh: bool = False) -> list[str]:
"""Return distinct bearer candidates for the active profile."""
home = _hermes_home()
cached = _token_cache.get(home)
now = time.monotonic()
values: list[Optional[str]] = []
if cached and now < cached[0]:
values.append(cached[1])
values.append(_process_token_for_home(home))
if refresh or not values or not any(values):
values.extend((_token_from_env_file(), _token_from_sessions()))
candidates: list[str] = []
for value in values:
if isinstance(value, str):
token = value.strip()
if token and token not in candidates:
candidates.append(token)
return candidates
def _bridge_token() -> Optional[str]: def _bridge_token() -> Optional[str]:
"""Bearer token for every relay bridge call. return os.getenv("ANDROID_BRIDGE_TOKEN")
Resolution order:
1. ``ANDROID_BRIDGE_TOKEN`` in the process environment.
2. ``ANDROID_BRIDGE_TOKEN`` in ``~/.hermes/.env`` — the file
:func:`android_setup` writes.
3. The most recently seen paired session in
``~/.hermes/hermes-relay-sessions.json``.
Steps 2 and 3 exist because the environment is snapshotted when the host
process starts: a token written (by ``android_setup``, ``hermes-pair``, or
by hand) *after* that point stayed invisible until a full restart, and
every bridge endpoint answered ``401 Authorization: Bearer *** required``
on a phone that was in fact paired and connected.
"""
candidates = _bridge_token_candidates()
return candidates[0] if candidates else None
def _bridge_request(method: str, path: str, **kwargs: Any) -> requests.Response:
"""Send a bridge request, retrying live disk credentials after auth denial."""
home = _hermes_home()
base_headers = dict(kwargs.pop("headers", {}) or {})
first = _bridge_token()
candidates: list[Optional[str]] = [first]
attempted: set[Optional[str]] = set()
response: Optional[requests.Response] = None
while candidates:
token = candidates.pop(0)
attempted.add(token)
headers = dict(base_headers)
if token:
headers["Authorization"] = f"Bearer {token}"
request = requests.get if method.upper() == "GET" else requests.post
response = request(
f"{_bridge_url()}{path}",
headers=headers,
**kwargs,
)
if response.status_code not in (401, 403):
if token:
_token_cache[home] = (
time.monotonic() + _TOKEN_CACHE_TTL,
token,
)
return response
if not candidates:
candidates.extend(
candidate
for candidate in _bridge_token_candidates(refresh=True)
if candidate not in attempted
)
assert response is not None
return response
def _relay_port() -> int: def _relay_port() -> int:
# Unified relay default. Override via ANDROID_RELAY_PORT or RELAY_PORT. # Unified relay default. Override via ANDROID_RELAY_PORT or RELAY_PORT.
@@ -289,7 +116,7 @@ _DEVICE_SELECTOR_PROPERTY = {
} }
def _auth_headers() -> dict: def _auth_headers() -> dict:
"""Build auth headers with the preferred bridge session token.""" """Build auth headers with pairing code if configured."""
token = _bridge_token() token = _bridge_token()
if token: if token:
return {"Authorization": f"Bearer {token}"} return {"Authorization": f"Bearer {token}"}
@@ -324,7 +151,11 @@ def _check_requirements() -> bool:
direct-phone development shims that predate the unified relay. direct-phone development shims that predate the unified relay.
""" """
try: try:
r = _bridge_request("GET", "/bridge/status", timeout=2) r = requests.get(
f"{_bridge_url()}/bridge/status",
headers=_auth_headers(),
timeout=2,
)
if r.status_code == 200: if r.status_code == 200:
data = r.json() data = r.json()
bridge = data.get("bridge") if isinstance(data, dict) else None bridge = data.get("bridge") if isinstance(data, dict) else None
@@ -337,7 +168,7 @@ def _check_requirements() -> bool:
pass pass
try: try:
r = _bridge_request("GET", "/ping", timeout=2) r = requests.get(f"{_bridge_url()}/ping", headers=_auth_headers(), timeout=2)
if r.status_code == 200: if r.status_code == 200:
data = r.json() data = r.json()
return bool( return bool(
@@ -354,14 +185,14 @@ def _post(path: str, payload: dict, *, device: Optional[str] = None) -> dict:
selector = _selected_device(device) selector = _selected_device(device)
if selector and "device" not in body: if selector and "device" not in body:
body["device"] = selector body["device"] = selector
r = _bridge_request("POST", path, json=body, timeout=_timeout()) r = requests.post(f"{_bridge_url()}{path}", json=body,
headers=_auth_headers(), timeout=_timeout())
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
def _get(path: str, *, device: Optional[str] = None) -> dict: def _get(path: str, *, device: Optional[str] = None) -> dict:
r = _bridge_request( r = requests.get(f"{_bridge_url()}{_path_with_device(path, device)}",
"GET", _path_with_device(path, device), timeout=_timeout() headers=_auth_headers(), timeout=_timeout())
)
r.raise_for_status() r.raise_for_status()
return r.json() return r.json()
@@ -1398,10 +1229,7 @@ def _get_public_ip() -> str:
return "<your-server-ip>" return "<your-server-ip>"
def android_setup( def android_setup(bridge_session_token: str) -> str:
bridge_session_token: Optional[str] = None,
pairing_code: Optional[str] = None,
) -> str:
""" """
FALLBACK helper — tell the android_* tools about an existing session token. FALLBACK helper — tell the android_* tools about an existing session token.
@@ -1420,40 +1248,27 @@ def android_setup(
The parameter name was historically ``pairing_code``, but the value is The parameter name was historically ``pairing_code``, but the value is
actually used as a long-lived bearer token, not a one-shot pairing code. actually used as a long-lived bearer token, not a one-shot pairing code.
``bridge_session_token`` is the canonical name; ``pairing_code`` is kept Renamed for clarity.
as an accepted alias so older callers (and the tool schema that shipped
before the rename) keep working instead of raising ``TypeError``.
Example: android_setup("eyJraWQiOi...long-token...") Example: android_setup("eyJraWQiOi...long-token...")
""" """
# Local alias keeps the rest of the function readable without # Local alias keeps the rest of the function readable without
# propagating the rename through every line. Either spelling is # propagating the rename through every line.
# accepted; the canonical name wins when both are supplied. pairing_code = bridge_session_token
token = bridge_session_token or pairing_code
if not token or not str(token).strip():
return json.dumps({
"status": "error",
"message": (
"android_setup requires a bridge session token. Pass it as "
"bridge_session_token (canonical) or pairing_code (legacy "
"alias). For a first-time pair use `hermes-pair` or "
"/hermes-relay-pair instead."
),
})
pairing_code = str(token).strip()
try: try:
port = _relay_port() port = _relay_port()
public_ip = _get_public_ip() public_ip = _get_public_ip()
# Save config to ~/.hermes/.env # Save config to ~/.hermes/.env
relay_url = f"http://127.0.0.1:{port}" relay_url = f"http://localhost:{port}"
try: try:
from hermes_cli.config import save_env_value from hermes_cli.config import save_env_value
save_env_value("ANDROID_BRIDGE_URL", relay_url) save_env_value("ANDROID_BRIDGE_URL", relay_url)
save_env_value("ANDROID_BRIDGE_TOKEN", pairing_code) save_env_value("ANDROID_BRIDGE_TOKEN", pairing_code)
save_env_value("ANDROID_RELAY_PORT", str(port)) save_env_value("ANDROID_RELAY_PORT", str(port))
except ImportError: except ImportError:
env_path = _hermes_home() / ".env" from pathlib import Path
env_path = Path.home() / ".hermes" / ".env"
env_path.parent.mkdir(parents=True, exist_ok=True) env_path.parent.mkdir(parents=True, exist_ok=True)
_update_env_file(env_path, "ANDROID_BRIDGE_URL", relay_url) _update_env_file(env_path, "ANDROID_BRIDGE_URL", relay_url)
_update_env_file(env_path, "ANDROID_BRIDGE_TOKEN", pairing_code) _update_env_file(env_path, "ANDROID_BRIDGE_TOKEN", pairing_code)
@@ -1468,7 +1283,7 @@ def android_setup(
relay_running = False relay_running = False
phone_connected = False phone_connected = False
try: try:
health = requests.get(f"{relay_url}/health", timeout=2) health = requests.get(f"http://localhost:{port}/health", timeout=2)
if health.status_code == 200: if health.status_code == 200:
relay_running = True relay_running = True
except Exception: except Exception:
@@ -1476,7 +1291,11 @@ def android_setup(
if relay_running: if relay_running:
try: try:
ping = _bridge_request("GET", "/ping", timeout=2) ping = requests.get(
f"http://localhost:{port}/ping",
headers=_auth_headers(),
timeout=2,
)
if ping.status_code == 200: if ping.status_code == 200:
phone_connected = True phone_connected = True
except Exception: except Exception:
@@ -1487,7 +1306,7 @@ def android_setup(
"status": "error", "status": "error",
"message": ( "message": (
"Unified Hermes-Relay is not running on " "Unified Hermes-Relay is not running on "
f"127.0.0.1:{port}. Start it with " f"localhost:{port}. Start it with "
"`systemctl --user start hermes-relay` and retry." "`systemctl --user start hermes-relay` and retry."
), ),
"server_address": server_address, "server_address": server_address,
@@ -2148,38 +1967,16 @@ _SCHEMAS = {
}, },
"android_setup": { "android_setup": {
"name": "android_setup", "name": "android_setup",
"description": ( "description": "Start the Android bridge relay and set the pairing code. Call this when the user wants to connect their phone. The relay runs on this server — the phone connects to it remotely via WebSocket. Only needs the pairing code shown in the Hermes Bridge app on the phone.",
"FALLBACK — teach this host's android_* tools about a bridge "
"session token you already have. This is NOT the pairing flow: "
"to pair a phone for the first time use `hermes-pair` or the "
"/hermes-relay-pair slash command (QR scan). Writes "
"ANDROID_BRIDGE_TOKEN/ANDROID_BRIDGE_URL to ~/.hermes/.env and "
"verifies the relay is reachable."
),
"parameters": { "parameters": {
"type": "object", "type": "object",
"properties": { "properties": {
"bridge_session_token": {
"type": "string",
"description": (
"Long-lived bridge session token from a previous "
"successful pair (not a 6-character pairing code)."
),
},
"pairing_code": { "pairing_code": {
"type": "string", "type": "string",
"description": ( "description": "6-character pairing code shown in the Hermes Bridge app on the phone",
"Deprecated alias for bridge_session_token, accepted "
"for backwards compatibility. Prefer "
"bridge_session_token."
),
}, },
}, },
# Neither key is schema-required: exactly one of the two must be "required": ["pairing_code"],
# supplied, which JSON Schema draft-07 cannot express in a way
# every tool-call validator honours. The handler returns a
# structured error when both are missing.
"required": [],
}, },
}, },
"android_macro": { "android_macro": {
+29 -156
View File
@@ -49,17 +49,17 @@ Tools registered (Phase B + remote-PC ergonomics, alpha.7):
Architecture mirrors ``android_tool.py``: Architecture mirrors ``android_tool.py``:
Tools ──HTTP──> Unified Relay (127.0.0.1:8767) ──WSS desktop channel──> Desktop CLI Tools ──HTTP──> Unified Relay (localhost:8767) ──WSS desktop channel──> Desktop CLI
Each handler POSTs to ``/desktop/<tool_name>`` on the relay. The relay Each handler POSTs to ``/desktop/<tool_name>`` on the relay. The relay
forwards a ``desktop.command`` envelope to the connected desktop client forwards a ``desktop.command`` envelope to the connected desktop client
(see ``plugin/relay/channels/desktop.py``), awaits a ``desktop.response``, (see ``plugin/relay/channels/desktop.py``), awaits a ``desktop.response``,
and returns the structured result. and returns the structured result.
``check_fn`` shares a short-lived ``/desktop/health`` snapshot across the ``check_fn`` pings ``/desktop/_ping?tool=<name>`` — 200 if a client is
Desktop toolset. Availability matches the relay's multi-client advertisement connected and advertises the tool, 503 otherwise. This is how Hermes
rules, including compatibility for connected legacy clients. With no client, becomes aware: with no client, the tool fails closed and the LLM learns
client-routed tools fail closed and the LLM learns to stop calling them. to stop calling it.
``desktop_health`` is the one tool that does NOT round-trip to the client ``desktop_health`` is the one tool that does NOT round-trip to the client
— the relay already has the client's heartbeat-advertised metadata, so we — the relay already has the client's heartbeat-advertised metadata, so we
@@ -71,9 +71,7 @@ from __future__ import annotations
import json import json
import os import os
import time
from contextvars import ContextVar from contextvars import ContextVar
from dataclasses import dataclass
from typing import Any, Optional from typing import Any, Optional
import requests import requests
@@ -114,8 +112,8 @@ def _trusted_call_context(kwargs: dict[str, Any]) -> dict[str, str]:
def _relay_url() -> str: def _relay_url() -> str:
"""URL of the unified relay. Defaults to its IPv4 loopback listener.""" """URL of the unified relay. Defaults to localhost:8767."""
return os.getenv("DESKTOP_RELAY_URL", "http://127.0.0.1:8767") return os.getenv("DESKTOP_RELAY_URL", "http://localhost:8767")
def _relay_token() -> Optional[str]: def _relay_token() -> Optional[str]:
@@ -193,163 +191,38 @@ def _get(path: str, params: Optional[dict] = None) -> dict:
return data return data
_AVAILABILITY_CACHE_TTL_SECONDS = 3.0 def _check_tool(tool_name: str) -> bool:
_AVAILABILITY_CACHE_MAX_ENTRIES = 32 """Returns True if a desktop client is connected AND advertises ``tool_name``.
Hits ``/desktop/_ping?tool=<tool_name>``. 200 = available, 503 = no
@dataclass(frozen=True) client / tool not advertised.
class _DesktopAvailability: """
"""Validated relay health state shared by one serialized registry pass."""
reachable: bool
valid: bool
connected: bool
client_toolsets: tuple[frozenset[str], ...]
_UNREACHABLE_AVAILABILITY = _DesktopAvailability(False, False, False, ())
_availability_cache: dict[
tuple[str, str | None], tuple[float, _DesktopAvailability]
] = {}
_availability_probe_generation: dict[tuple[str, str | None], object] = {}
def _availability_cache_key() -> tuple[str, str | None]:
"""Separate snapshots by endpoint and the credential used to reach it."""
return (_relay_url().rstrip("/"), _relay_token())
def _clear_availability_cache() -> None:
"""Reset process-local availability state for focused tests."""
_availability_cache.clear()
_availability_probe_generation.clear()
def _prune_availability_cache(now: float) -> None:
"""Discard expired endpoint/credential history from long-lived hosts."""
expired = [
key
for key, (expires_at, _) in _availability_cache.items()
if now >= expires_at
]
for key in expired:
_availability_cache.pop(key, None)
_availability_probe_generation.pop(key, None)
def _validated_toolset(value: Any) -> frozenset[str] | None:
if not isinstance(value, list):
return None
if any(not isinstance(name, str) or not name for name in value):
return None
return frozenset(value)
def _parse_availability(data: Any) -> _DesktopAvailability:
"""Validate current and pre-multi-client ``/desktop/health`` responses."""
if not isinstance(data, dict) or type(data.get("connected")) is not bool:
return _DesktopAvailability(True, False, False, ())
connected = data["connected"]
advertised = _validated_toolset(data.get("advertised_tools"))
if advertised is None:
return _DesktopAvailability(True, False, False, ())
if "clients" not in data:
# Older relays exposed only the latest/sole client's advertised tools.
# An empty set while connected retains the relay's legacy-client
# optimism for every tool except the experimental computer-use family.
if not connected and advertised:
return _DesktopAvailability(True, False, False, ())
return _DesktopAvailability(True, True, connected, (advertised,) if connected else ())
clients = data["clients"]
if not isinstance(clients, list):
return _DesktopAvailability(True, False, False, ())
toolsets: list[frozenset[str]] = []
for client in clients:
if not isinstance(client, dict):
return _DesktopAvailability(True, False, False, ())
tools = _validated_toolset(client.get("advertised_tools"))
if tools is None:
return _DesktopAvailability(True, False, False, ())
toolsets.append(tools)
# The current relay always emits one clients[] row per connected target.
# Contradictory shapes are unsafe to interpret as tool availability.
if connected != bool(toolsets) or (not connected and advertised):
return _DesktopAvailability(True, False, False, ())
return _DesktopAvailability(True, True, connected, tuple(toolsets))
def _probe_availability() -> _DesktopAvailability:
try: try:
response = requests.get( r = requests.get(
f"{_relay_url().rstrip('/')}/desktop/health", f"{_relay_url()}/desktop/_ping",
params={"tool": tool_name},
headers=_auth_headers(), headers=_auth_headers(),
timeout=2, timeout=2,
) )
return r.status_code == 200
except Exception: except Exception:
return _UNREACHABLE_AVAILABILITY
if response.status_code != 200:
return _DesktopAvailability(True, False, False, ())
try:
return _parse_availability(response.json())
except Exception:
return _DesktopAvailability(True, False, False, ())
def _availability() -> _DesktopAvailability:
"""Return one cached health snapshot for all Desktop availability checks.
Hermes invokes shared-tool availability checks serially during a registry
pass, so one health request serves the whole Desktop toolset. No threading
primitive is introduced into the plugin: simultaneous callers may perform
duplicate probes, but generation ordering prevents an older probe from
overwriting the cache entry from a newer one.
"""
key = _availability_cache_key()
now = time.monotonic()
_prune_availability_cache(now)
cached = _availability_cache.get(key)
if cached is not None and now < cached[0]:
return cached[1]
generation = object()
_availability_probe_generation[key] = generation
snapshot = _probe_availability()
expires_at = time.monotonic() + _AVAILABILITY_CACHE_TTL_SECONDS
if _availability_probe_generation.get(key) is generation:
if (
key not in _availability_cache
and len(_availability_cache) >= _AVAILABILITY_CACHE_MAX_ENTRIES
):
evicted = min(
_availability_cache,
key=lambda cached_key: _availability_cache[cached_key][0],
)
_availability_cache.pop(evicted, None)
_availability_probe_generation.pop(evicted, None)
_availability_cache[key] = (expires_at, snapshot)
return snapshot
def _check_tool(tool_name: str) -> bool:
"""Match DesktopHandler.has_client_for across every connected client."""
snapshot = _availability()
if not snapshot.reachable or not snapshot.valid or not snapshot.connected:
return False return False
return any(tool_name in tools for tools in snapshot.client_toolsets) or (
not tool_name.startswith("desktop_computer_")
and any(not tools for tools in snapshot.client_toolsets)
)
def _check_relay() -> bool: def _check_relay() -> bool:
"""Keep ``desktop_health`` callable on a valid relay with no client.""" """``check_fn`` for ``desktop_health`` — the relay must be reachable, but
snapshot = _availability() a client need not be connected. The whole point of ``desktop_health`` is
return snapshot.reachable and snapshot.valid to tell the agent whether a client IS connected, so it must remain callable
when one is not."""
try:
r = requests.get(
f"{_relay_url()}/desktop/health",
headers=_auth_headers(),
timeout=2,
)
return r.status_code == 200
except Exception:
return False
# ── Tool implementations ─────────────────────────────────────────────────────── # ── Tool implementations ───────────────────────────────────────────────────────
+1 -49
View File
@@ -34,7 +34,6 @@ SESSION_RESUME = "gateway.session_resume_durable"
SESSION_ACTIVE_LIST = "gateway.session_active_list" SESSION_ACTIVE_LIST = "gateway.session_active_list"
SESSION_EXCLUSIVE_SUBMIT = "gateway.session_exclusive_submit" SESSION_EXCLUSIVE_SUBMIT = "gateway.session_exclusive_submit"
SUBAGENT_CHILD_WATCH = "gateway.subagent_child_watch" SUBAGENT_CHILD_WATCH = "gateway.subagent_child_watch"
SESSION_INITIALIZATION = "gateway.session_initialization"
API_BOUNDARY = "api.fallback_boundary" API_BOUNDARY = "api.fallback_boundary"
ALL_CONTRACTS = ( ALL_CONTRACTS = (
GATEWAY_TERMINAL, GATEWAY_TERMINAL,
@@ -44,7 +43,6 @@ ALL_CONTRACTS = (
SESSION_ACTIVE_LIST, SESSION_ACTIVE_LIST,
SESSION_EXCLUSIVE_SUBMIT, SESSION_EXCLUSIVE_SUBMIT,
SUBAGENT_CHILD_WATCH, SUBAGENT_CHILD_WATCH,
SESSION_INITIALIZATION,
API_BOUNDARY, API_BOUNDARY,
) )
@@ -62,7 +60,6 @@ class CheckResult:
class SourceFile: class SourceFile:
def __init__(self, root: Path, relative: str): def __init__(self, root: Path, relative: str):
self.root = root
self.relative = relative self.relative = relative
self.path = root / relative self.path = root / relative
if not self.path.is_file(): if not self.path.is_file():
@@ -186,21 +183,6 @@ def _check_gateway_terminal(server: SourceFile) -> CheckResult:
return CheckResult(contract, False, (), str(exc)) return CheckResult(contract, False, (), str(exc))
def _check_session_initialization(server: SourceFile) -> CheckResult:
try:
build = server.function("_start_agent_build")
if not _call_lines(build, "_emit", "error") or "agent init failed:" not in server.segment(build):
raise ValueError("deferred build no longer emits the initialization failure")
ready_owner = build
if _call_lines(build, "_announce_built_agent"):
ready_owner = server.function("_announce_built_agent")
if not _call_lines(ready_owner, "_emit", "session.info"):
raise ValueError("deferred build no longer emits the ready session.info")
return CheckResult(SESSION_INITIALIZATION, True, (server.evidence(build, "ready and initialization-failure events"),))
except ValueError as exc:
return CheckResult(SESSION_INITIALIZATION, False, (), str(exc))
def _check_settled_info(server: SourceFile) -> CheckResult: def _check_settled_info(server: SourceFile) -> CheckResult:
contract = GATEWAY_SETTLED_INFO contract = GATEWAY_SETTLED_INFO
try: try:
@@ -404,32 +386,6 @@ def _check_subagent_child_watch(server: SourceFile, methods: SourceFile) -> Chec
try: try:
resume = methods.method_handler("session.resume") resume = methods.method_handler("session.resume")
resume_segment = methods.segment(resume) resume_segment = methods.segment(resume)
if "_resume_lazy(ctx)" in resume_segment:
lazy = methods.function("_resume_lazy")
child_history = methods.function("child_history")
constructor = next((
node for node in methods.tree.body
if isinstance(node, ast.ClassDef) and node.name == "_Resume"
), None)
if constructor is None:
raise ValueError("missing _Resume context")
lazy_text = methods.segment(lazy)
history_text = methods.segment(child_history)
if not {"lazy", "close_on_disconnect"} <= _string_constants(constructor):
raise ValueError("resume context no longer carries lazy/close_on_disconnect")
if "ctx.child_history(" not in lazy_text or "lazy=True" not in lazy_text:
raise ValueError("lazy resume no longer creates a child-history watcher")
if "get_messages_as_conversation(self.target" not in history_text or "include_ancestors=True" in history_text:
raise ValueError("child history no longer uses the child-only conversation")
mirror = SourceFile(server.root, "tui_gateway/agent_callbacks.py")
mirror_fn = mirror.function("_mirror_subagent_to_child")
if not {"child_session_id", "subagent.text", "reasoning.delta", "message.delta"} <= _string_constants(mirror.tree):
raise ValueError("child mirror event contract missing")
return CheckResult(contract, True, (
methods.evidence(resume, "session.resume dispatches lazy watch"),
methods.evidence(lazy, "lazy child-only history and live status"),
mirror.evidence(mirror_fn, "child_session_id routes child mirror events"),
))
resume_strings = _string_constants(resume) resume_strings = _string_constants(resume)
server_strings = _string_constants(server.tree) server_strings = _string_constants(server.tree)
missing_resume = sorted( missing_resume = sorted(
@@ -548,10 +504,7 @@ def audit_sources(root: Path, requirements: Iterable[str]) -> list[CheckResult]:
raise ValueError("fork marker(s) found in upstream source: " + ", ".join(fork_hits)) raise ValueError("fork marker(s) found in upstream source: " + ", ".join(fork_hits))
checks = { checks = {
GATEWAY_TERMINAL: lambda: _check_gateway_terminal( GATEWAY_TERMINAL: lambda: _check_gateway_terminal(server),
SourceFile(root, "tui_gateway/prompt_turn.py")
if (root / "tui_gateway/prompt_turn.py").is_file() else server
),
GATEWAY_SETTLED_INFO: lambda: _check_settled_info(server), GATEWAY_SETTLED_INFO: lambda: _check_settled_info(server),
SESSION_ACTIVATE: lambda: _check_activate(server, methods), SESSION_ACTIVATE: lambda: _check_activate(server, methods),
SESSION_RESUME: lambda: _check_resume(methods), SESSION_RESUME: lambda: _check_resume(methods),
@@ -560,7 +513,6 @@ def audit_sources(root: Path, requirements: Iterable[str]) -> list[CheckResult]:
server, methods, prompt_methods, active_sessions, server, methods, prompt_methods, active_sessions,
), ),
SUBAGENT_CHILD_WATCH: lambda: _check_subagent_child_watch(server, methods), SUBAGENT_CHILD_WATCH: lambda: _check_subagent_child_watch(server, methods),
SESSION_INITIALIZATION: lambda: _check_session_initialization(server),
API_BOUNDARY: lambda: _check_api_boundary(api), API_BOUNDARY: lambda: _check_api_boundary(api),
} }
return [checks[requirement]() for requirement in requirements] return [checks[requirement]() for requirement in requirements]

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