feat: gate chat send until session fully resumed (#1187)

This commit is contained in:
M57 authored and GitHub committed 2026-09-17 23:25:12 +03:00
1 parent 9690ce8dc6
commit a415445bbf
13 files changed
+310 -33

No files matched your search

@@ -81,6 +81,7 @@ class ComposerInteractionTest {
isListening = listening,
isAgentTyping = false,
isConnected = true,
isSessionReady = true,
commandCatalog = CommandCatalog(),
currentSessionModel = currentModel,
reasoningLevel = reasoningLevel,
@@ -0,0 +1,55 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.text.input.TextFieldValue
import com.m57.hermescontrol.data.ws.CommandCatalog
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class ComposerReadinessTest {
@get:Rule
val compose = createComposeRule()
@Test
fun draftRemainsEditableAndSendStaysDisabledUntilReady() {
val ready = mutableStateOf(false)
var sends = 0
compose.setContent {
var input by remember { mutableStateOf(TextFieldValue("")) }
ChatInputBar(
inputFieldValue = input,
onInputChange = { input = it },
onSend = { sends++ },
onMicTap = {},
isListening = false,
isAgentTyping = true,
isConnected = true,
commandCatalog = CommandCatalog(),
isSessionReady = ready.value,
)
}
compose.onNodeWithTag("chat_input").performTextInput("held draft")
compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
compose.onNodeWithTag("chat_session_preparing").assertExists()
compose.onNodeWithTag("send_button").assertIsNotEnabled().performClick()
compose.runOnIdle { assertEquals(0, sends) }
compose.runOnIdle { ready.value = true }
compose.onNodeWithTag("chat_session_preparing").assertDoesNotExist()
compose.onNodeWithTag("send_button").assertIsEnabled().performClick()
compose.runOnIdle { assertEquals(1, sends) }
compose.runOnIdle { ready.value = false }
compose.onNodeWithTag("send_button").assertIsNotEnabled()
compose.onNodeWithTag("chat_input").assertTextEquals("held draft")
}
}
@@ -24,7 +24,8 @@ object ChatInputPolicy {
text: String,
pendingAttachments: List<Any>,
isConnected: Boolean,
): Boolean = (text.isNotBlank() || pendingAttachments.isNotEmpty()) && isConnected
isSessionReady: Boolean,
): Boolean = (text.isNotBlank() || pendingAttachments.isNotEmpty()) && isConnected && isSessionReady
/**
* Whether the input placeholder should read "queued" rather than the plain
@@ -759,15 +759,18 @@ fun ChatScreen(
inputFieldValue = inputFieldValue,
onInputChange = { inputFieldValue = it },
onSend = {
viewModel.sendMessage(inputFieldValue.text)
inputFieldValue = TextFieldValue("")
// Jump to bottom after send (serialized through the controller).
scrollController.jumpToBottom(animated = true)
if (viewModel.sendMessage(inputFieldValue.text)) {
inputFieldValue = TextFieldValue("")
// Jump only after an accepted send. A readiness race keeps the draft intact.
scrollController.jumpToBottom(animated = true)
}
},
onMicTap = mediaLaunchers.onMicTap,
isListening = mediaLaunchers.isListening,
isAgentTyping = state.isAgentTyping,
isConnected = state.isConnected,
isSessionReady = state.isSessionReady,
sessionPreparationFailed = state.resumeError != null,
commandCatalog = state.commandCatalog,
slashUsageCounts = state.slashUsageCounts,
pendingAttachments = state.pendingAttachments,
@@ -181,6 +181,7 @@ data class ChatUiState(
/** Agent todo / plan items (issue #736). */
val todos: List<TodoItem> = emptyList(),
// Session resume recovery (desktop parity: bounded auto-retry + error UI)
val isSessionReady: Boolean = false,
val resumeError: String? = null,
val isResumeRetrying: Boolean = false,
/** Text staged to prefill the composer (e.g. from /undo). */
@@ -587,6 +588,13 @@ class ChatViewModel(
// fire with a stale id and 4001 "session not found";
// handleGatewayReady rebinds it on the re-resume.
runtimeSessionId = null
resumedGeneration = -1L
hydratedGeneration = -1L
activeResumeRequestSequence = ++resumeRequestSequence
activeHydrationRequestSequence = ++hydrationRequestSequence
// Preserve metadata so late results and errors are rejected as stale.
sessionGeneration++
_uiState.update { it.copy(isSessionReady = false) }
// Fail any in-flight awaited RPCs so callers don't hang
// across the disconnect (delegated to HermesWsClient, issue #526).
wsClient.rejectAllPending()
@@ -1250,8 +1258,14 @@ class ChatViewModel(
if (request != null && isStaleSessionRequest(request)) return
when (method) {
WsMethods.SESSION_CREATE -> {
val resultMap = result as? Map<String, Any?> ?: return
val runtimeId = resultMap["session_id"] as? String ?: return
val resultMap = result as? Map<String, Any?>
val runtimeId = (resultMap?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
if (runtimeId == null) {
_uiState.update {
it.copy(isLoading = false, resumeError = "Invalid session creation response")
}
return
}
val storageId = resultMap["stored_session_id"] as? String ?: runtimeId
runtimeSessionId = runtimeId
// The gateway persists the row lazily on the first prompt —
@@ -1261,6 +1275,7 @@ class ChatViewModel(
_uiState.update {
it.copy(
currentSessionId = storageId,
isSessionReady = true,
isLoading = false,
messages = if (pendingInitialPrompt != null) it.messages else emptyList(),
chatTitle = "Hermes",
@@ -1308,7 +1323,7 @@ class ChatViewModel(
// must stay the storage key — storing the runtime id here made
// every later resume 4007 "session not found" (the DB lookup
// misses) and the REST transcript 404.
val runtimeId = resultMap["session_id"] as? String ?: return
val runtimeId = (resultMap["session_id"] as? String)?.takeIf { it.isNotBlank() } ?: return
val storageId = resultMap["stored_session_id"] as? String ?: runtimeId
val generation =
resetSessionState(
@@ -1317,6 +1332,7 @@ class ChatViewModel(
isLoading = false,
)
runtimeSessionId = runtimeId
resumedGeneration = generation
ActiveSessionHolder.set(runtimeId, storageId)
sessionHasServerPresence = false
sessionGoneRecoveryInFlight = false
@@ -1348,16 +1364,22 @@ class ChatViewModel(
WsMethods.SESSION_RESUME -> {
val resultMap = result as? Map<String, Any?>
runtimeSessionId = resultMap?.get("session_id") as? String
val runtimeId = (resultMap?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
if (runtimeId == null) {
val sessionId = request?.sessionId ?: _uiState.value.currentSessionId ?: return
handleResumeFailure(sessionId, sessionGeneration, "Invalid session resume response")
return
}
runtimeSessionId = runtimeId
// Resume succeeded — the gateway confirmed the DB row.
sessionHasServerPresence = true
val sessionId =
request?.sessionId
?: (resultMap?.get("resumed") as? String)
?: (resultMap["resumed"] as? String)
?: _uiState.value.currentSessionId
// Parse session info from backend — model, provider, reasoning_effort
val infoMap = resultMap?.get("info") as? Map<String, Any?>
val infoMap = resultMap["info"] as? Map<String, Any?>
val model = infoMap?.get("model") as? String
val provider = infoMap?.get("provider") as? String
val reasoningEffort = infoMap?.get("reasoning_effort") as? String
@@ -1423,7 +1445,7 @@ class ChatViewModel(
// Reconnect replay: resume payload can carry `pending_approval`
// (server `_session_info_payload`); surface it, then ask for
// the full queue in case more are parked.
val pendingApproval = resultMap?.get("pending_approval") as? Map<*, *>
val pendingApproval = resultMap["pending_approval"] as? Map<*, *>
if (pendingApproval != null) {
approvalsDelegate.maybeSurfacePendingApproval(
pendingApproval,
@@ -1582,6 +1604,7 @@ class ChatViewModel(
it.copy(
isLoading = false,
errorMessage = "Error ($method): $errorMsg",
resumeError = if (method == WsMethods.SESSION_CREATE) errorMsg else it.resumeError,
)
}
}
@@ -1603,8 +1626,9 @@ class ChatViewModel(
* 6. For each file → await `file.attach` (requires session_id), collect @file: refs
* 7. Send `prompt.submit` with text + @file: refs — images auto-picked up by backend
*/
fun sendMessage(text: String) {
if (text.isBlank() && _uiState.value.pendingAttachments.isEmpty()) return
fun sendMessage(text: String): Boolean {
if (!canSubmitMessage()) return false
if (text.isBlank() && _uiState.value.pendingAttachments.isEmpty()) return false
val trimmed = text.trim()
if (trimmed.startsWith("/", ignoreCase = true)) {
@@ -1612,10 +1636,10 @@ class ChatViewModel(
// of requiring the user to hand-type the provider/model.
if (modelSwitchDelegate.isModelPickerCommand(trimmed)) {
openModelPicker()
return
return true
}
handleSlashCommand(trimmed)
return
return true
}
val oversizedAttachment =
@@ -1629,7 +1653,7 @@ class ChatViewModel(
composerTextToRestore = text,
)
}
return
return true
}
// Snapshot + clear attachments so the input bar empties immediately
@@ -1661,7 +1685,7 @@ class ChatViewModel(
// Issue #969: Session creation is still in-flight. Hold the prompt
// so it is dispatched automatically the moment SESSION_CREATE lands.
pendingInitialPrompt = PendingPrompt(text, attachments, wasStreaming, userMessage)
return
return true
}
dispatchPrompt(
@@ -1672,8 +1696,23 @@ class ChatViewModel(
agentSessionId = agentSessionId,
userMessage = userMessage,
)
return true
}
private fun canSubmitMessage(): Boolean =
wsClient.connectionStatus.value == ConnectionStatus.CONNECTED &&
(
(_uiState.value.isSessionReady && runtimeSessionId != null) ||
(
_uiState.value.currentSessionId == null &&
pendingInitialPrompt == null &&
idToMethod.any { (id, method) ->
method == WsMethods.SESSION_CREATE &&
sessionRequestById[id]?.let { !isStaleSessionRequest(it) } == true
}
)
)
private fun dispatchPrompt(
text: String,
attachments: List<Attachment>,
@@ -2867,6 +2906,7 @@ class ChatViewModel(
_uiState.update {
it.copy(
messages = emptyList(),
isSessionReady = false,
currentSessionId = sessionId,
chatTitle = title,
isAgentTyping = false,
@@ -2913,6 +2953,9 @@ class ChatViewModel(
sessionId: String,
generation: Long,
) {
resumedGeneration = -1L
hydratedGeneration = -1L
_uiState.update { it.copy(isSessionReady = false) }
val requestSequence = ++resumeRequestSequence
activeResumeRequestSequence = requestSequence
val profile = AuthManager.activeProfileId.value
@@ -2963,6 +3006,7 @@ class ChatViewModel(
isResumeRetrying = false,
resumeError = null,
errorMessage = null,
isSessionReady = runtimeSessionId != null,
)
}
}
@@ -3037,7 +3081,7 @@ class ChatViewModel(
) {
// Only handle if still on this session.
if (!isCurrentSessionRequest(sessionId, generation)) return
_uiState.update { it.copy(errorMessage = null) }
_uiState.update { it.copy(errorMessage = null, isSessionReady = false) }
// New session → reset the counter for a fresh backoff cycle.
if (resumeRetrySessionId != sessionId) {
@@ -3108,7 +3152,11 @@ class ChatViewModel(
* resumeSession: reconnect / reselect / Retry all reset the counter).
*/
fun retryResumeSession() {
val sessionId = _uiState.value.currentSessionId ?: return
val sessionId = _uiState.value.currentSessionId
if (sessionId == null) {
createNewSession()
return
}
val generation = sessionGeneration
cancelResumeRetry()
_uiState.update {
@@ -80,6 +80,8 @@ fun ChatInputBar(
isAgentTyping: Boolean,
isConnected: Boolean,
commandCatalog: CommandCatalog,
isSessionReady: Boolean,
sessionPreparationFailed: Boolean = false,
slashUsageCounts: Map<String, Int> = emptyMap(),
pendingAttachments: List<Attachment> = emptyList(),
onCameraTap: () -> Unit = {},
@@ -103,7 +105,7 @@ fun ChatInputBar(
// gateway's prompt.submit busy-input policy queues it as the next turn
// (tui_gateway/server.py:_handle_busy_submit), so the message is never
// dropped. Slash commands were already allowed; regular prompts now are too.
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected)
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
// Attachment tray state
var showAttachmentTray by remember { mutableStateOf(false) }
@@ -203,6 +205,22 @@ fun ChatInputBar(
}
}
if (isConnected && !isSessionReady) {
Text(
text =
stringResource(
if (sessionPreparationFailed) {
R.string.chat_session_not_ready
} else {
R.string.chat_session_preparing
},
),
style = MaterialTheme.typography.labelSmall,
color = palette.placeholder,
modifier = Modifier.padding(horizontal = 20.dp).testTag("chat_session_preparing"),
)
}
// ── TOP ROW: Borderless input field ──
Row(
modifier =
@@ -296,6 +314,7 @@ fun ChatInputBar(
reasoningLevel = reasoningLevel,
isListening = isListening,
canSend = canSend,
showSend = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty(),
onSend = onSend,
onAttachTap = { showAttachmentTray = !showAttachmentTray },
onModelTap = onModelTap,
@@ -95,6 +95,7 @@ fun ComposerToolbar(
onMicTap: () -> Unit,
modifier: Modifier = Modifier,
canSend: Boolean = false,
showSend: Boolean = canSend,
onSend: () -> Unit = {},
canDisableReasoning: Boolean? = null,
supportsReasoning: Boolean? = null,
@@ -360,7 +361,7 @@ fun ComposerToolbar(
// Flat mic / stop button — only while the action button is in send mode
AnimatedVisibility(
visible = canSend,
visible = showSend,
enter = fadeIn() + scaleIn(initialScale = 0.8f),
exit = fadeOut() + scaleOut(targetScale = 0.8f),
) {
@@ -382,10 +383,10 @@ fun ComposerToolbar(
// Action button — send when a send is possible, mic / stop otherwise
FilledIconButton(
onClick = if (canSend) onSend else onMicTap,
enabled = canSend || isConnected,
onClick = if (showSend) onSend else onMicTap,
enabled = if (showSend) canSend else isConnected,
colors =
if (!canSend && isListening) {
if (!showSend && isListening) {
listeningIconButtonColors()
} else {
IconButtonDefaults.filledIconButtonColors(
@@ -398,7 +399,7 @@ fun ComposerToolbar(
.size(ControlSize)
.testTag(
when {
canSend -> "send_button"
showSend -> "send_button"
isListening -> "mic_stop_button"
else -> "mic_button"
},
@@ -407,7 +408,7 @@ fun ComposerToolbar(
Crossfade(
targetState =
when {
canSend -> ActionGlyph.SEND
showSend -> ActionGlyph.SEND
isListening -> ActionGlyph.STOP
else -> ActionGlyph.VOICE
},
+2
View File
@@ -1475,4 +1475,6 @@
<string name="session_integrations_retry">إعادة المحاولة</string>
<string name="session_integrations_close">إغلاق</string>
<string name="session_integrations_menu_item">تكاملات الجلسة</string>
<string name="chat_session_preparing">جارٍ تجهيز الجلسة…</string>
<string name="chat_session_not_ready">الجلسة غير جاهزة — أعد المحاولة للمتابعة</string>
</resources>
+2
View File
@@ -1159,4 +1159,6 @@
<string name="session_integrations_retry">다시 시도</string>
<string name="session_integrations_close">닫기</string>
<string name="session_integrations_menu_item">세션 연동</string>
<string name="chat_session_preparing">세션 준비 중…</string>
<string name="chat_session_not_ready">세션이 준비되지 않았습니다. 다시 시도하세요</string>
</resources>
+2
View File
@@ -1428,4 +1428,6 @@
<string name="session_integrations_retry">重试</string>
<string name="session_integrations_close">关闭</string>
<string name="session_integrations_menu_item">会话集成</string>
<string name="chat_session_preparing">正在准备会话…</string>
<string name="chat_session_not_ready">会话未就绪,请重试以继续</string>
</resources>
+2
View File
@@ -1642,4 +1642,6 @@
<string name="session_integrations_retry">Retry</string>
<string name="session_integrations_close">Close</string>
<string name="session_integrations_menu_item">Session Integrations</string>
<string name="chat_session_preparing">Preparing session…</string>
<string name="chat_session_not_ready">Session not ready — retry to continue</string>
</resources>
@@ -16,11 +16,20 @@ import org.junit.Test
* signal "queued" when the user has text ready during a busy turn.
*/
class ChatInputPolicyTest {
@Test
fun canSend_requiresReadySessionForTextCommandsAndAttachments() {
assertFalse(ChatInputPolicy.canSend("draft", emptyList(), true, false))
assertFalse(ChatInputPolicy.canSend("/queue draft", emptyList(), true, false))
assertFalse(ChatInputPolicy.canSend("", listOf(Any()), true, false))
assertTrue(ChatInputPolicy.canSend("draft", emptyList(), true, true))
assertTrue(ChatInputPolicy.canSend("", listOf(Any()), true, true))
}
@Test
fun canSend_allowsRegularPromptWhileAgentTyping() {
assertTrue(
"regular prompt must be sendable while agent is typing",
ChatInputPolicy.canSend("next task", emptyList(), isConnected = true),
ChatInputPolicy.canSend("next task", emptyList(), isConnected = true, isSessionReady = true),
)
}
@@ -31,7 +40,7 @@ class ChatInputPolicyTest {
// text are the only gates, so it must be allowed.
assertTrue(
"regular prompt must be sendable while an approval is pending",
ChatInputPolicy.canSend("yes, proceed", emptyList(), isConnected = true),
ChatInputPolicy.canSend("yes, proceed", emptyList(), isConnected = true, isSessionReady = true),
)
}
@@ -39,7 +48,7 @@ class ChatInputPolicyTest {
fun canSend_blocksWhenDisconnected() {
assertFalse(
"no sends while disconnected",
ChatInputPolicy.canSend("next task", emptyList(), isConnected = false),
ChatInputPolicy.canSend("next task", emptyList(), isConnected = false, isSessionReady = true),
)
}
@@ -47,7 +56,7 @@ class ChatInputPolicyTest {
fun canSend_blocksWhenInputEmptyAndNoAttachments() {
assertFalse(
"empty input with no attachments must not enable send",
ChatInputPolicy.canSend("", emptyList(), isConnected = true),
ChatInputPolicy.canSend("", emptyList(), isConnected = true, isSessionReady = true),
)
}
@@ -55,7 +64,7 @@ class ChatInputPolicyTest {
fun canSend_allowsAttachmentWithEmptyText() {
assertTrue(
"a pending attachment enables send even with empty text",
ChatInputPolicy.canSend("", listOf("att"), isConnected = true),
ChatInputPolicy.canSend("", listOf("att"), isConnected = true, isSessionReady = true),
)
}
@@ -89,7 +98,7 @@ class ChatInputPolicyTest {
// not regress that by blocking a non-blank slash command.
assertTrue(
"/stop must remain sendable while typing",
ChatInputPolicy.canSend("/stop", emptyList(), isConnected = true),
ChatInputPolicy.canSend("/stop", emptyList(), isConnected = true, isSessionReady = true),
)
}
@@ -2695,6 +2695,138 @@ class ChatViewModelTest {
// ── Session resume recovery (desktop parity: warm cache + bounded retry) ──
/** Override the send stub to capture (method → id) pairs. */
@Test
fun testSendReadiness_restBeforeResume_preservesDraftUntilAck() =
runTest {
stubSession456Rests(success = true)
val (viewModel, _) = createViewModelWithSession()
assertTrue(viewModel.uiState.value.isSessionReady)
val captured = captureSends()
viewModel.switchSession("session-456")
advanceUntilIdle()
val before = viewModel.uiState.value
assertFalse(before.isSessionReady)
assertFalse(viewModel.sendMessage("keep this draft"))
assertEquals(before.messages, viewModel.uiState.value.messages)
assertEquals(before.pendingAttachments, viewModel.uiState.value.pendingAttachments)
assertFalse(viewModel.uiState.value.isAgentTyping)
val id = captured.last { it.first == WsMethods.SESSION_RESUME }.second
mockEventsFlow.emit(WsEvent.RpcResult(id, mapOf("session_id" to "runtime-456")))
advanceUntilIdle()
assertTrue(viewModel.uiState.value.isSessionReady)
assertTrue(viewModel.sendMessage("keep this draft"))
advanceUntilIdle()
assertEquals(
1,
viewModel.uiState.value.messages
.count { it.content == "keep this draft" },
)
}
@Test
fun testSendReadiness_resumeBeforeRest_waitsForHydration() =
runTest {
stubSession456Rests(success = true)
val response =
CompletableDeferred<retrofit2.Response<com.m57.hermescontrol.data.model.SessionMessagesResponse>>()
coEvery {
ApiClient.hermesApi.getSessionMessages("session-456", any(), any(), any(), any())
} coAnswers { response.await() }
val (viewModel, _) = createViewModelWithSession()
val captured = captureSends()
viewModel.switchSession("session-456")
runCurrent()
val id = captured.last { it.first == WsMethods.SESSION_RESUME }.second
mockEventsFlow.emit(WsEvent.RpcResult(id, mapOf("session_id" to "runtime-456")))
runCurrent()
assertFalse(viewModel.uiState.value.isSessionReady)
assertFalse(viewModel.sendMessage("/queue draft"))
response.complete(
retrofit2.Response.success(
com.m57.hermescontrol.data.model
.SessionMessagesResponse(messages = emptyList()),
),
)
advanceUntilIdle()
assertTrue(viewModel.uiState.value.isSessionReady)
}
@Test
fun testSendReadiness_reconnectRejectsOldAckAndRequiresFreshHydration() =
runTest {
stubSession456Rests(success = true)
val (viewModel, _) = createViewModelWithSession()
val captured = captureSends()
viewModel.switchSession("session-456")
advanceUntilIdle()
val oldId = captured.last { it.first == WsMethods.SESSION_RESUME }.second
mockConnectionStatus.value = ConnectionStatus.RECONNECTING
runCurrent()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.RpcResult(oldId, mapOf("session_id" to "stale-runtime")))
runCurrent()
assertFalse(viewModel.uiState.value.isSessionReady)
assertFalse(viewModel.sendMessage("draft"))
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
assertFalse(viewModel.uiState.value.isSessionReady)
val freshId = captured.last { it.first == WsMethods.SESSION_RESUME }.second
mockEventsFlow.emit(WsEvent.RpcResult(freshId, mapOf("session_id" to "fresh-runtime")))
advanceUntilIdle()
assertTrue(viewModel.uiState.value.isSessionReady)
viewModel.retryResumeSession()
runCurrent()
assertFalse(viewModel.uiState.value.isSessionReady)
}
@Test
fun testSendReadiness_malformedAckNeverEnablesSend() =
runTest {
stubSession456Rests(success = true)
val (viewModel, _) = createViewModelWithSession()
val captured = captureSends()
viewModel.switchSession("session-456")
runCurrent()
val id = captured.last { it.first == WsMethods.SESSION_RESUME }.second
mockEventsFlow.emit(WsEvent.RpcResult(id, mapOf("session_id" to "")))
runCurrent()
assertFalse(viewModel.uiState.value.isSessionReady)
assertFalse(viewModel.sendMessage("draft"))
assertTrue(viewModel.uiState.value.isResumeRetrying)
}
@Test
fun testSendReadiness_abandonedCreateCannotAcceptDraft() =
runTest {
val viewModel = createViewModel()
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.RECONNECTING
runCurrent()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
runCurrent()
assertFalse(viewModel.sendMessage("retain me"))
assertFalse(viewModel.uiState.value.isAgentTyping)
}
@Test
fun testSendReadiness_pendingCreateAppliesBackpressure() =
runTest {
val viewModel = createViewModel()
advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle()
assertTrue(viewModel.sendMessage("first"))
assertFalse(viewModel.sendMessage("second"))
assertFalse(
viewModel.uiState.value.messages
.any { it.content == "second" },
)
}
private fun captureSends(): MutableList<Pair<String, String>> {
val captured = mutableListOf<Pair<String, String>>()
every { HermesWsClient.send(any(), any(), any()) } answers {