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
@@ -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