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,
|
isListening = listening,
|
||||||
isAgentTyping = false,
|
isAgentTyping = false,
|
||||||
isConnected = true,
|
isConnected = true,
|
||||||
|
isSessionReady = true,
|
||||||
commandCatalog = CommandCatalog(),
|
commandCatalog = CommandCatalog(),
|
||||||
currentSessionModel = currentModel,
|
currentSessionModel = currentModel,
|
||||||
reasoningLevel = reasoningLevel,
|
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,
|
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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user