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

@@ -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 {