feat: auto-dismiss reply notifications when read in chat (#1195)

This commit is contained in:
M57 authored and GitHub committed 2026-09-19 22:58:43 +03:00
1 parent 03ce822119
commit dc77b306b6
30 files changed
+4070 -15

No files matched your search

@@ -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)
}
}
}
@@ -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 =
@@ -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)
}
}