feat(notifications): optional alert when any session completes (#1401)

Co-authored-by: M57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
Nikhil NairandM57 authored and GitHub committed 2026-09-30 23:31:08 +03:00
1 parent 0c336ecc4d
commit 5bf43e4a21
14 files changed
+474 -40

No files matched your search

@@ -287,6 +287,7 @@ class BackgroundConnectionControllerTest {
every { AuthManager.isGatedMode() } returns true
every { AuthManager.getToken() } returns null
every { AuthManager.isKeepConnectedInBackground() } returns true
every { AuthManager.isNotifySessionCompletions() } returns false
every { AuthManager.isAutoReconnect() } returns true
every { ChatNotificationService.isAppInForeground() } returns false
every { HermesWsClient.pendingReply } returns false
@@ -320,6 +321,7 @@ class BackgroundConnectionControllerTest {
every { AuthManager.isGatedMode() } returns false
every { AuthManager.getToken() } returns null
every { AuthManager.isKeepConnectedInBackground() } returns true
every { AuthManager.isNotifySessionCompletions() } returns false
every { AuthManager.isAutoReconnect() } returns true
every { ChatNotificationService.isAppInForeground() } returns false
every { HermesWsClient.pendingReply } returns false
@@ -352,6 +354,7 @@ class BackgroundConnectionControllerTest {
every { AuthManager.isGatedMode() } returns false
every { AuthManager.getToken() } returns "some-token"
every { AuthManager.isKeepConnectedInBackground() } returns true
every { AuthManager.isNotifySessionCompletions() } returns false
every { AuthManager.isAutoReconnect() } returns true
every { ChatNotificationService.isAppInForeground() } returns false
every { HermesWsClient.pendingReply } returns false
@@ -385,6 +388,7 @@ class BackgroundConnectionControllerTest {
every { AuthManager.isGatedMode() } returns false
every { AuthManager.getToken() } returns "some-token"
every { AuthManager.isKeepConnectedInBackground() } returns true
every { AuthManager.isNotifySessionCompletions() } returns false
every { AuthManager.isAutoReconnect() } returns false
every { ChatNotificationService.isAppInForeground() } returns false
every { HermesWsClient.pendingReply } returns false
@@ -250,4 +250,46 @@ class BackgroundConnectionPolicyTest {
assertTrue(decision.shouldHoldPersistentLease)
assertEquals(BackgroundNotificationState.Connecting, decision.notificationState)
}
// ── Session-completion notify opt-in (implies the keep-connected lease) ──
@Test
fun testBackground_notifyCompletionsOn_alone_holdsServiceAndLease() {
val state =
BackgroundConnectionSnapshot(
appInForeground = false,
isDeparting = false,
keepConnectedOptIn = false,
pendingReply = false,
isEligibleForConnection = true,
status = ConnectionStatus.CONNECTED,
isAutoReconnect = true,
hasActiveNetwork = true,
notifyCompletionsOptIn = true,
)
val decision = BackgroundConnectionPolicy.evaluate(state)
assertTrue(decision.shouldHoldService)
assertTrue(decision.shouldHoldPersistentLease)
assertEquals(BackgroundNotificationState.ConnectedInBackground, decision.notificationState)
}
@Test
fun testBackground_notifyCompletionsOff_pendingReplyOnly_holdsServiceWithoutLease() {
val state =
BackgroundConnectionSnapshot(
appInForeground = false,
isDeparting = false,
keepConnectedOptIn = false,
pendingReply = true,
isEligibleForConnection = true,
status = ConnectionStatus.CONNECTED,
isAutoReconnect = true,
hasActiveNetwork = true,
notifyCompletionsOptIn = false,
)
val decision = BackgroundConnectionPolicy.evaluate(state)
assertTrue(decision.shouldHoldService)
assertFalse(decision.shouldHoldPersistentLease)
assertEquals(BackgroundNotificationState.WaitingForReplies, decision.notificationState)
}
}
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.notification
import android.content.Context
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.WsEvent
import io.mockk.mockk
import io.mockk.verify
@@ -164,3 +165,116 @@ class ChatNotificationServiceTest {
assertEquals("Answer\ncontinued", plan.correlationText)
}
}
class ChatNotificationServiceActiveListTest {
@Test
fun `parseActiveSessionLookup decodes the typed JSON result and maps ids plus title`() {
val raw =
"""
{"sessions":[
{"id":"rt1","session_key":"stored-1","title":"Nightly report","status":"idle","last_active":1780000000.0},
{"id":"rt2","session_key":"stored-2","status":"idle"}
]}
""".trimIndent()
val found = parseActiveSessionLookup(raw, "rt1")
assertEquals("stored-1", found?.storedId)
assertEquals("Nightly report", found?.title)
// Row without a title: notification falls back to the app name.
val untitled = parseActiveSessionLookup(raw, "rt2")
assertEquals("stored-2", untitled?.storedId)
assertNull(untitled?.title)
}
@Test
fun `parseActiveSessionLookup accepts the JsonElement a typed RPC call returns`() {
val raw = """{"sessions":[{"id":"rt9","session_key":"stored-9","title":"T"}]}"""
val element =
kotlinx.serialization.json.Json
.parseToJsonElement(raw)
val found = parseActiveSessionLookup(element, "rt9")
assertEquals("stored-9", found?.storedId)
assertEquals("T", found?.title)
}
@Test
fun `parseActiveSessionLookup rejects unknown runtime ids and empty keys`() {
val raw = """{"sessions":[{"id":"rt1","session_key":"","status":"idle"}]}"""
assertNull(parseActiveSessionLookup(raw, "rt1"))
assertNull(parseActiveSessionLookup(raw, "missing"))
assertNull(parseActiveSessionLookup(null, "rt1"))
assertNull(parseActiveSessionLookup(raw, ""))
assertNull(parseActiveSessionLookup("not json", "rt1"))
}
}
class MessageCompleteRouteTest {
@Test
fun `active session routes to the reply path`() {
val route = messageCompleteRoute(holderSessionId = "rt1", toggleOn = true, eventSessionId = "rt1")
assertEquals(MessageCompleteRoute.Reply, route)
}
@Test
fun `toggle off keeps legacy behavior for foreign sessions`() {
val route = messageCompleteRoute(holderSessionId = "rt1", toggleOn = false, eventSessionId = "other")
assertEquals(MessageCompleteRoute.Reply, route)
}
@Test
fun `toggle on routes foreign sessions to the plain alert`() {
assertTrue(
messageCompleteRoute(holderSessionId = "rt1", toggleOn = true, eventSessionId = "other") is
MessageCompleteRoute.ForeignSession,
)
}
@Test
fun `toggle on with a cleared holder routes the pinned alert`() {
// After a background reconnect the holder is cleared: the user's own
// reply lands in the plain alert (resolved stored id keeps the tap
// working) instead of being dropped.
assertTrue(
messageCompleteRoute(holderSessionId = null, toggleOn = true, eventSessionId = "rt1") is
MessageCompleteRoute.ForeignSession,
)
}
@Test
fun `toggle off with a cleared holder still uses the reply path`() {
val route = messageCompleteRoute(holderSessionId = null, toggleOn = false, eventSessionId = "rt1")
assertEquals(MessageCompleteRoute.Reply, route)
}
@Test
fun `legacy reply route retires the service when the wait is over`() {
// Toggle off + holder cleared by a reconnect: the route is Reply, and the
// Reply branch always ends in onReplyCompleted(generation).
assertEquals(
MessageCompleteRoute.Reply,
messageCompleteRoute(holderSessionId = null, toggleOn = false, eventSessionId = "rt1"),
)
var completedGeneration: Long? = null
val controller =
BackgroundConnectionController(
snapshotProvider = {
BackgroundConnectionSnapshot(
appInForeground = false,
isDeparting = false,
keepConnectedOptIn = false,
pendingReply = true,
isEligibleForConnection = true,
status = ConnectionStatus.CONNECTED,
isAutoReconnect = true,
hasActiveNetwork = true,
)
},
requestServiceComplete = { gen -> completedGeneration = gen },
)
controller.onReplyCompleted(7L)
assertEquals(7L, completedGeneration)
}
}
@@ -93,6 +93,8 @@ class SettingsViewModelTest {
every { AuthManager.isTokensPerSecondEnabled() } returns true
every { AuthManager.isModelProviderShown() } returns false
every { AuthManager.isKeepConnectedInBackground() } returns false
every { AuthManager.isNotifySessionCompletions() } returns false
every { AuthManager.setNotifySessionCompletions(any()) } returns Unit
every { AuthManager.getConnectionProfiles() } returns emptyList()
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"