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