feat: gate chat send until session fully resumed (#1187)
This commit is contained in:
13 files changed
+310
-33
No files matched your search
+1
@@ -81,6 +81,7 @@ class ComposerInteractionTest {
|
||||
isListening = listening,
|
||||
isAgentTyping = false,
|
||||
isConnected = true,
|
||||
isSessionReady = true,
|
||||
commandCatalog = CommandCatalog(),
|
||||
currentSessionModel = currentModel,
|
||||
reasoningLevel = reasoningLevel,
|
||||
|
||||
+55
@@ -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
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user