feat: auto-dismiss reply notifications when read in chat (#1195)
This commit is contained in:
30 files changed
+4070
-15
No files matched your search
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 8,
|
||||
"identityHash": "4f44d92f9b8db2b02a1b156b37d64651",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "chat_messages",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `session_id` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `reasoning_text` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `tool_name` TEXT, `tool_call_id` TEXT NOT NULL, `tool_status` TEXT, `is_streaming` INTEGER NOT NULL, `display_kind` TEXT, `token_count` INTEGER, `tps` REAL, `completion_id` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sessionId",
|
||||
"columnName": "session_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "role",
|
||||
"columnName": "role",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "content",
|
||||
"columnName": "content",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "reasoningText",
|
||||
"columnName": "reasoning_text",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toolName",
|
||||
"columnName": "tool_name",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "toolCallId",
|
||||
"columnName": "tool_call_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "toolStatus",
|
||||
"columnName": "tool_status",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isStreaming",
|
||||
"columnName": "is_streaming",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "displayKind",
|
||||
"columnName": "display_kind",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tokenCount",
|
||||
"columnName": "token_count",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "tps",
|
||||
"columnName": "tps",
|
||||
"affinity": "REAL"
|
||||
},
|
||||
{
|
||||
"fieldPath": "completionId",
|
||||
"columnName": "completion_id",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_chat_messages_session_id_timestamp",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"session_id",
|
||||
"timestamp"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` ON `${TABLE_NAME}` (`session_id`, `timestamp`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4f44d92f9b8db2b02a1b156b37d64651')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.service.notification.StatusBarNotification
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.notification.ChatNotificationService
|
||||
import com.m57.hermescontrol.notification.ReplyNotificationTracker
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects
|
||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
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.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@MediumTest
|
||||
class ChatReadNotificationTest {
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var notificationManager: NotificationManager
|
||||
private val testChannelId = "test_chat_channel"
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
InstrumentationRegistry
|
||||
.getInstrumentation()
|
||||
.uiAutomation
|
||||
.grantRuntimePermission(
|
||||
context.packageName,
|
||||
android.Manifest.permission.POST_NOTIFICATIONS,
|
||||
)
|
||||
}
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
testChannelId,
|
||||
"Test Chat Notifications",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
notificationManager.cancelAll()
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
notificationManager.cancelAll()
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
}
|
||||
|
||||
private fun awaitNotification(
|
||||
timeoutMs: Long = 3000L,
|
||||
predicate: (Array<StatusBarNotification>) -> Boolean,
|
||||
): Boolean {
|
||||
val start = System.currentTimeMillis()
|
||||
while (System.currentTimeMillis() - start < timeoutMs) {
|
||||
if (predicate(notificationManager.activeNotifications)) return true
|
||||
Thread.sleep(50)
|
||||
}
|
||||
return predicate(notificationManager.activeNotifications)
|
||||
}
|
||||
|
||||
private fun postRealNotification(
|
||||
kind: String,
|
||||
scopeId: String = "default",
|
||||
sessionId: String = "session-1",
|
||||
completionId: String = "comp-1",
|
||||
textSnippet: String = "Here is the reply",
|
||||
serverMessageId: Int? = null,
|
||||
): Long {
|
||||
val gen =
|
||||
if (kind == ReplyNotificationTracker.KIND_REPLY) {
|
||||
ReplyNotificationTracker.registerPendingReply(
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
completionId = completionId,
|
||||
textSnippet = textSnippet,
|
||||
)
|
||||
} else {
|
||||
ReplyNotificationTracker.nextGeneration()
|
||||
}
|
||||
|
||||
val notification =
|
||||
NotificationCompat
|
||||
.Builder(context, testChannelId)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle("Agent")
|
||||
.setContentText(textSnippet)
|
||||
.addExtras(
|
||||
Bundle().apply {
|
||||
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, kind)
|
||||
putString(ReplyNotificationTracker.EXTRA_SCOPE_ID, scopeId)
|
||||
putString(ReplyNotificationTracker.EXTRA_SESSION_ID, sessionId)
|
||||
putString(ReplyNotificationTracker.EXTRA_COMPLETION_ID, completionId)
|
||||
putString(ReplyNotificationTracker.EXTRA_TEXT_SNIPPET, textSnippet)
|
||||
putLong(ReplyNotificationTracker.EXTRA_GENERATION, gen)
|
||||
serverMessageId?.let {
|
||||
putInt(ReplyNotificationTracker.EXTRA_SERVER_MESSAGE_ID, it)
|
||||
}
|
||||
},
|
||||
).build()
|
||||
|
||||
when (kind) {
|
||||
ReplyNotificationTracker.KIND_REPLY -> {
|
||||
ReplyNotificationTracker.postReplyNotification(context, notification, gen)
|
||||
}
|
||||
|
||||
ReplyNotificationTracker.KIND_ACTION -> {
|
||||
ReplyNotificationTracker.postActionNotification(context, notification)
|
||||
}
|
||||
|
||||
ReplyNotificationTracker.KIND_REPLIED -> {
|
||||
ReplyNotificationTracker.postRepliedNotification(context, notification)
|
||||
}
|
||||
}
|
||||
|
||||
val active = awaitNotification { sbns -> sbns.any { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID } }
|
||||
assertTrue("Notification ID 2 must be active in system after posting", active)
|
||||
return gen
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatLifecycleEffects_dismissesRealSystemNotification_whenAssistantMessageDisplayed() {
|
||||
val sessionId = "session-read-test"
|
||||
val completionId = "comp-12345"
|
||||
val replyText = "Here is the completed response."
|
||||
|
||||
postRealNotification(
|
||||
kind = ReplyNotificationTracker.KIND_REPLY,
|
||||
scopeId = AuthManager.activeProfileId.value.orEmpty(),
|
||||
sessionId = sessionId,
|
||||
completionId = completionId,
|
||||
textSnippet = replyText,
|
||||
)
|
||||
|
||||
val assistantMessage =
|
||||
ChatMessage(
|
||||
id = "msg-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = replyText,
|
||||
completionId = completionId,
|
||||
)
|
||||
val messages = listOf(assistantMessage)
|
||||
val mockVm = mockk<ChatViewModel>(relaxed = true)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val listState = rememberLazyListState()
|
||||
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
|
||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.size(300.dp, 600.dp),
|
||||
) {
|
||||
items(messages, key = { "prose-${it.id}" }) {
|
||||
Text(it.content)
|
||||
}
|
||||
}
|
||||
|
||||
ChatLifecycleEffects(
|
||||
sessionId = sessionId,
|
||||
connectionStatus = ConnectionStatus.CONNECTED,
|
||||
currentSessionId = sessionId,
|
||||
messages = messages,
|
||||
errorMessage = null,
|
||||
backgroundCompleteMessage = null,
|
||||
openError = null,
|
||||
clarifyRequest = null,
|
||||
sudoPrompt = null,
|
||||
secretPrompt = null,
|
||||
listState = listState,
|
||||
scrollController = scrollController,
|
||||
snackbarHostState = snackbarHostState,
|
||||
viewModel = mockVm,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
val dismissed =
|
||||
awaitNotification { sbns -> sbns.none { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID } }
|
||||
assertTrue(
|
||||
"Notification ID 2 must be dismissed from real system notifications when message is viewed",
|
||||
dismissed,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The mobile-produced durable row id must survive in the real posted
|
||||
* notification; after a process death it is the only thing that can name the
|
||||
* row whose visibility dismisses this notification.
|
||||
*/
|
||||
@Test
|
||||
fun serverRowIdSurvivesProcessDeathThroughRealNotificationExtras() {
|
||||
val sessionId = "session-exact-row-test"
|
||||
val replyText = "Done"
|
||||
|
||||
postRealNotification(
|
||||
kind = ReplyNotificationTracker.KIND_REPLY,
|
||||
scopeId = AuthManager.activeProfileId.value.orEmpty(),
|
||||
sessionId = sessionId,
|
||||
completionId = "comp-exact-row",
|
||||
textSnippet = replyText,
|
||||
serverMessageId = 52,
|
||||
)
|
||||
|
||||
// Simulate process death: nothing in memory, only the posted extras.
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
val recovered = ReplyNotificationTracker.getActiveTarget(context)
|
||||
assertEquals(
|
||||
"Recovered target must carry the durable row id from the notification extras",
|
||||
52,
|
||||
recovered?.serverMessageId,
|
||||
)
|
||||
|
||||
val history =
|
||||
listOf(
|
||||
SessionMessage(id = 10, role = "assistant", content = JsonPrimitive("Done")),
|
||||
SessionMessage(id = 52, role = "assistant", content = JsonPrimitive("Done")),
|
||||
)
|
||||
val messages = mapServerMessages(sessionId, history, 0, true, emptyList(), context = context)
|
||||
assertEquals("Older duplicate must stay unmapped", null, messages[0].completionId)
|
||||
assertEquals("Exact row must receive the completion id", "comp-exact-row", messages[1].completionId)
|
||||
|
||||
val mockVm = mockk<ChatViewModel>(relaxed = true)
|
||||
composeTestRule.setContent {
|
||||
val listState = rememberLazyListState()
|
||||
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
|
||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.size(300.dp, 600.dp),
|
||||
) {
|
||||
items(messages, key = { "prose-${it.id}" }) {
|
||||
Text(it.content)
|
||||
}
|
||||
}
|
||||
|
||||
ChatLifecycleEffects(
|
||||
sessionId = sessionId,
|
||||
connectionStatus = ConnectionStatus.CONNECTED,
|
||||
currentSessionId = sessionId,
|
||||
messages = messages,
|
||||
errorMessage = null,
|
||||
backgroundCompleteMessage = null,
|
||||
openError = null,
|
||||
clarifyRequest = null,
|
||||
sudoPrompt = null,
|
||||
secretPrompt = null,
|
||||
listState = listState,
|
||||
scrollController = scrollController,
|
||||
snackbarHostState = snackbarHostState,
|
||||
viewModel = mockVm,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
|
||||
val dismissed =
|
||||
awaitNotification { sbns -> sbns.none { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID } }
|
||||
assertTrue(
|
||||
"Viewing the exact hydrated row must dismiss the real system notification",
|
||||
dismissed,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatLifecycleEffects_doesNotDismissRealSystemNotification_forActionRequest() {
|
||||
val sessionId = "session-action-test"
|
||||
val completionId = "comp-action"
|
||||
val promptText = "Approval required"
|
||||
|
||||
postRealNotification(
|
||||
kind = ReplyNotificationTracker.KIND_ACTION,
|
||||
scopeId = AuthManager.activeProfileId.value.orEmpty(),
|
||||
sessionId = sessionId,
|
||||
completionId = completionId,
|
||||
textSnippet = promptText,
|
||||
)
|
||||
|
||||
val assistantMessage =
|
||||
ChatMessage(
|
||||
id = "msg-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = promptText,
|
||||
completionId = completionId,
|
||||
)
|
||||
val messages = listOf(assistantMessage)
|
||||
val mockVm = mockk<ChatViewModel>(relaxed = true)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val listState = rememberLazyListState()
|
||||
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
|
||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.size(300.dp, 600.dp),
|
||||
) {
|
||||
items(messages, key = { "prose-${it.id}" }) {
|
||||
Text(it.content)
|
||||
}
|
||||
}
|
||||
|
||||
ChatLifecycleEffects(
|
||||
sessionId = sessionId,
|
||||
connectionStatus = ConnectionStatus.CONNECTED,
|
||||
currentSessionId = sessionId,
|
||||
messages = messages,
|
||||
errorMessage = null,
|
||||
backgroundCompleteMessage = null,
|
||||
openError = null,
|
||||
clarifyRequest = null,
|
||||
sudoPrompt = null,
|
||||
secretPrompt = null,
|
||||
listState = listState,
|
||||
scrollController = scrollController,
|
||||
snackbarHostState = snackbarHostState,
|
||||
viewModel = mockVm,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
Thread.sleep(200)
|
||||
|
||||
assertTrue(
|
||||
"Action notifications must NOT be auto-dismissed when viewing chat",
|
||||
notificationManager.activeNotifications.any { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatLifecycleEffects_doesNotDismissRealSystemNotification_forRepliedNotification() {
|
||||
val sessionId = "session-replied-test"
|
||||
val completionId = "comp-replied"
|
||||
val replyStatusText = "Replied: done"
|
||||
|
||||
postRealNotification(
|
||||
kind = ReplyNotificationTracker.KIND_REPLIED,
|
||||
scopeId = AuthManager.activeProfileId.value.orEmpty(),
|
||||
sessionId = sessionId,
|
||||
completionId = completionId,
|
||||
textSnippet = replyStatusText,
|
||||
)
|
||||
|
||||
val assistantMessage =
|
||||
ChatMessage(
|
||||
id = "msg-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = replyStatusText,
|
||||
completionId = completionId,
|
||||
)
|
||||
val messages = listOf(assistantMessage)
|
||||
val mockVm = mockk<ChatViewModel>(relaxed = true)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val listState = rememberLazyListState()
|
||||
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
|
||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.size(300.dp, 600.dp),
|
||||
) {
|
||||
items(messages, key = { "prose-${it.id}" }) {
|
||||
Text(it.content)
|
||||
}
|
||||
}
|
||||
|
||||
ChatLifecycleEffects(
|
||||
sessionId = sessionId,
|
||||
connectionStatus = ConnectionStatus.CONNECTED,
|
||||
currentSessionId = sessionId,
|
||||
messages = messages,
|
||||
errorMessage = null,
|
||||
backgroundCompleteMessage = null,
|
||||
openError = null,
|
||||
clarifyRequest = null,
|
||||
sudoPrompt = null,
|
||||
secretPrompt = null,
|
||||
listState = listState,
|
||||
scrollController = scrollController,
|
||||
snackbarHostState = snackbarHostState,
|
||||
viewModel = mockVm,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
Thread.sleep(200)
|
||||
|
||||
assertTrue(
|
||||
"Replied status notifications must NOT be auto-dismissed when viewing chat",
|
||||
notificationManager.activeNotifications.any { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatLifecycleEffects_doesNotDismissNotification_forDifferentSession() {
|
||||
val sessionIdWithNotif = "session-with-notif"
|
||||
val differentSessionId = "other-session"
|
||||
val completionId = "comp-12345"
|
||||
val replyText = "Here is the completed response."
|
||||
|
||||
postRealNotification(
|
||||
kind = ReplyNotificationTracker.KIND_REPLY,
|
||||
scopeId = AuthManager.activeProfileId.value.orEmpty(),
|
||||
sessionId = sessionIdWithNotif,
|
||||
completionId = completionId,
|
||||
textSnippet = replyText,
|
||||
)
|
||||
|
||||
val assistantMessage =
|
||||
ChatMessage(
|
||||
id = "msg-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = replyText,
|
||||
completionId = completionId,
|
||||
)
|
||||
val messages = listOf(assistantMessage)
|
||||
val mockVm = mockk<ChatViewModel>(relaxed = true)
|
||||
|
||||
composeTestRule.setContent {
|
||||
val listState = rememberLazyListState()
|
||||
val scrollScope = remember { CoroutineScope(Dispatchers.Main) }
|
||||
val scrollController = rememberChatScrollController(listState, scrollScope)
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.size(300.dp, 600.dp),
|
||||
) {
|
||||
items(messages, key = { "prose-${it.id}" }) {
|
||||
Text(it.content)
|
||||
}
|
||||
}
|
||||
|
||||
ChatLifecycleEffects(
|
||||
sessionId = differentSessionId,
|
||||
connectionStatus = ConnectionStatus.CONNECTED,
|
||||
currentSessionId = differentSessionId,
|
||||
messages = messages,
|
||||
errorMessage = null,
|
||||
backgroundCompleteMessage = null,
|
||||
openError = null,
|
||||
clarifyRequest = null,
|
||||
sudoPrompt = null,
|
||||
secretPrompt = null,
|
||||
listState = listState,
|
||||
scrollController = scrollController,
|
||||
snackbarHostState = snackbarHostState,
|
||||
viewModel = mockVm,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.waitForIdle()
|
||||
Thread.sleep(200)
|
||||
|
||||
assertTrue(
|
||||
"Notification ID 2 must remain active for a different session",
|
||||
notificationManager.activeNotifications.any { it.id == ChatNotificationService.PENDING_NOTIFICATION_ID },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import com.m57.hermescontrol.data.local.SessionListCacheStore
|
||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.update.UpdateNoticeManager
|
||||
import com.m57.hermescontrol.notification.TurnCorrelationTracker
|
||||
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -31,6 +32,9 @@ class HermesControlApp :
|
||||
AuthManager.init(this)
|
||||
NetworkMonitor.init(this)
|
||||
SessionListCacheStore.init(this)
|
||||
// Reply-notification turn boundaries survive process death (armed before
|
||||
// each mobile prompt, consumed by the matching completion).
|
||||
TurnCorrelationTracker.attach(this)
|
||||
appScope.launch {
|
||||
AuthManager.initializationState.first { it == AuthManager.InitializationState.Ready }
|
||||
// Issue #537 follow-up (A): preload analytics in the background after launch
|
||||
|
||||
@@ -46,4 +46,6 @@ data class ChatMessageEntity(
|
||||
val tokenCount: Int? = null,
|
||||
@ColumnInfo(name = "tps")
|
||||
val tps: Double? = null,
|
||||
@ColumnInfo(name = "completion_id")
|
||||
val completionId: String? = null,
|
||||
)
|
||||
@@ -37,6 +37,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
|
||||
displayKind = displayKind,
|
||||
tokenCount = tokenCount,
|
||||
tps = tps,
|
||||
completionId = completionId,
|
||||
)
|
||||
|
||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||
@@ -54,4 +55,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||
displayKind = displayKind,
|
||||
tokenCount = tokenCount,
|
||||
tps = tps,
|
||||
completionId = completionId,
|
||||
)
|
||||
@@ -14,7 +14,7 @@ import java.io.File
|
||||
|
||||
@Database(
|
||||
entities = [ChatMessageEntity::class],
|
||||
version = 7,
|
||||
version = 8,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class HermesDatabase : RoomDatabase() {
|
||||
@@ -81,6 +81,15 @@ abstract class HermesDatabase : RoomDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_7_8: Migration =
|
||||
object : Migration(7, 8) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL(
|
||||
"ALTER TABLE `chat_messages` ADD COLUMN `completion_id` TEXT",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun get(context: Context): HermesDatabase =
|
||||
withContext(Dispatchers.IO) {
|
||||
instance?.let { return@withContext it }
|
||||
@@ -116,6 +125,7 @@ abstract class HermesDatabase : RoomDatabase() {
|
||||
MIGRATION_4_5,
|
||||
MIGRATION_5_6,
|
||||
MIGRATION_6_7,
|
||||
MIGRATION_7_8,
|
||||
).fallbackToDestructiveMigration(false)
|
||||
.build()
|
||||
.also { instance = it }
|
||||
|
||||
@@ -110,7 +110,18 @@ object EventParser {
|
||||
"message.complete" -> {
|
||||
val text = payload?.get("text") as? String ?: ""
|
||||
val reasoning = payload?.get("reasoning") as? String
|
||||
WsEvent.MessageComplete(text, sessionId, reasoning, rawPayload = payload)
|
||||
val completionId =
|
||||
payload?.get("completion_id") as? String
|
||||
?: java.util.UUID
|
||||
.randomUUID()
|
||||
.toString()
|
||||
WsEvent.MessageComplete(
|
||||
text,
|
||||
sessionId,
|
||||
reasoning,
|
||||
rawPayload = payload,
|
||||
completionId = completionId,
|
||||
)
|
||||
}
|
||||
|
||||
"message.done" -> {
|
||||
|
||||
@@ -63,6 +63,11 @@ sealed class WsEvent {
|
||||
val storedSessionId: String? = null,
|
||||
/** Full raw payload map (including usage/avg_tps) emitted with message.complete. */
|
||||
val rawPayload: Map<String, Any?>? = null,
|
||||
/** Stable completion identity for notification and read-tracking correlation. */
|
||||
val completionId: String =
|
||||
java.util.UUID
|
||||
.randomUUID()
|
||||
.toString(),
|
||||
) : WsEvent()
|
||||
|
||||
data class MessageDone(
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.RemoteInput
|
||||
import com.m57.hermescontrol.MainActivity
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
@@ -69,6 +68,12 @@ class ChatNotificationService : Service() {
|
||||
private val isAppInForeground = AtomicBoolean(false)
|
||||
internal val lifecycle = ForegroundServiceLifecycle()
|
||||
|
||||
/**
|
||||
* Turn-row correlation used when a reply completes in the background.
|
||||
* Injectable so the resolution path can be exercised without REST.
|
||||
*/
|
||||
internal var turnRowResolver: TurnRowResolver = defaultTurnRowResolver
|
||||
|
||||
@Volatile
|
||||
internal var activeServiceInstance: ChatNotificationService? = null
|
||||
|
||||
@@ -111,10 +116,23 @@ class ChatNotificationService : Service() {
|
||||
.take(100)
|
||||
.replace("\n", " ")
|
||||
.ifBlank { getString(R.string.notif_new_message) }
|
||||
showReplyNotification(
|
||||
preview,
|
||||
val targetSessionId =
|
||||
event.storedSessionId
|
||||
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId),
|
||||
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId)
|
||||
showReplyNotification(
|
||||
text = preview,
|
||||
sessionId = targetSessionId,
|
||||
isReplyMessage = true,
|
||||
completionId = event.completionId,
|
||||
// The durable REST row for this turn, when the
|
||||
// boundary armed before the prompt was submitted
|
||||
// still lets us name it unambiguously. Null is a
|
||||
// normal, safe outcome: the notification is then
|
||||
// never auto-dismissed from REST hydration, which
|
||||
// is strictly better than dismissing the wrong
|
||||
// duplicate reply.
|
||||
serverMessageId =
|
||||
coalesceTurnRow(targetSessionId, event.text),
|
||||
)
|
||||
// The wait is over — retire the foreground
|
||||
// service. The reply notification above
|
||||
@@ -167,9 +185,37 @@ class ChatNotificationService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the exact REST row this completion produced, using the turn
|
||||
* boundary armed before the prompt was submitted. Any failure to prove that
|
||||
* identity returns null — the notification is still posted, it just will not
|
||||
* be auto-dismissed from REST hydration.
|
||||
*/
|
||||
private suspend fun coalesceTurnRow(
|
||||
sessionId: String?,
|
||||
completionText: String,
|
||||
): Int? {
|
||||
if (sessionId.isNullOrBlank()) return null
|
||||
return try {
|
||||
correlateCompletedTurnRow(
|
||||
scopeId = correlationScopeId(),
|
||||
sessionId = sessionId,
|
||||
completionText = completionText,
|
||||
resolver = turnRowResolver,
|
||||
)
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun showReplyNotification(
|
||||
text: String,
|
||||
sessionId: String?,
|
||||
isReplyMessage: Boolean = false,
|
||||
completionId: String? = null,
|
||||
serverMessageId: Int? = null,
|
||||
) {
|
||||
val builder =
|
||||
NotificationCompat
|
||||
@@ -183,6 +229,39 @@ class ChatNotificationService : Service() {
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(buildContentIntent(sessionId))
|
||||
|
||||
var replyGeneration: Long? = null
|
||||
if (isReplyMessage && !sessionId.isNullOrBlank() && !completionId.isNullOrBlank()) {
|
||||
val scopeId = correlationScopeId()
|
||||
val generation =
|
||||
ReplyNotificationTracker.registerPendingReply(
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
completionId = completionId,
|
||||
textSnippet = text,
|
||||
serverMessageId = serverMessageId,
|
||||
)
|
||||
replyGeneration = generation
|
||||
builder.addExtras(
|
||||
android.os.Bundle().apply {
|
||||
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, ReplyNotificationTracker.KIND_REPLY)
|
||||
putString(ReplyNotificationTracker.EXTRA_SCOPE_ID, scopeId)
|
||||
putString(ReplyNotificationTracker.EXTRA_SESSION_ID, sessionId)
|
||||
putString(ReplyNotificationTracker.EXTRA_COMPLETION_ID, completionId)
|
||||
putString(ReplyNotificationTracker.EXTRA_TEXT_SNIPPET, text)
|
||||
putLong(ReplyNotificationTracker.EXTRA_GENERATION, generation)
|
||||
serverMessageId?.let {
|
||||
putInt(ReplyNotificationTracker.EXTRA_SERVER_MESSAGE_ID, it)
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
builder.addExtras(
|
||||
android.os.Bundle().apply {
|
||||
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, ReplyNotificationTracker.KIND_ACTION)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (!sessionId.isNullOrBlank()) {
|
||||
val replyLabel = getString(R.string.notif_reply_placeholder)
|
||||
val remoteInput =
|
||||
@@ -194,6 +273,11 @@ class ChatNotificationService : Service() {
|
||||
val replyIntent =
|
||||
Intent(this, NotificationReplyReceiver::class.java).apply {
|
||||
action = "$packageName.ACTION_NOTIFICATION_REPLY"
|
||||
component =
|
||||
android.content.ComponentName(
|
||||
this@ChatNotificationService,
|
||||
NotificationReplyReceiver::class.java,
|
||||
)
|
||||
setPackage(packageName)
|
||||
putExtra(NotificationReplyReceiver.EXTRA_SESSION_ID, sessionId)
|
||||
}
|
||||
@@ -218,19 +302,32 @@ class ChatNotificationService : Service() {
|
||||
builder.addAction(action)
|
||||
}
|
||||
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(PENDING_NOTIFICATION_ID, builder.build())
|
||||
val notification = builder.build()
|
||||
if (replyGeneration != null) {
|
||||
ReplyNotificationTracker.postReplyNotification(
|
||||
context = this,
|
||||
notification = notification,
|
||||
generation = replyGeneration,
|
||||
)
|
||||
} else {
|
||||
ReplyNotificationTracker.postActionNotification(
|
||||
context = this,
|
||||
notification = notification,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildContentIntent(sessionId: String?): PendingIntent {
|
||||
val intent =
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
action = MainActivity.ACTION_OPEN_CHAT_FROM_NOTIFICATION
|
||||
component = android.content.ComponentName(this@ChatNotificationService, MainActivity::class.java)
|
||||
setPackage(packageName)
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
if (!sessionId.isNullOrBlank()) {
|
||||
putExtra(NotificationReplyReceiver.EXTRA_SESSION_ID, sessionId)
|
||||
}
|
||||
}
|
||||
if (!sessionId.isNullOrBlank()) {
|
||||
intent.putExtra(NotificationReplyReceiver.EXTRA_SESSION_ID, sessionId)
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
this,
|
||||
sessionId?.hashCode() ?: 0,
|
||||
|
||||
@@ -24,6 +24,13 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
const val KEY_TEXT_REPLY = "key_text_reply"
|
||||
const val EXTRA_SESSION_ID = "extra_session_id"
|
||||
private const val REPLY_TIMEOUT_MS = 5_000L
|
||||
|
||||
/**
|
||||
* Turn-boundary read budget inside the reply deadline. Deliberately tight:
|
||||
* the probe sits on the reply path, so a slow or unreachable gateway must
|
||||
* cost the user's reply only a sliver of its 5s deadline.
|
||||
*/
|
||||
private const val BOUNDARY_TIMEOUT_MS = 250L
|
||||
}
|
||||
|
||||
// Reusable scope for async reply processing — avoids creating a new
|
||||
@@ -50,7 +57,11 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
.setContentText("Replied")
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
.addExtras(
|
||||
android.os.Bundle().apply {
|
||||
putString(ReplyNotificationTracker.EXTRA_NOTIF_KIND, ReplyNotificationTracker.KIND_REPLIED)
|
||||
},
|
||||
).build()
|
||||
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
@@ -78,6 +89,17 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
return@withContext
|
||||
}
|
||||
|
||||
// Mobile-originated follow-up turn: arm its durable
|
||||
// boundary BEFORE prompt.submit. Budgeted tighter
|
||||
// than the chat composer's — this receiver has its
|
||||
// own 5s deadline and a failed capture must only
|
||||
// make the reply uncorrelatable, never lose it.
|
||||
captureTurnBoundary(
|
||||
scopeId = correlationScopeId(),
|
||||
sessionId = sessionId,
|
||||
timeoutMs = BOUNDARY_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
val runtimeSessionId =
|
||||
ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
|
||||
?: resumeSession(sessionId)
|
||||
@@ -102,8 +124,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
dao.upsert(entity)
|
||||
|
||||
val repliedNotification = buildReplyNotification(context)
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, repliedNotification)
|
||||
ReplyNotificationTracker.postRepliedNotification(context, repliedNotification)
|
||||
|
||||
// The follow-up turn is now pending — keep the
|
||||
// foreground service alive if it retired after the
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
package com.m57.hermescontrol.notification
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
data class ReplyNotificationTarget(
|
||||
val scopeId: String,
|
||||
val sessionId: String,
|
||||
val completionId: String,
|
||||
val generation: Long,
|
||||
val textSnippet: String = "",
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
/**
|
||||
* Durable REST `messages.id` of the row this reply produced. Resolved by
|
||||
* [TurnCorrelationTracker] from the boundary armed before the prompt was
|
||||
* submitted — never taken from the completion event, text, or a clock. Null
|
||||
* means the turn could not be correlated, and then hydration must not touch
|
||||
* this notification at all (fail closed).
|
||||
*/
|
||||
val serverMessageId: Int? = null,
|
||||
) {
|
||||
fun matches(
|
||||
candidateScopeId: String?,
|
||||
candidateSessionId: String?,
|
||||
candidateCompletionId: String?,
|
||||
): Boolean {
|
||||
if (scopeId.isNotBlank() && scopeId != candidateScopeId) {
|
||||
return false
|
||||
}
|
||||
if (candidateSessionId.isNullOrBlank() || sessionId != candidateSessionId) {
|
||||
return false
|
||||
}
|
||||
if (candidateCompletionId.isNullOrBlank() || completionId.isBlank()) {
|
||||
return false
|
||||
}
|
||||
return candidateCompletionId == completionId
|
||||
}
|
||||
}
|
||||
|
||||
internal data class ActiveReplyInfo(
|
||||
val id: Int,
|
||||
val kind: String?,
|
||||
val scopeId: String?,
|
||||
val sessionId: String?,
|
||||
val completionId: String?,
|
||||
val textSnippet: String?,
|
||||
val generation: Long,
|
||||
val timestamp: Long,
|
||||
val serverMessageId: Int? = null,
|
||||
)
|
||||
|
||||
object ReplyNotificationTracker {
|
||||
const val EXTRA_NOTIF_KIND = "hermes_notif_kind"
|
||||
const val EXTRA_SCOPE_ID = "hermes_scope_id"
|
||||
const val EXTRA_SESSION_ID = "hermes_session_id"
|
||||
const val EXTRA_COMPLETION_ID = "hermes_completion_id"
|
||||
const val EXTRA_TEXT_SNIPPET = "hermes_text_snippet"
|
||||
const val EXTRA_GENERATION = "hermes_generation"
|
||||
const val EXTRA_SERVER_MESSAGE_ID = "hermes_server_message_id"
|
||||
|
||||
const val KIND_REPLY = "reply"
|
||||
const val KIND_ACTION = "action"
|
||||
const val KIND_REPLIED = "replied"
|
||||
|
||||
private val generationCounter = AtomicLong(0L)
|
||||
private val tombstoneGeneration = AtomicLong(0L)
|
||||
|
||||
@Volatile
|
||||
private var activeTarget: ReplyNotificationTarget? = null
|
||||
|
||||
private val defaultActiveNotificationProvider: (Context) -> ActiveReplyInfo? = { context ->
|
||||
runCatching {
|
||||
val manager =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||
val sbn =
|
||||
manager?.activeNotifications?.firstOrNull {
|
||||
it.id == ChatNotificationService.PENDING_NOTIFICATION_ID
|
||||
}
|
||||
if (sbn != null) {
|
||||
val extras = sbn.notification?.extras
|
||||
ActiveReplyInfo(
|
||||
id = sbn.id,
|
||||
kind = extras?.getString(EXTRA_NOTIF_KIND),
|
||||
scopeId = extras?.getString(EXTRA_SCOPE_ID),
|
||||
sessionId = extras?.getString(EXTRA_SESSION_ID),
|
||||
completionId = extras?.getString(EXTRA_COMPLETION_ID),
|
||||
textSnippet = extras?.getString(EXTRA_TEXT_SNIPPET),
|
||||
generation = extras?.getLong(EXTRA_GENERATION, 0L) ?: 0L,
|
||||
timestamp = sbn.postTime,
|
||||
serverMessageId = extras?.getInt(EXTRA_SERVER_MESSAGE_ID, -1)?.takeIf { it >= 0 },
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
internal var activeNotificationProvider: (Context) -> ActiveReplyInfo? = defaultActiveNotificationProvider
|
||||
|
||||
fun nextGeneration(): Long {
|
||||
val floor = maxOf(generationCounter.get(), tombstoneGeneration.get())
|
||||
return generationCounter.updateAndGet { maxOf(it, floor) + 1 }
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun registerPendingReply(
|
||||
scopeId: String,
|
||||
sessionId: String,
|
||||
completionId: String,
|
||||
textSnippet: String,
|
||||
timestamp: Long = System.currentTimeMillis(),
|
||||
serverMessageId: Int? = null,
|
||||
): Long {
|
||||
val generation = nextGeneration()
|
||||
activeTarget =
|
||||
ReplyNotificationTarget(
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
completionId = completionId,
|
||||
generation = generation,
|
||||
textSnippet = textSnippet,
|
||||
timestamp = timestamp,
|
||||
serverMessageId = serverMessageId,
|
||||
)
|
||||
return generation
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun postReplyNotification(
|
||||
context: Context,
|
||||
notification: Notification,
|
||||
generation: Long,
|
||||
): Boolean {
|
||||
if (generation <= tombstoneGeneration.get()) {
|
||||
return false
|
||||
}
|
||||
val current = activeTarget
|
||||
if (current != null && current.generation > generation) {
|
||||
return false
|
||||
}
|
||||
val manager =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||
?: return false
|
||||
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun postActionNotification(
|
||||
context: Context,
|
||||
notification: Notification,
|
||||
): Boolean {
|
||||
val nextGen = nextGeneration()
|
||||
tombstoneGeneration.updateAndGet { maxOf(it, nextGen) }
|
||||
activeTarget = null
|
||||
val manager =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||
?: return false
|
||||
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun postRepliedNotification(
|
||||
context: Context,
|
||||
notification: Notification,
|
||||
): Boolean {
|
||||
val nextGen = nextGeneration()
|
||||
tombstoneGeneration.updateAndGet { maxOf(it, nextGen) }
|
||||
activeTarget = null
|
||||
val manager =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||
?: return false
|
||||
manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, notification)
|
||||
return true
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onReplyNotificationPosted(
|
||||
scopeId: String,
|
||||
sessionId: String,
|
||||
completionId: String,
|
||||
textSnippet: String,
|
||||
generation: Long,
|
||||
timestamp: Long = System.currentTimeMillis(),
|
||||
serverMessageId: Int? = null,
|
||||
) {
|
||||
if (generation <= tombstoneGeneration.get()) return
|
||||
generationCounter.updateAndGet { maxOf(it, generation) }
|
||||
activeTarget =
|
||||
ReplyNotificationTarget(
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
completionId = completionId,
|
||||
generation = generation,
|
||||
textSnippet = textSnippet,
|
||||
timestamp = timestamp,
|
||||
serverMessageId = serverMessageId,
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onNonReplyNotificationPosted() {
|
||||
val nextGen = nextGeneration()
|
||||
tombstoneGeneration.updateAndGet { maxOf(it, nextGen) }
|
||||
activeTarget = null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun getActiveTarget(context: Context? = null): ReplyNotificationTarget? {
|
||||
val inMemory = activeTarget
|
||||
if (inMemory != null) return inMemory
|
||||
return if (context != null) resolveTarget(context) else null
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun onMessageVisible(
|
||||
context: Context,
|
||||
scopeId: String?,
|
||||
sessionId: String?,
|
||||
completionId: String?,
|
||||
): Boolean {
|
||||
if (sessionId.isNullOrBlank()) return false
|
||||
val target = resolveTarget(context) ?: return false
|
||||
if (target.matches(scopeId, sessionId, completionId)) {
|
||||
return cancelReplyNotification(context, target.generation)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun cancelReplyNotification(
|
||||
context: Context,
|
||||
expectedGeneration: Long,
|
||||
): Boolean {
|
||||
val target = resolveTarget(context)
|
||||
if (target != null && target.generation > expectedGeneration) {
|
||||
return false
|
||||
}
|
||||
val manager =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||
?: return false
|
||||
|
||||
val activeInfo = activeNotificationProvider(context)
|
||||
if (activeInfo != null) {
|
||||
if (activeInfo.kind != KIND_REPLY) {
|
||||
activeTarget = null
|
||||
return false
|
||||
}
|
||||
if (activeInfo.generation <= tombstoneGeneration.get()) {
|
||||
activeTarget = null
|
||||
return false
|
||||
}
|
||||
if (activeInfo.generation > expectedGeneration) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
tombstoneGeneration.updateAndGet { maxOf(it, expectedGeneration) }
|
||||
manager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID)
|
||||
activeTarget = null
|
||||
return true
|
||||
}
|
||||
|
||||
private fun resolveTarget(context: Context): ReplyNotificationTarget? {
|
||||
val inMemory = activeTarget
|
||||
if (inMemory != null) return inMemory
|
||||
|
||||
val activeInfo = activeNotificationProvider(context) ?: return null
|
||||
if (activeInfo.kind != KIND_REPLY) return null
|
||||
generationCounter.updateAndGet { maxOf(it, activeInfo.generation) }
|
||||
if (activeInfo.generation <= tombstoneGeneration.get()) return null
|
||||
val sessionId = activeInfo.sessionId.orEmpty()
|
||||
if (sessionId.isBlank()) return null
|
||||
|
||||
return ReplyNotificationTarget(
|
||||
scopeId = activeInfo.scopeId.orEmpty(),
|
||||
sessionId = sessionId,
|
||||
completionId = activeInfo.completionId.orEmpty(),
|
||||
generation = activeInfo.generation,
|
||||
textSnippet = activeInfo.textSnippet.orEmpty(),
|
||||
timestamp = activeInfo.timestamp,
|
||||
serverMessageId = activeInfo.serverMessageId,
|
||||
).also { activeTarget = it }
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun resetForTest() {
|
||||
activeTarget = null
|
||||
generationCounter.set(0L)
|
||||
tombstoneGeneration.set(0L)
|
||||
activeNotificationProvider = defaultActiveNotificationProvider
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
fun setTargetForTest(target: ReplyNotificationTarget?) {
|
||||
activeTarget = target
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
package com.m57.hermescontrol.notification
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Durable lower bound for one mobile-originated turn: the highest REST
|
||||
* transcript row id that existed immediately BEFORE `prompt.submit` left the
|
||||
* device.
|
||||
*
|
||||
* The id is the gateway's AUTOINCREMENT `messages.id`, so every row persisted
|
||||
* by the turn itself is strictly greater than [beforeMessageId]. That gives the
|
||||
* notification layer a turn-scoped window it can search without text
|
||||
* heuristics, row ordering, or cross-machine clocks.
|
||||
*
|
||||
* Captured before the prompt is submitted, never after: a fast turn can persist
|
||||
* its assistant row before a post-submit snapshot request finishes, which would
|
||||
* place the reply inside the "before" snapshot (and a lower bound can then never
|
||||
* exclude it).
|
||||
*/
|
||||
data class TurnBoundary(
|
||||
val scopeId: String,
|
||||
val sessionId: String,
|
||||
val beforeMessageId: Int,
|
||||
/** Monotonic arm order — a stale clear must not remove a newer turn's boundary. */
|
||||
val generation: Long,
|
||||
val armedAt: Long,
|
||||
) {
|
||||
fun matchesKey(
|
||||
otherScopeId: String,
|
||||
otherSessionId: String,
|
||||
): Boolean = scopeId == otherScopeId && sessionId == otherSessionId
|
||||
}
|
||||
|
||||
/** Persistence seam so the tracker can be unit-tested without an Android context. */
|
||||
internal interface TurnBoundaryStore {
|
||||
fun read(): List<TurnBoundary>
|
||||
|
||||
fun write(boundaries: List<TurnBoundary>)
|
||||
}
|
||||
|
||||
/**
|
||||
* SharedPreferences-backed store — a handful of tiny `scope|session -> state`
|
||||
* rows, so a process death during a running turn does not lose the boundary.
|
||||
*/
|
||||
internal class PrefsTurnBoundaryStore(
|
||||
context: Context,
|
||||
) : TurnBoundaryStore {
|
||||
private val prefs =
|
||||
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
override fun read(): List<TurnBoundary> =
|
||||
prefs.all.mapNotNull { (key, value) -> decodeBoundary(key, value as? String) }
|
||||
|
||||
override fun write(boundaries: List<TurnBoundary>) {
|
||||
val editor = prefs.edit().clear()
|
||||
boundaries.forEach { editor.putString(encodeKey(it.scopeId, it.sessionId), encodeValue(it)) }
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
const val PREFS_NAME = "hermes_turn_boundaries"
|
||||
private const val KEY_SEPARATOR = '|'
|
||||
|
||||
fun encodeKey(
|
||||
scopeId: String,
|
||||
sessionId: String,
|
||||
): String = "$scopeId$KEY_SEPARATOR$sessionId"
|
||||
|
||||
fun encodeValue(boundary: TurnBoundary): String =
|
||||
"${boundary.beforeMessageId}:${boundary.generation}:${boundary.armedAt}"
|
||||
|
||||
fun decodeBoundary(
|
||||
key: String,
|
||||
value: String?,
|
||||
): TurnBoundary? {
|
||||
val separator = key.indexOf(KEY_SEPARATOR)
|
||||
if (separator <= 0 || value == null) return null
|
||||
val parts = value.split(':')
|
||||
if (parts.size != 3) return null
|
||||
val beforeMessageId = parts[0].toIntOrNull() ?: return null
|
||||
val generation = parts[1].toLongOrNull() ?: return null
|
||||
val armedAt = parts[2].toLongOrNull() ?: return null
|
||||
return TurnBoundary(
|
||||
scopeId = key.substring(0, separator),
|
||||
sessionId = key.substring(separator + 1),
|
||||
beforeMessageId = beforeMessageId,
|
||||
generation = generation,
|
||||
armedAt = armedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn-boundary registry for reply-notification correlation.
|
||||
*
|
||||
* A boundary is armed per (profile scope, stored session id) immediately before
|
||||
* a mobile-originated prompt is submitted, and consumed when a completion for
|
||||
* that session is handled. Sessions that never had a boundary (desktop/TUI
|
||||
* turns, another connected client, a failed capture, queued or group-chat
|
||||
* turns) simply fail closed: the reply notification is posted without a durable
|
||||
* row id and therefore is never auto-dismissed from REST hydration alone.
|
||||
*
|
||||
* Scope isolation is structural — a boundary is only visible to the exact
|
||||
* profile that armed it, so switching profiles makes the old boundary
|
||||
* unreachable instead of resolvable against the wrong namespace.
|
||||
*/
|
||||
object TurnCorrelationTracker {
|
||||
/** Bound the registry: stale turns must not accumulate across app restarts. */
|
||||
private const val MAX_ENTRIES = 32
|
||||
|
||||
/** A boundary older than this belongs to a turn nobody is waiting on anymore. */
|
||||
private const val MAX_AGE_MS = 6 * 60 * 60 * 1000L
|
||||
|
||||
private val generationCounter = AtomicLong(0L)
|
||||
private val lock = Any()
|
||||
|
||||
@Volatile
|
||||
private var cache: List<TurnBoundary> = emptyList()
|
||||
|
||||
@Volatile
|
||||
private var store: TurnBoundaryStore? = null
|
||||
|
||||
/** Idempotent — called once from [com.m57.hermescontrol.HermesControlApp]. */
|
||||
fun attach(context: Context) {
|
||||
synchronized(lock) {
|
||||
if (store != null) return
|
||||
// Never let persistence break app startup: an unusable context
|
||||
// degrades to in-memory boundaries.
|
||||
val prefsStore = runCatching { PrefsTurnBoundaryStore(context) }.getOrNull() ?: return
|
||||
store = prefsStore
|
||||
val restored = prune(runCatching { prefsStore.read() }.getOrDefault(emptyList()))
|
||||
cache = restored
|
||||
reserveGenerationFloor(restored)
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: swap the persistent store (or detach with `null`). */
|
||||
@VisibleForTesting
|
||||
internal fun attachStore(store: TurnBoundaryStore?) {
|
||||
synchronized(lock) {
|
||||
this.store = store
|
||||
val restored = store?.let { prune(it.read()) } ?: emptyList()
|
||||
cache = restored
|
||||
reserveGenerationFloor(restored)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the pre-submit high-watermark. Returns the armed boundary, or null
|
||||
* when the identity is unusable (blank scope/session, no rows and no
|
||||
* confirmed read).
|
||||
*/
|
||||
fun armBoundary(
|
||||
scopeId: String,
|
||||
sessionId: String,
|
||||
beforeMessageId: Int,
|
||||
now: Long = System.currentTimeMillis(),
|
||||
): TurnBoundary? {
|
||||
if (scopeId.isBlank() || sessionId.isBlank() || beforeMessageId < 0) return null
|
||||
val boundary =
|
||||
TurnBoundary(
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
beforeMessageId = beforeMessageId,
|
||||
generation = generationCounter.incrementAndGet(),
|
||||
armedAt = now,
|
||||
)
|
||||
synchronized(lock) {
|
||||
val next = prune(cache.filterNot { it.matchesKey(scopeId, sessionId) } + boundary, now = now)
|
||||
cache = next
|
||||
runCatching { store?.write(next) }
|
||||
}
|
||||
return boundary
|
||||
}
|
||||
|
||||
fun boundaryFor(
|
||||
scopeId: String,
|
||||
sessionId: String,
|
||||
now: Long = System.currentTimeMillis(),
|
||||
): TurnBoundary? {
|
||||
if (scopeId.isBlank() || sessionId.isBlank()) return null
|
||||
return synchronized(lock) {
|
||||
cache.firstOrNull { it.matchesKey(scopeId, sessionId) && isFresh(it, now) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumes a boundary. [generation] guards the race where a newer turn armed
|
||||
* a replacement while an older completion was still being correlated.
|
||||
*/
|
||||
fun clearBoundary(
|
||||
scopeId: String,
|
||||
sessionId: String,
|
||||
generation: Long? = null,
|
||||
): Boolean {
|
||||
synchronized(lock) {
|
||||
val existing = cache.firstOrNull { it.matchesKey(scopeId, sessionId) } ?: return false
|
||||
if (generation != null && existing.generation != generation) return false
|
||||
val next = cache.filterNot { it === existing }
|
||||
cache = next
|
||||
runCatching { store?.write(next) }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun prune(
|
||||
boundaries: List<TurnBoundary>,
|
||||
now: Long = System.currentTimeMillis(),
|
||||
): List<TurnBoundary> =
|
||||
boundaries
|
||||
.filter { isFresh(it, now) }
|
||||
.sortedByDescending { it.armedAt }
|
||||
.take(MAX_ENTRIES)
|
||||
|
||||
/**
|
||||
* Keeps generations unique across process death.
|
||||
*
|
||||
* The counter lives in memory only, so after a restart a restored boundary
|
||||
* (generation N) and the next armed boundary would both be N — and the
|
||||
* stale-clear guard, which exists precisely to stop an old completion's
|
||||
* clear from removing a newer turn's boundary, would stop protecting
|
||||
* anything. Lifting the counter above the recovered generations keeps the
|
||||
* guard meaningful.
|
||||
*/
|
||||
private fun reserveGenerationFloor(restored: List<TurnBoundary>) {
|
||||
val maxRestored = restored.maxOfOrNull { it.generation } ?: 0L
|
||||
generationCounter.updateAndGet { maxOf(it, maxRestored) }
|
||||
}
|
||||
|
||||
private fun isFresh(
|
||||
boundary: TurnBoundary,
|
||||
now: Long,
|
||||
): Boolean = now - boundary.armedAt <= MAX_AGE_MS
|
||||
|
||||
@VisibleForTesting
|
||||
internal fun resetForTest() {
|
||||
synchronized(lock) {
|
||||
cache = emptyList()
|
||||
store = null
|
||||
generationCounter.set(0L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How long the pre-submit boundary read may take before the turn goes uncorrelatable. */
|
||||
private const val TURN_BOUNDARY_TIMEOUT_MS = 1_500L
|
||||
|
||||
/**
|
||||
* Correlation scope for the active connection profile.
|
||||
*
|
||||
* `AuthManager.activeProfileId` stays null until a server profile is explicitly
|
||||
* selected, and the rest of AuthManager treats that as
|
||||
* [AuthManager.DEFAULT_PROFILE_ID] (see `currentDataScope()`). Collapsing
|
||||
* null/blank here gives this feature ONE scope identity — a raw `.orEmpty()`
|
||||
* would reject the default profile, so a normal install would arm no boundary,
|
||||
* resolve no row, and silently never auto-dismiss a reply notification.
|
||||
*
|
||||
* The arming side (prompt submit), the resolution side (completion), the
|
||||
* notification extras, and the chat viewport observer must ALL use this, or the
|
||||
* two halves disagree on the scope and dismissal stops working.
|
||||
*/
|
||||
internal fun correlationScopeId(): String =
|
||||
AuthManager.activeProfileId.value?.takeIf { it.isNotBlank() }
|
||||
?: AuthManager.DEFAULT_PROFILE_ID
|
||||
|
||||
/** Rows scanned when correlating a completed turn (newest-first page). */
|
||||
private const val TURN_ROW_SCAN_LIMIT = 20
|
||||
|
||||
/**
|
||||
* Reads the durable REST transcript high-watermark for [sessionId] and arms a
|
||||
* boundary for it. Call immediately BEFORE submitting the prompt.
|
||||
*
|
||||
* Deliberately does not send a `profile` query param: the boundary ids are later
|
||||
* compared against rows hydrated by the chat screen, which fetches this
|
||||
* endpoint without a profile. Keeping both reads in the same namespace is what
|
||||
* makes the id comparison meaningful.
|
||||
*
|
||||
* Returns false on any failure (including a gateway that does not confirm
|
||||
* `order=latest`) — the caller sends its prompt anyway and the turn simply
|
||||
* becomes uncorrelatable.
|
||||
*/
|
||||
internal suspend fun captureTurnBoundary(
|
||||
scopeId: String,
|
||||
sessionId: String,
|
||||
timeoutMs: Long = TURN_BOUNDARY_TIMEOUT_MS,
|
||||
): Boolean {
|
||||
if (scopeId.isBlank() || sessionId.isBlank()) return false
|
||||
val maxMessageId =
|
||||
try {
|
||||
withTimeoutOrNull(timeoutMs) { fetchBoundaryTailId(sessionId) }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
// Total by contract: this probe is best-effort identity for a
|
||||
// notification convenience and must NEVER fail the caller's turn.
|
||||
// Swallowing Throwable (not just Exception) is deliberate — a
|
||||
// gateway-layer Error (uninitialised client, NoClassDefFound) used to
|
||||
// escape past the caller's `catch (Exception)` and get thrown inside
|
||||
// a background scope, which aborted the send and leaked as an
|
||||
// uncaught exception attributed to an unrelated later test.
|
||||
null
|
||||
} ?: return false
|
||||
return TurnCorrelationTracker.armBoundary(scopeId, sessionId, maxMessageId) != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest row id of the transcript, or null when the read cannot be trusted.
|
||||
*
|
||||
* `pagination` is only returned by gateways that honoured `order=latest`; a
|
||||
* backend that ignores the param would answer a `limit=1` probe with the OLDEST
|
||||
* row, producing a boundary that is too low — a lower bound that is too low is
|
||||
* exactly what lets a historical duplicate win. Absent proof, fail closed.
|
||||
*/
|
||||
private suspend fun fetchBoundaryTailId(sessionId: String): Int? {
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessionMessages(
|
||||
sessionId = sessionId,
|
||||
limit = 1,
|
||||
offset = 0,
|
||||
includeCompacted = true,
|
||||
order = "latest",
|
||||
)
|
||||
}
|
||||
val body = (result as? NetworkResult.Success)?.data ?: return null
|
||||
// The whole safety property rests on this single row being the HIGHEST id in
|
||||
// the transcript. Require the gateway to confirm it honoured order=latest
|
||||
// (same proof the chat hydration uses) — a backend that ignores the param
|
||||
// answers with the OLDEST row, and a lower bound that is too low is exactly
|
||||
// what lets a historical duplicate win.
|
||||
if (body.pagination?.order != "latest") return null
|
||||
return body.messages.mapNotNull { it.id }.maxOrNull() ?: 0
|
||||
}
|
||||
|
||||
/** Newest-first REST page used to correlate a completed turn. */
|
||||
internal suspend fun fetchTurnRows(sessionId: String): List<SessionMessage>? {
|
||||
val result =
|
||||
safeApiCall {
|
||||
ApiClient.hermesApi.getSessionMessages(
|
||||
sessionId = sessionId,
|
||||
limit = TURN_ROW_SCAN_LIMIT,
|
||||
offset = 0,
|
||||
includeCompacted = true,
|
||||
order = "latest",
|
||||
)
|
||||
}
|
||||
return (result as? NetworkResult.Success)?.data?.messages
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the exact REST row persisted by the turn that just completed, given
|
||||
* the boundary armed before that turn was submitted.
|
||||
*
|
||||
* The boundary is consumed either way in `finally`: the turn it describes is
|
||||
* over, and leaving it armed would let the next completion bind to a row above
|
||||
* a stale lower bound.
|
||||
*/
|
||||
internal suspend fun correlateCompletedTurnRow(
|
||||
scopeId: String,
|
||||
sessionId: String,
|
||||
completionText: String,
|
||||
resolver: TurnRowResolver = defaultTurnRowResolver,
|
||||
): Int? {
|
||||
val boundary = TurnCorrelationTracker.boundaryFor(scopeId, sessionId) ?: return null
|
||||
return try {
|
||||
resolver.resolve(sessionId, boundary, completionText)
|
||||
} finally {
|
||||
TurnCorrelationTracker.clearBoundary(scopeId, sessionId, boundary.generation)
|
||||
}
|
||||
}
|
||||
|
||||
/** Production resolver — reads the newest REST page for the session. */
|
||||
internal val defaultTurnRowResolver =
|
||||
TurnRowResolver(fetchLatestRows = { sessionId -> fetchTurnRows(sessionId) })
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.m57.hermescontrol.notification
|
||||
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.ui.chat.HostMediaExtractor
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Resolves the one persisted REST assistant row produced by a completed
|
||||
* mobile-originated turn.
|
||||
*
|
||||
* Design rules (all of them are load-bearing):
|
||||
* - Only rows strictly above [TurnBoundary.beforeMessageId] are candidates, so
|
||||
* historical duplicates can never win.
|
||||
* - The FULL completion text is compared, never the 100-character notification
|
||||
* preview.
|
||||
* - Exactly one candidate resolves. Zero candidates retry (the row may not be
|
||||
* persisted yet). More than one candidate is ambiguous and fails closed —
|
||||
* `firstOrNull`, `lastOrNull`, `maxBy` and "newest match" are all guessing.
|
||||
* - Never compares Android notification time with server timestamps.
|
||||
*/
|
||||
internal class TurnRowResolver(
|
||||
private val fetchLatestRows: suspend (String) -> List<SessionMessage>?,
|
||||
/** Attempt delays; the first attempt runs immediately. */
|
||||
private val retryDelaysMs: List<Long> = listOf(0L, 200L, 400L),
|
||||
) {
|
||||
suspend fun resolve(
|
||||
sessionId: String,
|
||||
boundary: TurnBoundary,
|
||||
completionText: String,
|
||||
): Int? {
|
||||
val wanted = assistantTextKey(completionText)
|
||||
if (wanted.isEmpty) return null
|
||||
if (sessionId.isBlank()) return null
|
||||
|
||||
for ((attempt, delayMs) in retryDelaysMs.withIndex()) {
|
||||
if (attempt > 0) delay(delayMs)
|
||||
val rows = fetchRows(sessionId) ?: continue
|
||||
val candidates =
|
||||
rows.mapNotNull { row ->
|
||||
val id = row.id ?: return@mapNotNull null
|
||||
if (id <= boundary.beforeMessageId) return@mapNotNull null
|
||||
if (!row.role.equals("assistant", ignoreCase = true)) return@mapNotNull null
|
||||
if (assistantTextKey(row.contentText) != wanted) return@mapNotNull null
|
||||
id
|
||||
}
|
||||
if (candidates.size == 1) return candidates.single()
|
||||
// Two rows above the boundary match this completion: the turn's own
|
||||
// row cannot be told apart from the other. Leave the notification
|
||||
// active rather than bind it to a coin flip.
|
||||
if (candidates.size > 1) return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun fetchRows(sessionId: String): List<SessionMessage>? =
|
||||
try {
|
||||
fetchLatestRows(sessionId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized comparison key for an assistant row.
|
||||
*
|
||||
* Text replies compare their trimmed full text. `MEDIA:` replies compare the
|
||||
* directive-stripped text PLUS the extracted host paths, using the same
|
||||
* canonicalization the chat mapper applies before rendering — so a MEDIA row
|
||||
* and the matching completion agree on both halves, and a MEDIA-only reply
|
||||
* (which strips to an empty string) still compares by its paths instead of
|
||||
* degenerating into "any assistant row".
|
||||
*/
|
||||
internal data class AssistantTextKey(
|
||||
val text: String,
|
||||
val media: List<String>,
|
||||
) {
|
||||
val isEmpty: Boolean
|
||||
get() = text.isBlank() && media.isEmpty()
|
||||
}
|
||||
|
||||
private const val MEDIA_DIRECTIVE = "MEDIA:"
|
||||
|
||||
internal fun assistantTextKey(raw: String): AssistantTextKey {
|
||||
val trimmed = raw.trim()
|
||||
if (!trimmed.contains(MEDIA_DIRECTIVE)) return AssistantTextKey(trimmed, emptyList())
|
||||
return AssistantTextKey(
|
||||
text = HostMediaExtractor.strip(trimmed).trim(),
|
||||
media = HostMediaExtractor.extract(trimmed).map { it.path },
|
||||
)
|
||||
}
|
||||
@@ -696,6 +696,11 @@ class GroupChatViewModel(
|
||||
storedId?.let { inFlightTurns[it] = turnDeferred }
|
||||
|
||||
try {
|
||||
// Bot-group turns are orchestrated across per-bot sessions that
|
||||
// are created and swapped mid-flight, so no clean pre-submit
|
||||
// turn boundary can be proven for them. They stay uncorrelated
|
||||
// (reply notifications from group turns are never auto-dismissed
|
||||
// from REST hydration) instead of borrowing another turn's bound.
|
||||
HermesWsClient
|
||||
.request(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
|
||||
@@ -85,6 +85,8 @@ data class ChatMessage(
|
||||
val tokenCount: Int? = null,
|
||||
/** Tokens per second generation speed (persisted to SQLite). */
|
||||
val tps: Double? = null,
|
||||
/** Stable completion identity correlating with reply notifications for read tracking. */
|
||||
val completionId: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -155,6 +155,7 @@ fun ChatScreen(
|
||||
val credentialWarning by HermesWsClient.credentialWarning.collectAsStateWithLifecycle()
|
||||
val connectorsViewModel: ChatConnectorsViewModel = viewModel()
|
||||
val connectorsState by connectorsViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val actionProgressState by viewModel.actionProgress.state.collectAsStateWithLifecycle()
|
||||
// Snapshot-backed search state — read directly so only the scopes that
|
||||
// read its fields recompose on search changes (bar, matched bubbles).
|
||||
val searchState = viewModel.searchState
|
||||
@@ -426,6 +427,20 @@ fun ChatScreen(
|
||||
context = context,
|
||||
)
|
||||
|
||||
val isChatContentReadable =
|
||||
!showReloginDialog &&
|
||||
!state.updateConfirmOpen &&
|
||||
!actionProgressState.visible &&
|
||||
!state.showModelPicker &&
|
||||
state.modelSwitchConfirmMessage == null &&
|
||||
state.sudoPrompt == null &&
|
||||
state.secretPrompt == null &&
|
||||
!(showContextSheet && state.contextBreakdown != null) &&
|
||||
!showSubagentInspectionSheet &&
|
||||
state.btwState == null &&
|
||||
viewingImage == null &&
|
||||
!connectorsState.isVisible
|
||||
|
||||
// Lifecycle effects, permissions, session switching, auto-scroll, errors
|
||||
ChatLifecycleEffects(
|
||||
sessionId = sessionId,
|
||||
@@ -442,6 +457,7 @@ fun ChatScreen(
|
||||
scrollController = scrollController,
|
||||
snackbarHostState = snackbarHostState,
|
||||
viewModel = viewModel,
|
||||
isOverlayActive = !isChatContentReadable,
|
||||
)
|
||||
|
||||
HermesScaffold(
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
||||
import com.m57.hermescontrol.notification.ReplyNotificationTracker
|
||||
|
||||
/**
|
||||
* Maps REST transcript rows ([SessionMessage]) into UI [ChatMessage]s.
|
||||
@@ -20,12 +21,93 @@ internal fun mapServerMessages(
|
||||
offset: Int,
|
||||
latestPaging: Boolean,
|
||||
liveMessages: List<ChatMessage>,
|
||||
isPagingOlder: Boolean = false,
|
||||
context: android.content.Context? = null,
|
||||
): List<ChatMessage> {
|
||||
val existingReasoningMap =
|
||||
liveMessages
|
||||
.filter { it.reasoningText.isNotBlank() }
|
||||
.associateBy { it.content }
|
||||
|
||||
val liveByExactId =
|
||||
liveMessages
|
||||
.filter { !it.completionId.isNullOrBlank() }
|
||||
.associateBy { it.id }
|
||||
|
||||
val unmappedLiveWsAssistants =
|
||||
if (isPagingOlder) {
|
||||
emptyList()
|
||||
} else {
|
||||
liveMessages
|
||||
.filter {
|
||||
it.role == MessageRole.ASSISTANT && !it.id.startsWith("rest-") &&
|
||||
!it.completionId.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
|
||||
val wsCompletionIdByRestIndex = mutableMapOf<Int, String>()
|
||||
if (!isPagingOlder) {
|
||||
if (unmappedLiveWsAssistants.isNotEmpty()) {
|
||||
val remainingWs = unmappedLiveWsAssistants.toMutableList()
|
||||
for (i in messages.indices.reversed()) {
|
||||
if (remainingWs.isEmpty()) break
|
||||
val m = messages[i]
|
||||
val r =
|
||||
when (m.role?.lowercase()) {
|
||||
"user" -> MessageRole.USER
|
||||
"system" -> MessageRole.SYSTEM
|
||||
"tool" -> MessageRole.TOOL
|
||||
else -> MessageRole.ASSISTANT
|
||||
}
|
||||
if (r != MessageRole.ASSISTANT) continue
|
||||
val rawContent = m.contentText
|
||||
// Reasoning-only or empty tool placeholder: skip, does not become visible prose
|
||||
if (rawContent.isBlank() && !rawContent.contains("MEDIA:")) continue
|
||||
|
||||
val restId =
|
||||
if (latestPaging) {
|
||||
m.id?.let { "rest-$sessionId-$it" } ?: "rest-$sessionId-${offset + i}"
|
||||
} else {
|
||||
"rest-$sessionId-${offset + i}"
|
||||
}
|
||||
if (liveByExactId.containsKey(restId)) continue
|
||||
|
||||
val canonicalContent =
|
||||
if (rawContent.contains("MEDIA:")) {
|
||||
HostMediaExtractor.strip(rawContent).trim()
|
||||
} else {
|
||||
rawContent.trim()
|
||||
}
|
||||
|
||||
val wsIdx = remainingWs.indexOfLast { it.content.trim() == canonicalContent }
|
||||
if (wsIdx >= 0) {
|
||||
remainingWs.removeAt(wsIdx).completionId?.let { compId ->
|
||||
wsCompletionIdByRestIndex[i] = compId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// REST-only recovery is fail-closed: only a durable server row ID may
|
||||
// transfer notification identity after the WebSocket copy is gone.
|
||||
val activeTarget = ReplyNotificationTracker.getActiveTarget(context)
|
||||
if (
|
||||
activeTarget != null &&
|
||||
activeTarget.sessionId == sessionId &&
|
||||
activeTarget.serverMessageId != null &&
|
||||
wsCompletionIdByRestIndex.values.none { it == activeTarget.completionId }
|
||||
) {
|
||||
val exactIndex =
|
||||
messages.indexOfFirst { message ->
|
||||
message.id == activeTarget.serverMessageId &&
|
||||
message.role.equals("assistant", ignoreCase = true)
|
||||
}
|
||||
if (exactIndex >= 0 && !liveByExactId.containsKey("rest-$sessionId-${activeTarget.serverMessageId}")) {
|
||||
wsCompletionIdByRestIndex[exactIndex] = activeTarget.completionId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool rows in the REST transcript carry NO tool name — the live WS
|
||||
// stream was the only source of `toolName`. Match each REST tool row
|
||||
// to its WS counterpart by RESULT CONTENT (not position — pagination
|
||||
@@ -171,6 +253,12 @@ internal fun mapServerMessages(
|
||||
}
|
||||
}
|
||||
|
||||
val completionId =
|
||||
if (role == MessageRole.ASSISTANT) {
|
||||
liveByExactId[restId]?.completionId ?: wsCompletionIdByRestIndex[index]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val tokenCount = msg.tokenCount ?: TokenEstimator.estimate(finalContent).takeIf { it > 0 }
|
||||
mapped.add(
|
||||
ChatMessage(
|
||||
@@ -184,6 +272,7 @@ internal fun mapServerMessages(
|
||||
isStreaming = false,
|
||||
displayKind = msg.display_kind,
|
||||
tokenCount = tokenCount,
|
||||
completionId = completionId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.data.ws.toJsonElement
|
||||
import com.m57.hermescontrol.notification.captureTurnBoundary
|
||||
import com.m57.hermescontrol.notification.correlationScopeId
|
||||
import com.m57.hermescontrol.ui.common.ActionProgressController
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -1879,6 +1881,15 @@ class ChatViewModel(
|
||||
},
|
||||
)
|
||||
} else {
|
||||
// Arm the durable turn boundary BEFORE prompt.submit leaves
|
||||
// the device: the REST high-watermark has to be read while
|
||||
// the reply row cannot exist yet. A failed capture is
|
||||
// non-fatal — the prompt is still sent, the turn just
|
||||
// becomes uncorrelatable and its reply notification is never
|
||||
// auto-dismissed from hydration instead of being bound to a
|
||||
// guessed row.
|
||||
prepareTurnCorrelation(storageSessionId)
|
||||
if (dispatchGeneration != sessionGeneration) return@launch
|
||||
wsClient.sendMessage(
|
||||
agentSessionId,
|
||||
fullText,
|
||||
@@ -1898,6 +1909,17 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arms the reply-notification turn boundary for a prompt about to be
|
||||
* submitted. Best-effort by design: sending chat never depends on REST
|
||||
* health, so a failed read only makes this turn's reply notification
|
||||
* uncorrelatable — it stays active instead of being dismissed by a guess.
|
||||
*/
|
||||
private suspend fun prepareTurnCorrelation(storageSessionId: String?) {
|
||||
if (storageSessionId.isNullOrBlank()) return
|
||||
captureTurnBoundary(scopeId = correlationScopeId(), sessionId = storageSessionId)
|
||||
}
|
||||
|
||||
private fun captureTurnUsageBaselineIfNeeded() {
|
||||
if (_streamingState.value.turnUsageBaselineCaptured) return
|
||||
_streamingState.update {
|
||||
@@ -2354,8 +2376,15 @@ class ChatViewModel(
|
||||
) {
|
||||
if (text.isBlank()) return
|
||||
val sessionId = runtimeSessionId ?: return
|
||||
val storageSessionId = _uiState.value.currentSessionId
|
||||
_uiState.update { it.copy(isAgentTyping = true) }
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
// A queued prompt runs behind a turn that is already in flight, so
|
||||
// no clean lower bound exists for it (see /queue). It stays
|
||||
// uncorrelated rather than borrowing the running turn's boundary.
|
||||
if (!queued) {
|
||||
prepareTurnCorrelation(storageSessionId)
|
||||
}
|
||||
wsClient.sendMessage(
|
||||
sessionId,
|
||||
text,
|
||||
@@ -2778,6 +2807,7 @@ class ChatViewModel(
|
||||
serverOffset,
|
||||
latestPaging,
|
||||
_uiState.value.messages,
|
||||
context = getApplication(),
|
||||
)
|
||||
loadedMessageOffset = serverOffset
|
||||
withContext(ioDispatcher) {
|
||||
@@ -3211,6 +3241,7 @@ class ChatViewModel(
|
||||
returnedOffset,
|
||||
latestPaging,
|
||||
_uiState.value.messages,
|
||||
isPagingOlder = true,
|
||||
)
|
||||
loadedMessageOffset = returnedOffset
|
||||
withContext(ioDispatcher) { repo.persistMessages(older, sessionId) }
|
||||
@@ -3295,6 +3326,7 @@ class ChatViewModel(
|
||||
nextOffset,
|
||||
latestPaging,
|
||||
_uiState.value.messages,
|
||||
isPagingOlder = true,
|
||||
)
|
||||
if (incoming.isEmpty()) return@launch
|
||||
withContext(ioDispatcher) { repo.persistMessages(incoming, sessionId) }
|
||||
|
||||
@@ -429,9 +429,38 @@ object ChatWsEventReducer {
|
||||
// above before this return because the final snapshot is still needed
|
||||
// as the next turn's cumulative baseline.
|
||||
if (text.isBlank()) {
|
||||
val effects = mutableListOf<ReducerEffect>()
|
||||
val sid = state.currentSessionId
|
||||
var updatedTargetMsg: ChatMessage? = null
|
||||
val updatedMessages =
|
||||
if (!event.completionId.isNullOrBlank()) {
|
||||
val lastAssistantIdx =
|
||||
usageState.messages
|
||||
.indexOfLast {
|
||||
it.role == MessageRole.ASSISTANT &&
|
||||
(streamingState.sealedOrphanIds.contains(it.id) || it.id == streaming?.id)
|
||||
}.takeIf { it >= 0 }
|
||||
if (lastAssistantIdx != null) {
|
||||
usageState.messages.toMutableList().also { list ->
|
||||
val updated = list[lastAssistantIdx].copy(completionId = event.completionId)
|
||||
list[lastAssistantIdx] = updated
|
||||
updatedTargetMsg = updated
|
||||
}
|
||||
} else {
|
||||
usageState.messages
|
||||
}
|
||||
} else {
|
||||
usageState.messages
|
||||
}
|
||||
if (sid != null && updatedTargetMsg != null) {
|
||||
effects.add(ReducerEffect.PersistMessage(updatedTargetMsg, sid))
|
||||
}
|
||||
effects.add(ReducerEffect.RefreshSessions)
|
||||
effects.add(ReducerEffect.RefreshContextUsage)
|
||||
return ReducerResult(
|
||||
state = usageState.copy(isAgentTyping = false),
|
||||
state = usageState.copy(messages = updatedMessages, isAgentTyping = false),
|
||||
streamingState = StreamingState(),
|
||||
effects = effects,
|
||||
)
|
||||
}
|
||||
val tps = turnUsage?.avgTps.validTpsOrNull() ?: usageState.latestTps.validTpsOrNull()
|
||||
@@ -449,6 +478,7 @@ object ChatWsEventReducer {
|
||||
finishTimestamp = System.currentTimeMillis(),
|
||||
tokenCount = tokenCount,
|
||||
tps = tps,
|
||||
completionId = event.completionId,
|
||||
) ?: ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = text,
|
||||
@@ -456,6 +486,7 @@ object ChatWsEventReducer {
|
||||
finishTimestamp = System.currentTimeMillis(),
|
||||
tokenCount = tokenCount,
|
||||
tps = tps,
|
||||
completionId = event.completionId,
|
||||
)
|
||||
val effects = mutableListOf<ReducerEffect>()
|
||||
val sid = state.currentSessionId
|
||||
|
||||
@@ -10,18 +10,24 @@ import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.notification.ReplyNotificationTracker
|
||||
import com.m57.hermescontrol.notification.correlationScopeId
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.ChatViewModel
|
||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||
import com.m57.hermescontrol.ui.chat.SecretPromptUi
|
||||
import com.m57.hermescontrol.ui.chat.SudoPromptUi
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@Composable
|
||||
fun ChatLifecycleEffects(
|
||||
@@ -39,6 +45,7 @@ fun ChatLifecycleEffects(
|
||||
scrollController: ChatScrollController,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
viewModel: ChatViewModel,
|
||||
isOverlayActive: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
@@ -87,6 +94,32 @@ fun ChatLifecycleEffects(
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val messageMap = remember(messages) { messages.associateBy { it.id } }
|
||||
|
||||
// Auto-dismiss reply notifications when their message is displayed in the viewport
|
||||
LaunchedEffect(lifecycleOwner, currentSessionId, messageMap, listState, isOverlayActive) {
|
||||
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
snapshotFlow<List<ChatMessage>> {
|
||||
if (currentSessionId.isNullOrBlank() || isOverlayActive) {
|
||||
emptyList()
|
||||
} else {
|
||||
ChatReadObserver.findVisibleAssistantMessages(listState.layoutInfo, messageMap)
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
.collect { visibleAssistantMsgs ->
|
||||
val scopeId = correlationScopeId()
|
||||
for (msg in visibleAssistantMsgs) {
|
||||
ReplyNotificationTracker.onMessageVisible(
|
||||
context = context,
|
||||
scopeId = scopeId,
|
||||
sessionId = currentSessionId,
|
||||
completionId = msg.completionId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Request POST_NOTIFICATIONS permission on Android 13+
|
||||
val requestNotificationPermission =
|
||||
rememberLauncherForActivityResult(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListLayoutInfo
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
|
||||
object ChatReadObserver {
|
||||
/**
|
||||
* Inspects the LazyColumn's visible items to determine which completed assistant
|
||||
* messages currently have any portion visible within the viewport.
|
||||
*
|
||||
* In [com.m57.hermescontrol.ui.chat.fullbleed.FullBleedChatList], assistant prose
|
||||
* bubbles are keyed with `"prose-${message.id}"`. An item is visible when its top
|
||||
* edge is before the viewport end offset AND its bottom edge (offset + size) is past
|
||||
* the viewport start offset.
|
||||
*/
|
||||
fun findVisibleAssistantMessages(
|
||||
layoutInfo: LazyListLayoutInfo,
|
||||
messageMap: Map<String, ChatMessage>,
|
||||
): List<ChatMessage> {
|
||||
val visible = layoutInfo.visibleItemsInfo
|
||||
if (visible.isEmpty() || messageMap.isEmpty()) return emptyList()
|
||||
|
||||
val start = layoutInfo.viewportStartOffset
|
||||
val end = layoutInfo.viewportEndOffset
|
||||
|
||||
return visible
|
||||
.filter { it.offset + it.size > start && it.offset < end }
|
||||
.mapNotNull { item ->
|
||||
val key = item.key as? String ?: return@mapNotNull null
|
||||
if (key.startsWith("prose-")) {
|
||||
val msgId = key.removePrefix("prose-")
|
||||
val msg = messageMap[msgId]
|
||||
if (msg != null && msg.role == MessageRole.ASSISTANT && !msg.isStreaming) {
|
||||
msg
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun findVisibleAssistantMessages(
|
||||
layoutInfo: LazyListLayoutInfo,
|
||||
messages: List<ChatMessage>,
|
||||
): List<ChatMessage> = findVisibleAssistantMessages(layoutInfo, messages.associateBy { it.id })
|
||||
}
|
||||
@@ -85,4 +85,16 @@ class HermesDatabaseMigrationsTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun migration7to8_executesCompletionIdColumnAdditionOnSQLiteConnection() =
|
||||
runBlocking {
|
||||
val connection = mockk<SQLiteConnection>(relaxed = true)
|
||||
HermesDatabase.MIGRATION_7_8.migrate(connection)
|
||||
verify(exactly = 1) {
|
||||
connection.execSQL(
|
||||
"ALTER TABLE `chat_messages` ADD COLUMN `completion_id` TEXT",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ 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
|
||||
@@ -63,6 +64,7 @@ class NotificationReplyReceiverTest {
|
||||
fun setUp() {
|
||||
upsertCallCount = 0
|
||||
ActiveSessionHolder.clear()
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
|
||||
// Mock Android framework statics (same pattern as HermesWsClientTest)
|
||||
mockkStatic(android.util.Log::class)
|
||||
@@ -128,6 +130,7 @@ class NotificationReplyReceiverTest {
|
||||
@After
|
||||
fun tearDown() {
|
||||
ActiveSessionHolder.clear()
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
HermesDatabase.setForTest(null)
|
||||
unmockkAll()
|
||||
}
|
||||
@@ -186,6 +189,24 @@ class NotificationReplyReceiverTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid reply clears active reply notification target`() {
|
||||
ReplyNotificationTracker.onReplyNotificationPosted(
|
||||
scopeId = "prof-1",
|
||||
sessionId = "session-abc",
|
||||
completionId = "comp-1",
|
||||
textSnippet = "old reply",
|
||||
generation = 1L,
|
||||
)
|
||||
assertNotNull(ReplyNotificationTracker.getActiveTarget())
|
||||
|
||||
givenValidReply("session-abc", "Hello")
|
||||
receiver.onReceive(mockContext, mockIntent)
|
||||
Thread.sleep(500)
|
||||
|
||||
assertNull(ReplyNotificationTracker.getActiveTarget())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid reply finishes pending result after Room save`() {
|
||||
givenValidReply("session-abc", "Hello")
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package com.m57.hermescontrol.notification
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ReplyNotificationTrackerTest {
|
||||
private lateinit var mockContext: Context
|
||||
private lateinit var mockNotificationManager: NotificationManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockContext = mockk(relaxed = true)
|
||||
mockNotificationManager = mockk(relaxed = true)
|
||||
every { mockContext.getSystemService(Context.NOTIFICATION_SERVICE) } returns mockNotificationManager
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onReplyNotificationPosted sets target correctly`() {
|
||||
val gen = ReplyNotificationTracker.nextGeneration()
|
||||
ReplyNotificationTracker.onReplyNotificationPosted(
|
||||
scopeId = "prof-1",
|
||||
sessionId = "sess-123",
|
||||
completionId = "comp-456",
|
||||
textSnippet = "Hello world reply",
|
||||
generation = gen,
|
||||
)
|
||||
|
||||
val target = ReplyNotificationTracker.getActiveTarget()
|
||||
assertEquals("prof-1", target?.scopeId)
|
||||
assertEquals("sess-123", target?.sessionId)
|
||||
assertEquals("comp-456", target?.completionId)
|
||||
assertEquals("Hello world reply", target?.textSnippet)
|
||||
assertEquals(gen, target?.generation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matches verifies scope, session and completionId`() {
|
||||
val target =
|
||||
ReplyNotificationTarget(
|
||||
scopeId = "prof-1",
|
||||
sessionId = "sess-123",
|
||||
completionId = "comp-456",
|
||||
generation = 1L,
|
||||
textSnippet = "Answer text",
|
||||
)
|
||||
|
||||
assertTrue(target.matches("prof-1", "sess-123", "comp-456"))
|
||||
// Scoped target rejects blank or null scope
|
||||
assertFalse(target.matches("", "sess-123", "comp-456"))
|
||||
assertFalse(target.matches(null, "sess-123", "comp-456"))
|
||||
|
||||
// Target with blank scope matches unspecified scope
|
||||
val unscopedTarget = target.copy(scopeId = "")
|
||||
assertTrue(unscopedTarget.matches("", "sess-123", "comp-456"))
|
||||
assertTrue(unscopedTarget.matches(null, "sess-123", "comp-456"))
|
||||
|
||||
// Wrong session fails
|
||||
assertFalse(target.matches("prof-1", "other-sess", "comp-456"))
|
||||
// Wrong scope fails
|
||||
assertFalse(target.matches("prof-2", "sess-123", "comp-456"))
|
||||
// Wrong completionId fails
|
||||
assertFalse(target.matches("prof-1", "sess-123", "other-comp"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matches requires exact completionId match and rejects null or blank`() {
|
||||
val target =
|
||||
ReplyNotificationTarget(
|
||||
scopeId = "prof-1",
|
||||
sessionId = "sess-123",
|
||||
completionId = "comp-456",
|
||||
generation = 1L,
|
||||
textSnippet = "Here is the full summary of the changes",
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
target.matches(
|
||||
candidateScopeId = "prof-1",
|
||||
candidateSessionId = "sess-123",
|
||||
candidateCompletionId = "comp-456",
|
||||
),
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
"Missing candidate completionId must not match target",
|
||||
target.matches(
|
||||
candidateScopeId = "prof-1",
|
||||
candidateSessionId = "sess-123",
|
||||
candidateCompletionId = null,
|
||||
),
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
"Different candidate completionId must not match target",
|
||||
target.matches(
|
||||
candidateScopeId = "prof-1",
|
||||
candidateSessionId = "sess-123",
|
||||
candidateCompletionId = "other-comp",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onNonReplyNotificationPosted clears target`() {
|
||||
ReplyNotificationTracker.onReplyNotificationPosted(
|
||||
scopeId = "prof-1",
|
||||
sessionId = "sess-123",
|
||||
completionId = "comp-456",
|
||||
textSnippet = "Reply",
|
||||
generation = 1L,
|
||||
)
|
||||
assertTrue(ReplyNotificationTracker.getActiveTarget() != null)
|
||||
|
||||
ReplyNotificationTracker.onNonReplyNotificationPosted()
|
||||
assertNull(ReplyNotificationTracker.getActiveTarget())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onMessageVisible cancels notification on match and clears target`() {
|
||||
ReplyNotificationTracker.onReplyNotificationPosted(
|
||||
scopeId = "prof-1",
|
||||
sessionId = "sess-123",
|
||||
completionId = "comp-456",
|
||||
textSnippet = "Reply text",
|
||||
generation = 1L,
|
||||
)
|
||||
|
||||
val cancelled =
|
||||
ReplyNotificationTracker.onMessageVisible(
|
||||
context = mockContext,
|
||||
scopeId = "prof-1",
|
||||
sessionId = "sess-123",
|
||||
completionId = "comp-456",
|
||||
)
|
||||
|
||||
assertTrue(cancelled)
|
||||
verify { mockNotificationManager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) }
|
||||
assertNull(ReplyNotificationTracker.getActiveTarget())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `onMessageVisible does not cancel if session does not match`() {
|
||||
ReplyNotificationTracker.onReplyNotificationPosted(
|
||||
scopeId = "prof-1",
|
||||
sessionId = "sess-123",
|
||||
completionId = "comp-456",
|
||||
textSnippet = "Reply text",
|
||||
generation = 1L,
|
||||
)
|
||||
|
||||
val cancelled =
|
||||
ReplyNotificationTracker.onMessageVisible(
|
||||
context = mockContext,
|
||||
scopeId = "prof-1",
|
||||
sessionId = "different-session",
|
||||
completionId = "comp-456",
|
||||
)
|
||||
|
||||
assertFalse(cancelled)
|
||||
verify(inverse = true) { mockNotificationManager.cancel(any()) }
|
||||
assertTrue(ReplyNotificationTracker.getActiveTarget() != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelReplyNotification refuses to cancel if newer generation was posted`() {
|
||||
ReplyNotificationTracker.onReplyNotificationPosted(
|
||||
scopeId = "prof-1",
|
||||
sessionId = "sess-123",
|
||||
completionId = "comp-newer",
|
||||
textSnippet = "Newer reply",
|
||||
generation = 5L,
|
||||
)
|
||||
|
||||
val cancelled =
|
||||
ReplyNotificationTracker.cancelReplyNotification(
|
||||
context = mockContext,
|
||||
expectedGeneration = 4L, // older generation
|
||||
)
|
||||
|
||||
assertFalse(cancelled)
|
||||
verify(inverse = true) { mockNotificationManager.cancel(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelReplyNotification refuses to cancel if active notification is not a reply`() {
|
||||
ReplyNotificationTracker.setTargetForTest(null)
|
||||
ReplyNotificationTracker.activeNotificationProvider = {
|
||||
ActiveReplyInfo(
|
||||
id = ChatNotificationService.PENDING_NOTIFICATION_ID,
|
||||
kind = ReplyNotificationTracker.KIND_ACTION,
|
||||
scopeId = "prof-1",
|
||||
sessionId = "sess-123",
|
||||
completionId = "comp-1",
|
||||
textSnippet = "Approval needed",
|
||||
generation = 1L,
|
||||
timestamp = 1000L,
|
||||
)
|
||||
}
|
||||
|
||||
val cancelled =
|
||||
ReplyNotificationTracker.cancelReplyNotification(
|
||||
context = mockContext,
|
||||
expectedGeneration = 1L,
|
||||
)
|
||||
|
||||
assertFalse(cancelled)
|
||||
verify(inverse = true) { mockNotificationManager.cancel(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cold start recovery restores target from active notification extras`() {
|
||||
ReplyNotificationTracker.setTargetForTest(null)
|
||||
ReplyNotificationTracker.activeNotificationProvider = {
|
||||
ActiveReplyInfo(
|
||||
id = ChatNotificationService.PENDING_NOTIFICATION_ID,
|
||||
kind = ReplyNotificationTracker.KIND_REPLY,
|
||||
scopeId = "prof-recovered",
|
||||
sessionId = "sess-recovered",
|
||||
completionId = "comp-recovered",
|
||||
textSnippet = "recovered snippet",
|
||||
generation = 3L,
|
||||
timestamp = 1000L,
|
||||
)
|
||||
}
|
||||
|
||||
val cancelled =
|
||||
ReplyNotificationTracker.onMessageVisible(
|
||||
context = mockContext,
|
||||
scopeId = "prof-recovered",
|
||||
sessionId = "sess-recovered",
|
||||
completionId = "comp-recovered",
|
||||
)
|
||||
|
||||
assertTrue(cancelled)
|
||||
verify { mockNotificationManager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package com.m57.hermescontrol.notification
|
||||
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
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 java.io.IOException
|
||||
|
||||
/**
|
||||
* Turn-boundary correlation contract for reply notifications.
|
||||
*
|
||||
* The invariant under test: a completion may only be bound to a REST row that
|
||||
* is provably inside its own turn — strictly above the pre-submit high-watermark
|
||||
* — and only when exactly one such row matches. Everything else fails closed,
|
||||
* because a notification that stays active is strictly better than one that is
|
||||
* dismissed by the wrong duplicate reply.
|
||||
*
|
||||
* There is deliberately no clock anywhere in this file: Android notification
|
||||
* time and Hermes server time come from different machines.
|
||||
*/
|
||||
class TurnCorrelationTrackerTest {
|
||||
private val writeLog = mutableListOf<List<TurnBoundary>>()
|
||||
private var storedBoundaries: List<TurnBoundary> = emptyList()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
writeLog.clear()
|
||||
storedBoundaries = emptyList()
|
||||
TurnCorrelationTracker.resetForTest()
|
||||
TurnCorrelationTracker.attachStore(recordingStore())
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TurnCorrelationTracker.resetForTest()
|
||||
}
|
||||
|
||||
// ── Harness ─────────────────────────────────────────────────────────────
|
||||
|
||||
private fun recordingStore(): TurnBoundaryStore =
|
||||
object : TurnBoundaryStore {
|
||||
override fun read(): List<TurnBoundary> = storedBoundaries
|
||||
|
||||
override fun write(boundaries: List<TurnBoundary>) {
|
||||
storedBoundaries = boundaries
|
||||
writeLog.add(boundaries)
|
||||
}
|
||||
}
|
||||
|
||||
private fun row(
|
||||
id: Int,
|
||||
text: String,
|
||||
role: String = "assistant",
|
||||
) = SessionMessage(id = id, role = role, content = JsonPrimitive(text))
|
||||
|
||||
private fun boundary(
|
||||
beforeMessageId: Int,
|
||||
scopeId: String = "default",
|
||||
sessionId: String = "session",
|
||||
) = TurnBoundary(
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
beforeMessageId = beforeMessageId,
|
||||
generation = 1L,
|
||||
armedAt = 1L,
|
||||
)
|
||||
|
||||
/** Resolver over a scripted sequence of REST pages; records how often it fetched. */
|
||||
private fun scriptedResolver(
|
||||
pages: List<List<SessionMessage>>,
|
||||
fetches: MutableList<Int> = mutableListOf(),
|
||||
): TurnRowResolver {
|
||||
var index = 0
|
||||
return TurnRowResolver(
|
||||
fetchLatestRows = {
|
||||
fetches.add(index)
|
||||
pages.getOrNull(index++)
|
||||
},
|
||||
retryDelaysMs = listOf(0L, 0L, 0L),
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolve(
|
||||
pages: List<List<SessionMessage>>,
|
||||
completionText: String,
|
||||
beforeMessageId: Int = 0,
|
||||
): Int? =
|
||||
runBlocking {
|
||||
scriptedResolver(pages).resolve("session", boundary(beforeMessageId), completionText)
|
||||
}
|
||||
|
||||
// ── A/B: historical duplicates can never win ─────────────────────────────
|
||||
|
||||
@Test
|
||||
fun historicalDuplicateBelowBoundaryIsExcluded() {
|
||||
val rows = listOf(row(5, "Done"), row(11, "Done"))
|
||||
assertEquals(11, resolve(listOf(rows), "Done", beforeMessageId = 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oldDuplicateVisibleBeforeOwnRowAppearsIsNeverAccepted() {
|
||||
// Attempt 1 sees only the historical duplicate; the turn's own row has
|
||||
// not been persisted yet. Resolving on the first attempt would bind the
|
||||
// notification to the wrong reply.
|
||||
val fetches = mutableListOf<Int>()
|
||||
val pages =
|
||||
listOf(
|
||||
listOf(row(5, "Done")),
|
||||
listOf(row(5, "Done"), row(11, "Done")),
|
||||
)
|
||||
val resolved =
|
||||
runBlocking {
|
||||
scriptedResolver(pages, fetches)
|
||||
.resolve("session", boundary(beforeMessageId = 10), "Done")
|
||||
}
|
||||
assertEquals(11, resolved)
|
||||
assertEquals(2, fetches.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rowPersistedOnlyOnFinalAttemptStillResolves() {
|
||||
val pages =
|
||||
listOf(
|
||||
listOf(row(5, "Done")),
|
||||
listOf(row(5, "Done")),
|
||||
listOf(row(5, "Done"), row(11, "Done")),
|
||||
)
|
||||
assertEquals(11, resolve(pages, "Done", beforeMessageId = 10))
|
||||
}
|
||||
|
||||
// ── C: ambiguity fails closed ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun twoMatchingRowsAboveBoundaryFailClosedWithoutRetrying() {
|
||||
val fetches = mutableListOf<Int>()
|
||||
val pages = listOf(listOf(row(11, "Done"), row(12, "Done")), listOf(row(11, "Done"), row(12, "Done")))
|
||||
val resolved =
|
||||
runBlocking {
|
||||
scriptedResolver(pages, fetches)
|
||||
.resolve("session", boundary(beforeMessageId = 10), "Done")
|
||||
}
|
||||
assertNull("Ambiguous turn must not be guessed", resolved)
|
||||
assertEquals("Ambiguity is terminal — no pointless retries", 1, fetches.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonMatchingContentAboveBoundaryFailsClosed() {
|
||||
assertNull(resolve(listOf(listOf(row(11, "Other"))), "Done", beforeMessageId = 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun userRowIsNotACandidate() {
|
||||
assertNull(resolve(listOf(listOf(row(11, "Done", role = "user"))), "Done", beforeMessageId = 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankCompletionTextNeverResolves() {
|
||||
assertNull(resolve(listOf(listOf(row(11, "Done"))), " ", beforeMessageId = 10))
|
||||
}
|
||||
|
||||
// ── Truncation / MEDIA ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun identicalHundredCharacterPrefixStillDisambiguates() {
|
||||
val prefix = "x".repeat(100)
|
||||
val rows =
|
||||
listOf(
|
||||
row(11, prefix + "A"),
|
||||
row(12, prefix + "B"),
|
||||
)
|
||||
// The full completion text is compared, never the 100-char preview, so
|
||||
// the shared prefix cannot make the two rows interchangeable.
|
||||
assertEquals(12, resolve(listOf(rows), prefix + "B", beforeMessageId = 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaReplyResolvesByCanonicalTextAndPath() {
|
||||
val completion = "Here is the image MEDIA:/opt/hermes/image.png"
|
||||
val rows =
|
||||
listOf(
|
||||
row(11, "Here is another image MEDIA:/opt/hermes/other.png"),
|
||||
row(12, "Here is the image MEDIA:/opt/hermes/image.png"),
|
||||
)
|
||||
assertEquals(12, resolve(listOf(rows), completion, beforeMessageId = 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaOnlyReplyResolvesByPathInsteadOfMatchingAnyAssistantRow() {
|
||||
val rows =
|
||||
listOf(
|
||||
row(11, "something else entirely"),
|
||||
row(12, "MEDIA:/opt/hermes/image.png"),
|
||||
)
|
||||
assertEquals(12, resolve(listOf(rows), "MEDIA:/opt/hermes/image.png", beforeMessageId = 10))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateMediaRowsAfterBoundaryFailClosed() {
|
||||
val rows =
|
||||
listOf(
|
||||
row(11, "MEDIA:/opt/hermes/image.png"),
|
||||
row(12, "MEDIA:/opt/hermes/image.png"),
|
||||
)
|
||||
assertNull(resolve(listOf(rows), "MEDIA:/opt/hermes/image.png", beforeMessageId = 10))
|
||||
}
|
||||
|
||||
// ── F: REST failure ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun restFailureFailsClosed() {
|
||||
val resolver =
|
||||
TurnRowResolver(
|
||||
fetchLatestRows = { throw IOException("gateway unreachable") },
|
||||
retryDelaysMs = listOf(0L, 0L, 0L),
|
||||
)
|
||||
assertNull(runBlocking { resolver.resolve("session", boundary(10), "Done") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun emptyPagesFailClosed() {
|
||||
assertNull(resolve(listOf(emptyList(), emptyList()), "Done", beforeMessageId = 10))
|
||||
}
|
||||
|
||||
// ── G/H/I: missing, cross-profile and cross-session boundaries ───────────
|
||||
|
||||
@Test
|
||||
fun noBoundaryMeansNoLookupAtAll() {
|
||||
val fetches = mutableListOf<Int>()
|
||||
val resolved =
|
||||
runBlocking {
|
||||
correlateCompletedTurnRow(
|
||||
scopeId = "default",
|
||||
sessionId = "session",
|
||||
completionText = "Done",
|
||||
resolver = scriptedResolver(listOf(listOf(row(11, "Done"))), fetches),
|
||||
)
|
||||
}
|
||||
assertNull(resolved)
|
||||
assertTrue("Uncorrelatable turns must not even hit REST", fetches.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun boundaryFromAnotherProfileIsInvisible() {
|
||||
TurnCorrelationTracker.armBoundary("other-profile", "session", 10)
|
||||
val fetches = mutableListOf<Int>()
|
||||
val resolved =
|
||||
runBlocking {
|
||||
correlateCompletedTurnRow(
|
||||
scopeId = "default",
|
||||
sessionId = "session",
|
||||
completionText = "Done",
|
||||
resolver = scriptedResolver(listOf(listOf(row(11, "Done"))), fetches),
|
||||
)
|
||||
}
|
||||
assertNull(resolved)
|
||||
assertTrue(fetches.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun boundaryFromAnotherSessionIsInvisible() {
|
||||
TurnCorrelationTracker.armBoundary("default", "other-session", 10)
|
||||
val fetches = mutableListOf<Int>()
|
||||
val resolved =
|
||||
runBlocking {
|
||||
correlateCompletedTurnRow(
|
||||
scopeId = "default",
|
||||
sessionId = "session",
|
||||
completionText = "Done",
|
||||
resolver = scriptedResolver(listOf(listOf(row(11, "Done"))), fetches),
|
||||
)
|
||||
}
|
||||
assertNull(resolved)
|
||||
assertTrue(fetches.isEmpty())
|
||||
}
|
||||
|
||||
// ── Boundary lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun correlateResolvesThenConsumesTheBoundary() {
|
||||
TurnCorrelationTracker.armBoundary("default", "session", 10)
|
||||
val pages = listOf(listOf(row(11, "Done")))
|
||||
val first =
|
||||
runBlocking {
|
||||
correlateCompletedTurnRow("default", "session", "Done", scriptedResolver(pages))
|
||||
}
|
||||
assertEquals(11, first)
|
||||
assertNull(
|
||||
"The turn is over — its boundary must be spent",
|
||||
TurnCorrelationTracker.boundaryFor("default", "session"),
|
||||
)
|
||||
|
||||
val second =
|
||||
runBlocking {
|
||||
correlateCompletedTurnRow("default", "session", "Done", scriptedResolver(pages))
|
||||
}
|
||||
assertNull("A second completion must not reuse the consumed boundary", second)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun armingPersistsAndSurvivesProcessRestart() {
|
||||
TurnCorrelationTracker.armBoundary("default", "session", 41)
|
||||
assertEquals(41, storedBoundaries.single().beforeMessageId)
|
||||
|
||||
// Simulate a fresh process: same store content, empty in-memory cache.
|
||||
TurnCorrelationTracker.resetForTest()
|
||||
TurnCorrelationTracker.attachStore(recordingStore())
|
||||
assertEquals(41, TurnCorrelationTracker.boundaryFor("default", "session")?.beforeMessageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The generation counter is in-memory only, so a restart must lift it above
|
||||
* whatever the restored boundaries already used. Otherwise an old
|
||||
* completion's clear (generation N) matches the NEW boundary (also N) and the
|
||||
* stale-clear guard stops protecting the newer turn.
|
||||
*/
|
||||
@Test
|
||||
fun restoredBoundaryGenerationAdvancesTheCounterPastIt() {
|
||||
storedBoundaries =
|
||||
listOf(
|
||||
TurnBoundary(
|
||||
scopeId = "default",
|
||||
sessionId = "session",
|
||||
beforeMessageId = 10,
|
||||
generation = 37L,
|
||||
armedAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
TurnCorrelationTracker.resetForTest()
|
||||
TurnCorrelationTracker.attachStore(recordingStore())
|
||||
assertEquals(37L, TurnCorrelationTracker.boundaryFor("default", "session")?.generation)
|
||||
|
||||
val next = TurnCorrelationTracker.armBoundary("default", "session", 25)
|
||||
assertNotNull(next)
|
||||
assertTrue(
|
||||
"A post-restart boundary must not reuse a recovered generation (got ${next?.generation})",
|
||||
(next?.generation ?: 0L) > 37L,
|
||||
)
|
||||
|
||||
// The old completion, still holding generation 37, must not clear it.
|
||||
assertFalse(TurnCorrelationTracker.clearBoundary("default", "session", 37L))
|
||||
assertEquals(25, TurnCorrelationTracker.boundaryFor("default", "session")?.beforeMessageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reArmingForTheSameSessionReplacesTheBoundary() {
|
||||
TurnCorrelationTracker.armBoundary("default", "session", 10)
|
||||
TurnCorrelationTracker.armBoundary("default", "session", 25)
|
||||
assertEquals(25, TurnCorrelationTracker.boundaryFor("default", "session")?.beforeMessageId)
|
||||
assertEquals(1, storedBoundaries.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleGenerationCannotClearANewerBoundary() {
|
||||
val stale = TurnCorrelationTracker.armBoundary("default", "session", 10)
|
||||
TurnCorrelationTracker.armBoundary("default", "session", 25)
|
||||
assertNotNull(stale)
|
||||
assertFalse(TurnCorrelationTracker.clearBoundary("default", "session", stale?.generation))
|
||||
assertEquals(25, TurnCorrelationTracker.boundaryFor("default", "session")?.beforeMessageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankIdentityOrNegativeBoundaryNeverArms() {
|
||||
assertNull(TurnCorrelationTracker.armBoundary("", "session", 10))
|
||||
assertNull(TurnCorrelationTracker.armBoundary("default", " ", 10))
|
||||
assertNull(TurnCorrelationTracker.armBoundary("default", "session", -1))
|
||||
assertTrue(writeLog.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zeroBoundaryIsValidForAnEmptyTranscript() {
|
||||
// A brand-new session has no rows yet: the next turn's rows are all
|
||||
// above 0, and there are no historical duplicates to exclude.
|
||||
assertNotNull(TurnCorrelationTracker.armBoundary("default", "session", 0))
|
||||
assertEquals(0, TurnCorrelationTracker.boundaryFor("default", "session")?.beforeMessageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The probe is best-effort by contract and must never fail the caller's turn.
|
||||
*
|
||||
* A gateway-layer `Error` (uninitialised client, NoClassDefFound) is not an
|
||||
* `Exception`, so it used to escape the caller's `catch (Exception)`, abort
|
||||
* the notification-reply send, and leak out of a background scope — which CI
|
||||
* then reported as `UncaughtExceptionsBeforeTest` in an unrelated later test
|
||||
* class. Encode that as a regression so it cannot come back.
|
||||
*/
|
||||
@Test
|
||||
fun boundaryProbeSwallowsEvenGatewayLayerErrors() {
|
||||
mockkObject(ApiClient)
|
||||
try {
|
||||
every { ApiClient.hermesApi } throws ExceptionInInitializerError("ApiClient unavailable")
|
||||
assertFalse(
|
||||
"A broken gateway layer must not fail the turn",
|
||||
runBlocking { captureTurnBoundary("default", "session") },
|
||||
)
|
||||
assertNull(TurnCorrelationTracker.boundaryFor("default", "session"))
|
||||
} finally {
|
||||
unmockkObject(ApiClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListLayoutInfo
|
||||
import com.m57.hermescontrol.notification.ChatNotificationService
|
||||
import com.m57.hermescontrol.notification.ReplyNotificationTracker
|
||||
import com.m57.hermescontrol.ui.chat.components.ChatReadObserver
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ChatReadNotificationIntegrationTest {
|
||||
private lateinit var mockContext: Context
|
||||
private lateinit var mockNotificationManager: NotificationManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockContext = mockk(relaxed = true)
|
||||
mockNotificationManager = mockk(relaxed = true)
|
||||
every { mockContext.getSystemService(Context.NOTIFICATION_SERVICE) } returns mockNotificationManager
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
}
|
||||
|
||||
private fun createMockItem(
|
||||
key: Any,
|
||||
offset: Int,
|
||||
size: Int,
|
||||
): LazyListItemInfo {
|
||||
val item = mockk<LazyListItemInfo>()
|
||||
every { item.key } returns key
|
||||
every { item.offset } returns offset
|
||||
every { item.size } returns size
|
||||
return item
|
||||
}
|
||||
|
||||
private fun createMockLayoutInfo(
|
||||
items: List<LazyListItemInfo>,
|
||||
startOffset: Int = 0,
|
||||
endOffset: Int = 1000,
|
||||
): LazyListLayoutInfo {
|
||||
val layout = mockk<LazyListLayoutInfo>()
|
||||
every { layout.visibleItemsInfo } returns items
|
||||
every { layout.viewportStartOffset } returns startOffset
|
||||
every { layout.viewportEndOffset } returns endOffset
|
||||
return layout
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `end-to-end flow - visible target message cancels notification`() {
|
||||
val scopeId = "profile-main"
|
||||
val sessionId = "session-42"
|
||||
val completionId = "completion-abc"
|
||||
val textSnippet = "Here is the result of your task"
|
||||
|
||||
val generation = ReplyNotificationTracker.nextGeneration()
|
||||
ReplyNotificationTracker.onReplyNotificationPosted(
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
completionId = completionId,
|
||||
textSnippet = textSnippet,
|
||||
generation = generation,
|
||||
)
|
||||
|
||||
val targetMessage =
|
||||
ChatMessage(
|
||||
id = "msg-target",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = textSnippet,
|
||||
completionId = completionId,
|
||||
)
|
||||
|
||||
val layout =
|
||||
createMockLayoutInfo(
|
||||
listOf(
|
||||
createMockItem("prose-msg-target", offset = 200, size = 150),
|
||||
),
|
||||
)
|
||||
|
||||
val visibleMessages = ChatReadObserver.findVisibleAssistantMessages(layout, listOf(targetMessage))
|
||||
var wasCancelled = false
|
||||
for (msg in visibleMessages) {
|
||||
if (ReplyNotificationTracker.onMessageVisible(
|
||||
context = mockContext,
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
completionId = msg.completionId,
|
||||
)
|
||||
) {
|
||||
wasCancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(wasCancelled)
|
||||
verify { mockNotificationManager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scrolled above target - notification stays until scrolled to target`() {
|
||||
val scopeId = "profile-main"
|
||||
val sessionId = "session-42"
|
||||
val completionId = "completion-abc"
|
||||
val textSnippet = "Newest assistant reply"
|
||||
|
||||
val generation = ReplyNotificationTracker.nextGeneration()
|
||||
ReplyNotificationTracker.onReplyNotificationPosted(
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
completionId = completionId,
|
||||
textSnippet = textSnippet,
|
||||
generation = generation,
|
||||
)
|
||||
|
||||
val olderMessage =
|
||||
ChatMessage(
|
||||
id = "msg-older",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Old assistant message",
|
||||
completionId = "comp-old",
|
||||
)
|
||||
val targetMessage =
|
||||
ChatMessage(
|
||||
id = "msg-target",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = textSnippet,
|
||||
completionId = completionId,
|
||||
)
|
||||
val allMessages = listOf(olderMessage, targetMessage)
|
||||
|
||||
// Viewport 0..1000: user is at the top, only older message is visible; target is at offset 1500 (below viewport)
|
||||
val scrolledUpLayout =
|
||||
createMockLayoutInfo(
|
||||
listOf(
|
||||
createMockItem("prose-msg-older", offset = 100, size = 200),
|
||||
createMockItem("prose-msg-target", offset = 1500, size = 300),
|
||||
),
|
||||
startOffset = 0,
|
||||
endOffset = 1000,
|
||||
)
|
||||
|
||||
val visibleWhenScrolledUp = ChatReadObserver.findVisibleAssistantMessages(scrolledUpLayout, allMessages)
|
||||
var wasCancelledWhenScrolledUp = false
|
||||
for (msg in visibleWhenScrolledUp) {
|
||||
if (ReplyNotificationTracker.onMessageVisible(
|
||||
context = mockContext,
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
completionId = msg.completionId,
|
||||
)
|
||||
) {
|
||||
wasCancelledWhenScrolledUp = true
|
||||
}
|
||||
}
|
||||
|
||||
assertFalse(wasCancelledWhenScrolledUp)
|
||||
verify(inverse = true) { mockNotificationManager.cancel(any()) }
|
||||
|
||||
// Now user scrolls down: target message enters viewport (offset 400, size 300)
|
||||
val scrolledDownLayout =
|
||||
createMockLayoutInfo(
|
||||
listOf(
|
||||
createMockItem("prose-msg-target", offset = 400, size = 300),
|
||||
),
|
||||
startOffset = 0,
|
||||
endOffset = 1000,
|
||||
)
|
||||
|
||||
val visibleWhenScrolledDown = ChatReadObserver.findVisibleAssistantMessages(scrolledDownLayout, allMessages)
|
||||
var wasCancelledWhenScrolledDown = false
|
||||
for (msg in visibleWhenScrolledDown) {
|
||||
if (ReplyNotificationTracker.onMessageVisible(
|
||||
context = mockContext,
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
completionId = msg.completionId,
|
||||
)
|
||||
) {
|
||||
wasCancelledWhenScrolledDown = true
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(wasCancelledWhenScrolledDown)
|
||||
verify { mockNotificationManager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `viewing a different session does not dismiss notification`() {
|
||||
val scopeId = "profile-main"
|
||||
val sessionIdWithNotif = "session-notif"
|
||||
val completionId = "completion-abc"
|
||||
val textSnippet = "Important reply"
|
||||
|
||||
ReplyNotificationTracker.onReplyNotificationPosted(
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionIdWithNotif,
|
||||
completionId = completionId,
|
||||
textSnippet = textSnippet,
|
||||
generation = 1L,
|
||||
)
|
||||
|
||||
val messageInOtherSession =
|
||||
ChatMessage(
|
||||
id = "msg-other",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Some other reply",
|
||||
completionId = "other-comp",
|
||||
)
|
||||
|
||||
val layout =
|
||||
createMockLayoutInfo(
|
||||
listOf(
|
||||
createMockItem("prose-msg-other", offset = 200, size = 150),
|
||||
),
|
||||
)
|
||||
|
||||
val visibleMessages = ChatReadObserver.findVisibleAssistantMessages(layout, listOf(messageInOtherSession))
|
||||
var wasCancelled = false
|
||||
for (msg in visibleMessages) {
|
||||
if (ReplyNotificationTracker.onMessageVisible(
|
||||
context = mockContext,
|
||||
scopeId = scopeId,
|
||||
sessionId = "different-session-id",
|
||||
completionId = msg.completionId,
|
||||
)
|
||||
) {
|
||||
wasCancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
assertFalse(wasCancelled)
|
||||
verify(inverse = true) { mockNotificationManager.cancel(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `action notification replacement prevents dismissal by message read`() {
|
||||
val scopeId = "profile-main"
|
||||
val sessionId = "session-42"
|
||||
val completionId = "completion-abc"
|
||||
val textSnippet = "Reply before action"
|
||||
|
||||
// 1. Reply notification posted
|
||||
ReplyNotificationTracker.onReplyNotificationPosted(
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
completionId = completionId,
|
||||
textSnippet = textSnippet,
|
||||
generation = 1L,
|
||||
)
|
||||
|
||||
// 2. Later, an approval request arrives and replaces notification ID 2
|
||||
ReplyNotificationTracker.onNonReplyNotificationPosted()
|
||||
|
||||
// 3. User opens chat and views the previous reply message
|
||||
val targetMessage =
|
||||
ChatMessage(
|
||||
id = "msg-target",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = textSnippet,
|
||||
completionId = completionId,
|
||||
)
|
||||
val layout =
|
||||
createMockLayoutInfo(
|
||||
listOf(
|
||||
createMockItem("prose-msg-target", offset = 200, size = 150),
|
||||
),
|
||||
)
|
||||
|
||||
val visibleMessages = ChatReadObserver.findVisibleAssistantMessages(layout, listOf(targetMessage))
|
||||
var wasCancelled = false
|
||||
for (msg in visibleMessages) {
|
||||
if (ReplyNotificationTracker.onMessageVisible(
|
||||
context = mockContext,
|
||||
scopeId = scopeId,
|
||||
sessionId = sessionId,
|
||||
completionId = msg.completionId,
|
||||
)
|
||||
) {
|
||||
wasCancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
assertFalse("Action notification must NOT be dismissed by reading chat message", wasCancelled)
|
||||
verify(inverse = true) { mockNotificationManager.cancel(any()) }
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.model.PaginationInfo
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.GatewayFile
|
||||
import com.m57.hermescontrol.data.remote.GatewayFileClient
|
||||
@@ -21,6 +24,7 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.JsonRpcError
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.notification.TurnCorrelationTracker
|
||||
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
|
||||
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
|
||||
import io.mockk.*
|
||||
@@ -52,6 +56,7 @@ import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -5590,4 +5595,234 @@ class ChatViewModelTest {
|
||||
|
||||
io.mockk.verify { AuthManager.setLastOpenedSessionId("session-other-456") }
|
||||
}
|
||||
|
||||
// ── Turn-boundary capture ordering (reply-notification correlation) ───────
|
||||
|
||||
/**
|
||||
* Turn boundaries are keyed by the active profile, so these tests need one.
|
||||
* Scoped per test rather than added to setUp: a non-blank profile is also
|
||||
* appended to `session.resume` params, which the older resume/switch tests
|
||||
* assert exactly.
|
||||
*/
|
||||
private fun stubActiveProfile() {
|
||||
every { AuthManager.activeProfileId } returns MutableStateFlow<String?>("default")
|
||||
}
|
||||
|
||||
/**
|
||||
* The REST high-watermark must be read BEFORE prompt.submit leaves the
|
||||
* device. Capturing it afterwards lets a fast turn persist its assistant row
|
||||
* first, and a lower bound that already contains the reply can never exclude
|
||||
* a historical duplicate.
|
||||
*/
|
||||
@Test
|
||||
fun sendMessage_readsTurnBoundaryBeforeSubmittingPrompt() =
|
||||
runTest {
|
||||
TurnCorrelationTracker.resetForTest()
|
||||
stubActiveProfile()
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
val events = mutableListOf<String>()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
coEvery { api.getSessionMessages(any(), any(), any(), any(), any(), any()) } answers {
|
||||
// Only the boundary probe asks for a single newest row.
|
||||
if (arg<Int?>(1) == 1 && arg<String?>(3) == "latest") events += "boundary"
|
||||
retrofit2.Response.success(
|
||||
SessionMessagesResponse(
|
||||
messages = listOf(SessionMessage(id = 41, role = "user", content = JsonPrimitive("hi"))),
|
||||
pagination = PaginationInfo(limit = 1, offset = 0, order = "latest", returned = 1),
|
||||
),
|
||||
)
|
||||
}
|
||||
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
|
||||
events += "submit"
|
||||
reqCount++
|
||||
val id = "req-msg-$reqCount"
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
viewModel.sendMessage("hello there")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf("boundary", "submit"), events)
|
||||
assertEquals(41, TurnCorrelationTracker.boundaryFor("default", sessionId)?.beforeMessageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendMessage_stillSubmitsWhenBoundaryReadFails() =
|
||||
runTest {
|
||||
TurnCorrelationTracker.resetForTest()
|
||||
stubActiveProfile()
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
coEvery { api.getSessionMessages(any(), any(), any(), any(), any(), any()) } returns
|
||||
retrofit2.Response.error(500, "boom".toResponseBody())
|
||||
var submits = 0
|
||||
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
|
||||
submits++
|
||||
reqCount++
|
||||
val id = "req-msg-$reqCount"
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
viewModel.sendMessage("hello there")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals("REST health must never block sending chat", 1, submits)
|
||||
assertNull(TurnCorrelationTracker.boundaryFor("default", sessionId))
|
||||
}
|
||||
|
||||
/**
|
||||
* A gateway that ignores `order=latest` answers a `limit=1` probe with the
|
||||
* OLDEST row; trusting that number would arm a boundary that is far too low
|
||||
* and let historical duplicates win. Without the pagination proof the turn is
|
||||
* left uncorrelatable, and the prompt still goes out.
|
||||
*/
|
||||
@Test
|
||||
fun sendMessage_leavesTurnUncorrelatableWhenGatewayCannotConfirmOrder() =
|
||||
runTest {
|
||||
TurnCorrelationTracker.resetForTest()
|
||||
stubActiveProfile()
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
coEvery { api.getSessionMessages(any(), any(), any(), any(), any(), any()) } answers {
|
||||
retrofit2.Response.success(
|
||||
SessionMessagesResponse(
|
||||
messages = listOf(SessionMessage(id = 1, role = "user", content = JsonPrimitive("oldest"))),
|
||||
),
|
||||
)
|
||||
}
|
||||
var submits = 0
|
||||
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
|
||||
submits++
|
||||
reqCount++
|
||||
val id = "req-msg-$reqCount"
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
viewModel.sendMessage("hello there")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, submits)
|
||||
assertNull(
|
||||
"Unconfirmed row order must not produce a boundary",
|
||||
TurnCorrelationTracker.boundaryFor("default", sessionId),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `AuthManager.activeProfileId` is null until a server profile is explicitly
|
||||
* selected, and the rest of AuthManager treats that as
|
||||
* [AuthManager.DEFAULT_PROFILE_ID]. A raw `.orEmpty()` made the whole feature
|
||||
* a no-op on a normal install: no boundary armed, so no reply notification
|
||||
* could ever be REST-auto-dismissed.
|
||||
*/
|
||||
@Test
|
||||
fun sendMessage_nullActiveProfileArmsBoundaryUnderTheDefaultScope() =
|
||||
runTest {
|
||||
TurnCorrelationTracker.resetForTest()
|
||||
every { AuthManager.activeProfileId } returns MutableStateFlow<String?>(null)
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
coEvery { api.getSessionMessages(any(), any(), any(), any(), any(), any()) } answers {
|
||||
retrofit2.Response.success(
|
||||
SessionMessagesResponse(
|
||||
messages = listOf(SessionMessage(id = 41, role = "user", content = JsonPrimitive("hi"))),
|
||||
pagination = PaginationInfo(limit = 1, offset = 0, order = "latest", returned = 1),
|
||||
),
|
||||
)
|
||||
}
|
||||
var submits = 0
|
||||
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
|
||||
submits++
|
||||
reqCount++
|
||||
val id = "req-msg-$reqCount"
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
viewModel.sendMessage("hello there")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, submits)
|
||||
assertEquals(
|
||||
"A default-profile install must still be correlatable",
|
||||
41,
|
||||
TurnCorrelationTracker.boundaryFor(AuthManager.DEFAULT_PROFILE_ID, sessionId)?.beforeMessageId,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* `pagination.order` is the only proof the gateway honoured `order=latest`.
|
||||
* A gateway that reports `oldest` answered the `limit=1` probe with the
|
||||
* OLDEST row, and that as a lower bound would sit below the whole transcript
|
||||
* — exactly what lets a historical duplicate win.
|
||||
*/
|
||||
@Test
|
||||
fun sendMessage_leavesTurnUncorrelatableWhenGatewayReportsOldestOrder() =
|
||||
runTest {
|
||||
TurnCorrelationTracker.resetForTest()
|
||||
stubActiveProfile()
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
coEvery { api.getSessionMessages(any(), any(), any(), any(), any(), any()) } answers {
|
||||
retrofit2.Response.success(
|
||||
SessionMessagesResponse(
|
||||
messages = listOf(SessionMessage(id = 1, role = "user", content = JsonPrimitive("oldest"))),
|
||||
pagination = PaginationInfo(limit = 1, offset = 0, order = "oldest", returned = 1),
|
||||
),
|
||||
)
|
||||
}
|
||||
var submits = 0
|
||||
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
|
||||
submits++
|
||||
reqCount++
|
||||
val id = "req-msg-$reqCount"
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
viewModel.sendMessage("hello there")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, submits)
|
||||
assertNull(
|
||||
"An unproven row order must not produce a boundary",
|
||||
TurnCorrelationTracker.boundaryFor("default", sessionId),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A queued prompt runs behind a turn that is already in flight, so no clean
|
||||
* lower bound exists for it — it must not borrow the running turn's boundary.
|
||||
*/
|
||||
@Test
|
||||
fun queuedPromptNeverArmsATurnBoundary() =
|
||||
runTest {
|
||||
TurnCorrelationTracker.resetForTest()
|
||||
stubActiveProfile()
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
val api = mockk<com.m57.hermescontrol.data.remote.HermesApiService>(relaxed = true)
|
||||
every { ApiClient.hermesApi } returns api
|
||||
every { HermesWsClient.sendMessage(any(), any(), any(), any()) } answers {
|
||||
reqCount++
|
||||
val id = "req-msg-$reqCount"
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
id
|
||||
}
|
||||
|
||||
viewModel.sendMessage("/queue do the thing")
|
||||
advanceUntilIdle()
|
||||
|
||||
io.mockk.coVerify(exactly = 0) {
|
||||
api.getSessionMessages(any(), any(), any(), any(), any(), any())
|
||||
}
|
||||
assertNull(TurnCorrelationTracker.boundaryFor("default", sessionId))
|
||||
}
|
||||
}
|
||||
@@ -450,6 +450,25 @@ class ChatWsEventReducerTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMessageComplete_setsCompletionIdOnAssistantMessage() {
|
||||
val event =
|
||||
WsEvent.MessageComplete(
|
||||
text = "hello from assistant",
|
||||
sessionId = "session-1",
|
||||
completionId = "comp-123",
|
||||
)
|
||||
val result =
|
||||
ChatWsEventReducer.reduce(
|
||||
state = ChatUiState(),
|
||||
streamingState = StreamingState(),
|
||||
event = event,
|
||||
currentSessionId = "session-1",
|
||||
)
|
||||
val msg = result.state.messages.single()
|
||||
assertEquals("comp-123", msg.completionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMessageStart_prunesCompletedSubagents() {
|
||||
val completedSubagent =
|
||||
|
||||
+807
@@ -0,0 +1,807 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import com.m57.hermescontrol.data.config.ServerStore
|
||||
import com.m57.hermescontrol.data.config.ServerStoreState
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.toEntity
|
||||
import com.m57.hermescontrol.data.local.toUiModel
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.notification.ActiveReplyInfo
|
||||
import com.m57.hermescontrol.notification.ChatNotificationService
|
||||
import com.m57.hermescontrol.notification.ReplyNotificationTarget
|
||||
import com.m57.hermescontrol.notification.ReplyNotificationTracker
|
||||
import com.m57.hermescontrol.notification.TurnCorrelationTracker
|
||||
import com.m57.hermescontrol.notification.TurnRowResolver
|
||||
import com.m57.hermescontrol.notification.correlateCompletedTurnRow
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkObject
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* Regression matrix for reply-notification read dismissal.
|
||||
*
|
||||
* Split by design:
|
||||
* - The `alreadyResolvedServerId*` cases start from a durable server row id that
|
||||
* the notification already carries, and prove hydration binds it to that exact
|
||||
* row only — the downstream half of the contract.
|
||||
* - [mobileTurnBoundaryResolvesTheExactRowThenOnlyThatRowDismisses] covers the
|
||||
* production path that PRODUCES that id (turn boundary → unique row).
|
||||
* - The mobile-side resolution rules themselves live in
|
||||
* `TurnCorrelationTrackerTest`, and the send-order guarantee in
|
||||
* `ChatViewModelTest`.
|
||||
*
|
||||
* Nothing here compares Android notification time with Hermes server time;
|
||||
* server row timestamps are not identity.
|
||||
*/
|
||||
class ReadNotificationReviewRegressionTest {
|
||||
private lateinit var context: Context
|
||||
private lateinit var manager: NotificationManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = mockk(relaxed = true)
|
||||
manager = mockk(relaxed = true)
|
||||
every { context.getSystemService(Context.NOTIFICATION_SERVICE) } returns manager
|
||||
mockkObject(AuthManager)
|
||||
val mockStore = mockk<ServerStore>(relaxed = true)
|
||||
every { mockStore.getLatestState() } returns ServerStoreState(baseUrl = "http://localhost:8080")
|
||||
every { AuthManager.serverStore } returns mockStore
|
||||
every { AuthManager.getToken() } returns "token"
|
||||
every { AuthManager.activeProfileId } returns MutableStateFlow("default")
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
unmockkObject(AuthManager)
|
||||
}
|
||||
|
||||
private fun postTarget(text: String = "Done") {
|
||||
ReplyNotificationTracker.onReplyNotificationPosted("default", "session", "new-completion", text, 1L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roomRoundTripMustRetainCompletionIdentity() {
|
||||
val live = ChatMessage(role = MessageRole.ASSISTANT, content = "Done", completionId = "new-completion")
|
||||
val restored = live.toEntity("session").toUiModel()
|
||||
assertEquals("Room reload lost notification identity", "new-completion", restored.completionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun repeatedTextInHistoryMustNotCopyLatestIdentityOntoOlderMessage() {
|
||||
val live =
|
||||
listOf(
|
||||
ChatMessage(role = MessageRole.ASSISTANT, content = "Done", completionId = "old-completion"),
|
||||
ChatMessage(role = MessageRole.ASSISTANT, content = "Done", completionId = "new-completion"),
|
||||
)
|
||||
val history =
|
||||
listOf(
|
||||
SessionMessage(id = 100, role = "assistant", content = JsonPrimitive("Done")),
|
||||
SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done")),
|
||||
)
|
||||
val mapped = mapServerMessages("session", history, 0, true, live)
|
||||
assertEquals(listOf("old-completion", "new-completion"), mapped.map { it.completionId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun viewingOldCachedTextMustNotClearNewReplyNotification() {
|
||||
postTarget("Done")
|
||||
val old =
|
||||
ChatMessage(role = MessageRole.ASSISTANT, content = "Done yesterday; unrelated old response")
|
||||
.toEntity("session")
|
||||
.toUiModel()
|
||||
val cancelled =
|
||||
ReplyNotificationTracker.onMessageVisible(
|
||||
context,
|
||||
"default",
|
||||
"session",
|
||||
old.completionId,
|
||||
)
|
||||
assertFalse("Old text incorrectly acknowledged the new reply", cancelled)
|
||||
verify(exactly = 0) { manager.cancel(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun strippedCommentaryCompletionMustBindTerminalVisibleMessage() {
|
||||
val orphan = ChatMessage(id = "orphan", role = MessageRole.ASSISTANT, content = "already shown")
|
||||
val state = ChatUiState(currentSessionId = "session", messages = listOf(orphan))
|
||||
val streaming = StreamingState(streamingMessage = orphan, sealedOrphanIds = listOf("orphan"))
|
||||
val result =
|
||||
ChatWsEventReducer.reduce(
|
||||
state,
|
||||
streaming,
|
||||
WsEvent.MessageComplete("already shown", "session", completionId = "new-completion"),
|
||||
"session",
|
||||
)
|
||||
assertEquals(
|
||||
"Completion lost when no new final bubble is needed",
|
||||
"new-completion",
|
||||
result.state.messages
|
||||
.single()
|
||||
.completionId,
|
||||
)
|
||||
val persistEffect =
|
||||
result.effects.filterIsInstance<ReducerEffect.PersistMessage>().singleOrNull()
|
||||
assertEquals(
|
||||
"PersistMessage effect must be emitted for the sealed orphan bubble so Room persists it",
|
||||
"new-completion",
|
||||
persistEffect?.message?.completionId,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadOlderMessagesMustNotStealCompletionIdFromLiveMessage() {
|
||||
val live =
|
||||
listOf(
|
||||
ChatMessage(
|
||||
id = "ws-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Done",
|
||||
completionId = "new-completion",
|
||||
),
|
||||
)
|
||||
val olderHistory =
|
||||
listOf(
|
||||
SessionMessage(id = 50, role = "assistant", content = JsonPrimitive("Done")),
|
||||
)
|
||||
val mapped = mapServerMessages("session", olderHistory, 50, true, live, isPagingOlder = true)
|
||||
assertEquals("Older paged message must not steal live completionId", null, mapped.single().completionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateTextOutsideFetchedPageMustNotShiftCompletionId() {
|
||||
val live =
|
||||
listOf(
|
||||
ChatMessage(
|
||||
id = "rest-session-10",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Done",
|
||||
completionId = null,
|
||||
),
|
||||
ChatMessage(
|
||||
id = "rest-session-20",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Done",
|
||||
completionId = "new-completion",
|
||||
),
|
||||
)
|
||||
// Page only includes message 20 (message 10 is outside this page)
|
||||
val history =
|
||||
listOf(
|
||||
SessionMessage(id = 20, role = "assistant", content = JsonPrimitive("Done")),
|
||||
)
|
||||
val mapped = mapServerMessages("session", history, 0, true, live, isPagingOlder = false)
|
||||
assertEquals(
|
||||
"Exact REST id match must preserve identity regardless of older duplicates",
|
||||
"new-completion",
|
||||
mapped.single().completionId,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun metadataFreeReplacementMustNotBeCancelled() {
|
||||
postTarget()
|
||||
ReplyNotificationTracker.activeNotificationProvider = {
|
||||
ActiveReplyInfo(2, null, null, null, null, null, 0L, 1L)
|
||||
}
|
||||
assertFalse(ReplyNotificationTracker.onMessageVisible(context, "default", "session", "new-completion"))
|
||||
verify(exactly = 0) { manager.cancel(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun actionPostingMustNotInterleaveBetweenInspectionAndCancellation() {
|
||||
val reply =
|
||||
ActiveReplyInfo(
|
||||
2,
|
||||
ReplyNotificationTracker.KIND_REPLY,
|
||||
"default",
|
||||
"session",
|
||||
"new-completion",
|
||||
"Done",
|
||||
1L,
|
||||
1L,
|
||||
)
|
||||
val actualSlot = AtomicReference("reply")
|
||||
val ready = CountDownLatch(1)
|
||||
val allowNotify = CountDownLatch(1)
|
||||
val posted = CountDownLatch(1)
|
||||
val action = mockk<Notification>()
|
||||
every { manager.notify(2, action) } answers { actualSlot.set("action") }
|
||||
every { manager.cancel(2) } answers { actualSlot.set("empty") }
|
||||
postTarget()
|
||||
val writer =
|
||||
Thread {
|
||||
// Same split ownership/write sequence as showReplyNotification and NotificationReplyReceiver.
|
||||
ReplyNotificationTracker.onNonReplyNotificationPosted()
|
||||
ready.countDown()
|
||||
check(allowNotify.await(5, TimeUnit.SECONDS))
|
||||
manager.notify(2, action)
|
||||
posted.countDown()
|
||||
}
|
||||
writer.start()
|
||||
assertTrue(ready.await(5, TimeUnit.SECONDS))
|
||||
var reads = 0
|
||||
ReplyNotificationTracker.activeNotificationProvider = {
|
||||
val snapshot = reply
|
||||
reads++
|
||||
if (reads == 2) {
|
||||
// Pause after querying activeNotifications but before cancel(), then complete the action write.
|
||||
allowNotify.countDown()
|
||||
check(posted.await(5, TimeUnit.SECONDS))
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
try {
|
||||
ReplyNotificationTracker.onMessageVisible(context, "default", "session", "new-completion")
|
||||
} finally {
|
||||
allowNotify.countDown()
|
||||
writer.join(6000)
|
||||
}
|
||||
assertEquals("New action notification was cancelled by the old read", "action", actualSlot.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oldReplyRacingNewerActionMustNotPostOrOverwriteAction() {
|
||||
val gen1 = ReplyNotificationTracker.registerPendingReply("default", "session", "comp-1", "Old Reply")
|
||||
val actionNotif = mockk<Notification>()
|
||||
val replyNotif = mockk<Notification>()
|
||||
|
||||
// Action arrives and posts
|
||||
assertTrue(ReplyNotificationTracker.postActionNotification(context, actionNotif))
|
||||
verify(exactly = 1) { manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, actionNotif) }
|
||||
|
||||
// Stale reply 1 tries to post after action arrived
|
||||
val posted = ReplyNotificationTracker.postReplyNotification(context, replyNotif, gen1)
|
||||
assertFalse("Stale reply must be rejected after action notification was posted", posted)
|
||||
verify(exactly = 0) { manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, replyNotif) }
|
||||
assertNull("Active target must remain null for action", ReplyNotificationTracker.getActiveTarget())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oldReplyRacingNewerReplyMustNotOverwriteNewerReply() {
|
||||
val gen1 = ReplyNotificationTracker.registerPendingReply("default", "session", "comp-1", "Old Reply")
|
||||
val gen2 = ReplyNotificationTracker.registerPendingReply("default", "session", "comp-2", "New Reply")
|
||||
val replyNotif1 = mockk<Notification>()
|
||||
val replyNotif2 = mockk<Notification>()
|
||||
|
||||
// Gen 2 posts successfully
|
||||
assertTrue(ReplyNotificationTracker.postReplyNotification(context, replyNotif2, gen2))
|
||||
assertEquals("comp-2", ReplyNotificationTracker.getActiveTarget()?.completionId)
|
||||
|
||||
// Stale Gen 1 tries to post
|
||||
assertFalse(ReplyNotificationTracker.postReplyNotification(context, replyNotif1, gen1))
|
||||
assertEquals("comp-2", ReplyNotificationTracker.getActiveTarget()?.completionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readCancelRacingReplyPublicationMustPreventZombieNotification() {
|
||||
val gen1 = ReplyNotificationTracker.registerPendingReply("default", "session", "comp-1", "Pending Reply")
|
||||
val replyNotif = mockk<Notification>()
|
||||
|
||||
// User views message in foreground chat before background service calls postReplyNotification
|
||||
val cancelled =
|
||||
ReplyNotificationTracker.onMessageVisible(
|
||||
context,
|
||||
"default",
|
||||
"session",
|
||||
"comp-1",
|
||||
)
|
||||
assertTrue("Message visibility should cancel the pending reply target", cancelled)
|
||||
|
||||
// Delayed background service finally tries to post reply
|
||||
val posted = ReplyNotificationTracker.postReplyNotification(context, replyNotif, gen1)
|
||||
assertFalse("Tombstoned/cancelled generation must not post notification ID 2", posted)
|
||||
verify(exactly = 0) { manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, replyNotif) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaRepliesRetainCompletionIdAcrossRestRehydration() {
|
||||
val live =
|
||||
listOf(
|
||||
ChatMessage(
|
||||
id = "ws-media",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Here is your chart",
|
||||
completionId = "comp-media-123",
|
||||
),
|
||||
)
|
||||
val history =
|
||||
listOf(
|
||||
SessionMessage(
|
||||
id = 1,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Here is your chart MEDIA:/opt/hermes/chart.png"),
|
||||
),
|
||||
)
|
||||
val mapped = mapServerMessages("session", history, 0, true, live, isPagingOlder = false)
|
||||
assertEquals("comp-media-123", mapped.single().completionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaOnlyReplyRetainsCompletionIdAcrossRestRehydration() {
|
||||
val live =
|
||||
listOf(
|
||||
ChatMessage(
|
||||
id = "ws-media-only",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "",
|
||||
completionId = "comp-media-only",
|
||||
),
|
||||
)
|
||||
val history =
|
||||
listOf(
|
||||
SessionMessage(
|
||||
id = 1,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("MEDIA:/opt/hermes/photo.jpg"),
|
||||
),
|
||||
)
|
||||
val mapped = mapServerMessages("session", history, 0, true, live, isPagingOlder = false)
|
||||
assertEquals("comp-media-only", mapped.single().completionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaReplyWithReasoningOnlyRowBeforeItRetainsCompletionId() {
|
||||
val live =
|
||||
listOf(
|
||||
ChatMessage(
|
||||
id = "ws-media-reasoning",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "The result",
|
||||
completionId = "comp-reasoning-media",
|
||||
),
|
||||
)
|
||||
val history =
|
||||
listOf(
|
||||
SessionMessage(
|
||||
id = 1,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(""),
|
||||
reasoning = JsonPrimitive("thinking step"),
|
||||
),
|
||||
SessionMessage(id = 2, role = "assistant", content = JsonPrimitive("The result MEDIA:/opt/pic.png")),
|
||||
)
|
||||
val mapped = mapServerMessages("session", history, 0, true, live, isPagingOlder = false)
|
||||
assertEquals("comp-reasoning-media", mapped.single().completionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankMessageCompleteMustNotCorruptHistoricalAssistantMessage() {
|
||||
val oldAssistant =
|
||||
ChatMessage(
|
||||
id = "old-assistant",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Old reply from yesterday",
|
||||
completionId = "old-comp",
|
||||
)
|
||||
val state = ChatUiState(currentSessionId = "session", messages = listOf(oldAssistant))
|
||||
val streaming = StreamingState() // No current streaming message or sealed orphans
|
||||
|
||||
val result =
|
||||
ChatWsEventReducer.reduce(
|
||||
state,
|
||||
streaming,
|
||||
WsEvent.MessageComplete("", "session", completionId = "new-unrelated-completion"),
|
||||
"session",
|
||||
)
|
||||
|
||||
// Historical assistant message must be completely untouched
|
||||
assertEquals(
|
||||
"old-comp",
|
||||
result.state.messages
|
||||
.single()
|
||||
.completionId,
|
||||
)
|
||||
assertFalse(
|
||||
"PersistMessage effect must not be emitted for unrelated historical messages",
|
||||
result.effects.any { it is ReducerEffect.PersistMessage },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun coldStartRecoveredGenerationMustAdvanceCounterAndAllowFutureReplies() {
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
// Active notification in system with generation 37
|
||||
val activeInfo =
|
||||
ActiveReplyInfo(
|
||||
id = ChatNotificationService.PENDING_NOTIFICATION_ID,
|
||||
kind = ReplyNotificationTracker.KIND_REPLY,
|
||||
scopeId = "default",
|
||||
sessionId = "session",
|
||||
completionId = "comp-37",
|
||||
textSnippet = "Reply 37",
|
||||
generation = 37L,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
)
|
||||
ReplyNotificationTracker.activeNotificationProvider = { activeInfo }
|
||||
|
||||
// User views message -> recovers and cancels generation 37
|
||||
val cancelled = ReplyNotificationTracker.onMessageVisible(context, "default", "session", "comp-37")
|
||||
assertTrue("Generation 37 should be recovered and cancelled", cancelled)
|
||||
|
||||
// New reply arrives
|
||||
val newGen = ReplyNotificationTracker.registerPendingReply("default", "session", "comp-38", "Reply 38")
|
||||
assertTrue("New generation must exceed recovered generation (was $newGen)", newGen > 37L)
|
||||
|
||||
val replyNotif = mockk<Notification>()
|
||||
val posted = ReplyNotificationTracker.postReplyNotification(context, replyNotif, newGen)
|
||||
assertTrue("New reply notification must be accepted and posted", posted)
|
||||
verify { manager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, replyNotif) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveredGenerationFollowedByActionAndReplyMustRemainStrictlyMonotonic() {
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
val activeInfo =
|
||||
ActiveReplyInfo(
|
||||
id = ChatNotificationService.PENDING_NOTIFICATION_ID,
|
||||
kind = ReplyNotificationTracker.KIND_REPLY,
|
||||
scopeId = "default",
|
||||
sessionId = "session",
|
||||
completionId = "comp-37",
|
||||
textSnippet = "Reply 37",
|
||||
generation = 37L,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
)
|
||||
ReplyNotificationTracker.activeNotificationProvider = { activeInfo }
|
||||
|
||||
// Recover and cancel 37
|
||||
assertTrue(ReplyNotificationTracker.onMessageVisible(context, "default", "session", "comp-37"))
|
||||
|
||||
// Post action
|
||||
val actionNotif = mockk<Notification>()
|
||||
assertTrue(ReplyNotificationTracker.postActionNotification(context, actionNotif))
|
||||
|
||||
// Register new reply
|
||||
val newGen = ReplyNotificationTracker.registerPendingReply("default", "session", "comp-39", "Reply 39")
|
||||
assertTrue("New generation must strictly exceed 37 and action tombstone", newGen > 38L)
|
||||
|
||||
val replyNotif = mockk<Notification>()
|
||||
assertTrue(ReplyNotificationTracker.postReplyNotification(context, replyNotif, newGen))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alreadyResolvedServerIdBindsOnlyTheExactRowAmongShortDuplicates() {
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
val activeInfo =
|
||||
ActiveReplyInfo(
|
||||
id = ChatNotificationService.PENDING_NOTIFICATION_ID,
|
||||
kind = ReplyNotificationTracker.KIND_REPLY,
|
||||
scopeId = "default",
|
||||
sessionId = "session",
|
||||
completionId = "comp-newest-done",
|
||||
serverMessageId = 2,
|
||||
textSnippet = "Done",
|
||||
generation = 5L,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
)
|
||||
ReplyNotificationTracker.activeNotificationProvider = { activeInfo }
|
||||
|
||||
// Two identical "Done" rows. The durable id (row 2) is what decides —
|
||||
// there is no server row timestamp comparison anywhere in the mapper.
|
||||
val history =
|
||||
listOf(
|
||||
SessionMessage(
|
||||
id = 1,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Done"),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 2,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Done"),
|
||||
),
|
||||
)
|
||||
|
||||
val mapped =
|
||||
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
|
||||
assertEquals("Older message must have null completionId", null, mapped[0].completionId)
|
||||
assertEquals("Newest message must receive target completionId", "comp-newest-done", mapped[1].completionId)
|
||||
|
||||
// Showing ONLY the older Done must NOT cancel the notification
|
||||
val cancelledOld =
|
||||
ReplyNotificationTracker.onMessageVisible(
|
||||
context = context,
|
||||
scopeId = "default",
|
||||
sessionId = "session",
|
||||
completionId = mapped[0].completionId,
|
||||
)
|
||||
assertFalse("Viewing older duplicate message must NOT cancel notification", cancelledOld)
|
||||
verify(exactly = 0) { manager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) }
|
||||
|
||||
// Showing the newest Done MUST cancel the notification
|
||||
val cancelledNew =
|
||||
ReplyNotificationTracker.onMessageVisible(
|
||||
context = context,
|
||||
scopeId = "default",
|
||||
sessionId = "session",
|
||||
completionId = mapped[1].completionId,
|
||||
)
|
||||
assertTrue("Viewing newest message must cancel notification", cancelledNew)
|
||||
verify(exactly = 1) { manager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alreadyResolvedServerIdIgnoresSharedHundredCharacterPrefix() {
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
val textPrefix = "x".repeat(100)
|
||||
val text1 = textPrefix + "A"
|
||||
val text2 = textPrefix + "B"
|
||||
val activeInfo =
|
||||
ActiveReplyInfo(
|
||||
id = ChatNotificationService.PENDING_NOTIFICATION_ID,
|
||||
kind = ReplyNotificationTracker.KIND_REPLY,
|
||||
scopeId = "default",
|
||||
sessionId = "session",
|
||||
completionId = "comp-long-2",
|
||||
serverMessageId = 10,
|
||||
textSnippet = text2.take(100),
|
||||
generation = 5L,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
)
|
||||
ReplyNotificationTracker.activeNotificationProvider = { activeInfo }
|
||||
|
||||
val history =
|
||||
listOf(
|
||||
SessionMessage(
|
||||
id = 10,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(text1),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 20,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(text2),
|
||||
),
|
||||
)
|
||||
|
||||
val mapped =
|
||||
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
|
||||
assertEquals("comp-long-2", mapped[0].completionId)
|
||||
assertEquals(null, mapped[1].completionId)
|
||||
|
||||
assertTrue(
|
||||
ReplyNotificationTracker.onMessageVisible(
|
||||
context,
|
||||
"default",
|
||||
"session",
|
||||
mapped[0].completionId,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
ReplyNotificationTracker.onMessageVisible(
|
||||
context,
|
||||
"default",
|
||||
"session",
|
||||
mapped[1].completionId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alreadyResolvedServerIdBindsMediaRowAndDismissesOnVisible() {
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
val activeInfo =
|
||||
ActiveReplyInfo(
|
||||
id = ChatNotificationService.PENDING_NOTIFICATION_ID,
|
||||
kind = ReplyNotificationTracker.KIND_REPLY,
|
||||
scopeId = "default",
|
||||
sessionId = "session",
|
||||
completionId = "comp-media-cold",
|
||||
serverMessageId = 100,
|
||||
textSnippet = "Here is the image MEDIA:/opt/hermes/image.png",
|
||||
generation = 5L,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
)
|
||||
ReplyNotificationTracker.activeNotificationProvider = { activeInfo }
|
||||
|
||||
val history =
|
||||
listOf(
|
||||
SessionMessage(
|
||||
id = 100,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("Here is the image MEDIA:/opt/hermes/image.png"),
|
||||
),
|
||||
)
|
||||
|
||||
val mapped =
|
||||
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
|
||||
val row = mapped.single()
|
||||
assertEquals("comp-media-cold", row.completionId)
|
||||
assertEquals("Here is the image", row.content)
|
||||
|
||||
val dismissed =
|
||||
ReplyNotificationTracker.onMessageVisible(
|
||||
context = context,
|
||||
scopeId = "default",
|
||||
sessionId = "session",
|
||||
completionId = row.completionId,
|
||||
)
|
||||
assertTrue("Viewing hydrated MEDIA row must dismiss notification", dismissed)
|
||||
verify { manager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alreadyResolvedServerIdBindsMediaOnlyRowAndDismissesOnVisible() {
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
val activeInfo =
|
||||
ActiveReplyInfo(
|
||||
id = ChatNotificationService.PENDING_NOTIFICATION_ID,
|
||||
kind = ReplyNotificationTracker.KIND_REPLY,
|
||||
scopeId = "default",
|
||||
sessionId = "session",
|
||||
completionId = "comp-media-only-cold",
|
||||
serverMessageId = 100,
|
||||
textSnippet = "MEDIA:/opt/hermes/image.png",
|
||||
generation = 5L,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
)
|
||||
ReplyNotificationTracker.activeNotificationProvider = { activeInfo }
|
||||
|
||||
val history =
|
||||
listOf(
|
||||
SessionMessage(
|
||||
id = 100,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("MEDIA:/opt/hermes/image.png"),
|
||||
),
|
||||
)
|
||||
|
||||
val mapped =
|
||||
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
|
||||
val row = mapped.single()
|
||||
assertEquals("comp-media-only-cold", row.completionId)
|
||||
assertEquals("", row.content)
|
||||
|
||||
val dismissed =
|
||||
ReplyNotificationTracker.onMessageVisible(
|
||||
context = context,
|
||||
scopeId = "default",
|
||||
sessionId = "session",
|
||||
completionId = row.completionId,
|
||||
)
|
||||
assertTrue("Viewing hydrated MEDIA-only row must dismiss notification", dismissed)
|
||||
verify { manager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alreadyResolvedServerIdBindsOnlyTheExactMediaRowAmongDuplicates() {
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
val activeInfo =
|
||||
ActiveReplyInfo(
|
||||
id = ChatNotificationService.PENDING_NOTIFICATION_ID,
|
||||
kind = ReplyNotificationTracker.KIND_REPLY,
|
||||
scopeId = "default",
|
||||
sessionId = "session",
|
||||
completionId = "comp-media-dup-2",
|
||||
serverMessageId = 1,
|
||||
textSnippet = "MEDIA:/opt/hermes/image.png",
|
||||
generation = 5L,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
)
|
||||
ReplyNotificationTracker.activeNotificationProvider = { activeInfo }
|
||||
|
||||
val history =
|
||||
listOf(
|
||||
SessionMessage(
|
||||
id = 1,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("MEDIA:/opt/hermes/image.png"),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 2,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("MEDIA:/opt/hermes/image.png"),
|
||||
),
|
||||
)
|
||||
|
||||
val mapped =
|
||||
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
|
||||
assertEquals("comp-media-dup-2", mapped[0].completionId)
|
||||
assertEquals(null, mapped[1].completionId)
|
||||
|
||||
assertTrue(
|
||||
ReplyNotificationTracker.onMessageVisible(
|
||||
context,
|
||||
"default",
|
||||
"session",
|
||||
mapped[0].completionId,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
ReplyNotificationTracker.onMessageVisible(
|
||||
context,
|
||||
"default",
|
||||
"session",
|
||||
mapped[1].completionId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The path production actually runs, end to end: the turn boundary armed
|
||||
* before the prompt names exactly one REST row above it, that row id travels
|
||||
* in the notification, only that row is hydrated with the completion id, and
|
||||
* only that row dismisses the notification.
|
||||
*/
|
||||
@Test
|
||||
fun mobileTurnBoundaryResolvesTheExactRowThenOnlyThatRowDismisses() {
|
||||
ReplyNotificationTracker.resetForTest()
|
||||
TurnCorrelationTracker.resetForTest()
|
||||
TurnCorrelationTracker.armBoundary("default", "session", 50)
|
||||
|
||||
val history =
|
||||
listOf(
|
||||
SessionMessage(id = 10, role = "assistant", content = JsonPrimitive("Done")),
|
||||
SessionMessage(id = 51, role = "user", content = JsonPrimitive("next")),
|
||||
SessionMessage(id = 52, role = "assistant", content = JsonPrimitive("Done")),
|
||||
)
|
||||
val resolved =
|
||||
runBlocking {
|
||||
correlateCompletedTurnRow(
|
||||
scopeId = "default",
|
||||
sessionId = "session",
|
||||
completionText = "Done",
|
||||
resolver = TurnRowResolver({ history }, listOf(0L)),
|
||||
)
|
||||
}
|
||||
assertEquals("The historical duplicate below the boundary must not win", 52, resolved)
|
||||
|
||||
// The service stores that id in the notification extras; after process
|
||||
// death the extras are all the tracker can recover from.
|
||||
ReplyNotificationTracker.activeNotificationProvider = {
|
||||
ActiveReplyInfo(
|
||||
id = ChatNotificationService.PENDING_NOTIFICATION_ID,
|
||||
kind = ReplyNotificationTracker.KIND_REPLY,
|
||||
scopeId = "default",
|
||||
sessionId = "session",
|
||||
completionId = "comp-turn",
|
||||
textSnippet = "Done",
|
||||
generation = 5L,
|
||||
timestamp = 0L,
|
||||
serverMessageId = resolved,
|
||||
)
|
||||
}
|
||||
|
||||
val mapped =
|
||||
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
|
||||
assertEquals(listOf(null, null, "comp-turn"), mapped.map { it.completionId })
|
||||
|
||||
assertFalse(
|
||||
"Viewing the older duplicate must not dismiss",
|
||||
ReplyNotificationTracker.onMessageVisible(context, "default", "session", mapped[0].completionId),
|
||||
)
|
||||
assertTrue(
|
||||
"Viewing the turn's own row must dismiss",
|
||||
ReplyNotificationTracker.onMessageVisible(context, "default", "session", mapped[2].completionId),
|
||||
)
|
||||
verify(exactly = 1) { manager.cancel(ChatNotificationService.PENDING_NOTIFICATION_ID) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownScopeMustNotAcknowledgeScopedReply() {
|
||||
val target = ReplyNotificationTarget("other-profile", "session", "completion", 1L, "Done")
|
||||
assertFalse(target.matches(null, "session", "completion"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListLayoutInfo
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatReadObserverTest {
|
||||
private fun createMockItem(
|
||||
key: Any,
|
||||
offset: Int,
|
||||
size: Int,
|
||||
): LazyListItemInfo {
|
||||
val item = mockk<LazyListItemInfo>()
|
||||
every { item.key } returns key
|
||||
every { item.offset } returns offset
|
||||
every { item.size } returns size
|
||||
return item
|
||||
}
|
||||
|
||||
private fun createMockLayoutInfo(
|
||||
items: List<LazyListItemInfo>,
|
||||
startOffset: Int = 0,
|
||||
endOffset: Int = 1000,
|
||||
): LazyListLayoutInfo {
|
||||
val layout = mockk<LazyListLayoutInfo>()
|
||||
every { layout.visibleItemsInfo } returns items
|
||||
every { layout.viewportStartOffset } returns startOffset
|
||||
every { layout.viewportEndOffset } returns endOffset
|
||||
return layout
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns empty when visible items or messages are empty`() {
|
||||
val layout = createMockLayoutInfo(emptyList())
|
||||
val msg =
|
||||
ChatMessage(
|
||||
id = "msg-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Hello",
|
||||
)
|
||||
|
||||
assertTrue(ChatReadObserver.findVisibleAssistantMessages(layout, listOf(msg)).isEmpty())
|
||||
|
||||
val layoutWithItem = createMockLayoutInfo(listOf(createMockItem("prose-msg-1", 100, 50)))
|
||||
assertTrue(ChatReadObserver.findVisibleAssistantMessages(layoutWithItem, emptyList()).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `identifies visible completed assistant messages`() {
|
||||
val msg1 =
|
||||
ChatMessage(
|
||||
id = "msg-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Answer 1",
|
||||
completionId = "comp-1",
|
||||
)
|
||||
val msg2 =
|
||||
ChatMessage(
|
||||
id = "msg-2",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Answer 2",
|
||||
completionId = "comp-2",
|
||||
)
|
||||
|
||||
val items =
|
||||
listOf(
|
||||
createMockItem("prose-msg-1", 100, 200),
|
||||
createMockItem("prose-msg-2", 400, 250),
|
||||
)
|
||||
val layout = createMockLayoutInfo(items)
|
||||
|
||||
val visible = ChatReadObserver.findVisibleAssistantMessages(layout, listOf(msg1, msg2))
|
||||
assertEquals(2, visible.size)
|
||||
assertEquals("msg-1", visible[0].id)
|
||||
assertEquals("msg-2", visible[1].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignores non-prose items like user bubbles, reasoning cards, and tool rows`() {
|
||||
val assistant =
|
||||
ChatMessage(
|
||||
id = "ast-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Done",
|
||||
)
|
||||
val user =
|
||||
ChatMessage(
|
||||
id = "usr-1",
|
||||
role = MessageRole.USER,
|
||||
content = "Question",
|
||||
)
|
||||
|
||||
val items =
|
||||
listOf(
|
||||
createMockItem("user-usr-1", 50, 100),
|
||||
createMockItem("reasoning-ast-1", 150, 100),
|
||||
createMockItem("tool-tool-1", 250, 100),
|
||||
createMockItem("prose-ast-1", 350, 100),
|
||||
)
|
||||
val layout = createMockLayoutInfo(items)
|
||||
|
||||
val visible = ChatReadObserver.findVisibleAssistantMessages(layout, listOf(assistant, user))
|
||||
assertEquals(1, visible.size)
|
||||
assertEquals("ast-1", visible.single().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ignores streaming assistant messages`() {
|
||||
val streaming =
|
||||
ChatMessage(
|
||||
id = "stream-1",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "typing...",
|
||||
isStreaming = true,
|
||||
)
|
||||
|
||||
val items = listOf(createMockItem("prose-stream-1", 100, 200))
|
||||
val layout = createMockLayoutInfo(items)
|
||||
|
||||
val visible = ChatReadObserver.findVisibleAssistantMessages(layout, listOf(streaming))
|
||||
assertTrue(visible.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filters out items outside viewport bounds`() {
|
||||
val msgAbove =
|
||||
ChatMessage(
|
||||
id = "above",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Above viewport",
|
||||
)
|
||||
val msgInside =
|
||||
ChatMessage(
|
||||
id = "inside",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Inside viewport",
|
||||
)
|
||||
val msgBelow =
|
||||
ChatMessage(
|
||||
id = "below",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Below viewport",
|
||||
)
|
||||
|
||||
// Viewport is 200..800
|
||||
val items =
|
||||
listOf(
|
||||
// Fully above: offset -100, size 100 -> offset + size = 0 <= 200
|
||||
createMockItem("prose-above", -100, 100),
|
||||
// Inside: offset 300, size 150
|
||||
createMockItem("prose-inside", 300, 150),
|
||||
// Fully below: offset 900, size 100 >= 800
|
||||
createMockItem("prose-below", 900, 100),
|
||||
)
|
||||
val layout = createMockLayoutInfo(items, startOffset = 200, endOffset = 800)
|
||||
|
||||
val visible = ChatReadObserver.findVisibleAssistantMessages(layout, listOf(msgAbove, msgInside, msgBelow))
|
||||
assertEquals(1, visible.size)
|
||||
assertEquals("inside", visible.single().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `includes partially visible messages at viewport edges`() {
|
||||
val topPartial =
|
||||
ChatMessage(
|
||||
id = "top",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Top edge",
|
||||
)
|
||||
val bottomPartial =
|
||||
ChatMessage(
|
||||
id = "bottom",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = "Bottom edge",
|
||||
)
|
||||
|
||||
// Viewport 100..500
|
||||
val items =
|
||||
listOf(
|
||||
// Crosses top boundary: offset 50, size 100 (bottom is 150 > 100)
|
||||
createMockItem("prose-top", 50, 100),
|
||||
// Crosses bottom boundary: offset 450, size 100 (top is 450 < 500)
|
||||
createMockItem("prose-bottom", 450, 100),
|
||||
)
|
||||
val layout = createMockLayoutInfo(items, startOffset = 100, endOffset = 500)
|
||||
|
||||
val visible = ChatReadObserver.findVisibleAssistantMessages(layout, listOf(topPartial, bottomPartial))
|
||||
assertEquals(2, visible.size)
|
||||
assertEquals("top", visible[0].id)
|
||||
assertEquals("bottom", visible[1].id)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user