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:
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
|
||||
|
||||
Reference in new issue
Block a user