feat(chat): add explicit busy-send modes and recoverable queue (#1254)

Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
AlexDev_andm57 authored and GitHub committed 2026-09-25 13:26:25 +03:00
1 parent f3921957b5
commit 35e6ebc8d5
33 files changed
+4482 -229

No files matched your search

@@ -4,6 +4,7 @@ import com.m57.hermescontrol.ui.chat.fakes.FakeChatMessageDao
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.Test
@@ -106,6 +107,23 @@ class ChatPersistenceRepositoryTest {
assertEquals(1000L, entity.timestamp)
}
@Test
fun deleteMessageRemovesOnlyUnconfirmedOptimisticRows() =
runTest {
repository.persistMessage(ChatMessage(id = "local", role = MessageRole.USER, content = "draft"), "s")
repository.persistMessage(
ChatMessage(id = "confirmed", role = MessageRole.USER, content = "history", restId = "rest-s-7"),
"s",
)
repository.deleteMessage("local")
repository.deleteMessage("confirmed")
val rows = repository.loadMessages("s")
assertFalse(rows.any { it.id == "local" })
assertEquals("confirmed", rows.single().id)
}
@Test
fun persistMessages_upsertsAllToDao() =
runTest {
@@ -0,0 +1,48 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.model.BusySendMode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatQueuePresentationTest {
private val message = ChatMessage("queued-id", MessageRole.USER, "Later")
private fun pending(state: PendingSendState) =
PendingSend(message.id, "scope", "session", message.content, mode = BusySendMode.QUEUE, state = state)
@Test
fun queuedAndParkedMessagesRemainInternalUntilDispatch() {
val messages = listOf(message)
for (state in listOf(PendingSendState.QUEUED, PendingSendState.PARKED)) {
val receipts = listOf(pending(state))
assertTrue(messagesWithoutUnsentQueue(messages, receipts).isEmpty())
assertEquals(listOf(message), messages)
assertEquals(state, receipts.single().state)
}
}
@Test
fun queuedMessageAppearsOnceWhenSendingStarts() {
val messages = listOf(message)
assertTrue(messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))).isEmpty())
for (state in listOf(PendingSendState.SENDING, PendingSendState.ACCEPTED)) {
assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(state))))
}
}
@Test
fun ordinarySendAndOtherMessagesAreUnchanged() {
val other = ChatMessage("other", MessageRole.USER, "Send now")
val messages = listOf(other, message)
assertSame(messages, messagesWithoutUnsentQueue(messages, emptyList()))
assertEquals(listOf(other), messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))))
}
@Test
fun canonicalHistoryIsNeverHiddenByStaleQueueReceipt() {
val messages = listOf(message.copy(restId = "rest-session-12"))
assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))))
}
}
@@ -0,0 +1,207 @@
package com.m57.hermescontrol.ui.chat
import android.content.SharedPreferences
import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.model.BusySendMode
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatSendStoreTest {
private fun pending(
id: String,
text: String,
state: PendingSendState = PendingSendState.ACCEPTED,
) = PendingSend(id, "scope", "session", text, mode = BusySendMode.QUEUE, state = state)
private fun mappedHistoryAliases(
incoming: List<ChatMessage>,
existing: List<ChatMessage>,
): List<ChatMessage> =
incoming.zip(matchTranscriptMessages(incoming, existing)) { rest, match ->
match?.copy(restId = rest.canonicalRestId) ?: rest
}
@Test
fun durableAliasConfirmsAttachmentAndSteerNormalizedReceipt() {
val local = ChatMessage("send-1", MessageRole.USER, "See attachment")
val rest =
ChatMessage(
"rest-session-10",
MessageRole.USER,
"""
[OUT-OF-BAND USER MESSAGE — direct steering]
@file:files/agent-vault/hermes/attachments/note.txt
See attachment
[/OUT-OF-BAND USER MESSAGE]
""".trimIndent(),
)
val confirmed =
pendingSendIdsConfirmedByDurableAliases(
mappedHistoryAliases(listOf(rest), listOf(local)),
listOf(pending("send-1", "See attachment")),
)
assertEquals(setOf("send-1"), confirmed)
}
@Test
fun durableAliasConsumesOnlyOneDuplicateReceiptOccurrence() {
val existing =
listOf(
ChatMessage("send-1", MessageRole.USER, "repeat"),
ChatMessage("send-2", MessageRole.USER, "repeat"),
)
val aliases =
mappedHistoryAliases(
listOf(ChatMessage("rest-session-10", MessageRole.USER, "repeat")),
existing,
)
val confirmed =
pendingSendIdsConfirmedByDurableAliases(
aliases,
listOf(pending("send-1", "repeat"), pending("send-2", "repeat")),
)
assertEquals(setOf("send-1"), confirmed)
}
@Test
fun repeatedHydrationDoesNotConfirmAnotherDuplicateReceipt() {
val aliases =
mappedHistoryAliases(
listOf(ChatMessage("rest-session-10", MessageRole.USER, "repeat")),
listOf(ChatMessage("send-1", MessageRole.USER, "repeat")),
)
val first =
pendingSendIdsConfirmedByDurableAliases(
aliases,
listOf(pending("send-1", "repeat"), pending("send-2", "repeat")),
)
val remaining = listOf(pending("send-2", "repeat")).filterNot { it.id in first }
assertTrue(pendingSendIdsConfirmedByDurableAliases(aliases, remaining).isEmpty())
}
@Test
fun durableAliasDoesNotConfirmQueuedOrParkedReceipts() {
val aliases =
listOf(
ChatMessage("queued", MessageRole.USER, "same", restId = "rest-session-10"),
ChatMessage("parked", MessageRole.USER, "same", restId = "rest-session-11"),
)
val confirmed =
pendingSendIdsConfirmedByDurableAliases(
aliases,
listOf(
pending("queued", "same", PendingSendState.QUEUED),
pending("parked", "same", PendingSendState.PARKED),
),
)
assertTrue(confirmed.isEmpty())
}
@Test
fun promoteMovesTheChosenMessageAheadOfEarlierQueuedMessages() {
val store = ChatSendStore()
store.put(PendingSend("first", "scope", "session", "one", mode = BusySendMode.QUEUE))
store.put(PendingSend("second", "scope", "session", "two", mode = BusySendMode.QUEUE))
store.promote("second")
assertEquals(listOf("second", "first"), store.all().map { it.id })
}
@Test
fun recreationPreservesQueueAndQuarantinesAmbiguousSendsAcrossScopes() {
var saved: String? = null
val prefs = mockk<SharedPreferences>()
val editor = mockk<SharedPreferences.Editor>()
every { prefs.getString("rows", null) } answers { saved }
every { prefs.edit() } returns editor
every { editor.putString("rows", any()) } answers {
saved = secondArg()
editor
}
every { editor.commit() } returns true
val first = ChatSendStore(prefs)
first.put(
PendingSend(
"queued",
"connection-a",
"session-a",
"later",
attachments = listOf(Attachment("file:///private/photo", "photo.png", "image/png", 3)),
mode = BusySendMode.QUEUE,
),
)
first.put(
PendingSend(
"sending",
"connection-b",
"session-b",
"maybe",
mode = BusySendMode.CORRECT,
state = PendingSendState.SENDING,
),
)
first.park("connection-a", "session-a")
val restored = ChatSendStore(prefs)
assertEquals(PendingSendState.PARKED, restored.all().single { it.id == "queued" }.state)
assertEquals(PendingSendState.UNKNOWN, restored.all().single { it.id == "sending" }.state)
assertEquals("connection-a", restored.all().single { it.id == "queued" }.scope)
assertEquals(
"file:///private/photo",
restored
.all()
.single { it.id == "queued" }
.attachments
.single()
.uri,
)
assertEquals("session-b", restored.all().single { it.id == "sending" }.sessionId)
restored.remove("queued")
assertTrue(ChatSendStore(prefs).all().none { it.id == "queued" })
assertEquals(1, ChatSendStore(prefs).all().size)
}
@Test
fun recreationQuarantinesAcceptedReceiptWithoutAutomaticReplay() {
var saved: String? = null
val prefs = mockk<SharedPreferences>()
val editor = mockk<SharedPreferences.Editor>()
every { prefs.getString("rows", null) } answers { saved }
every { prefs.edit() } returns editor
every { editor.putString("rows", any()) } answers {
saved = secondArg()
editor
}
every { editor.commit() } returns true
ChatSendStore(prefs).put(
PendingSend(
"accepted",
"connection-a",
"session-a",
"possibly accepted",
mode = BusySendMode.QUEUE,
state = PendingSendState.ACCEPTED,
),
)
val restored = ChatSendStore(prefs).all().single()
assertEquals(PendingSendState.UNKNOWN, restored.state)
assertEquals(0, restored.attempts)
}
}
@@ -87,6 +87,10 @@ class ChatUpdateCommandTest {
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
every { AuthManager.getToken() } returns "test-token"
every { AuthManager.getPinnedModels() } returns emptyList()
every { AuthManager.getBaseUrl() } returns "http://test.local/"
every { AuthManager.getSelectedProfileId() } returns null
every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.isMessageStatsEnabled() } returns false
@@ -148,7 +152,15 @@ class ChatUpdateCommandTest {
// threads — with a relaxed-mock app that NPEs on cacheDir and leaks
// an uncaught exception into the NEXT runTest class (CI flakes:
// UncaughtExceptionsBeforeTest).
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
@@ -185,7 +197,15 @@ class ChatUpdateCommandTest {
@Test
fun `applyUpdate triggers the REST action and starts the progress popup`() =
runTest {
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
coEvery { mockApi.updateHermes() } returns
Response.success(ActionResponse(ok = true, name = "hermes-update"))
// One running poll, then the action exits — runTest's teardown
@@ -224,7 +244,15 @@ class ChatUpdateCommandTest {
@Test
fun `applyUpdate settles on success when the action exits cleanly`() =
runTest {
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
coEvery { mockApi.updateHermes() } returns
Response.success(ActionResponse(ok = true, name = "hermes-update"))
coEvery { mockApi.getActionStatus("hermes-update") } returns
@@ -252,7 +280,15 @@ class ChatUpdateCommandTest {
@Test
fun `applyUpdate surfaces a rejected trigger in the popup`() =
runTest {
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
coEvery { mockApi.updateHermes() } returns
Response.error(404, "no update endpoint".toResponseBody())
File diff suppressed because it is too large. Load diff
@@ -94,6 +94,10 @@ class SlashCommandDispatchRpcTest {
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
every { AuthManager.getToken() } returns "test-token"
every { AuthManager.getPinnedModels() } returns emptyList()
every { AuthManager.getBaseUrl() } returns "http://test.local/"
every { AuthManager.getSelectedProfileId() } returns null
every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.isMessageStatsEnabled() } returns false
@@ -152,7 +156,15 @@ class SlashCommandDispatchRpcTest {
}
private suspend fun TestScope.createViewModelWithSession(): Pair<ChatViewModel, String> {
val vm = ChatViewModel(app, false, fakeRepo, FakeSlashUsageStore(), ioDispatcher = testDispatcher)
val vm =
ChatViewModel(
app,
false,
fakeRepo,
FakeSlashUsageStore(),
ioDispatcher = testDispatcher,
sendStore = ChatSendStore(),
)
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
@@ -83,6 +83,10 @@ class FakeChatMessageDao : ChatMessageDao {
messages.values.removeAll { it.sessionId == sessionId }
}
override suspend fun deleteUnconfirmedMessage(id: String) {
if (messages[id]?.restId == null) messages.remove(id)
}
/** Direct access for test setup — bypasses the suspend modifier. */
fun addMessageDirect(message: ChatMessageEntity) {
val existing = messages[message.id]
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.settings
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.BusySendMode
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.theme.ThemePreference
@@ -47,6 +48,17 @@ class SettingsViewModelTest {
return vm
}
@Test
fun busySendDefaultLoadsAndSaves() {
every { AuthManager.getBusySendMode() } returns BusySendMode.GUIDE
val viewModel = createViewModel()
assertEquals(BusySendMode.GUIDE, viewModel.uiState.value.busySendMode)
viewModel.onBusySendModeChange(BusySendMode.QUEUE)
assertEquals(BusySendMode.QUEUE, viewModel.uiState.value.busySendMode)
verify(exactly = 1) { AuthManager.setBusySendMode(BusySendMode.QUEUE) }
}
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
@@ -71,6 +83,8 @@ class SettingsViewModelTest {
every { AuthManager.isUseDynamicColors() } returns true
every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getBusySendMode() } returns com.m57.hermescontrol.data.model.BusySendMode.CORRECT
every { AuthManager.setBusySendMode(any()) } returns Unit
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.getChatFontScale() } returns 1.0f
every { AuthManager.isMessageStatsEnabled() } returns false