feat: gate chat send until session fully resumed (#1187)
This commit is contained in:
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 {
|
||||
|
||||
Reference in new issue
Block a user