diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt index 19530c0d..b9e04410 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt @@ -81,6 +81,7 @@ class ComposerInteractionTest { isListening = listening, isAgentTyping = false, isConnected = true, + isSessionReady = true, commandCatalog = CommandCatalog(), currentSessionModel = currentModel, reasoningLevel = reasoningLevel, diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt new file mode 100644 index 00000000..796c9a5e --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerReadinessTest.kt @@ -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") + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt index ec4b6d19..73619f6c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatInputPolicy.kt @@ -24,7 +24,8 @@ object ChatInputPolicy { text: String, pendingAttachments: List, 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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 74076dd0..43c67627 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 3ed442b6..2bd30c3e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -181,6 +181,7 @@ data class ChatUiState( /** Agent todo / plan items (issue #736). */ val todos: List = 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 ?: return - val runtimeId = resultMap["session_id"] as? String ?: return + val resultMap = result as? Map + 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 - 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 + val infoMap = resultMap["info"] as? Map 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, @@ -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 { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index c3d1a9b0..50900bd3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -80,6 +80,8 @@ fun ChatInputBar( isAgentTyping: Boolean, isConnected: Boolean, commandCatalog: CommandCatalog, + isSessionReady: Boolean, + sessionPreparationFailed: Boolean = false, slashUsageCounts: Map = emptyMap(), pendingAttachments: List = 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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index aab2a2f4..e5165cc9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -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 }, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index f8470fde..dfddfbed 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1475,4 +1475,6 @@ إعادة المحاولة إغلاق تكاملات الجلسة + جارٍ تجهيز الجلسة… + الجلسة غير جاهزة — أعد المحاولة للمتابعة \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 178ca427..6e5a2abf 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1159,4 +1159,6 @@ 다시 시도 닫기 세션 연동 + 세션 준비 중… + 세션이 준비되지 않았습니다. 다시 시도하세요 \ No newline at end of file diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index f5212d20..a14d975d 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1428,4 +1428,6 @@ 重试 关闭 会话集成 + 正在准备会话… + 会话未就绪,请重试以继续 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4e886710..6769d05a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1642,4 +1642,6 @@ Retry Close Session Integrations + Preparing session… + Session not ready — retry to continue diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt index 5381e410..b628f560 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatInputPolicyTest.kt @@ -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), ) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index b6846926..ee5330df 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -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>() + 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> { val captured = mutableListOf>() every { HermesWsClient.send(any(), any(), any()) } answers {