Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6395e73927 | ||
|
|
0c337c9384 | ||
|
|
179080d6d9 | ||
|
|
ac1f42b7d5 | ||
|
|
88591fca89 | ||
|
|
00c5f5daa2 | ||
|
|
69b6c005cd | ||
|
|
c902215a15 | ||
|
|
d39368bc51 | ||
|
|
414dc08b9f | ||
|
|
3b78a17bd7 | ||
|
|
a5efb9ec96 | ||
|
|
e5228f2089 | ||
|
|
c738a2c2c0 | ||
|
|
8ed6b35e76 | ||
|
|
c6d4a15226 | ||
|
|
22c8f76c72 | ||
|
|
a0161f51ef | ||
|
|
7aa3b1c697 | ||
|
|
e7112dda9e | ||
|
|
c1413c494f | ||
|
|
bb23e6ab48 | ||
|
|
816638b46a | ||
|
|
df0fe59b0b | ||
|
|
df4d4c8f2a | ||
|
|
398ad253f1 | ||
|
|
ad4bbb2c86 | ||
|
|
7c5894213d | ||
|
|
82c088aa89 | ||
|
|
ed599995d6 |
@@ -115,8 +115,8 @@ assert.match(
|
||||
/ref: \$\{\{ contains\(inputs\.version, '-'\) && 'dev' \|\| 'main' \}\}/,
|
||||
);
|
||||
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@v5/);
|
||||
assert.match(desktopCiWorkflow, /name: Restore exact-source tray build cache[\s\S]*?actions\/cache@v5/);
|
||||
assert.match(cliReleaseWorkflow, /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@v6/);
|
||||
assert.match(pluginReleaseWorkflow, /workflow_dispatch:[\s\S]*?Approved Plugin version/);
|
||||
assert.match(androidPreflightWorkflow, /Package immutable preflight artifacts/);
|
||||
assert.match(androidApprovalWorkflow, /Android public approval accepts stable SemVer only/);
|
||||
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Restore exact-source tray build cache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index
|
||||
|
||||
@@ -111,4 +111,8 @@ jobs:
|
||||
plugin/tests/test_git_state.py \
|
||||
plugin/tests/test_git_state_write.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
|
||||
|
||||
@@ -308,7 +308,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Restore exact-source tray build cache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index
|
||||
|
||||
@@ -107,7 +107,11 @@ jobs:
|
||||
plugin/tests/test_voice_routes.py \
|
||||
plugin/tests/test_session_grants.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:
|
||||
name: Build and publish Plugin package
|
||||
|
||||
@@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [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
|
||||
|
||||
### Changed
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
+42
@@ -28,6 +28,8 @@ 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.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.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -115,9 +117,18 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
compose.setContent {
|
||||
val messages by viewModel.messages.collectAsStateWithLifecycle()
|
||||
val streaming by viewModel.isStreaming.collectAsStateWithLifecycle()
|
||||
val children by viewModel.subagentActivities.collectAsStateWithLifecycle()
|
||||
val signInRequired by historySignInRequired.collectAsStateWithLifecycle()
|
||||
MaterialTheme {
|
||||
Column(Modifier.testTag("contract-transcript")) {
|
||||
GatewayBackgroundProcessStrip(
|
||||
processes = emptyList(),
|
||||
subagentActivities = children,
|
||||
subagentPreviewVisibility = SubagentPreviewVisibility(),
|
||||
loading = false,
|
||||
onClick = {},
|
||||
modifier = Modifier.testTag("child-activity"),
|
||||
)
|
||||
Text(
|
||||
text = if (streaming) "STREAMING" else "IDLE",
|
||||
modifier = Modifier.testTag("stream-state"),
|
||||
@@ -153,6 +164,37 @@ class GatewayForegroundRecoveryInstrumentedTest {
|
||||
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
|
||||
fun terminalGapActivate_recoversForegroundTurnWithoutNavigationOrCrossSessionLeak() {
|
||||
viewModel.sendMessage("Run a long foreground task")
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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)?\)\.""",
|
||||
)
|
||||
@@ -0,0 +1,222 @@
|
||||
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,6 +170,12 @@ data class ChatMessage(
|
||||
* but server history never owns these presentation blocks.
|
||||
*/
|
||||
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(
|
||||
|
||||
@@ -1451,6 +1451,12 @@ class ChatHandler {
|
||||
val loaded = renderedItems.mapNotNull { item ->
|
||||
val displayKind = item.displayKind?.trim()?.lowercase()
|
||||
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 {
|
||||
displayKind == "model_switch" ||
|
||||
displayKind == "async_delegation_complete" ||
|
||||
@@ -1611,6 +1617,9 @@ class ChatHandler {
|
||||
// this as the same visible row across the post-turn reload.
|
||||
prior.copy(
|
||||
id = messageId,
|
||||
activitySourceId = activitySourceId,
|
||||
activityTaskCount = activityTaskCount,
|
||||
activityFailedCount = activityFailedCount,
|
||||
rowId = item.resolvedRowId,
|
||||
reactions = item.reactions,
|
||||
role = role,
|
||||
@@ -1643,6 +1652,9 @@ class ChatHandler {
|
||||
// nothing local to carry).
|
||||
ChatMessage(
|
||||
id = messageId,
|
||||
activitySourceId = activitySourceId,
|
||||
activityTaskCount = activityTaskCount,
|
||||
activityFailedCount = activityFailedCount,
|
||||
rowId = item.resolvedRowId,
|
||||
reactions = item.reactions,
|
||||
role = role,
|
||||
|
||||
@@ -1525,10 +1525,14 @@ class DashboardApiClient(
|
||||
password: String,
|
||||
next: String = "/",
|
||||
): 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 {
|
||||
put("provider", provider)
|
||||
put("username", username)
|
||||
put("password", password)
|
||||
put("username", normalizedUsername)
|
||||
put("password", normalizedPassword)
|
||||
put("next", next)
|
||||
}
|
||||
val httpUrl = resolveUrl("/auth/password-login")
|
||||
|
||||
@@ -431,6 +431,9 @@ class GatewayChatClient(
|
||||
private val lazyLiveSessions = ConcurrentHashMap.newKeySet<String>()
|
||||
private val readyLiveSessions = ConcurrentHashMap.newKeySet<String>()
|
||||
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. */
|
||||
private val childWatchGeneration = AtomicLong(0)
|
||||
@@ -633,6 +636,13 @@ class GatewayChatClient(
|
||||
@Volatile
|
||||
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. */
|
||||
@Volatile
|
||||
private var sessionDirectoryInvalidationListener: (() -> Unit)? = null
|
||||
@@ -1563,7 +1573,10 @@ class GatewayChatClient(
|
||||
.orEmpty()
|
||||
.also { recoveryEvents = null }
|
||||
}
|
||||
buffered.forEach { event -> boundTurn?.onEvent(event.type, event.payload) }
|
||||
buffered.forEach { event ->
|
||||
dispatchSubagentEvent(event.type, event.payload, event.sessionId)
|
||||
boundTurn.onEvent(event.type, event.payload)
|
||||
}
|
||||
queued?.let { queuedTurn ->
|
||||
queuedTurnProvider?.invoke(queuedTurn)?.let { registration ->
|
||||
boundTurn.installQueuedSuccessor(registration)
|
||||
@@ -2950,6 +2963,7 @@ class GatewayChatClient(
|
||||
unmatchedTurnCompleteListener = null
|
||||
backgroundInteractionListener = null
|
||||
processEventListener = null
|
||||
subagentEventListener = null
|
||||
sessionDirectoryInvalidationListener = null
|
||||
closeSocket("client shutdown")
|
||||
backgroundCloseJob?.cancel()
|
||||
@@ -3485,6 +3499,7 @@ class GatewayChatClient(
|
||||
liveId: String,
|
||||
sessionResult: JsonObject? = null,
|
||||
) {
|
||||
sessionReadyFailures[liveId]?.let { throw GatewayPreflightException(it) }
|
||||
val lazy = (sessionResult?.get("info") as? JsonObject)?.booleanField("lazy") == true
|
||||
if (lazy) lazyLiveSessions += liveId
|
||||
if (liveId !in lazyLiveSessions) return
|
||||
@@ -3494,6 +3509,9 @@ class GatewayChatClient(
|
||||
}
|
||||
val waiter = sessionReadyWaiters.computeIfAbsent(liveId) { CompletableDeferred() }
|
||||
if (readyLiveSessions.remove(liveId)) waiter.complete(Unit)
|
||||
sessionReadyFailures[liveId]?.let { waiter.completeExceptionally(GatewayRpcException(it)) }
|
||||
val preparingStoredId = storedSessionId
|
||||
_preparingSessionId.value = preparingStoredId
|
||||
try {
|
||||
withTimeout(sessionReadyTimeoutMs) { waiter.await() }
|
||||
lazyLiveSessions.remove(liveId)
|
||||
@@ -3502,11 +3520,14 @@ class GatewayChatClient(
|
||||
error.message ?: "Hermes session initialization timed out",
|
||||
)
|
||||
} finally {
|
||||
if (_preparingSessionId.value == preparingStoredId) _preparingSessionId.value = null
|
||||
sessionReadyWaiters.remove(liveId, waiter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun failSessionReadyWaiters(message: String) {
|
||||
_preparingSessionId.value = null
|
||||
sessionReadyFailures.clear()
|
||||
sessionReadyWaiters.values.forEach {
|
||||
it.completeExceptionally(GatewayRpcException(message))
|
||||
}
|
||||
@@ -3696,6 +3717,7 @@ class GatewayChatClient(
|
||||
if (type == "session.info" && !eventSessionId.isNullOrBlank() &&
|
||||
payload?.booleanField("lazy") != true
|
||||
) {
|
||||
sessionReadyFailures.remove(eventSessionId)
|
||||
readyLiveSessions += eventSessionId
|
||||
sessionReadyWaiters.remove(eventSessionId)?.complete(Unit)
|
||||
}
|
||||
@@ -3705,10 +3727,16 @@ class GatewayChatClient(
|
||||
// immediately so the optimistic prompt remains retryable/Not sent
|
||||
// instead of waiting for the five-minute readiness timeout.
|
||||
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")
|
||||
?: "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)
|
||||
readyLiveSessions.remove(eventSessionId)
|
||||
sessionReadyWaiters.remove(eventSessionId)
|
||||
@@ -3915,6 +3943,7 @@ class GatewayChatClient(
|
||||
dispatchProcessEvent(type, payload, eventSessionId)
|
||||
if (consumeCancelledTurnEvent(type, eventSessionId)) return
|
||||
if (consumeSettledTurnTerminal(type, eventSessionId)) return
|
||||
dispatchSubagentEvent(type, payload, eventSessionId)
|
||||
var turn = activeTurn
|
||||
if (turn == null && type == "message.start") {
|
||||
// Unsolicited turns are accepted only with an explicit exact live-
|
||||
@@ -3976,10 +4005,25 @@ class GatewayChatClient(
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver session-scoped process events before the active-turn gate. The
|
||||
* 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.
|
||||
* Detached child updates belong to the session even between parent turns.
|
||||
* Recheck socket, session, profile and listener ownership on UI dispatch.
|
||||
*/
|
||||
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?) {
|
||||
val liveId = liveSessionId ?: return
|
||||
if (eventSessionId.isNullOrBlank() || eventSessionId != liveId) return
|
||||
|
||||
+37
-28
@@ -292,34 +292,7 @@ class GatewayEventMapper(
|
||||
"subagent.progress", "subagent.complete",
|
||||
-> {
|
||||
clearActivityStatuses()
|
||||
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"),
|
||||
),
|
||||
)
|
||||
parseSubagentEvent(type, payload)?.let(callbacks.onSubagentEvent)
|
||||
}
|
||||
|
||||
"tool.output_risk" -> {
|
||||
@@ -467,6 +440,42 @@ class GatewayEventMapper(
|
||||
}
|
||||
|
||||
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 COMPACTION_STATUS_KIND = "compacting"
|
||||
const val ERROR_STATUS_KIND = "error"
|
||||
|
||||
@@ -293,6 +293,8 @@ data class GatewaySubagentEvent(
|
||||
val depth: Int? = null,
|
||||
/** Effective child model, when the emitter exposes it. */
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import com.hermesandroid.relay.ui.components.ThemedMessageHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -230,11 +230,7 @@ val LocalSnackbarHost = staticCompositionLocalOf<SnackbarHostState> {
|
||||
// Short-lived snackbar by default; retryable errors get Long so users have
|
||||
// time to tap the action before it auto-dismisses.
|
||||
suspend fun SnackbarHostState.showHumanError(err: HumanError): SnackbarResult {
|
||||
return showSnackbar(
|
||||
message = err.body,
|
||||
actionLabel = err.actionLabel,
|
||||
duration = if (err.retryable) SnackbarDuration.Long else SnackbarDuration.Short,
|
||||
)
|
||||
return showSnackbar(com.hermesandroid.relay.ui.components.HumanErrorVisuals(err))
|
||||
}
|
||||
|
||||
/** Startup chrome should wait for either standard chat surface, not Relay. */
|
||||
@@ -1952,6 +1948,8 @@ fun RelayApp() {
|
||||
// child TopAppBar doesn't double-pad when this banner owns the top edge.
|
||||
val activeMessageCount by UiMessageBus.activeCount.collectAsState()
|
||||
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,
|
||||
// sideload = GitHub releases. The handle filters dismissed versions +
|
||||
// throttles checks internally, exposing a surfaceable status for the
|
||||
@@ -2077,6 +2075,14 @@ fun RelayApp() {
|
||||
includeStatusBarPadding =
|
||||
!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
|
||||
// floating overlay TOASTS in the Box below (see the top-overlay Column
|
||||
@@ -2117,7 +2123,7 @@ fun RelayApp() {
|
||||
// participates in the top-inset accounting.
|
||||
if (showUnattendedBanner || showDemoBanner || showHostResourcePressure ||
|
||||
connectionChipVisible ||
|
||||
showMessageBanner
|
||||
showMessageBanner || showActionMessage
|
||||
) {
|
||||
Modifier.consumeWindowInsets(WindowInsets.statusBars)
|
||||
} else {
|
||||
@@ -2125,7 +2131,6 @@ fun RelayApp() {
|
||||
}
|
||||
),
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
bottomBar = {
|
||||
if (
|
||||
!suppressGlobalChrome &&
|
||||
@@ -2212,7 +2217,10 @@ fun RelayApp() {
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
CompositionLocalProvider(LocalSnackbarHost provides snackbarHostState) {
|
||||
CompositionLocalProvider(
|
||||
LocalSnackbarHost provides snackbarHostState,
|
||||
com.hermesandroid.relay.ui.components.LocalMessageActionHost provides snackbarHostState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
||||
@@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/** Visual tone of a transient banner message. */
|
||||
enum class UiMessageSeverity { Info, Success, Status, Warning }
|
||||
enum class UiMessageSeverity { Info, Success, Status, Warning, Error }
|
||||
|
||||
data class UiMessage(
|
||||
val id: Long,
|
||||
@@ -23,6 +23,7 @@ data class UiMessage(
|
||||
sealed interface UiMessageEvent {
|
||||
data class Show(val message: UiMessage) : UiMessageEvent
|
||||
data class Clear(val key: String) : UiMessageEvent
|
||||
data object ClearAll : UiMessageEvent
|
||||
}
|
||||
|
||||
internal fun reduceUiMessages(
|
||||
@@ -30,6 +31,7 @@ internal fun reduceUiMessages(
|
||||
event: UiMessageEvent,
|
||||
maxRetained: Int,
|
||||
): List<UiMessage> = when (event) {
|
||||
UiMessageEvent.ClearAll -> emptyList()
|
||||
is UiMessageEvent.Clear -> current.filterNot { it.key == event.key }
|
||||
is UiMessageEvent.Show -> {
|
||||
val incoming = event.message
|
||||
@@ -41,15 +43,14 @@ internal fun reduceUiMessages(
|
||||
}
|
||||
|
||||
/**
|
||||
* App-wide bus for transient, non-error status/confirmation messages that
|
||||
* App-wide bus for transient status, confirmation, and error messages that
|
||||
* surface in the top [com.hermesandroid.relay.ui.components.MessageBannerHost]
|
||||
* — 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
|
||||
* and coalesces duplicates.
|
||||
*
|
||||
* App-owned errors and persistent/actionable messages keep going to the
|
||||
* snackbar so they demand acknowledgement. Upstream keyed AgentNotices may use
|
||||
* the warning tone here because their own sticky/clear lifecycle owns them.
|
||||
* Messages requiring Retry or Undo use ThemedMessageHost and retain their
|
||||
* action-result contract. Upstream keyed AgentNotices own their sticky/clear lifecycle.
|
||||
* Migrate frequent
|
||||
* `snackbarHostState.showSnackbar("…")` confirmations/status to [info] /
|
||||
* [success] / [status] here.
|
||||
@@ -62,6 +63,23 @@ object UiMessageBus {
|
||||
const val STATUS_TTL_MS = 6_000L
|
||||
|
||||
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)
|
||||
val events: SharedFlow<UiMessageEvent> = _events.asSharedFlow()
|
||||
|
||||
@@ -97,6 +115,11 @@ object UiMessageBus {
|
||||
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"). */
|
||||
fun info(text: String, ttlMillis: Long = DEFAULT_TTL_MS) =
|
||||
post(text, UiMessageSeverity.Info, ttlMillis)
|
||||
|
||||
+19
-23
@@ -1,6 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -209,7 +209,7 @@ fun ActiveCardRelayStatusSection(
|
||||
val relayUiState by connectionViewModel.relayUiState.collectAsState()
|
||||
val relayRowState by connectionViewModel.relayRowState.collectAsState()
|
||||
|
||||
// Pre-resolve strings for Toast (non-composable context)
|
||||
// Pre-resolve strings for action callbacks (non-composable context).
|
||||
val reconnectingRelayToast = stringResource(R.string.active_section_reconnecting_relay)
|
||||
val relayStatusText = when (relayRowState.phase) {
|
||||
RelayUiState.NotConfigured -> stringResource(R.string.relay_state_optional)
|
||||
@@ -250,11 +250,7 @@ fun ActiveCardRelayStatusSection(
|
||||
onClick = {
|
||||
if (relayUiState == RelayUiState.Stale) {
|
||||
connectionViewModel.reconnectIfStale()
|
||||
Toast.makeText(
|
||||
context,
|
||||
reconnectingRelayToast,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
UiMessageBus.status(reconnectingRelayToast)
|
||||
} else {
|
||||
onOpenRelayInfo()
|
||||
}
|
||||
@@ -817,7 +813,7 @@ private fun ManualUrlSubsection(
|
||||
}
|
||||
val autoRelayUrl = RelayUrlDeriver.deriveFromApiUrl(apiUrlInput)
|
||||
|
||||
// Pre-resolve strings for Toast (non-composable context)
|
||||
// Pre-resolve strings for action callbacks (non-composable context).
|
||||
val apiHermesVoiceReachableToast = stringResource(R.string.active_section_api_hermes_voice_reachable)
|
||||
val apiRelayVoiceReachableToast = stringResource(R.string.active_section_api_relay_voice_reachable)
|
||||
val apiReachableVoiceReviewToast = stringResource(R.string.active_section_api_reachable_voice_review)
|
||||
@@ -926,21 +922,21 @@ private fun ManualUrlSubsection(
|
||||
relayOverrideVisible = true
|
||||
result.relayUrl?.let { relayUrlInput = it }
|
||||
}
|
||||
Toast.makeText(
|
||||
context,
|
||||
when {
|
||||
result.apiReachable && result.voiceConfigReachable ->
|
||||
if (result.voiceRoute == "standard") {
|
||||
apiHermesVoiceReachableToast
|
||||
} else {
|
||||
apiRelayVoiceReachableToast
|
||||
}
|
||||
result.apiReachable ->
|
||||
apiReachableVoiceReviewToast
|
||||
else -> cannotReachApiToast
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
val feedback = when {
|
||||
result.apiReachable && result.voiceConfigReachable ->
|
||||
if (result.voiceRoute == "standard") {
|
||||
apiHermesVoiceReachableToast
|
||||
} else {
|
||||
apiRelayVoiceReachableToast
|
||||
}
|
||||
result.apiReachable -> apiReachableVoiceReviewToast
|
||||
else -> cannotReachApiToast
|
||||
}
|
||||
when {
|
||||
result.apiReachable && result.voiceConfigReachable -> UiMessageBus.success(feedback)
|
||||
result.apiReachable -> UiMessageBus.warning(feedback)
|
||||
else -> UiMessageBus.error(feedback)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = apiUrlInput.isNotBlank() && !isTestingApi && inputApiKeyError == null,
|
||||
|
||||
@@ -9,7 +9,7 @@ import android.media.audiofx.Visualizer
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
@@ -312,110 +312,112 @@ fun AttachmentViewer(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current ||
|
||||
attachment.renderMode != AttachmentRenderMode.IMAGE
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
MessageOverlayScope {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current ||
|
||||
attachment.renderMode != AttachmentRenderMode.IMAGE
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
var revealed by remember(attachment.cachedUri, attachment.relayToken) {
|
||||
mutableStateOf(initiallyRevealed)
|
||||
}
|
||||
val blurred = !revealed &&
|
||||
attachment.renderMode == AttachmentRenderMode.IMAGE &&
|
||||
shouldBlurImage(blurMode, attachment.sensitive)
|
||||
|
||||
val title = attachment.fileName
|
||||
?: attachment.contentType.substringBefore(';').ifBlank { stringResource(R.string.attachment_title) }
|
||||
|
||||
// --- One shared Share / Save / Open-externally action set ----------
|
||||
fun runWithBytes(action: suspend (ByteArray) -> Unit) {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = attachmentBytes(context, attachment)
|
||||
if (bytes == null) {
|
||||
busy = false
|
||||
viewerToast(context, context.getString(R.string.inbound_attach_share_failed))
|
||||
return@launch
|
||||
}
|
||||
action(bytes)
|
||||
busy = false
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
var revealed by remember(attachment.cachedUri, attachment.relayToken) {
|
||||
mutableStateOf(initiallyRevealed)
|
||||
}
|
||||
}
|
||||
val blurred = !revealed &&
|
||||
attachment.renderMode == AttachmentRenderMode.IMAGE &&
|
||||
shouldBlurImage(blurMode, attachment.sensitive)
|
||||
|
||||
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)
|
||||
val title = attachment.fileName
|
||||
?: attachment.contentType.substringBefore(';').ifBlank { stringResource(R.string.attachment_title) }
|
||||
|
||||
// --- One shared Share / Save / Open-externally action set ----------
|
||||
fun runWithBytes(action: suspend (ByteArray) -> Unit) {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = attachmentBytes(context, attachment)
|
||||
if (bytes == null) {
|
||||
busy = false
|
||||
UiMessageBus.error(context.getString(R.string.inbound_attach_share_failed))
|
||||
return@launch
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed ->
|
||||
viewerToast(context, context.getString(R.string.inbound_attach_save_failed, result.message))
|
||||
action(bytes)
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
val onOpenExternal = {
|
||||
val cached = attachment.cachedUri
|
||||
if (!cached.isNullOrBlank()) {
|
||||
MediaSaver.open(context, Uri.parse(cached), attachment.contentType)
|
||||
} else {
|
||||
|
||||
val onShare = {
|
||||
runWithBytes { bytes ->
|
||||
val uri = MediaSaver.stageForShare(context, bytes, attachment.fileName, attachment.contentType)
|
||||
MediaSaver.open(context, uri, attachment.contentType)
|
||||
MediaSaver.share(context, uri, attachment.contentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.96f))
|
||||
.testTag("attachment-viewer"),
|
||||
) {
|
||||
// Body fills; toolbar floats on top. PDF/TEXT add their own top
|
||||
// inset so the first line clears the toolbar.
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
when (attachment.renderMode) {
|
||||
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)
|
||||
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 ->
|
||||
UiMessageBus.success(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 ->
|
||||
UiMessageBus.error(context.getString(R.string.inbound_attach_save_failed, result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
val onOpenExternal = {
|
||||
val cached = attachment.cachedUri
|
||||
if (!cached.isNullOrBlank()) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MediaViewerToolbar(
|
||||
title = title,
|
||||
busy = busy,
|
||||
actionsEnabled = !blurred,
|
||||
exportAllowed = exportAllowed,
|
||||
onShare = onShare,
|
||||
onSave = onSave,
|
||||
onOpenExternal = onOpenExternal,
|
||||
onClose = onDismiss,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.96f))
|
||||
.testTag("attachment-viewer"),
|
||||
) {
|
||||
// Body fills; toolbar floats on top. PDF/TEXT add their own top
|
||||
// inset so the first line clears the toolbar.
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
when (attachment.renderMode) {
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,164 +454,165 @@ internal fun AttachmentGalleryViewer(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
val revealed = remember { mutableStateMapOf<String, Boolean>() }
|
||||
LaunchedEffect(initiallyRevealedKeys) {
|
||||
initiallyRevealedKeys.forEach { revealed[it] = true }
|
||||
}
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = initialIndex.coerceIn(attachments.indices),
|
||||
pageCount = { attachments.size },
|
||||
)
|
||||
|
||||
val currentIndex = pagerState.currentPage.coerceIn(attachments.indices)
|
||||
val attachment = attachments[currentIndex]
|
||||
val currentKey = galleryAttachmentKey(attachment, currentIndex)
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
val currentBlurred = revealed[currentKey] != true &&
|
||||
shouldBlurImage(blurMode, attachment.sensitive)
|
||||
val title = attachment.fileName
|
||||
?: attachment.contentType.substringBefore(';').ifBlank { "Image" }
|
||||
val toolbarTitle = "$title · ${currentIndex + 1} of ${attachments.size}"
|
||||
|
||||
// Capture the currently visible attachment in each click lambda. A
|
||||
// swipe while IO is running must not redirect Save/Share to a new page.
|
||||
fun runWithBytes(action: suspend (Attachment, ByteArray) -> Unit) {
|
||||
if (currentBlurred || busy) return
|
||||
val target = attachment
|
||||
scope.launch {
|
||||
busy = true
|
||||
try {
|
||||
val bytes = attachmentBytes(context, target)
|
||||
if (bytes == null) {
|
||||
viewerToast(context, "Couldn't read this image")
|
||||
return@launch
|
||||
}
|
||||
action(target, bytes)
|
||||
} catch (error: Exception) {
|
||||
viewerToast(
|
||||
context,
|
||||
error.message?.takeIf { it.isNotBlank() }
|
||||
?: "Couldn't complete that image action",
|
||||
)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
MessageOverlayScope {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
val revealed = remember { mutableStateMapOf<String, Boolean>() }
|
||||
LaunchedEffect(initiallyRevealedKeys) {
|
||||
initiallyRevealedKeys.forEach { revealed[it] = true }
|
||||
}
|
||||
}
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = initialIndex.coerceIn(attachments.indices),
|
||||
pageCount = { attachments.size },
|
||||
)
|
||||
|
||||
val onShare = {
|
||||
runWithBytes { target, bytes ->
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
target.fileName,
|
||||
target.contentType,
|
||||
)
|
||||
MediaSaver.share(context, uri, target.contentType)
|
||||
}
|
||||
}
|
||||
val onSave = {
|
||||
runWithBytes { target, bytes ->
|
||||
when (val result = MediaSaver.saveImage(
|
||||
context,
|
||||
bytes,
|
||||
target.fileName,
|
||||
target.contentType,
|
||||
)) {
|
||||
is MediaSaver.SaveResult.Saved ->
|
||||
viewerToast(context, "Saved to ${result.location}")
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
target.fileName,
|
||||
target.contentType,
|
||||
val currentIndex = pagerState.currentPage.coerceIn(attachments.indices)
|
||||
val attachment = attachments[currentIndex]
|
||||
val currentKey = galleryAttachmentKey(attachment, currentIndex)
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
val currentBlurred = revealed[currentKey] != true &&
|
||||
shouldBlurImage(blurMode, attachment.sensitive)
|
||||
val title = attachment.fileName
|
||||
?: attachment.contentType.substringBefore(';').ifBlank { "Image" }
|
||||
val toolbarTitle = "$title · ${currentIndex + 1} of ${attachments.size}"
|
||||
|
||||
// Capture the currently visible attachment in each click lambda. A
|
||||
// swipe while IO is running must not redirect Save/Share to a new page.
|
||||
fun runWithBytes(action: suspend (Attachment, ByteArray) -> Unit) {
|
||||
if (currentBlurred || busy) return
|
||||
val target = attachment
|
||||
scope.launch {
|
||||
busy = true
|
||||
try {
|
||||
val bytes = attachmentBytes(context, target)
|
||||
if (bytes == null) {
|
||||
UiMessageBus.error("Couldn't read this image")
|
||||
return@launch
|
||||
}
|
||||
action(target, bytes)
|
||||
} catch (error: Exception) {
|
||||
UiMessageBus.error(
|
||||
error.message?.takeIf { it.isNotBlank() }
|
||||
?: "Couldn't complete that image action",
|
||||
)
|
||||
MediaSaver.share(context, uri, target.contentType)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
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 onShare = {
|
||||
runWithBytes { target, bytes ->
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
item.fileName,
|
||||
item.contentType,
|
||||
target.fileName,
|
||||
target.contentType,
|
||||
)
|
||||
MediaSaver.open(context, uri, item.contentType)
|
||||
MediaSaver.share(context, uri, target.contentType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.96f)),
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
beyondViewportPageCount = 0,
|
||||
pageSpacing = 12.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.testTag("attachment-gallery-pager"),
|
||||
) { page ->
|
||||
val pageAttachment = attachments[page]
|
||||
val pageKey = galleryAttachmentKey(pageAttachment, page)
|
||||
val blurred = revealed[pageKey] != true && shouldBlurImage(
|
||||
blurMode,
|
||||
pageAttachment.sensitive,
|
||||
)
|
||||
ImageBody(
|
||||
attachment = pageAttachment,
|
||||
blurred = blurred,
|
||||
onReveal = { revealed[pageKey] = true },
|
||||
)
|
||||
val onSave = {
|
||||
runWithBytes { target, bytes ->
|
||||
when (val result = MediaSaver.saveImage(
|
||||
context,
|
||||
bytes,
|
||||
target.fileName,
|
||||
target.contentType,
|
||||
)) {
|
||||
is MediaSaver.SaveResult.Saved ->
|
||||
UiMessageBus.success("Saved to ${result.location}")
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
target.fileName,
|
||||
target.contentType,
|
||||
)
|
||||
MediaSaver.share(context, uri, target.contentType)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed ->
|
||||
UiMessageBus.error("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 {
|
||||
UiMessageBus.error("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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MediaViewerToolbar(
|
||||
title = toolbarTitle,
|
||||
busy = busy,
|
||||
actionsEnabled = !currentBlurred,
|
||||
exportAllowed = exportAllowed,
|
||||
onShare = onShare,
|
||||
onSave = onSave,
|
||||
onOpenExternal = onOpenExternal,
|
||||
onClose = onDismiss,
|
||||
modifier = Modifier.align(Alignment.TopCenter),
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.96f)),
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
beyondViewportPageCount = 0,
|
||||
pageSpacing = 12.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.testTag("attachment-gallery-pager"),
|
||||
) { page ->
|
||||
val pageAttachment = attachments[page]
|
||||
val pageKey = galleryAttachmentKey(pageAttachment, page)
|
||||
val blurred = revealed[pageKey] != true && shouldBlurImage(
|
||||
blurMode,
|
||||
pageAttachment.sensitive,
|
||||
)
|
||||
ImageBody(
|
||||
attachment = pageAttachment,
|
||||
blurred = blurred,
|
||||
onReveal = { revealed[pageKey] = true },
|
||||
)
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1191,10 +1194,6 @@ private fun rememberPlayableUri(attachment: Attachment): 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
|
||||
|
||||
// --- Non-composable IO helpers ---------------------------------------------
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,197 @@
|
||||
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
|
||||
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -107,143 +107,141 @@ fun ChatImageViewer(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
MessageOverlayScope {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
var revealed by remember(source) { mutableStateOf(initiallyRevealed) }
|
||||
val blurred = !revealed && shouldBlurImage(blurMode, sensitive)
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
var revealed by remember(source) { mutableStateOf(initiallyRevealed) }
|
||||
val blurred = !revealed && shouldBlurImage(blurMode, sensitive)
|
||||
|
||||
val gestureModifier = Modifier.fillMaxSize().zoomable()
|
||||
val gestureModifier = Modifier.fillMaxSize().zoomable()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.94f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
BlurredMedia(
|
||||
blurred = blurred,
|
||||
onReveal = { revealed = true },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
when (source) {
|
||||
is ChatImageViewerSource.Coil -> AsyncImage(
|
||||
model = source.model,
|
||||
contentDescription = source.displayName,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = gestureModifier,
|
||||
)
|
||||
|
||||
is ChatImageViewerSource.Bitmap -> Image(
|
||||
bitmap = source.bitmap,
|
||||
contentDescription = source.displayName,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = gestureModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
CircularProgressIndicator(color = Color.White)
|
||||
}
|
||||
|
||||
// Control bar — top-right, inset past the status bar / notch.
|
||||
Row(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.94f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
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)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = try {
|
||||
source.bytesProvider()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (bytes == null) {
|
||||
busy = false
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
}
|
||||
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
|
||||
is MediaSaver.SaveResult.Saved -> {
|
||||
busy = false
|
||||
toast(context, savedFmt.format(result.location))
|
||||
}
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
busy = false
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
source.displayName,
|
||||
source.mime,
|
||||
)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed -> {
|
||||
busy = false
|
||||
toast(context, failedFmt.format(result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Download, contentDescription = cdSave)
|
||||
BlurredMedia(
|
||||
blurred = blurred,
|
||||
onReveal = { revealed = true },
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
when (source) {
|
||||
is ChatImageViewerSource.Coil -> AsyncImage(
|
||||
model = source.model,
|
||||
contentDescription = source.displayName,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = gestureModifier,
|
||||
)
|
||||
|
||||
is ChatImageViewerSource.Bitmap -> Image(
|
||||
bitmap = source.bitmap,
|
||||
contentDescription = source.displayName,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = gestureModifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDismiss, colors = tint) {
|
||||
Icon(Icons.Filled.Close, contentDescription = cdClose)
|
||||
|
||||
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) {
|
||||
UiMessageBus.error(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)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = try {
|
||||
source.bytesProvider()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (bytes == null) {
|
||||
busy = false
|
||||
UiMessageBus.error(errorMsg)
|
||||
return@launch
|
||||
}
|
||||
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
|
||||
is MediaSaver.SaveResult.Saved -> {
|
||||
busy = false
|
||||
UiMessageBus.success(savedFmt.format(result.location))
|
||||
}
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
busy = false
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
source.displayName,
|
||||
source.mime,
|
||||
)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed -> {
|
||||
busy = false
|
||||
UiMessageBus.error(failedFmt.format(result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Download, contentDescription = cdSave)
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
+2
-8
@@ -1,6 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
@@ -140,18 +139,13 @@ fun ChatTransportStatusBadge(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val textColor = status.textColor()
|
||||
val background = status.backgroundColor()
|
||||
Surface(
|
||||
modifier = modifier.combinedClickable(
|
||||
onClick = { onClick?.invoke() },
|
||||
onLongClick = {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"${status.reason}: ${status.detail}",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
UiMessageBus.info("${status.reason}: ${status.detail}")
|
||||
},
|
||||
),
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
|
||||
+16
-4
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -20,10 +22,12 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionStepState
|
||||
|
||||
data class ConnectionSetupTimelineStep(
|
||||
val title: String,
|
||||
val detail: String,
|
||||
val state: ConnectionStepState = ConnectionStepState.Done,
|
||||
)
|
||||
|
||||
/** Compact completed-state timeline shared by connection and auth flows. */
|
||||
@@ -43,13 +47,21 @@ fun ConnectionSetupTimeline(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
|
||||
.background(when (step.state) {
|
||||
ConnectionStepState.Failed -> MaterialTheme.colorScheme.errorContainer
|
||||
ConnectionStepState.Pending -> MaterialTheme.colorScheme.surfaceVariant
|
||||
else -> MaterialTheme.colorScheme.primaryContainer
|
||||
}, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
if (step.state == ConnectionStepState.Active) {
|
||||
CircularProgressIndicator(Modifier.size(17.dp), strokeWidth = 2.dp)
|
||||
} 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,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
tint = if (step.state == ConnectionStepState.Failed) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(17.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import android.Manifest
|
||||
import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -343,11 +343,7 @@ fun ConnectionWizard(
|
||||
// user at the manual pairing paths (URL entry / 6-char code)
|
||||
// instead of leaving them on a vanishing toast with no scanner.
|
||||
step = WizardStep.Nearby
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.cw_camera_denied),
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
UiMessageBus.warning(context.getString(R.string.cw_camera_denied))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -104,120 +103,119 @@ private fun CrashReportDialog(report: ReliabilityReport, onDismiss: () -> Unit)
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.94f),
|
||||
shape = appearanceRoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.WarningAmber,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
MessageOverlayScope {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.94f),
|
||||
shape = appearanceRoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.WarningAmber,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
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_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
text = stringResource(R.string.crash_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.crash_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.crash_privacy),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.crash_privacy),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
|
||||
if (showDetails) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 300.dp)
|
||||
.clip(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),
|
||||
)
|
||||
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)),
|
||||
) {
|
||||
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))
|
||||
// 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) {
|
||||
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, 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)) }
|
||||
UiMessageBus.success(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)
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toast(context: Context, message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
+144
-149
@@ -1,7 +1,7 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.text.format.DateFormat
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -78,159 +78,158 @@ fun DiagnosticDetailDialog(entry: DiagnosticLogEntry, onDismiss: () -> Unit) {
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.94f),
|
||||
shape = appearanceRoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DiagnosticSeverityChip(entry.severity)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = entry.category.label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
MessageOverlayScope {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.94f),
|
||||
shape = appearanceRoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DiagnosticSeverityChip(entry.severity)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = entry.category.label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
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))
|
||||
Text(
|
||||
text = "Suggested next step",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
text = entry.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
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),
|
||||
)
|
||||
}
|
||||
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))
|
||||
Text(
|
||||
text = "Suggested next step",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = suggestion,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
} 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),
|
||||
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),
|
||||
),
|
||||
)
|
||||
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)) }
|
||||
) {
|
||||
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)) }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, plainText)
|
||||
UiMessageBus.success(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)
|
||||
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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,10 +275,6 @@ 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. */
|
||||
private fun DiagnosticLogEntry.toPlainText(): String = buildString {
|
||||
appendLine("Hermes-Relay diagnostic")
|
||||
|
||||
+50
-49
@@ -24,12 +24,12 @@ import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
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.ExpandMore
|
||||
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.Terminal
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -66,7 +66,6 @@ import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcess
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivity
|
||||
import com.hermesandroid.relay.viewmodel.SubagentActivityPhase
|
||||
import com.hermesandroid.relay.viewmodel.SubagentChildPreview
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -84,27 +83,18 @@ internal fun GatewayBackgroundProcessStrip(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
// Initial/switch refreshes are silent. The strip appears only after the
|
||||
// session actually owns a process, avoiding a transient "Checking" row on
|
||||
// every ordinary chat open.
|
||||
// Completed work remains reachable from its transcript receipt. Only active
|
||||
// work owns composer space; refreshing history must not resurrect the strip.
|
||||
val visibleActivities = subagentActivities.takeIf { subagentPreviewVisibility.showLifecycle }.orEmpty()
|
||||
if (processes.isEmpty() && visibleActivities.isEmpty()) return
|
||||
|
||||
val running = processes.count { it.isRunning }
|
||||
val runningAgents = visibleActivities.count { !it.isTerminal }
|
||||
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
|
||||
if (running == 0 && runningAgents == 0) return
|
||||
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)
|
||||
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)
|
||||
else -> "$running ${stringResource(R.string.bg_processes_running)}"
|
||||
}
|
||||
val openDescription = stringResource(R.string.current_chat_activity_open)
|
||||
|
||||
@@ -130,24 +120,7 @@ internal fun GatewayBackgroundProcessStrip(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
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
|
||||
},
|
||||
)
|
||||
}
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(9.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.current_chat_activity_title),
|
||||
@@ -157,11 +130,7 @@ internal fun GatewayBackgroundProcessStrip(
|
||||
Text(
|
||||
text = status,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (failureCount > 0 && running == 0) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Visibility,
|
||||
@@ -190,6 +159,8 @@ internal fun GatewayBackgroundProcessSheet(
|
||||
onDismissProcess: (String) -> Unit,
|
||||
onOpenSubagentChild: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
readOnlyHistory: Boolean = false,
|
||||
historyNotice: String? = null,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
@@ -246,17 +217,19 @@ internal fun GatewayBackgroundProcessSheet(
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
stringResource(R.string.current_chat_activity_title),
|
||||
stringResource(
|
||||
if (readOnlyHistory) R.string.chat_activity_history_title else R.string.current_chat_activity_title,
|
||||
),
|
||||
modifier = Modifier.semantics { heading() },
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.current_chat_activity_subtitle),
|
||||
historyNotice ?: stringResource(R.string.current_chat_activity_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (processes.isNotEmpty()) IconButton(onClick = onRefresh, enabled = !loading) {
|
||||
if (processes.isNotEmpty() && !readOnlyHistory) IconButton(onClick = onRefresh, enabled = !loading) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
@@ -300,7 +273,9 @@ internal fun GatewayBackgroundProcessSheet(
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.current_chat_activity_empty),
|
||||
stringResource(
|
||||
if (readOnlyHistory) R.string.chat_activity_history_empty else R.string.current_chat_activity_empty,
|
||||
),
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -341,7 +316,7 @@ internal fun GatewayBackgroundProcessSheet(
|
||||
GatewayProcessRow(
|
||||
process = process,
|
||||
stopping = process.id in stoppingProcessIds,
|
||||
onStop = { onStop(process.id) },
|
||||
onStop = if (readOnlyHistory) null else ({ onStop(process.id) }),
|
||||
onDismiss = null,
|
||||
)
|
||||
}
|
||||
@@ -356,7 +331,7 @@ internal fun GatewayBackgroundProcessSheet(
|
||||
process = process,
|
||||
stopping = false,
|
||||
onStop = null,
|
||||
onDismiss = { onDismissProcess(process.id) },
|
||||
onDismiss = if (readOnlyHistory) null else ({ onDismissProcess(process.id) }),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -384,7 +359,7 @@ private fun GatewayProcessRow(
|
||||
onDismiss: (() -> Unit)?,
|
||||
) {
|
||||
var expanded by remember(process.id) { mutableStateOf(false) }
|
||||
val failed = !process.isRunning && (process.exitCode ?: 0) != 0
|
||||
val failed = processDisplayPhase(process) == ProcessDisplayPhase.FAILED
|
||||
val output = sanitizeTerminalText(
|
||||
process.outputTail.orEmpty().ifBlank { process.outputPreview.orEmpty() },
|
||||
).trimEnd()
|
||||
@@ -478,9 +453,11 @@ private fun GatewayProcessRow(
|
||||
|
||||
@Composable
|
||||
private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping: Boolean) {
|
||||
val phase = processDisplayPhase(process)
|
||||
val tint: Color = when {
|
||||
failed -> MaterialTheme.colorScheme.error
|
||||
process.isRunning || stopping -> MaterialTheme.colorScheme.primary
|
||||
phase == ProcessDisplayPhase.UNKNOWN || phase == ProcessDisplayPhase.CANCELLED -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else -> MaterialTheme.colorScheme.tertiary
|
||||
}
|
||||
Surface(
|
||||
@@ -501,6 +478,14 @@ private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping:
|
||||
modifier = Modifier.size(18.dp),
|
||||
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(
|
||||
Icons.Filled.CheckCircle,
|
||||
contentDescription = stringResource(R.string.tool_completed_a11y),
|
||||
@@ -514,8 +499,11 @@ private fun ProcessStateIcon(process: GatewayProcess, failed: Boolean, stopping:
|
||||
|
||||
@Composable
|
||||
private fun processMetadata(process: GatewayProcess, failed: Boolean): String {
|
||||
val phase = processDisplayPhase(process)
|
||||
val state = when {
|
||||
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) +
|
||||
process.exitCode?.let { " · exit $it" }.orEmpty()
|
||||
else -> stringResource(R.string.task_status_complete) +
|
||||
@@ -525,6 +513,19 @@ private fun processMetadata(process: GatewayProcess, failed: Boolean): String {
|
||||
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(
|
||||
"\u001B(?:\\].*?(?:\u0007|\u001B\\\\)|\\[[0-?]*[ -/]*[@-~]|[ -/]*[@-~])",
|
||||
RegexOption.DOT_MATCHES_ALL,
|
||||
|
||||
@@ -5,7 +5,7 @@ import android.graphics.Bitmap
|
||||
import android.graphics.pdf.PdfRenderer
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.hermesandroid.relay.R
|
||||
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) {
|
||||
val bytes = attachmentBytes(context, attachment)
|
||||
if (bytes == null) {
|
||||
attachmentToast(context, context.getString(R.string.inbound_attach_share_failed))
|
||||
UiMessageBus.error(context.getString(R.string.inbound_attach_share_failed))
|
||||
return
|
||||
}
|
||||
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) {
|
||||
val bytes = attachmentBytes(context, attachment)
|
||||
if (bytes == null) {
|
||||
attachmentToast(context, context.getString(R.string.inbound_attach_share_failed))
|
||||
UiMessageBus.error(context.getString(R.string.inbound_attach_share_failed))
|
||||
return
|
||||
}
|
||||
val result = if (attachment.renderMode == AttachmentRenderMode.IMAGE) {
|
||||
@@ -636,13 +636,13 @@ suspend fun saveAttachment(context: Context, attachment: Attachment) {
|
||||
}
|
||||
when (result) {
|
||||
is MediaSaver.SaveResult.Saved ->
|
||||
attachmentToast(context, context.getString(R.string.inbound_attach_saved, result.location))
|
||||
UiMessageBus.success(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 ->
|
||||
attachmentToast(context, context.getString(R.string.inbound_attach_save_failed, result.message))
|
||||
UiMessageBus.error(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) {
|
||||
MediaSaver.open(context, uri, attachment.contentType)
|
||||
} else {
|
||||
attachmentToast(context, context.getString(R.string.inbound_attach_open_failed))
|
||||
UiMessageBus.error(context.getString(R.string.inbound_attach_open_failed))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,10 +774,6 @@ internal suspend fun attachmentBytes(context: Context, attachment: Attachment):
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachmentToast(context: Context, message: String) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun emojiAndLabelFor(
|
||||
mode: AttachmentRenderMode,
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -47,6 +48,7 @@ import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.compositeOver
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -64,21 +66,26 @@ private const val MAX_VISIBLE_EXPANDED = 3
|
||||
private const val ROW_MIN_HEIGHT_DP = 34
|
||||
|
||||
/**
|
||||
* Top, thin, info-only banner host. Collects [UiMessageBus] and renders the
|
||||
* Top, thin, themed banner host. Collects [UiMessageBus] and renders the
|
||||
* newest transient message on one line; tapping expands to the recent few
|
||||
* (scrolling past three). It takes its own vertical space — the Scaffold below
|
||||
* reflows, so content slides down smoothly instead of being covered by an
|
||||
* overlay. Auto-dismisses (paused while expanded) and coalesces duplicates so a
|
||||
* burst of the same status collapses to one refreshed row.
|
||||
*
|
||||
* App-owned errors stay on the snackbar. Keyed upstream AgentNotices may also
|
||||
* use the warning tone because their sticky/clear lifecycle is server-owned.
|
||||
* Actionable messages use ThemedMessageHost. Modal hosts render above their
|
||||
* dialog while the primary host retains messages for the return to the app.
|
||||
*/
|
||||
@Composable
|
||||
fun MessageBannerHost(
|
||||
modifier: Modifier = Modifier,
|
||||
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
|
||||
// parallel map so coalescing/auto-dismiss can address rows by id.
|
||||
val shown = remember { mutableStateListOf<UiMessage>() }
|
||||
@@ -122,12 +129,12 @@ fun MessageBannerHost(
|
||||
}
|
||||
|
||||
// Collapse + report count to the scaffold (for inset accounting).
|
||||
LaunchedEffect(shown.size) {
|
||||
LaunchedEffect(shown.size, active) {
|
||||
if (shown.isEmpty()) expanded = false
|
||||
UiMessageBus.reportActiveCount(shown.size)
|
||||
if (primary) UiMessageBus.reportActiveCount(if (active) shown.size else 0)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { UiMessageBus.reportActiveCount(0) }
|
||||
onDispose { if (primary) UiMessageBus.reportActiveCount(0) }
|
||||
}
|
||||
|
||||
// Mirror the live queue into a retained copy so the exit animation still
|
||||
@@ -147,7 +154,7 @@ fun MessageBannerHost(
|
||||
// under the notch. The smooth "slide" lives in animateContentSize below
|
||||
// (collapsed↔expanded and message-count changes).
|
||||
AnimatedVisibility(
|
||||
visible = shown.isNotEmpty(),
|
||||
visible = active && shown.isNotEmpty(),
|
||||
enter = fadeIn(tween(180)),
|
||||
exit = fadeOut(tween(160)),
|
||||
modifier = modifier,
|
||||
@@ -155,7 +162,7 @@ fun MessageBannerHost(
|
||||
MessageBannerContent(
|
||||
messages = rendered,
|
||||
expanded = expanded,
|
||||
onToggle = { if (rendered.size > 1) expanded = !expanded },
|
||||
onToggle = { expanded = !expanded },
|
||||
includeStatusBarPadding = includeStatusBarPadding,
|
||||
)
|
||||
}
|
||||
@@ -170,6 +177,7 @@ private fun MessageBannerContent(
|
||||
) {
|
||||
val newest = messages.lastOrNull() ?: return
|
||||
val multiple = messages.size > 1
|
||||
val expandable = multiple || newest.text.length > 80 || newest.severity in setOf(UiMessageSeverity.Warning, UiMessageSeverity.Error)
|
||||
val insetModifier = if (includeStatusBarPadding) {
|
||||
Modifier.windowInsetsPadding(WindowInsets.statusBars)
|
||||
} else {
|
||||
@@ -190,14 +198,14 @@ private fun MessageBannerContent(
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (multiple) Modifier.clickable(onClick = onToggle) else Modifier)
|
||||
.then(if (expandable) Modifier.clickable(onClick = onToggle) else Modifier)
|
||||
.animateContentSize(animationSpec = tween(durationMillis = 180)),
|
||||
) {
|
||||
if (!expanded) {
|
||||
MessageRow(
|
||||
message = newest,
|
||||
trailing = {
|
||||
if (multiple) {
|
||||
if (expandable) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
@@ -236,6 +244,7 @@ private fun MessageBannerContent(
|
||||
ordered.forEachIndexed { index, message ->
|
||||
MessageRow(
|
||||
message = message,
|
||||
expanded = true,
|
||||
trailing = {
|
||||
if (index == 0) {
|
||||
Icon(
|
||||
@@ -256,6 +265,7 @@ private fun MessageBannerContent(
|
||||
@Composable
|
||||
private fun MessageRow(
|
||||
message: UiMessage,
|
||||
expanded: Boolean = false,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
@@ -275,7 +285,7 @@ private fun MessageRow(
|
||||
Text(
|
||||
text = message.text,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
maxLines = if (expanded) 12 else 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
@@ -284,22 +294,25 @@ private fun MessageRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun severityContainer(severity: UiMessageSeverity): Color = when (severity) {
|
||||
internal fun severityContainer(severity: UiMessageSeverity): Color = (when (severity) {
|
||||
UiMessageSeverity.Error -> MaterialTheme.colorScheme.errorContainer
|
||||
UiMessageSeverity.Success -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.58f)
|
||||
UiMessageSeverity.Status -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.74f)
|
||||
UiMessageSeverity.Warning -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.72f)
|
||||
UiMessageSeverity.Info -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.90f)
|
||||
}
|
||||
}).compositeOver(MaterialTheme.colorScheme.surface)
|
||||
|
||||
@Composable
|
||||
private fun severityOnContainer(severity: UiMessageSeverity): Color = when (severity) {
|
||||
internal fun severityOnContainer(severity: UiMessageSeverity): Color = when (severity) {
|
||||
UiMessageSeverity.Error -> MaterialTheme.colorScheme.onErrorContainer
|
||||
UiMessageSeverity.Success -> MaterialTheme.colorScheme.onTertiaryContainer
|
||||
UiMessageSeverity.Status -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
UiMessageSeverity.Warning -> MaterialTheme.colorScheme.onErrorContainer
|
||||
UiMessageSeverity.Info -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
private fun severityIcon(severity: UiMessageSeverity): ImageVector = when (severity) {
|
||||
internal fun severityIcon(severity: UiMessageSeverity): ImageVector = when (severity) {
|
||||
UiMessageSeverity.Error -> Icons.Filled.Warning
|
||||
UiMessageSeverity.Success -> Icons.Filled.CheckCircle
|
||||
UiMessageSeverity.Status -> Icons.Filled.Sync
|
||||
UiMessageSeverity.Warning -> Icons.Filled.Warning
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -92,68 +92,70 @@ fun SupportBundleDialog(state: SupportReviewState, onDismiss: () -> Unit) {
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.94f),
|
||||
shape = appearanceRoundedCornerShape(24.dp),
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(stringResource(R.string.support_bundle_title), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.support_bundle_privacy, state.recordCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 160.dp, max = 420.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
|
||||
appearanceRoundedCornerShape(12.dp),
|
||||
),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = state.text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(12.dp),
|
||||
)
|
||||
MessageOverlayScope {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.94f),
|
||||
shape = appearanceRoundedCornerShape(24.dp),
|
||||
tonalElevation = 6.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(stringResource(R.string.support_bundle_title), style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
stringResource(R.string.support_bundle_privacy, state.recordCount),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 160.dp, max = 420.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
|
||||
appearanceRoundedCornerShape(12.dp),
|
||||
),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = state.text,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 15.sp,
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
|
||||
OutlinedButton(
|
||||
enabled = state.shareEnabled,
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, state.text)
|
||||
Toast.makeText(context, copied, Toast.LENGTH_LONG).show()
|
||||
},
|
||||
) { Text(stringResource(R.string.common_copy)) }
|
||||
Button(
|
||||
enabled = state.shareEnabled,
|
||||
onClick = {
|
||||
if (!IssueReport.share(
|
||||
context,
|
||||
subject = chooser,
|
||||
text = state.text,
|
||||
chooserTitle = chooser,
|
||||
)
|
||||
) {
|
||||
Spacer(Modifier.height(18.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.common_close)) }
|
||||
OutlinedButton(
|
||||
enabled = state.shareEnabled,
|
||||
onClick = {
|
||||
IssueReport.copyToClipboard(context, state.text)
|
||||
Toast.makeText(context, noShare, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(R.string.common_share)) }
|
||||
UiMessageBus.success(copied)
|
||||
},
|
||||
) { 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)
|
||||
UiMessageBus.warning(noShare)
|
||||
}
|
||||
},
|
||||
) { Text(stringResource(R.string.common_share)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
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,6 +57,18 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
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
|
||||
fun ToolProgressCard(
|
||||
@@ -116,7 +128,9 @@ fun ToolProgressCard(
|
||||
}
|
||||
|
||||
val toolIcon = toolIcon(toolCall.name)
|
||||
val detachedDispatch = isDetachedDelegationDispatch(toolCall)
|
||||
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 == false -> stringResource(R.string.tool_progress_status_failed)
|
||||
isPreparing -> stringResource(R.string.tool_preparing_a11y)
|
||||
@@ -187,7 +201,7 @@ fun ToolProgressCard(
|
||||
)
|
||||
|
||||
// Duration + completion time ("3.1s · 5:32 PM")
|
||||
val metaLabel = listOfNotNull(duration, timeLabel).joinToString(" · ")
|
||||
val metaLabel = if (detachedDispatch) statusText else listOfNotNull(duration, timeLabel).joinToString(" · ")
|
||||
if (metaLabel.isNotEmpty()) {
|
||||
Text(
|
||||
text = metaLabel,
|
||||
|
||||
@@ -4,7 +4,7 @@ package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -94,7 +94,7 @@ fun AboutScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
|
||||
// Pre-resolved Toast messages (Toast is not a composable scope)
|
||||
// Pre-resolved messages for non-composable action callbacks.
|
||||
val devUnlockedMsg = stringResource(R.string.about_dev_options_unlocked)
|
||||
val updateCopiedMsg = stringResource(R.string.about_update_copied)
|
||||
val emDash = stringResource(R.string.about_em_dash)
|
||||
@@ -235,17 +235,13 @@ fun AboutScreen(
|
||||
versionTapCount = 0
|
||||
scope.launch {
|
||||
FeatureFlags.unlockDevOptions(context)
|
||||
Toast.makeText(
|
||||
context,
|
||||
devUnlockedMsg,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
UiMessageBus.success(devUnlockedMsg)
|
||||
onUnlockDeveloperOptions()
|
||||
}
|
||||
}
|
||||
remaining <= 3 -> {
|
||||
val tapsMsg = context.getString(R.string.about_taps_to_unlock, remaining)
|
||||
Toast.makeText(context, tapsMsg, Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.info(tapsMsg)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -326,11 +322,7 @@ fun AboutScreen(
|
||||
if (ru.updateAvailable && !ru.updateCommand.isNullOrBlank()) {
|
||||
TextButton(onClick = {
|
||||
clipboard.setText(AnnotatedString(ru.updateCommand))
|
||||
Toast.makeText(
|
||||
context,
|
||||
updateCopiedMsg,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
UiMessageBus.success(updateCopiedMsg)
|
||||
}) {
|
||||
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.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import com.hermesandroid.relay.ui.components.ThemedMessageHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -268,7 +268,7 @@ fun AppearanceSettingsScreen(
|
||||
}
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
snackbarHost = { ThemedMessageHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -41,7 +41,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import com.hermesandroid.relay.ui.components.ThemedMessageHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -73,6 +73,7 @@ import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.BotGroupMessage
|
||||
import com.hermesandroid.relay.data.BotGroupRoom
|
||||
import com.hermesandroid.relay.data.BotGatewayRoute
|
||||
import com.hermesandroid.relay.data.BotGatewayRouteKey
|
||||
import com.hermesandroid.relay.data.BotModeState
|
||||
import com.hermesandroid.relay.data.BotRosterEntry
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
@@ -84,6 +85,13 @@ import kotlinx.coroutines.launch
|
||||
|
||||
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 {
|
||||
val activityAtMs: Long
|
||||
|
||||
@@ -109,7 +117,7 @@ fun BotModeScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
val resources = LocalResources.current
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
var openingProfile by remember { mutableStateOf<String?>(null) }
|
||||
var openingRoute by remember { mutableStateOf<BotGatewayRouteKey?>(null) }
|
||||
var showCreateBot by remember { mutableStateOf(false) }
|
||||
var creatingBot by remember { mutableStateOf(false) }
|
||||
var selectedGatewayId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
@@ -136,10 +144,11 @@ fun BotModeScreen(
|
||||
onBack = onBack,
|
||||
onRefresh = connectionViewModel::refreshBotMode,
|
||||
onSelectGateway = { selectedGatewayId = it },
|
||||
openingProfile = openingProfile,
|
||||
openingRoute = openingRoute,
|
||||
onOpenBot = { bot ->
|
||||
val route = bot.route ?: return@BotModeContent
|
||||
openingProfile = bot.profile.name
|
||||
if (openingRoute != null) return@BotModeContent
|
||||
openingRoute = route.key
|
||||
scope.launch {
|
||||
val result = connectionViewModel.ensureCanonicalBotChat(route)
|
||||
.map { it.resolvedSessionId }
|
||||
@@ -147,12 +156,12 @@ fun BotModeScreen(
|
||||
onSuccess = { onOpenBotChat(route, it) },
|
||||
onFailure = { snackbar.showSnackbar(it.message ?: chatOpenFailed) },
|
||||
)
|
||||
openingProfile = null
|
||||
openingRoute = null
|
||||
}
|
||||
},
|
||||
onOpenGroup = { onOpenGroup(it.key) },
|
||||
onNewBot = { showCreateBot = true },
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
snackbarHost = { ThemedMessageHost(snackbar) },
|
||||
botAvatar = { bot, size ->
|
||||
BotProfileAvatar(
|
||||
connectionViewModel = connectionViewModel,
|
||||
@@ -206,7 +215,7 @@ internal fun BotModeContent(
|
||||
onBack: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onSelectGateway: (String?) -> Unit,
|
||||
openingProfile: String? = null,
|
||||
openingRoute: BotGatewayRouteKey? = null,
|
||||
onOpenBot: (BotRosterEntry) -> Unit,
|
||||
onOpenGroup: (BotGroupRoom) -> Unit,
|
||||
onNewBot: () -> Unit,
|
||||
@@ -404,12 +413,12 @@ internal fun BotModeContent(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
items(activeBots, key = { it.profile.name }) { bot ->
|
||||
items(activeBots, key = { it.lazyItemKey }) { bot ->
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.width(78.dp)
|
||||
.clickable(enabled = openingProfile == null) { onOpenBot(bot) },
|
||||
.clickable(enabled = openingRoute == null) { onOpenBot(bot) },
|
||||
) {
|
||||
Box {
|
||||
botAvatar(bot, 56.dp)
|
||||
@@ -455,7 +464,7 @@ internal fun BotModeContent(
|
||||
itemsIndexed(
|
||||
items = rows,
|
||||
key = { _, row -> when (row) {
|
||||
is BotModeRow.Bot -> "bot:${row.value.profile.name}"
|
||||
is BotModeRow.Bot -> row.value.lazyItemKey
|
||||
is BotModeRow.Group -> "group:${row.value.key}"
|
||||
} },
|
||||
) { index, row ->
|
||||
@@ -463,7 +472,7 @@ internal fun BotModeContent(
|
||||
is BotModeRow.Bot -> BotConversationRow(
|
||||
bot = row.value,
|
||||
connectionLabel = row.value.route?.connectionLabel,
|
||||
opening = openingProfile == row.value.profile.name,
|
||||
opening = openingRoute != null && openingRoute == row.value.route?.key,
|
||||
onClick = { onOpenBot(row.value) },
|
||||
avatar = { botAvatar(row.value, 56.dp) },
|
||||
nowMs = nowMs,
|
||||
|
||||
@@ -12,6 +12,14 @@ import androidx.compose.foundation.MutatePriority
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import androidx.compose.foundation.background
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -150,7 +158,7 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import com.hermesandroid.relay.ui.components.ThemedMessageHost
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
@@ -159,7 +167,7 @@ import android.content.ClipData
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.PickVisualMediaRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -870,9 +878,17 @@ fun ChatScreen(
|
||||
|
||||
|
||||
val rawMessages by chatViewModel.messages.collectAsState()
|
||||
val messages = remember(rawMessages, supervised, supervisedPolicy.capabilities.generatedImages) {
|
||||
if (!supervised) rawMessages
|
||||
else rawMessages.map { message ->
|
||||
val activityRecords by chatViewModel.activityRecords.collectAsState()
|
||||
val activityOwner by chatViewModel.conversationBinding.collectAsState()
|
||||
val activitySessionId by chatViewModel.currentSessionId.collectAsState()
|
||||
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) {
|
||||
message.copy(
|
||||
attachments = if (supervisedPolicy.capabilities.generatedImages) {
|
||||
@@ -935,6 +951,10 @@ fun ChatScreen(
|
||||
val sessionArchivingSupported by chatViewModel.sessionArchivingSupported.collectAsState()
|
||||
val currentSessionId by chatViewModel.currentSessionId.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(
|
||||
structuredChatFailure,
|
||||
currentSessionId,
|
||||
@@ -961,6 +981,7 @@ fun ChatScreen(
|
||||
val backgroundProcessesLoading by chatViewModel.backgroundProcessesLoading.collectAsState()
|
||||
val stoppingProcessIds by chatViewModel.stoppingProcessIds.collectAsState()
|
||||
val subagentActivities by chatViewModel.subagentActivities.collectAsState()
|
||||
val retainedActivityPreview by chatViewModel.retainedActivityPreview.collectAsState()
|
||||
val subagentChildPreview by chatViewModel.subagentChildPreview.collectAsState()
|
||||
val isLoadingHistory by chatViewModel.isLoadingHistory.collectAsState()
|
||||
val isLoadingSessions by chatViewModel.isLoadingSessions.collectAsState()
|
||||
@@ -1199,7 +1220,10 @@ fun ChatScreen(
|
||||
// RPC burst while the session directory was also trying to hydrate.
|
||||
}
|
||||
DisposableEffect(chatViewModel) {
|
||||
onDispose { chatViewModel.setChatVisible(false) }
|
||||
onDispose {
|
||||
chatViewModel.setChatVisible(false)
|
||||
chatViewModel.closeActivityPreview()
|
||||
}
|
||||
}
|
||||
|
||||
// Cold-open recovery: the dashboard probe that flips gatewayAvailability to
|
||||
@@ -1370,14 +1394,10 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
if (request.payload.omittedUriCount > 0) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(
|
||||
UiMessageBus.warning(context.getString(
|
||||
R.string.chat_shared_files_limited,
|
||||
com.hermesandroid.relay.util.MAX_SHARED_CONTENT_ATTACHMENTS,
|
||||
),
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
))
|
||||
}
|
||||
com.hermesandroid.relay.util.SharedContentRequest.consume(request.id)
|
||||
}
|
||||
@@ -1416,6 +1436,9 @@ fun ChatScreen(
|
||||
var showEffortSheet by remember { mutableStateOf(false) }
|
||||
var showAgentInfo 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 showProfileManager by remember { mutableStateOf(false) }
|
||||
var showBackgroundProcesses by remember { mutableStateOf(false) }
|
||||
@@ -1432,7 +1455,7 @@ fun ChatScreen(
|
||||
// A process inventory is scoped to one gateway session. Never leave a
|
||||
// sheet opened onto a different chat after a drawer/profile switch.
|
||||
LaunchedEffect(currentSessionId, selectedProfile?.name, activeConnection?.id) {
|
||||
chatViewModel.closeSubagentChildPreview()
|
||||
chatViewModel.closeActivityPreview()
|
||||
showBackgroundProcesses = false
|
||||
}
|
||||
|
||||
@@ -1799,7 +1822,7 @@ fun ChatScreen(
|
||||
cameraLauncher.launch(uri)
|
||||
}.onFailure {
|
||||
pendingCameraUri = null
|
||||
Toast.makeText(context, context.getString(R.string.chat_camera_open_failed), Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.error(context.getString(R.string.chat_camera_open_failed))
|
||||
}
|
||||
}
|
||||
var pendingCameraAfterPermission by remember { mutableStateOf(false) }
|
||||
@@ -1811,11 +1834,7 @@ fun ChatScreen(
|
||||
if (granted && wanted) {
|
||||
launchCamera()
|
||||
} else if (!granted) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.chat_camera_perm_needed),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
UiMessageBus.warning(context.getString(R.string.chat_camera_perm_needed))
|
||||
}
|
||||
}
|
||||
val requestCameraCapture: () -> Unit = {
|
||||
@@ -2613,11 +2632,7 @@ fun ChatScreen(
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText(copySessionIdLabel, sessionId))
|
||||
)
|
||||
Toast.makeText(
|
||||
context,
|
||||
copiedToClipboardMsg,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
UiMessageBus.success(copiedToClipboardMsg)
|
||||
}
|
||||
},
|
||||
threadsCapabilityActive = threadsCapabilityActive,
|
||||
@@ -2763,6 +2778,7 @@ fun ChatScreen(
|
||||
) {
|
||||
// Top bar — messaging app style with avatar, name, model subtitle
|
||||
TopAppBar(
|
||||
modifier = Modifier.onSizeChanged { chatHeaderHeightPx = it.height },
|
||||
navigationIcon = {
|
||||
if (!supervised || supervisedPolicy.capabilities.conversationHistory) {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
@@ -2806,6 +2822,7 @@ fun ChatScreen(
|
||||
val showStreamingState = isStreaming &&
|
||||
(!supervised || supervisedVisibility.showWorkingStatus)
|
||||
val statusText = when {
|
||||
preparingGatewaySession -> stringResource(R.string.chat_debug_preparing)
|
||||
headerChatReady -> if (showStreamingState) {
|
||||
stringResource(R.string.chat_streaming)
|
||||
} else {
|
||||
@@ -2882,13 +2899,21 @@ fun ChatScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.clickable(enabled = !supervised) {
|
||||
.chatDebugHeaderGesture(enabled = !supervised,
|
||||
onHold = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
showProfileShelf = false
|
||||
showChatDebug = !showChatDebug
|
||||
},
|
||||
onClick = {
|
||||
showChatDebug = false
|
||||
if (profileShelfAvailable) {
|
||||
showProfileShelf = !showProfileShelf
|
||||
} else {
|
||||
showAgentInfo = true
|
||||
}
|
||||
}
|
||||
})
|
||||
.testTag("chat-agent-header")
|
||||
.semantics {
|
||||
contentDescription = if (profileShelfAvailable) {
|
||||
context.getString(
|
||||
@@ -3669,7 +3694,15 @@ fun ChatScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (processNotification != null) {
|
||||
if (message.activityRecord != null && (!supervised || supervisedVisibility.showWorkingStatus)) {
|
||||
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(
|
||||
top = if (isFirstInGroup) 6.dp else 2.dp,
|
||||
)
|
||||
@@ -3911,6 +3944,9 @@ fun ChatScreen(
|
||||
// optional diagnostic scaffolding. Keep lanes
|
||||
// visible in Off, Compact, and Detailed modes.
|
||||
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))
|
||||
SubagentLane(
|
||||
taskIndex = taskIndex,
|
||||
@@ -4046,7 +4082,7 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
// Copy feedback snackbar
|
||||
SnackbarHost(
|
||||
ThemedMessageHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
@@ -4061,7 +4097,7 @@ fun ChatScreen(
|
||||
subagentActivities = subagentActivities,
|
||||
subagentPreviewVisibility = subagentPreviewVisibility,
|
||||
loading = backgroundProcessesLoading,
|
||||
onClick = { showBackgroundProcesses = true },
|
||||
onClick = { chatViewModel.openCurrentActivityPreview(); showBackgroundProcesses = true },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4569,17 +4605,11 @@ fun ChatScreen(
|
||||
isDemoMode = isDemoMode,
|
||||
voiceReady = voiceReady,
|
||||
onDemoNotice = {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Voice is unavailable in the offline demo — connect to Hermes to use it",
|
||||
Toast.LENGTH_LONG,
|
||||
).show()
|
||||
UiMessageBus.warning("Voice is unavailable in the offline demo — connect to Hermes to use it")
|
||||
},
|
||||
onStartVoice = requestVoiceMode,
|
||||
onSetupNotice = {
|
||||
Toast.makeText(
|
||||
context,
|
||||
when (standardVoiceAvailability) {
|
||||
UiMessageBus.warning(when (standardVoiceAvailability) {
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.SignInRequired ->
|
||||
standardVoiceSignInRouteHint?.let { route ->
|
||||
"Voice needs a one-time sign-in on the $route route — open Manage"
|
||||
@@ -4588,9 +4618,7 @@ fun ChatScreen(
|
||||
"This Hermes build has no voice routes — update hermes-agent or pair Relay"
|
||||
else ->
|
||||
context.getString(R.string.chat_voice_needs_route)
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
})
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -4814,6 +4842,30 @@ fun ChatScreen(
|
||||
}
|
||||
} // 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.
|
||||
// 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
|
||||
@@ -4953,8 +5005,8 @@ fun ChatScreen(
|
||||
|
||||
if (showBackgroundProcesses) {
|
||||
GatewayBackgroundProcessSheet(
|
||||
processes = backgroundProcesses,
|
||||
subagentActivities = subagentActivities,
|
||||
processes = retainedActivityPreview?.processes ?: backgroundProcesses,
|
||||
subagentActivities = retainedActivityPreview?.record?.previewActivities() ?: subagentActivities,
|
||||
subagentChildPreview = subagentChildPreview,
|
||||
subagentPreviewVisibility = subagentPreviewVisibility,
|
||||
loading = backgroundProcessesLoading,
|
||||
@@ -4963,8 +5015,10 @@ fun ChatScreen(
|
||||
onStop = chatViewModel::stopBackgroundProcess,
|
||||
onDismissProcess = chatViewModel::dismissBackgroundProcess,
|
||||
onOpenSubagentChild = chatViewModel::openSubagentChildPreview,
|
||||
readOnlyHistory = retainedActivityPreview != null,
|
||||
historyNotice = if (retainedActivityPreview != null) stringResource(R.string.chat_activity_history_notice) else null,
|
||||
onDismiss = {
|
||||
chatViewModel.closeSubagentChildPreview()
|
||||
chatViewModel.closeActivityPreview()
|
||||
showBackgroundProcesses = false
|
||||
},
|
||||
)
|
||||
@@ -5505,13 +5559,9 @@ private suspend fun ingestAttachmentFromUri(
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (_: AttachmentTooLargeException) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.chat_file_too_large, maxAttachmentMb),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
UiMessageBus.warning(context.getString(R.string.chat_file_too_large, maxAttachmentMb))
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, context.getString(R.string.chat_failed_read_file), Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.error(context.getString(R.string.chat_failed_read_file))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import com.hermesandroid.relay.ui.components.ThemedMessageHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -124,7 +124,7 @@ fun CustomPetGuideScreen(
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
snackbarHost = { ThemedMessageHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
@@ -92,11 +92,7 @@ fun DeveloperSettingsScreen(
|
||||
) { uri ->
|
||||
if (uri != null && backupJson != null) {
|
||||
connectionViewModel.writeBackupToUri(uri, backupJson!!) { success ->
|
||||
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()
|
||||
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)
|
||||
backupJson = null
|
||||
}
|
||||
}
|
||||
@@ -108,11 +104,7 @@ fun DeveloperSettingsScreen(
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
connectionViewModel.importFromUri(uri) { success ->
|
||||
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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,15 +176,11 @@ fun DeveloperSettingsScreen(
|
||||
}
|
||||
IconButton(onClick = {
|
||||
connectionViewModel.resetOnboarding { success ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (success) {
|
||||
UiMessageBus.post(if (success) {
|
||||
context.getString(R.string.dev_settings_onboarding_reset_toast)
|
||||
} else {
|
||||
context.getString(R.string.dev_settings_reset_failed)
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}, severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
@@ -433,11 +421,7 @@ fun DeveloperSettingsScreen(
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
FeatureFlags.lockDevOptions(context)
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.dev_settings_locked_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
UiMessageBus.info(context.getString(R.string.dev_settings_locked_toast))
|
||||
onBack()
|
||||
}
|
||||
}) {
|
||||
@@ -455,6 +439,7 @@ fun DeveloperSettingsScreen(
|
||||
// logged error, a live update). Gated by isDevBuild so it never
|
||||
// ships in a release APK.
|
||||
if (FeatureFlags.isDevBuild) {
|
||||
MessagePreviewControls()
|
||||
Text(
|
||||
text = stringResource(R.string.dev_settings_test_harness),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
@@ -501,7 +486,7 @@ fun DeveloperSettingsScreen(
|
||||
context.getString(R.string.dev_settings_sample_stacktrace),
|
||||
),
|
||||
)
|
||||
Toast.makeText(context, context.getString(R.string.dev_settings_diagnostics_emitted_toast), Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.info(context.getString(R.string.dev_settings_diagnostics_emitted_toast))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -518,7 +503,7 @@ fun DeveloperSettingsScreen(
|
||||
is UpdateStatus.Downloaded -> context.getString(R.string.dev_settings_update_state_downloaded)
|
||||
else -> context.getString(R.string.dev_settings_update_state_off)
|
||||
}
|
||||
Toast.makeText(context, context.getString(R.string.dev_settings_update_banner_preview_toast, state), Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.info(context.getString(R.string.dev_settings_update_banner_preview_toast, state))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -568,11 +553,7 @@ fun DeveloperSettingsScreen(
|
||||
showExportDialog = false
|
||||
connectionViewModel.exportSettings { json ->
|
||||
if (json == null) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.dev_settings_export_failed),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
UiMessageBus.error(context.getString(R.string.dev_settings_export_failed))
|
||||
} else {
|
||||
backupJson = json
|
||||
exportLauncher.launch("hermes-relay-sensitive-backup.json")
|
||||
@@ -633,15 +614,11 @@ fun DeveloperSettingsScreen(
|
||||
onClick = {
|
||||
showResetDialog = false
|
||||
connectionViewModel.resetAppData { success ->
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (success) {
|
||||
UiMessageBus.post(if (success) {
|
||||
context.getString(R.string.dev_settings_app_data_reset_toast)
|
||||
} else {
|
||||
context.getString(R.string.dev_settings_reset_failed)
|
||||
},
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}, severity = if (success) com.hermesandroid.relay.ui.UiMessageSeverity.Success else com.hermesandroid.relay.ui.UiMessageSeverity.Error)
|
||||
}
|
||||
}
|
||||
) {
|
||||
@@ -658,7 +635,7 @@ fun DeveloperSettingsScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TestHarnessRow(
|
||||
internal fun TestHarnessRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -285,11 +285,7 @@ fun MediaSettingsScreen(
|
||||
R.string.media_clear_cache_freed,
|
||||
formatBytesHuman(context, freed)
|
||||
)
|
||||
Toast.makeText(
|
||||
context,
|
||||
freedMsg,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
UiMessageBus.info(freedMsg)
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
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
|
||||
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -170,7 +170,7 @@ fun PairScreen(
|
||||
ConnectionWizard(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onComplete = {
|
||||
Toast.makeText(context, context.getString(R.string.pair_connection_updated), Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.success(context.getString(R.string.pair_connection_updated))
|
||||
onComplete()
|
||||
},
|
||||
onCancel = onCancel,
|
||||
|
||||
@@ -38,7 +38,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import com.hermesandroid.relay.ui.components.ThemedMessageHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -150,7 +150,7 @@ fun PairedDevicesScreen(
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
snackbarHost = { ThemedMessageHost(snackbarHostState) }
|
||||
) { inner ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -36,7 +36,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import com.hermesandroid.relay.ui.components.ThemedMessageHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -189,7 +189,7 @@ fun PetdexBrowseScreen(
|
||||
),
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
snackbarHost = { ThemedMessageHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(156.dp),
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticCategory
|
||||
import com.hermesandroid.relay.diagnostics.DiagnosticsLog
|
||||
import com.hermesandroid.relay.diagnostics.NetworkDiagnosticGuidance
|
||||
import com.hermesandroid.relay.network.upstream.DashboardHttpException
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
@@ -86,6 +87,8 @@ private fun nullFallback(context: String?, ctx: Context?): HumanError {
|
||||
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? {
|
||||
// Ordered most-specific-first; callers have already handled the typed
|
||||
// SSL / timeout / connect exceptions so this only runs on generic IOs.
|
||||
@@ -149,11 +152,9 @@ private fun classifyIoMessage(msg: String, context: String?, ctx: Context?): Hum
|
||||
"server. Update the relay, then try again.",
|
||||
retryable = false,
|
||||
)
|
||||
"404" in msg -> HumanError(
|
||||
LEGACY_HTTP_STATUS.find(msg.trim())?.groupValues?.get(1) == "404" -> HumanError(
|
||||
title = ctx?.getString(R.string.error_classify_endpoint) ?: "Endpoint not found",
|
||||
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",
|
||||
body = "The requested resource or endpoint is unavailable on this server.",
|
||||
retryable = false,
|
||||
)
|
||||
"413" in msg -> HumanError(
|
||||
@@ -255,6 +256,30 @@ private fun String.diagnosticOperation(): String =
|
||||
private fun classifyErrorInternal(t: Throwable?, context: String?, ctx: Context?): HumanError {
|
||||
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()
|
||||
|
||||
if ("cannot create audiorecord" in msg ||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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,6 +94,10 @@ import com.hermesandroid.relay.network.upstream.GatewayModelOptions
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcess
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcessCapability
|
||||
import com.hermesandroid.relay.network.upstream.GatewayProcessEvent
|
||||
import com.hermesandroid.relay.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.ReasoningEffortAvailability
|
||||
import com.hermesandroid.relay.network.upstream.ReasoningEffortIdentity
|
||||
@@ -2235,6 +2239,11 @@ class ChatViewModel : ViewModel() {
|
||||
private var gatewayProcessSource: GatewayProcessSource? = null
|
||||
private val gatewayProcessController = GatewayProcessController(viewModelScope)
|
||||
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)
|
||||
internal val subagentChildPreview: StateFlow<SubagentChildPreview?> =
|
||||
subagentChildPreviewController.state
|
||||
@@ -2254,7 +2263,10 @@ class ChatViewModel : ViewModel() {
|
||||
subagentActivityController.activities
|
||||
|
||||
fun openSubagentChildPreview(activityKey: String) {
|
||||
val activity = subagentActivities.value.firstOrNull { it.stableKey == activityKey } ?: return
|
||||
if (supervisedModePolicy.enabled) 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 parentScopeKey = activeProfileContextKey
|
||||
val client = gatewayClient
|
||||
@@ -2276,6 +2288,49 @@ class ChatViewModel : ViewModel() {
|
||||
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)
|
||||
val messageReactionsSupported: StateFlow<Boolean> = _messageReactionsSupported.asStateFlow()
|
||||
|
||||
@@ -2691,10 +2746,12 @@ class ChatViewModel : ViewModel() {
|
||||
previousClient?.setColdPrewarmSessionReadyListener(null)
|
||||
previousClient?.setUnmatchedTurnCompleteListener(null)
|
||||
previousClient?.setBackgroundInteractionListener(null)
|
||||
previousClient?.setSubagentEventListener(null)
|
||||
previousClient?.setSessionDirectoryInvalidationListener(null)
|
||||
}
|
||||
gatewayClient = client
|
||||
if (changed) {
|
||||
chatActivityController.markUnavailable()
|
||||
dismissChatFailure()
|
||||
resetApprovalModeState()
|
||||
_messageReactionsSupported.value = true
|
||||
@@ -2737,6 +2794,29 @@ class ChatViewModel : ViewModel() {
|
||||
client?.setUnsolicitedTurnProvider { 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 {
|
||||
// Gateway emits this only after durable session state changes. It
|
||||
// is also a useful liveness edge after a Dashboard timeout: retry
|
||||
@@ -2826,6 +2906,8 @@ class ChatViewModel : ViewModel() {
|
||||
if (changed) {
|
||||
gatewayStateSyncJob?.cancel()
|
||||
gatewayStateSyncJob = null
|
||||
_gatewayPreparingSessionId.value = null
|
||||
_gatewaySocketState.value = GatewayConnectionState.Idle
|
||||
client?.let { startGatewayStateSync(it) }
|
||||
if (client != null) {
|
||||
gatewayVisibleReattachJob = viewModelScope.launch {
|
||||
@@ -2969,7 +3051,6 @@ class ChatViewModel : ViewModel() {
|
||||
): GatewayInboundTurnRegistration? {
|
||||
val handler = chatHandler ?: return null
|
||||
val eventScopeKey = activeProfileContextKey
|
||||
val eventProfile = currentSessionProfileName()
|
||||
fun matchesAdmissionContext(): Boolean =
|
||||
gatewayClient === client &&
|
||||
streamingEndpoint == "gateway" &&
|
||||
@@ -3128,13 +3209,6 @@ class ChatViewModel : ViewModel() {
|
||||
},
|
||||
onSubagentEvent = { event ->
|
||||
if (acceptsEvent()) {
|
||||
subagentActivityController.onEvent(
|
||||
sessionId = storedSessionId,
|
||||
eventScopeKey = eventScopeKey,
|
||||
turnId = messageId,
|
||||
event = event,
|
||||
profile = eventProfile,
|
||||
)
|
||||
handler.onSubagentEvent(messageId, event)
|
||||
}
|
||||
},
|
||||
@@ -3873,6 +3947,10 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId: String?,
|
||||
scopeKey: String? = activeProfileContextKey,
|
||||
) {
|
||||
_retainedActivityPreview.value?.record?.let {
|
||||
if (it.sessionId != sessionId || it.scopeKey != scopeKey) closeActivityPreview()
|
||||
}
|
||||
chatActivityController.selectSession(scopeKey, sessionId)
|
||||
subagentChildPreview.value?.let { preview ->
|
||||
if (preview.parentSessionId != sessionId || preview.parentScopeKey != scopeKey) {
|
||||
closeSubagentChildPreview()
|
||||
@@ -4136,6 +4214,10 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -4158,7 +4240,18 @@ class ChatViewModel : ViewModel() {
|
||||
private fun startGatewayStateSync(client: GatewayChatClient) {
|
||||
gatewayStateSyncJob?.cancel()
|
||||
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 {
|
||||
launch {
|
||||
client.preparingSessionId.collect {
|
||||
if (gatewayClient === client) _gatewayPreparingSessionId.value = it
|
||||
}
|
||||
}
|
||||
launch {
|
||||
backgroundProcesses.collect {
|
||||
if (gatewayClient !== client) return@collect
|
||||
@@ -4172,6 +4265,10 @@ class ChatViewModel : ViewModel() {
|
||||
launch {
|
||||
client.connectionState.collect { state ->
|
||||
if (gatewayClient !== client) return@collect
|
||||
if (_gatewaySocketState.value == GatewayConnectionState.Ready && state != GatewayConnectionState.Ready) {
|
||||
chatActivityController.markUnavailable()
|
||||
}
|
||||
_gatewaySocketState.value = state
|
||||
subagentActivityController.onConnectionReady(
|
||||
state == com.hermesandroid.relay.network.upstream.GatewayConnectionState.Ready,
|
||||
)
|
||||
@@ -4662,6 +4759,7 @@ class ChatViewModel : ViewModel() {
|
||||
dashboardMediaClientProvider: () -> DashboardApiClient? = { null },
|
||||
) {
|
||||
this.appContext = context.applicationContext
|
||||
if (!activityStoreInitialized) setChatActivityStore(DataStoreChatActivityStore(context.applicationContext))
|
||||
if (chatTurnCheckpointStore == null) {
|
||||
chatTurnCheckpointStore = DataStoreChatTurnCheckpointStore(context.applicationContext)
|
||||
}
|
||||
@@ -4689,6 +4787,7 @@ class ChatViewModel : ViewModel() {
|
||||
/** Route-owned Gateway chat setup without borrowing the active connection's Relay/media clients. */
|
||||
fun initializeGatewayOnly(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
if (!activityStoreInitialized) setChatActivityStore(DataStoreChatActivityStore(context.applicationContext))
|
||||
if (chatTurnCheckpointStore == null) {
|
||||
chatTurnCheckpointStore = DataStoreChatTurnCheckpointStore(context.applicationContext)
|
||||
}
|
||||
@@ -6159,6 +6258,7 @@ class ChatViewModel : ViewModel() {
|
||||
} else {
|
||||
client?.deleteSession(sessionId) == true
|
||||
}
|
||||
if (success && contextKey != null) chatActivityController.removeSession(contextKey, sessionId)
|
||||
if (
|
||||
activeProfileContextKey != contextKey ||
|
||||
currentSessionProfileName() != profileName
|
||||
@@ -8121,18 +8221,6 @@ class ChatViewModel : ViewModel() {
|
||||
},
|
||||
onSubagentEvent = { event ->
|
||||
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)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
}
|
||||
@@ -10664,13 +10752,6 @@ class ChatViewModel : ViewModel() {
|
||||
onSubagentEvent = { event ->
|
||||
ensurePostInterimMessage()
|
||||
streamDeltas.flushNow()
|
||||
subagentActivityController.onEvent(
|
||||
sessionId = handler.currentSessionId.value,
|
||||
eventScopeKey = activeProfileContextKey,
|
||||
turnId = currentMessageId,
|
||||
event = event,
|
||||
profile = currentSessionProfileName(),
|
||||
)
|
||||
handler.onSubagentEvent(currentMessageId, event)
|
||||
scheduleCheckpointWrite(immediate = true)
|
||||
},
|
||||
@@ -10831,6 +10912,10 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
fun cancelStream() {
|
||||
intentionallyCancelled = true
|
||||
if (activeStream != null) {
|
||||
subagentActivityController.interrupt()
|
||||
chatActivityController.captureSubagents(subagentActivities.value)
|
||||
}
|
||||
currentQueueDestination()?.let { destination ->
|
||||
if (queuedMessageItems.any { it.contextKey to it.sessionId == destination }) {
|
||||
pausedQueueDestinations += destination
|
||||
@@ -11366,9 +11451,8 @@ class ChatViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Classifier produces a specific label (disk full, bad
|
||||
// URI, permission, …) for both the in-card text and the
|
||||
// global snackbar — same event, two surfaces.
|
||||
// Attachment owns failure and retry. History hydration can
|
||||
// fetch many files; never queue a global popup per file.
|
||||
val human = classifyError(e, context = "media_fetch", ctx = appContext)
|
||||
updateAttachmentByToken(handler, messageId, fetchKey, expectedRole = expectedRole) { att ->
|
||||
att.copy(
|
||||
@@ -11376,7 +11460,6 @@ class ChatViewModel : ViewModel() {
|
||||
errorMessage = human.body
|
||||
)
|
||||
}
|
||||
_errorEvents.tryEmit(human)
|
||||
}
|
||||
},
|
||||
onFailure = { err ->
|
||||
@@ -11396,7 +11479,6 @@ class ChatViewModel : ViewModel() {
|
||||
errorMessage = human.body
|
||||
)
|
||||
}
|
||||
_errorEvents.tryEmit(human)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -11661,6 +11743,7 @@ class ChatViewModel : ViewModel() {
|
||||
gatewayClient?.setColdPrewarmSessionReadyListener(null)
|
||||
gatewayClient?.setUnmatchedTurnCompleteListener(null)
|
||||
gatewayClient?.setBackgroundInteractionListener(null)
|
||||
gatewayClient?.setSubagentEventListener(null)
|
||||
backgroundPendingInteractions.clear()
|
||||
backgroundNeedsInputKeys.clear()
|
||||
publishBackgroundSessionActivity()
|
||||
|
||||
@@ -517,11 +517,22 @@ internal fun resolveEffectiveDashboardUrl(
|
||||
connection.authenticatedDashboardOrigin
|
||||
?.let(::normalizeCredentialFreeAuthenticatedDashboardOrigin)
|
||||
?.let { return it }
|
||||
endpoint?.pluginProxyRoutesOrNull()?.dashboardBaseUrl?.let { return it }
|
||||
endpoint?.dashboard?.url
|
||||
// The resolver publishes independently of the active connection. During a
|
||||
// switch its last winner can still belong to the outgoing installation.
|
||||
// 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() }
|
||||
?.let { return it }
|
||||
endpoint?.api?.url?.let { apiUrl ->
|
||||
ownedEndpoint?.api?.url?.let { apiUrl ->
|
||||
connection.dashboardUrl
|
||||
?.takeIf { it.isNotBlank() && Connection.urlsShareHost(it, apiUrl) }
|
||||
?.let { return it }
|
||||
@@ -1747,15 +1758,20 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
/**
|
||||
* Dashboard URL for the active connection **on the currently-resolved
|
||||
* route** — snapshot twin of [effectiveDashboardUrl], which it delegates
|
||||
* to. Standard voice and the availability probe read this per call, so
|
||||
* route**. Read the authoritative id/list synchronously, not the combined
|
||||
* [effectiveDashboardUrl] StateFlow: its previous emission can outlive a
|
||||
* 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
|
||||
* way Manage does; an explicit dashboard override stays pinned. (This
|
||||
* used to read the persisted `resolvedDashboardUrl`, which kept voice
|
||||
* aimed at the LAN host after the resolver had moved chat to Tailscale.)
|
||||
*/
|
||||
fun activeDashboardUrl(): String? =
|
||||
effectiveDashboardUrl.value.takeIf { it.isNotBlank() }
|
||||
resolveEffectiveDashboardUrl(
|
||||
connection = activeConnectionSnapshot(),
|
||||
endpoint = connectionManager.activeEndpoint.value,
|
||||
).takeIf { it.isNotBlank() }
|
||||
|
||||
/**
|
||||
* Promote the exact reviewed Dashboard origin that completed cookie/OIDC
|
||||
|
||||
@@ -62,6 +62,10 @@ internal class GatewayProcessController(
|
||||
private var generation = 0L
|
||||
private var refreshSequence = 0L
|
||||
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. */
|
||||
private val dismissedIdentities = mutableMapOf<String, ProcessIdentity>()
|
||||
@@ -179,6 +183,7 @@ internal class GatewayProcessController(
|
||||
}
|
||||
|
||||
fun close() {
|
||||
snapshotListener = null
|
||||
source?.setEventListener(null)
|
||||
source = null
|
||||
capabilityJob?.cancel()
|
||||
@@ -229,6 +234,7 @@ internal class GatewayProcessController(
|
||||
allProcesses = coalesced
|
||||
publishVisibleSnapshot()
|
||||
updatePoller()
|
||||
snapshotListener?.invoke(coalesced)
|
||||
}
|
||||
|
||||
private fun publishVisibleSnapshot() {
|
||||
|
||||
+58
-17
@@ -30,9 +30,8 @@ internal data class SubagentActivityEvent(
|
||||
/**
|
||||
* A bounded, ephemeral projection of parent-session `subagent.*` events.
|
||||
*
|
||||
* This is intentionally not a child transcript. Upstream currently exposes no
|
||||
* 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.
|
||||
* The owning profile/session outlives individual parent turns. Child identity
|
||||
* keeps detached work visible until its own terminal event arrives.
|
||||
*/
|
||||
internal data class SubagentActivity(
|
||||
val laneId: Long,
|
||||
@@ -41,6 +40,7 @@ internal data class SubagentActivity(
|
||||
val taskCount: Int,
|
||||
val goal: String,
|
||||
val subagentId: String? = null,
|
||||
val delegationId: String? = null,
|
||||
val childSessionId: String? = null,
|
||||
val parentId: String? = null,
|
||||
val depth: Int? = null,
|
||||
@@ -91,6 +91,9 @@ internal class SubagentActivityController(
|
||||
private var laneSequence = 0L
|
||||
private var connectionWasReady = false
|
||||
private var pendingGap = false
|
||||
private var interrupted = false
|
||||
private var previewOpen = false
|
||||
private val retiredChildIds = linkedSetOf<String>()
|
||||
|
||||
fun selectSession(sessionId: String?, newScopeKey: String?) {
|
||||
if (storedSessionId == sessionId && scopeKey == newScopeKey) return
|
||||
@@ -101,10 +104,16 @@ internal class SubagentActivityController(
|
||||
laneSequence = 0L
|
||||
connectionWasReady = false
|
||||
pendingGap = false
|
||||
interrupted = false
|
||||
previewOpen = false
|
||||
retiredChildIds.clear()
|
||||
_activities.value = emptyList()
|
||||
}
|
||||
|
||||
fun resetConnection() {
|
||||
interrupted = false
|
||||
previewOpen = false
|
||||
retiredChildIds.clear()
|
||||
activeTurnId = null
|
||||
sequence = 0L
|
||||
laneSequence = 0L
|
||||
@@ -133,9 +142,34 @@ internal class SubagentActivityController(
|
||||
if (sessionId == null || sessionId != storedSessionId || eventScopeKey != scopeKey) return
|
||||
if (activeTurnId == turnId) return
|
||||
activeTurnId = turnId
|
||||
sequence = 0L
|
||||
laneSequence = 0L
|
||||
_activities.value = emptyList()
|
||||
interrupted = false
|
||||
_activities.value.filter { it.isTerminal }.forEach { activity ->
|
||||
activity.subagentId?.let(retiredChildIds::add)
|
||||
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(
|
||||
@@ -146,18 +180,21 @@ internal class SubagentActivityController(
|
||||
profile: String? = null,
|
||||
) {
|
||||
if (sessionId == null || sessionId != storedSessionId || eventScopeKey != scopeKey) return
|
||||
if (activeTurnId != turnId) return
|
||||
if (interrupted) return
|
||||
|
||||
val taskIndex = event.taskIndex.coerceAtLeast(0)
|
||||
val eventIdentity = event.subagentId?.takeIf(String::isNotBlank)
|
||||
?: event.childSessionId?.takeIf(String::isNotBlank)
|
||||
if (eventIdentity != null && eventIdentity in retiredChildIds) return
|
||||
val identityMatch = eventIdentity?.let { identity ->
|
||||
_activities.value.firstOrNull {
|
||||
it.subagentId == identity || it.childSessionId == identity
|
||||
}
|
||||
}
|
||||
val compatibleIndexMatches = _activities.value.filter { activity ->
|
||||
activity.taskIndex == taskIndex &&
|
||||
activity.turnId == turnId && activity.taskIndex == taskIndex &&
|
||||
(event.delegationId.isNullOrBlank() || activity.delegationId.isNullOrBlank() ||
|
||||
event.delegationId == activity.delegationId) &&
|
||||
(event.subagentId.isNullOrBlank() || activity.subagentId.isNullOrBlank() ||
|
||||
event.subagentId == activity.subagentId) &&
|
||||
(event.childSessionId.isNullOrBlank() || activity.childSessionId.isNullOrBlank() ||
|
||||
@@ -167,13 +204,14 @@ internal class SubagentActivityController(
|
||||
(event.depth == null || activity.depth == null || event.depth == activity.depth)
|
||||
}
|
||||
val current = identityMatch ?: compatibleIndexMatches.singleOrNull()
|
||||
if (
|
||||
current?.isTerminal == true &&
|
||||
event.phase != GatewaySubagentEvent.Phase.SPAWN_REQUESTED &&
|
||||
if (activeTurnId != turnId && identityMatch == null && eventIdentity == null) return
|
||||
if (current?.isTerminal == true) return
|
||||
// Progress and completion cannot invent children when their start was missed.
|
||||
if (current == null && event.phase != GatewaySubagentEvent.Phase.SPAWN_REQUESTED &&
|
||||
event.phase != GatewaySubagentEvent.Phase.START
|
||||
) return
|
||||
|
||||
val base = if (current?.isTerminal == true) null else current
|
||||
val base = current
|
||||
val phase = event.toActivityPhase()
|
||||
val goal = sanitize(event.goal, MAX_GOAL_CHARS)
|
||||
val preview = sanitize(event.preview, MAX_EVENT_TEXT_CHARS).ifBlank { null }
|
||||
@@ -206,16 +244,17 @@ internal class SubagentActivityController(
|
||||
val (boundedEvents, truncated) = boundEvents(appended)
|
||||
val next = SubagentActivity(
|
||||
laneId = base?.laneId ?: laneSequence++,
|
||||
turnId = turnId,
|
||||
turnId = base?.turnId ?: turnId,
|
||||
taskIndex = taskIndex,
|
||||
taskCount = maxOf(1, event.taskCount, base?.taskCount ?: 1),
|
||||
goal = goal.ifBlank { base?.goal.orEmpty() },
|
||||
subagentId = event.subagentId?.takeIf(String::isNotBlank) ?: base?.subagentId,
|
||||
delegationId = event.delegationId?.takeIf(String::isNotBlank) ?: base?.delegationId,
|
||||
childSessionId = event.childSessionId?.takeIf(String::isNotBlank) ?: base?.childSessionId,
|
||||
parentId = event.parentId?.takeIf(String::isNotBlank) ?: base?.parentId,
|
||||
depth = event.depth ?: base?.depth,
|
||||
model = event.model?.takeIf(String::isNotBlank) ?: base?.model,
|
||||
profile = profile?.takeIf(String::isNotBlank) ?: base?.profile,
|
||||
profile = if (base != null) base.profile else profile?.takeIf(String::isNotBlank),
|
||||
phase = phase,
|
||||
summary = summary ?: base?.summary,
|
||||
durationSeconds = event.durationSeconds ?: base?.durationSeconds,
|
||||
@@ -231,7 +270,9 @@ internal class SubagentActivityController(
|
||||
fun endTurn(turnId: String) {
|
||||
if (activeTurnId != turnId) return
|
||||
_activities.value = _activities.value.map { activity ->
|
||||
if (activity.isTerminal) activity else activity.copy(
|
||||
if (activity.isTerminal || !activity.subagentId.isNullOrBlank() ||
|
||||
!activity.childSessionId.isNullOrBlank()
|
||||
) activity else activity.copy(
|
||||
phase = SubagentActivityPhase.ENDED_WITH_PARENT,
|
||||
partialAfterGap = true,
|
||||
revision = activity.revision + 1,
|
||||
@@ -261,9 +302,9 @@ private fun GatewaySubagentEvent.toActivityPhase(): SubagentActivityPhase = when
|
||||
GatewaySubagentEvent.Phase.TOOL -> SubagentActivityPhase.TOOL
|
||||
GatewaySubagentEvent.Phase.PROGRESS -> SubagentActivityPhase.PROGRESS
|
||||
GatewaySubagentEvent.Phase.COMPLETE -> when (status?.trim()?.lowercase()) {
|
||||
"failed", "error" -> SubagentActivityPhase.FAILED
|
||||
"completed", "complete" -> SubagentActivityPhase.COMPLETED
|
||||
"interrupted", "cancelled", "canceled" -> SubagentActivityPhase.INTERRUPTED
|
||||
else -> SubagentActivityPhase.COMPLETED
|
||||
else -> SubagentActivityPhase.FAILED
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import com.hermesandroid.relay.ui.UiMessageBus
|
||||
import com.hermesandroid.relay.ui.UiMessageSeverity
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hermesandroid.relay.R
|
||||
@@ -2639,28 +2640,23 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// route is unavailable. Runs outside the turn state machine so it
|
||||
// doesn't disturb uiState.
|
||||
//
|
||||
// Three toasts so the user knows what's happening: "Testing voice…" on
|
||||
// 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.
|
||||
// Keyed progress feedback is cleared before an outcome replaces it.
|
||||
// Classified failures retain their existing error event/overlay flow.
|
||||
fun testVoice(
|
||||
sample: String = "Hello, this is Hermes. Voice mode is working.",
|
||||
onResult: (Result<Unit>) -> Unit = {},
|
||||
) {
|
||||
val app = getApplication<Application>()
|
||||
val audioClient = voiceAudioClient
|
||||
val relayClient = voiceClient
|
||||
val p = player
|
||||
if (audioClient == null || p == null) {
|
||||
onResult(Result.failure(IllegalStateException("Voice pipeline not initialized")))
|
||||
Toast.makeText(app, "Voice test failed: pipeline not initialized", Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.error("Voice test failed: pipeline not initialized")
|
||||
setError("Voice pipeline not initialized")
|
||||
return
|
||||
}
|
||||
val triggerToast = Toast.makeText(app, "Testing voice…", Toast.LENGTH_SHORT).also { it.show() }
|
||||
val feedbackKey = "voice-test-${System.nanoTime()}"
|
||||
UiMessageBus.post("Testing voice…", severity = UiMessageSeverity.Status, ttlMillis = 0L, key = feedbackKey)
|
||||
viewModelScope.launch {
|
||||
val profileAwareResult = if (audioClient.route == VoiceAudioRoute.Relay && relayClient != null) {
|
||||
testVoiceViaVoiceOutput(relayClient, sample)
|
||||
@@ -2669,9 +2665,9 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
val result = if (profileAwareResult != null) {
|
||||
if (profileAwareResult.isSuccess) {
|
||||
triggerToast.cancel()
|
||||
UiMessageBus.clear(feedbackKey)
|
||||
onResult(Result.success(Unit))
|
||||
Toast.makeText(app, "Voice test successful", Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.success("Voice test successful")
|
||||
return@launch
|
||||
}
|
||||
Log.w(TAG, "profile-aware voice test failed; falling back to legacy synthesize: ${profileAwareResult.exceptionOrNull()?.message}")
|
||||
@@ -2680,34 +2676,35 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
audioClient.synthesize(sample)
|
||||
}
|
||||
if (result.isFailure) {
|
||||
triggerToast.cancel()
|
||||
UiMessageBus.clear(feedbackKey)
|
||||
val msg = result.exceptionOrNull()?.message ?: "synthesize failed"
|
||||
onResult(Result.failure(result.exceptionOrNull() ?: IllegalStateException(msg)))
|
||||
Toast.makeText(app, "Voice test failed: $msg", Toast.LENGTH_LONG).show()
|
||||
surfaceError(result.exceptionOrNull(), context = "synthesize")
|
||||
return@launch
|
||||
}
|
||||
val file = result.getOrNull()
|
||||
if (file == null) {
|
||||
triggerToast.cancel()
|
||||
UiMessageBus.clear(feedbackKey)
|
||||
onResult(Result.failure(IllegalStateException("No audio returned")))
|
||||
Toast.makeText(app, "Voice test failed: no audio returned", Toast.LENGTH_LONG).show()
|
||||
UiMessageBus.error("Voice test failed: no audio returned")
|
||||
return@launch
|
||||
}
|
||||
trackTtsFile(file)
|
||||
try {
|
||||
p.play(file)
|
||||
p.awaitCompletion()
|
||||
triggerToast.cancel()
|
||||
UiMessageBus.clear(feedbackKey)
|
||||
onResult(Result.success(Unit))
|
||||
Toast.makeText(app, "Voice test successful", Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.success("Voice test successful")
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "test playback failed: ${e.message}")
|
||||
triggerToast.cancel()
|
||||
UiMessageBus.clear(feedbackKey)
|
||||
onResult(Result.failure(e))
|
||||
Toast.makeText(app, "Voice test failed: ${e.message ?: "playback error"}", Toast.LENGTH_LONG).show()
|
||||
UiMessageBus.error("Voice test failed: ${e.message ?: "playback error"}")
|
||||
}
|
||||
}
|
||||
}.invokeOnCompletion { UiMessageBus.clear(feedbackKey) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2930,17 +2927,16 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
sample: String = "Say a short confirmation that Hermes Realtime Agent is working.",
|
||||
onResult: (Result<Unit>) -> Unit = {},
|
||||
) {
|
||||
val app = getApplication<Application>()
|
||||
val client = voiceClient
|
||||
val pcmPlayer = realtimePcmPlayer
|
||||
if (client == null || pcmPlayer == null) {
|
||||
onResult(Result.failure(IllegalStateException("Voice pipeline not initialized")))
|
||||
Toast.makeText(app, "Realtime test failed: pipeline not initialized", Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.error("Realtime test failed: pipeline not initialized")
|
||||
setError("Voice pipeline not initialized")
|
||||
return
|
||||
}
|
||||
val triggerToast = Toast.makeText(app, "Testing Realtime Agent...", Toast.LENGTH_SHORT)
|
||||
.also { it.show() }
|
||||
val feedbackKey = "realtime-test-${System.nanoTime()}"
|
||||
UiMessageBus.post("Testing Realtime Agent...", severity = UiMessageSeverity.Status, ttlMillis = 0L, key = feedbackKey)
|
||||
viewModelScope.launch {
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
@@ -2970,12 +2966,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
audioBytes.addAndGet(audio.size)
|
||||
pcmPlayer.write(audio, rate)
|
||||
}
|
||||
triggerToast.cancel()
|
||||
UiMessageBus.clear(feedbackKey)
|
||||
if (result.isFailure) {
|
||||
pcmPlayer.stop()
|
||||
val msg = result.exceptionOrNull()?.message ?: "realtime agent failed"
|
||||
onResult(Result.failure(result.exceptionOrNull() ?: IllegalStateException(msg)))
|
||||
Toast.makeText(app, "Realtime test failed: $msg", Toast.LENGTH_LONG).show()
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
@@ -2988,7 +2983,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
if (audioBytes.get() <= 0) {
|
||||
pcmPlayer.stop()
|
||||
onResult(Result.failure(IllegalStateException("Provider returned no audio")))
|
||||
Toast.makeText(app, "Realtime test failed: no audio returned", Toast.LENGTH_LONG).show()
|
||||
UiMessageBus.error("Realtime test failed: no audio returned")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Error,
|
||||
@@ -3002,14 +2997,14 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
delay(drainMs)
|
||||
pcmPlayer.stop()
|
||||
onResult(Result.success(Unit))
|
||||
Toast.makeText(app, "Realtime test successful", Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.success("Realtime test successful")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
severity = DiagnosticSeverity.Info,
|
||||
title = getApplication<Application>().getString(R.string.voice_status_test_complete),
|
||||
detail = "${audioBytes.get()} bytes streamed",
|
||||
)
|
||||
}
|
||||
}.invokeOnCompletion { UiMessageBus.clear(feedbackKey) }
|
||||
}
|
||||
|
||||
private suspend fun testVoiceViaVoiceOutput(
|
||||
@@ -6673,7 +6668,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
_uiState.update {
|
||||
voiceNoSpeechState(it)
|
||||
}
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
UiMessageBus.warning(message)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4429,4 +4429,14 @@
|
||||
<string name="chat_queue_paused">Fila pausada</string>
|
||||
<string name="chat_queue_resume">Retomar</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>
|
||||
|
||||
@@ -4510,4 +4510,14 @@
|
||||
<string name="chat_queue_paused">队列已暂停</string>
|
||||
<string name="chat_queue_resume">继续</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>
|
||||
|
||||
@@ -4586,4 +4586,14 @@
|
||||
<string name="chat_queue_paused">Warteschlange pausiert</string>
|
||||
<string name="chat_queue_resume">Fortsetzen</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>
|
||||
|
||||
@@ -4277,4 +4277,14 @@
|
||||
<string name="chat_queue_paused">Cola pausada</string>
|
||||
<string name="chat_queue_resume">Reanudar</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>
|
||||
|
||||
@@ -4581,4 +4581,14 @@
|
||||
<string name="chat_queue_paused">キューを一時停止中</string>
|
||||
<string name="chat_queue_resume">再開</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>
|
||||
|
||||
@@ -4325,4 +4325,14 @@
|
||||
<string name="chat_queue_paused">Очередь приостановлена</string>
|
||||
<string name="chat_queue_resume">Продолжить</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>
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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_title">Hermes-Relay</string>
|
||||
<string name="agent_interface">agent interface</string>
|
||||
@@ -4602,4 +4630,28 @@
|
||||
<string name="chat_queue_paused">Queue paused</string>
|
||||
<string name="chat_queue_resume">Resume</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>
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
+35
@@ -12,6 +12,7 @@ import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
@@ -659,6 +660,40 @@ class DashboardApiClientTest {
|
||||
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(
|
||||
name: String,
|
||||
value: String,
|
||||
|
||||
@@ -2408,6 +2408,27 @@ class GatewayChatClientTest {
|
||||
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
|
||||
fun `child watch is profile pinned bounded and isolated from main session`() = runBlocking {
|
||||
harness.sessionProfileOverride = "operator"
|
||||
@@ -3992,6 +4013,27 @@ class GatewayChatClientTest {
|
||||
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
|
||||
fun `lazy fresh session readiness timeout never submits`() {
|
||||
rebuildClient(sessionReadyTimeoutMs = 50L)
|
||||
|
||||
+23
@@ -741,6 +741,29 @@ class GatewayEventMapperTest {
|
||||
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) ---
|
||||
|
||||
@Test
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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") }
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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,8 +2,11 @@ package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
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.BotModeRoster
|
||||
import com.hermesandroid.relay.data.BotModeState
|
||||
@@ -13,6 +16,7 @@ import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.ui.theme.HermesRelayTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -65,10 +69,64 @@ class BotModeScreenTest {
|
||||
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(
|
||||
onOpenBot: (BotRosterEntry) -> Unit = {},
|
||||
onOpenGroup: (BotGroupRoom) -> Unit = {},
|
||||
selectedGatewayId: String? = null,
|
||||
nowMs: Long = NOW + 200_000L,
|
||||
openingRoute: BotGatewayRouteKey? = null,
|
||||
) {
|
||||
compose.setContent {
|
||||
HermesRelayTheme(appThemeId = "hermes-relay", themePreference = "dark") {
|
||||
@@ -77,13 +135,14 @@ class BotModeScreenTest {
|
||||
connections = listOf(connection(), labConnection()),
|
||||
activeConnection = connection(),
|
||||
selectedGatewayId = selectedGatewayId,
|
||||
openingRoute = openingRoute,
|
||||
onBack = {},
|
||||
onRefresh = {},
|
||||
onSelectGateway = {},
|
||||
onOpenBot = onOpenBot,
|
||||
onOpenGroup = onOpenGroup,
|
||||
onNewBot = {},
|
||||
nowMs = NOW + 200_000L,
|
||||
nowMs = nowMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -106,10 +165,10 @@ class BotModeScreenTest {
|
||||
),
|
||||
),
|
||||
BotRosterEntry(
|
||||
profile = Profile(name = "researcher", model = "gpt-5.6", description = "Research"),
|
||||
profile = Profile(name = "default", model = "gpt-5.6", description = "Research"),
|
||||
displayName = "Researcher",
|
||||
route = com.hermesandroid.relay.data.BotGatewayRoute(
|
||||
key = com.hermesandroid.relay.data.BotGatewayRouteKey("lab", "researcher"),
|
||||
key = com.hermesandroid.relay.data.BotGatewayRouteKey("lab", "default"),
|
||||
connectionLabel = "Lab server",
|
||||
),
|
||||
canonicalSession = BotSessionSummary(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.hermesandroid.relay.util
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.DashboardHttpException
|
||||
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.SocketTimeoutException
|
||||
@@ -11,6 +13,54 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
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
|
||||
fun gatewayDrainIsNotMisclassifiedAsProviderOutage() {
|
||||
val err = classifyError(
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
+40
@@ -2475,6 +2475,15 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveredLegacyContextKeepsExplicitPersistedSubagentProfile() {
|
||||
assertRecoveredSubagentWatchProfile(
|
||||
contextKey = "connection-a/profile-default",
|
||||
persistedProfileKey = "default",
|
||||
expectedProfile = "default",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun currentServerDefaultCheckpointPersistsExplicitSentinel() {
|
||||
assertCurrentCheckpointProfileKey(
|
||||
@@ -2553,6 +2562,37 @@ class ChatViewModelGatewayInboundTurnTest {
|
||||
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
|
||||
fun explicitApprovalActionAloneEmitsResponseAndCollapsesCard() {
|
||||
viewModel.sendMessage("Run the guarded command")
|
||||
|
||||
@@ -14,6 +14,10 @@ import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
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.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
@@ -285,6 +289,35 @@ class ChatViewModelMediaStateTest {
|
||||
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
|
||||
fun assistantBarePathDoesNotBypassUpstreamSensitiveFileDenial() {
|
||||
val path = "/home/user/.ssh/id_ed25519"
|
||||
|
||||
+29
-4
@@ -13,6 +13,31 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
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
|
||||
fun `late dashboard probe cannot publish across connection or route change`() {
|
||||
assertTrue(
|
||||
@@ -82,7 +107,7 @@ class EffectiveDashboardRouteTest {
|
||||
|
||||
assertEquals(
|
||||
"http://100.71.8.56:9119",
|
||||
resolveEffectiveDashboardUrl(connection, tailscale),
|
||||
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(tailscale)), tailscale),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -102,7 +127,7 @@ class EffectiveDashboardRouteTest {
|
||||
|
||||
assertEquals(
|
||||
"https://hermes.example.com",
|
||||
resolveEffectiveDashboardUrl(connection, tailscale),
|
||||
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(tailscale)), tailscale),
|
||||
)
|
||||
assertEquals("http://100.71.8.56:8642", resolveEffectiveApiServerUrl(connection.apiServerUrl, tailscale))
|
||||
}
|
||||
@@ -138,7 +163,7 @@ class EffectiveDashboardRouteTest {
|
||||
|
||||
assertEquals(
|
||||
"https://hermes.example.com:443",
|
||||
resolveEffectiveDashboardUrl(connection, fallback),
|
||||
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(fallback)), fallback),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -156,7 +181,7 @@ class EffectiveDashboardRouteTest {
|
||||
|
||||
assertEquals(
|
||||
"http://100.71.8.56:9119",
|
||||
resolveEffectiveDashboardUrl(connection, tailscale),
|
||||
resolveEffectiveDashboardUrl(connection.copy(routeCandidates = listOf(tailscale)), tailscale),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,29 @@ import org.junit.Test
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
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
|
||||
fun duplicateProcessSnapshotsPublishOneComposeIdentityWithLatestState() = runTest {
|
||||
val source = FakeProcessSource().apply {
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.hermesandroid.relay.viewmodel
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
@@ -147,12 +148,14 @@ class GitStateWriteViewModelTest {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc")
|
||||
var committedTarget: GitTarget? = null
|
||||
val committedTarget = CompletableDeferred<GitTarget>()
|
||||
|
||||
vm.commit("add feature") { committedTarget = it }
|
||||
vm.commit("add feature") { committedTarget.complete(it) }
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
|
||||
assertEquals("alpha", committedTarget?.repoId)
|
||||
// Success is published before detail refresh and the callback complete.
|
||||
val target = withTimeout(5_000) { committedTarget.await() }
|
||||
assertEquals("alpha", target.repoId)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+359
@@ -0,0 +1,359 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
+80
@@ -10,6 +10,86 @@ class SubagentActivityControllerTest {
|
||||
private var now = 1_000L
|
||||
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
|
||||
fun `interleaved children retain independent lifecycle previews`() {
|
||||
controller.selectSession("parent", "connection::default")
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.3.2" apply false
|
||||
id("com.android.library") version "9.3.2" apply false
|
||||
id("com.android.application") version "9.4.0" apply false
|
||||
id("com.android.library") version "9.4.0" 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
|
||||
}
|
||||
|
||||
@@ -46,6 +46,16 @@ Android workflow from the selected `dev` ref.
|
||||
|
||||
## 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 lane automatically. For an ad hoc Gradle command, use:
|
||||
|
||||
|
||||
+7
-1
@@ -3982,7 +3982,13 @@ compression path instead of forking the relationship. New Bot creates an upstrea
|
||||
only the small `hermes-bots` metadata marker; profile skills/model remain managed
|
||||
through the established Hermes surfaces.
|
||||
|
||||
Every Bot owner is the immutable `(connectionId, profile)` pair. A typed route
|
||||
Every Bot owner is the immutable `(connectionId, profile)` pair. Both the active
|
||||
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
|
||||
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
|
||||
|
||||
@@ -68,6 +68,8 @@ 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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -82,6 +84,14 @@ 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 |
|
||||
| `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,
|
||||
connection number, RPC method, event type, scope classification, and outcome.
|
||||
It never records prompts, responses, RPC parameters, credentials, URLs,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
|
||||
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
|
||||
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -72,7 +72,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
|
||||
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -96,7 +96,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
|
||||
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -120,7 +120,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
|
||||
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -135,7 +135,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "1efc1c169120e525ddd1484f9ac33c15d27f558d74b766e7c04f42abd8169255",
|
||||
"main": "f177eedfe624875dd36d36501b2b43e68869360ea80c83410870f503ca6ac9dc",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
|
||||
@@ -496,7 +496,7 @@ Errors: relay connect-error / timeout / 5xx → `502 Bad Gateway` with a human-r
|
||||
## Health Check
|
||||
|
||||
```bash
|
||||
curl http://localhost:8767/health
|
||||
curl http://127.0.0.1:8767/health
|
||||
```
|
||||
|
||||
Returns JSON with server status and version.
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
#### 6.4.1 `android_*` tool surface
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[versions]
|
||||
appVersionName = "1.15.1"
|
||||
appVersionCode = "54"
|
||||
agp = "9.3.2"
|
||||
agp = "9.4.0"
|
||||
kotlin = "2.4.10"
|
||||
compose-bom = "2026.08.00"
|
||||
navigation-compose = "2.10.0"
|
||||
@@ -22,7 +22,7 @@ exifinterface = "1.4.2"
|
||||
datastore = "1.2.1"
|
||||
splashscreen = "1.2.0"
|
||||
markdown-renderer = "0.45.0"
|
||||
coil = "3.6.0"
|
||||
coil = "3.6.2"
|
||||
haze = "1.7.3"
|
||||
mlkit-barcode = "17.3.0"
|
||||
zxing-core = "3.5.4"
|
||||
|
||||
@@ -42,7 +42,7 @@ Off by default:
|
||||
Environment variables (env wins over config.yaml ``extra``):
|
||||
PHONE_ENABLED "1"/"true"/"yes"/"on" enables the platform (required)
|
||||
PHONE_RELAY_URL Relay base URL. Default: reuse ANDROID_BRIDGE_URL,
|
||||
else http://localhost:{ANDROID_RELAY_PORT|RELAY_PORT|8767}
|
||||
else http://127.0.0.1:{ANDROID_RELAY_PORT|RELAY_PORT|8767}
|
||||
PHONE_RELAY_TOKEN Optional bearer for the relay POST (loopback is
|
||||
unauthenticated by default; sent only if set)
|
||||
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
|
||||
``plugin/tools/android_tool.py`` (``ANDROID_BRIDGE_URL`` /
|
||||
``ANDROID_RELAY_PORT`` / ``RELAY_PORT``) so a single override flips both
|
||||
the android tools and this adapter. Defaults to ``http://localhost:8767``.
|
||||
the android tools and this adapter. Defaults to ``http://127.0.0.1:8767``.
|
||||
"""
|
||||
explicit = os.getenv("PHONE_RELAY_URL", "").strip()
|
||||
if explicit:
|
||||
@@ -165,7 +165,7 @@ def _relay_base_url() -> str:
|
||||
if bridge:
|
||||
return bridge.rstrip("/")
|
||||
port = os.getenv("ANDROID_RELAY_PORT", os.getenv("RELAY_PORT", "8767")).strip() or "8767"
|
||||
return f"http://localhost:{port}"
|
||||
return f"http://127.0.0.1:{port}"
|
||||
|
||||
|
||||
def _home_channel() -> str:
|
||||
|
||||
@@ -34,6 +34,7 @@ if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
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
|
||||
ParsedAction,
|
||||
VALID_ACTIONS,
|
||||
@@ -241,6 +242,46 @@ def _mk_screenshot(token: str = "hermes-relay://fake-token") -> nav._Screenshot:
|
||||
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):
|
||||
def setUp(self) -> None:
|
||||
# Belt-and-suspenders: make sure the stub env var never leaks
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
import responses
|
||||
import pytest
|
||||
|
||||
# Import tool functions directly (not via registry)
|
||||
from plugin.tools import android_tool
|
||||
from plugin.tools.android_tool import (
|
||||
android_ping,
|
||||
android_read_screen,
|
||||
@@ -318,6 +322,33 @@ class TestCurrentApp:
|
||||
|
||||
|
||||
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
|
||||
def test_setup_saves_config(self, monkeypatch):
|
||||
"""android_setup saves pairing code and sets env vars."""
|
||||
@@ -327,4 +358,226 @@ class TestSetup:
|
||||
result = json.loads(android_setup("ABC123"))
|
||||
# Config should be saved regardless of relay import
|
||||
assert os.environ.get("ANDROID_BRIDGE_TOKEN") == "ABC123"
|
||||
assert "localhost" in os.environ.get("ANDROID_BRIDGE_URL", "")
|
||||
assert os.environ.get("ANDROID_BRIDGE_URL") == "http://127.0.0.1:8767"
|
||||
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,6 +9,7 @@ from plugin.tools import android_tool
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
self.status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""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()
|
||||
@@ -172,7 +172,7 @@ class GatingTests(_EnvIsolated):
|
||||
|
||||
class RelayUrlTests(_EnvIsolated):
|
||||
def test_default(self) -> None:
|
||||
self.assertEqual(pp._relay_base_url(), "http://localhost:8767")
|
||||
self.assertEqual(pp._relay_base_url(), "http://127.0.0.1:8767")
|
||||
|
||||
def test_explicit_phone_relay_url_wins(self) -> None:
|
||||
os.environ["PHONE_RELAY_URL"] = "https://relay.example:9000/"
|
||||
@@ -183,12 +183,20 @@ class RelayUrlTests(_EnvIsolated):
|
||||
os.environ["ANDROID_BRIDGE_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:
|
||||
os.environ["ANDROID_RELAY_PORT"] = "8888"
|
||||
self.assertEqual(pp._relay_base_url(), "http://localhost:8888")
|
||||
self.assertEqual(pp._relay_base_url(), "http://127.0.0.1:8888")
|
||||
os.environ.pop("ANDROID_RELAY_PORT")
|
||||
os.environ["RELAY_PORT"] = "7777"
|
||||
self.assertEqual(pp._relay_base_url(), "http://localhost:7777")
|
||||
self.assertEqual(pp._relay_base_url(), "http://127.0.0.1:7777")
|
||||
|
||||
def test_token_header_only_when_set(self) -> None:
|
||||
url, headers = pp._relay_url_and_headers()
|
||||
@@ -254,7 +262,7 @@ class EnvEnablementTests(_EnvIsolated):
|
||||
assert seed is not None
|
||||
self.assertTrue(seed["enabled"])
|
||||
self.assertEqual(seed["home_channel"], {"chat_id": "myphone", "name": "Phone"})
|
||||
self.assertEqual(seed["relay_url"], "http://localhost:8767")
|
||||
self.assertEqual(seed["relay_url"], "http://127.0.0.1:8767")
|
||||
self.assertFalse(seed["typing_indicator"])
|
||||
|
||||
|
||||
|
||||
@@ -31,41 +31,19 @@ import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import requests
|
||||
from typing import Any, Callable
|
||||
|
||||
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")
|
||||
|
||||
# ── 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:
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{_bridge_url()}/ping", headers=_auth_headers(), timeout=2
|
||||
)
|
||||
r = _bridge_request("GET", "/ping", timeout=2)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
return data.get("phone_connected", False) or data.get(
|
||||
@@ -77,18 +55,16 @@ def _check_requirements() -> bool:
|
||||
|
||||
|
||||
def _get(path: str) -> dict:
|
||||
r = requests.get(
|
||||
f"{_bridge_url()}{path}", headers=_auth_headers(), timeout=_timeout()
|
||||
)
|
||||
r = _bridge_request("GET", path, timeout=_timeout())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _post(path: str, payload: dict) -> dict:
|
||||
r = requests.post(
|
||||
f"{_bridge_url()}{path}",
|
||||
r = _bridge_request(
|
||||
"POST",
|
||||
path,
|
||||
json=payload,
|
||||
headers=_auth_headers(),
|
||||
timeout=_timeout(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
+235
-32
@@ -76,21 +76,194 @@ except ImportError: # pragma: no cover - direct-script fallback
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Architecture: Phone connects OUT to Hermes server via WebSocket (NAT-friendly).
|
||||
# The unified Hermes-Relay server runs on localhost:8767 and multiplexes the
|
||||
# The unified Hermes-Relay server runs on 127.0.0.1:8767 and multiplexes the
|
||||
# bridge channel alongside chat, terminal, media, and voice. The legacy
|
||||
# standalone bridge relay on port 8766 was retired in Phase 3 Wave 1.
|
||||
#
|
||||
# Tools ──HTTP──> Unified Relay (localhost:8767) ──WSS bridge channel──> Phone
|
||||
# Tools ──HTTP──> Unified Relay (127.0.0.1:8767) ──WSS bridge channel──> Phone
|
||||
#
|
||||
# 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.
|
||||
|
||||
def _bridge_url() -> str:
|
||||
"""URL of the relay (default) or direct phone connection."""
|
||||
return os.getenv("ANDROID_BRIDGE_URL", "http://localhost:8767")
|
||||
return os.getenv("ANDROID_BRIDGE_URL", "http://127.0.0.1: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]:
|
||||
return os.getenv("ANDROID_BRIDGE_TOKEN")
|
||||
"""Bearer token for every relay bridge call.
|
||||
|
||||
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:
|
||||
# Unified relay default. Override via ANDROID_RELAY_PORT or RELAY_PORT.
|
||||
@@ -116,7 +289,7 @@ _DEVICE_SELECTOR_PROPERTY = {
|
||||
}
|
||||
|
||||
def _auth_headers() -> dict:
|
||||
"""Build auth headers with pairing code if configured."""
|
||||
"""Build auth headers with the preferred bridge session token."""
|
||||
token = _bridge_token()
|
||||
if token:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
@@ -151,11 +324,7 @@ def _check_requirements() -> bool:
|
||||
direct-phone development shims that predate the unified relay.
|
||||
"""
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{_bridge_url()}/bridge/status",
|
||||
headers=_auth_headers(),
|
||||
timeout=2,
|
||||
)
|
||||
r = _bridge_request("GET", "/bridge/status", timeout=2)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
bridge = data.get("bridge") if isinstance(data, dict) else None
|
||||
@@ -168,7 +337,7 @@ def _check_requirements() -> bool:
|
||||
pass
|
||||
|
||||
try:
|
||||
r = requests.get(f"{_bridge_url()}/ping", headers=_auth_headers(), timeout=2)
|
||||
r = _bridge_request("GET", "/ping", timeout=2)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
return bool(
|
||||
@@ -185,14 +354,14 @@ def _post(path: str, payload: dict, *, device: Optional[str] = None) -> dict:
|
||||
selector = _selected_device(device)
|
||||
if selector and "device" not in body:
|
||||
body["device"] = selector
|
||||
r = requests.post(f"{_bridge_url()}{path}", json=body,
|
||||
headers=_auth_headers(), timeout=_timeout())
|
||||
r = _bridge_request("POST", path, json=body, timeout=_timeout())
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def _get(path: str, *, device: Optional[str] = None) -> dict:
|
||||
r = requests.get(f"{_bridge_url()}{_path_with_device(path, device)}",
|
||||
headers=_auth_headers(), timeout=_timeout())
|
||||
r = _bridge_request(
|
||||
"GET", _path_with_device(path, device), timeout=_timeout()
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
@@ -1229,7 +1398,10 @@ def _get_public_ip() -> str:
|
||||
return "<your-server-ip>"
|
||||
|
||||
|
||||
def android_setup(bridge_session_token: str) -> str:
|
||||
def android_setup(
|
||||
bridge_session_token: Optional[str] = None,
|
||||
pairing_code: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
FALLBACK helper — tell the android_* tools about an existing session token.
|
||||
|
||||
@@ -1248,27 +1420,40 @@ def android_setup(bridge_session_token: str) -> str:
|
||||
|
||||
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.
|
||||
Renamed for clarity.
|
||||
``bridge_session_token`` is the canonical name; ``pairing_code`` is kept
|
||||
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...")
|
||||
"""
|
||||
# Local alias keeps the rest of the function readable without
|
||||
# propagating the rename through every line.
|
||||
pairing_code = bridge_session_token
|
||||
# propagating the rename through every line. Either spelling is
|
||||
# accepted; the canonical name wins when both are supplied.
|
||||
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:
|
||||
port = _relay_port()
|
||||
public_ip = _get_public_ip()
|
||||
|
||||
# Save config to ~/.hermes/.env
|
||||
relay_url = f"http://localhost:{port}"
|
||||
relay_url = f"http://127.0.0.1:{port}"
|
||||
try:
|
||||
from hermes_cli.config import save_env_value
|
||||
save_env_value("ANDROID_BRIDGE_URL", relay_url)
|
||||
save_env_value("ANDROID_BRIDGE_TOKEN", pairing_code)
|
||||
save_env_value("ANDROID_RELAY_PORT", str(port))
|
||||
except ImportError:
|
||||
from pathlib import Path
|
||||
env_path = Path.home() / ".hermes" / ".env"
|
||||
env_path = _hermes_home() / ".env"
|
||||
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_TOKEN", pairing_code)
|
||||
@@ -1283,7 +1468,7 @@ def android_setup(bridge_session_token: str) -> str:
|
||||
relay_running = False
|
||||
phone_connected = False
|
||||
try:
|
||||
health = requests.get(f"http://localhost:{port}/health", timeout=2)
|
||||
health = requests.get(f"{relay_url}/health", timeout=2)
|
||||
if health.status_code == 200:
|
||||
relay_running = True
|
||||
except Exception:
|
||||
@@ -1291,11 +1476,7 @@ def android_setup(bridge_session_token: str) -> str:
|
||||
|
||||
if relay_running:
|
||||
try:
|
||||
ping = requests.get(
|
||||
f"http://localhost:{port}/ping",
|
||||
headers=_auth_headers(),
|
||||
timeout=2,
|
||||
)
|
||||
ping = _bridge_request("GET", "/ping", timeout=2)
|
||||
if ping.status_code == 200:
|
||||
phone_connected = True
|
||||
except Exception:
|
||||
@@ -1306,7 +1487,7 @@ def android_setup(bridge_session_token: str) -> str:
|
||||
"status": "error",
|
||||
"message": (
|
||||
"Unified Hermes-Relay is not running on "
|
||||
f"localhost:{port}. Start it with "
|
||||
f"127.0.0.1:{port}. Start it with "
|
||||
"`systemctl --user start hermes-relay` and retry."
|
||||
),
|
||||
"server_address": server_address,
|
||||
@@ -1967,16 +2148,38 @@ _SCHEMAS = {
|
||||
},
|
||||
"android_setup": {
|
||||
"name": "android_setup",
|
||||
"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.",
|
||||
"description": (
|
||||
"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": {
|
||||
"type": "object",
|
||||
"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": {
|
||||
"type": "string",
|
||||
"description": "6-character pairing code shown in the Hermes Bridge app on the phone",
|
||||
"description": (
|
||||
"Deprecated alias for bridge_session_token, accepted "
|
||||
"for backwards compatibility. Prefer "
|
||||
"bridge_session_token."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["pairing_code"],
|
||||
# Neither key is schema-required: exactly one of the two must be
|
||||
# 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": {
|
||||
|
||||
+156
-29
@@ -49,17 +49,17 @@ Tools registered (Phase B + remote-PC ergonomics, alpha.7):
|
||||
|
||||
Architecture mirrors ``android_tool.py``:
|
||||
|
||||
Tools ──HTTP──> Unified Relay (localhost:8767) ──WSS desktop channel──> Desktop CLI
|
||||
Tools ──HTTP──> Unified Relay (127.0.0.1:8767) ──WSS desktop channel──> Desktop CLI
|
||||
|
||||
Each handler POSTs to ``/desktop/<tool_name>`` on the relay. The relay
|
||||
forwards a ``desktop.command`` envelope to the connected desktop client
|
||||
(see ``plugin/relay/channels/desktop.py``), awaits a ``desktop.response``,
|
||||
and returns the structured result.
|
||||
|
||||
``check_fn`` pings ``/desktop/_ping?tool=<name>`` — 200 if a client is
|
||||
connected and advertises the tool, 503 otherwise. This is how Hermes
|
||||
becomes aware: with no client, the tool fails closed and the LLM learns
|
||||
to stop calling it.
|
||||
``check_fn`` shares a short-lived ``/desktop/health`` snapshot across the
|
||||
Desktop toolset. Availability matches the relay's multi-client advertisement
|
||||
rules, including compatibility for connected legacy clients. With no client,
|
||||
client-routed tools fail closed and the LLM learns to stop calling them.
|
||||
|
||||
``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
|
||||
@@ -71,7 +71,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
import requests
|
||||
@@ -112,8 +114,8 @@ def _trusted_call_context(kwargs: dict[str, Any]) -> dict[str, str]:
|
||||
|
||||
|
||||
def _relay_url() -> str:
|
||||
"""URL of the unified relay. Defaults to localhost:8767."""
|
||||
return os.getenv("DESKTOP_RELAY_URL", "http://localhost:8767")
|
||||
"""URL of the unified relay. Defaults to its IPv4 loopback listener."""
|
||||
return os.getenv("DESKTOP_RELAY_URL", "http://127.0.0.1:8767")
|
||||
|
||||
|
||||
def _relay_token() -> Optional[str]:
|
||||
@@ -191,38 +193,163 @@ def _get(path: str, params: Optional[dict] = None) -> dict:
|
||||
return data
|
||||
|
||||
|
||||
def _check_tool(tool_name: str) -> bool:
|
||||
"""Returns True if a desktop client is connected AND advertises ``tool_name``.
|
||||
_AVAILABILITY_CACHE_TTL_SECONDS = 3.0
|
||||
_AVAILABILITY_CACHE_MAX_ENTRIES = 32
|
||||
|
||||
Hits ``/desktop/_ping?tool=<tool_name>``. 200 = available, 503 = no
|
||||
client / tool not advertised.
|
||||
"""
|
||||
|
||||
@dataclass(frozen=True)
|
||||
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:
|
||||
r = requests.get(
|
||||
f"{_relay_url()}/desktop/_ping",
|
||||
params={"tool": tool_name},
|
||||
response = requests.get(
|
||||
f"{_relay_url().rstrip('/')}/desktop/health",
|
||||
headers=_auth_headers(),
|
||||
timeout=2,
|
||||
)
|
||||
return r.status_code == 200
|
||||
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 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:
|
||||
"""``check_fn`` for ``desktop_health`` — the relay must be reachable, but
|
||||
a client need not be connected. The whole point of ``desktop_health`` is
|
||||
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
|
||||
"""Keep ``desktop_health`` callable on a valid relay with no client."""
|
||||
snapshot = _availability()
|
||||
return snapshot.reachable and snapshot.valid
|
||||
|
||||
|
||||
# ── Tool implementations ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,6 +34,7 @@ SESSION_RESUME = "gateway.session_resume_durable"
|
||||
SESSION_ACTIVE_LIST = "gateway.session_active_list"
|
||||
SESSION_EXCLUSIVE_SUBMIT = "gateway.session_exclusive_submit"
|
||||
SUBAGENT_CHILD_WATCH = "gateway.subagent_child_watch"
|
||||
SESSION_INITIALIZATION = "gateway.session_initialization"
|
||||
API_BOUNDARY = "api.fallback_boundary"
|
||||
ALL_CONTRACTS = (
|
||||
GATEWAY_TERMINAL,
|
||||
@@ -43,6 +44,7 @@ ALL_CONTRACTS = (
|
||||
SESSION_ACTIVE_LIST,
|
||||
SESSION_EXCLUSIVE_SUBMIT,
|
||||
SUBAGENT_CHILD_WATCH,
|
||||
SESSION_INITIALIZATION,
|
||||
API_BOUNDARY,
|
||||
)
|
||||
|
||||
@@ -60,6 +62,7 @@ class CheckResult:
|
||||
|
||||
class SourceFile:
|
||||
def __init__(self, root: Path, relative: str):
|
||||
self.root = root
|
||||
self.relative = relative
|
||||
self.path = root / relative
|
||||
if not self.path.is_file():
|
||||
@@ -183,6 +186,21 @@ def _check_gateway_terminal(server: SourceFile) -> CheckResult:
|
||||
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:
|
||||
contract = GATEWAY_SETTLED_INFO
|
||||
try:
|
||||
@@ -386,6 +404,32 @@ def _check_subagent_child_watch(server: SourceFile, methods: SourceFile) -> Chec
|
||||
try:
|
||||
resume = methods.method_handler("session.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)
|
||||
server_strings = _string_constants(server.tree)
|
||||
missing_resume = sorted(
|
||||
@@ -504,7 +548,10 @@ def audit_sources(root: Path, requirements: Iterable[str]) -> list[CheckResult]:
|
||||
raise ValueError("fork marker(s) found in upstream source: " + ", ".join(fork_hits))
|
||||
|
||||
checks = {
|
||||
GATEWAY_TERMINAL: lambda: _check_gateway_terminal(server),
|
||||
GATEWAY_TERMINAL: lambda: _check_gateway_terminal(
|
||||
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),
|
||||
SESSION_ACTIVATE: lambda: _check_activate(server, methods),
|
||||
SESSION_RESUME: lambda: _check_resume(methods),
|
||||
@@ -513,6 +560,7 @@ def audit_sources(root: Path, requirements: Iterable[str]) -> list[CheckResult]:
|
||||
server, methods, prompt_methods, active_sessions,
|
||||
),
|
||||
SUBAGENT_CHILD_WATCH: lambda: _check_subagent_child_watch(server, methods),
|
||||
SESSION_INITIALIZATION: lambda: _check_session_initialization(server),
|
||||
API_BOUNDARY: lambda: _check_api_boundary(api),
|
||||
}
|
||||
return [checks[requirement]() for requirement in requirements]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user