feat(chat): show session-scoped reply failure details (#1255)

This commit is contained in:
AlexDev_ authored and GitHub committed 2026-09-24 21:37:04 +03:00
1 parent 94ec95a33c
commit 0d4c0b40ad
13 files changed
+1251 -28

No files matched your search

@@ -1,9 +1,13 @@
package com.m57.hermescontrol.notification
import android.content.Context
import com.m57.hermescontrol.data.ws.WsEvent
import io.mockk.mockk
import io.mockk.verify
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
@@ -104,4 +108,59 @@ class ChatNotificationServiceTest {
atomicBoolean.get(),
)
}
@Test
fun `terminal reply error uses generic failure notification without success affordances`() {
val rawDiagnostic = "Authorization: Bearer secret-token"
val plan =
messageCompleteNotificationPlan(
event =
WsEvent.MessageComplete(
text = rawDiagnostic,
sessionId = "runtime-1",
storedSessionId = "stored-1",
completionId = "completion-1",
rawPayload =
mapOf(
"status" to "error",
"error" to rawDiagnostic,
),
),
targetSessionId = "stored-1",
newMessageText = "New message",
failureText = "Hermes couldn't finish this reply",
)
assertEquals("Hermes couldn't finish this reply", plan.text)
assertFalse(plan.text.contains(rawDiagnostic))
assertEquals("stored-1", plan.sessionId)
assertFalse(plan.isReplyMessage)
assertFalse(plan.allowInlineReply)
assertNull(plan.completionId)
assertNull(plan.correlationText)
}
@Test
fun `successful completion keeps reply correlation and inline reply`() {
val plan =
messageCompleteNotificationPlan(
event =
WsEvent.MessageComplete(
text = "Answer\ncontinued",
sessionId = "runtime-1",
completionId = "completion-1",
rawPayload = mapOf("status" to "ok"),
),
targetSessionId = "stored-1",
newMessageText = "New message",
failureText = "Hermes couldn't finish this reply",
)
assertEquals("Answer continued", plan.text)
assertEquals("stored-1", plan.sessionId)
assertTrue(plan.isReplyMessage)
assertTrue(plan.allowInlineReply)
assertEquals("completion-1", plan.completionId)
assertEquals("Answer\ncontinued", plan.correlationText)
}
}
@@ -0,0 +1,315 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.ws.WsEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatReplyErrorTest {
@Test
fun failedCompletionKeepsPartialReplyAndStopsThinking() {
val partial =
ChatMessage(id = "partial", role = MessageRole.ASSISTANT, content = "Partial reply", isStreaming = true)
val state =
ChatUiState(
currentSessionId = "runtime",
isAgentTyping = true,
isThinking = true,
streamingMessage = partial,
)
val streaming = StreamingState(streamingMessage = partial, isThinking = true)
val event =
WsEvent.MessageComplete(
text = "Partial reply",
sessionId = "runtime",
completionId = "completion-failed-1",
rawPayload = mapOf("status" to "error", "error" to "Provider failed", "partial" to true),
)
val result = ChatWsEventReducer.reduce(state, streaming, event, "runtime")
assertFalse(result.state.isThinking)
assertEquals(
"Partial reply",
result.state.messages
.single { it.id == "partial" }
.content,
)
assertNotNull(result.state.replyFailure)
assertFalse(result.state.isAgentTyping)
assertNull(result.streamingState.streamingMessage)
assertEquals(
"completion-failed-1",
result.state.messages
.single { it.id == "partial" }
.completionId,
)
assertTrue(result.effects.none { it is ReducerEffect.PersistMessage })
}
@Test
fun terminalErrorIsNeverPersistedAsAssistantProse() {
val event =
WsEvent.MessageComplete(
text = "Provider refused API_KEY=private-value",
sessionId = "runtime",
rawPayload = mapOf("status" to "error"),
)
val result = ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), event, "runtime")
assertNotNull(result.state.replyFailure)
assertFalse(
result.state.replyFailure!!
.details
.contains("private-value"),
)
assertTrue(result.state.messages.isEmpty())
assertTrue(result.effects.none { it is ReducerEffect.PersistMessage })
}
@Test
fun errorWithoutPartialFlagStillKeepsAlreadyStreamedProse() {
val streaming =
StreamingState(streamingMessage = ChatMessage(role = MessageRole.ASSISTANT, content = "Visible"))
val event = WsEvent.MessageComplete("Error!", null, rawPayload = mapOf("status" to "error"))
val result = ChatWsEventReducer.reduce(ChatUiState(), streaming, event)
assertEquals(
"Visible",
result.state.messages
.single()
.content,
)
assertNotNull(result.state.replyFailure)
}
@Test
fun otherSessionCannotShowFailureOrChangeStreaming() {
val state = ChatUiState(isAgentTyping = true)
val streaming = StreamingState(isThinking = true)
val event = WsEvent.MessageComplete("Error", "other", rawPayload = mapOf("status" to "error"))
val result = ChatWsEventReducer.reduce(state, streaming, event, "current")
assertEquals(state, result.state)
assertEquals(streaming, result.streamingState)
assertTrue(result.effects.isEmpty())
}
@Test
fun missingPartialTextPreservesAlreadyVisibleOutput() {
for (text in listOf("", " ")) {
val streaming =
StreamingState(
streamingMessage =
ChatMessage(
id = "visible",
role = MessageRole.ASSISTANT,
content = "Visible output",
),
)
val event =
WsEvent.MessageComplete(
text,
null,
rawPayload = mapOf("status" to "error", "partial" to true),
)
val result = ChatWsEventReducer.reduce(ChatUiState(), streaming, event)
assertEquals(
"Visible output",
result.state.messages
.single()
.content,
)
}
}
@Test
fun partialConversationIsNeverUsedAsDiagnosticFallback() {
val event =
WsEvent.MessageComplete(
"Private conversational content",
null,
rawPayload = mapOf("status" to "error", "partial" to true),
)
val result = ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), event)
assertEquals(
"Private conversational content",
result.state.messages
.single()
.content,
)
assertFalse(
result.state.replyFailure!!
.details
.contains("Private conversational content"),
)
assertTrue(
result.state.replyFailure!!
.details
.isNotBlank(),
)
}
@Test
fun normalReplyAndGatewayErrorAreNotTerminalReplyFailures() {
for (event in listOf(WsEvent.MessageComplete("Success", null), WsEvent.GatewayError("Connection failed"))) {
val result = ChatWsEventReducer.reduce(ChatUiState(), StreamingState(), event)
assertNull(result.state.replyFailure)
}
}
@Test
fun nextTurnClearsFailureButDoneDoesNot() {
val failure = ReplyFailure("Failed")
val failedPartial =
ChatMessage(id = "failed-partial", role = MessageRole.ASSISTANT, content = "Partial")
val state =
ChatUiState(
messages = listOf(ChatMessage(id = "normal", role = MessageRole.USER, content = "Keep"), failedPartial),
replyFailure = failure,
replyFailureProjection = ReplyFailureProjection("failed-partial", failure.id),
)
val done = ChatWsEventReducer.reduce(state, StreamingState(), WsEvent.MessageDone(null))
assertEquals(failure, done.state.replyFailure)
val start = ChatWsEventReducer.reduce(state, StreamingState(), WsEvent.MessageStart(null))
assertNull(start.state.replyFailure)
assertNull(start.state.replyFailureProjection)
assertEquals(listOf("normal"), start.state.messages.map { it.id })
}
@Test
fun retainedFailureWithEmptyAssistantNeverInheritsUnrelatedStream() {
val normal = ChatMessage(id = "normal", role = MessageRole.USER, content = "Keep")
val stale =
ChatMessage(
id = "unrelated-stream",
role = MessageRole.ASSISTANT,
content = "Stale text",
reasoningText = "Stale reasoning",
isStreaming = true,
)
val state =
ChatUiState(
messages = listOf(normal),
isAgentTyping = true,
isThinking = true,
thinkingText = "Stale thinking",
streamingMessage = stale,
)
val result =
ChatWsEventReducer.reduceRetainedReplyFailure(
state = state,
inflight = mapOf("assistant" to "", "status" to "error", "error" to "Provider failed"),
currentSessionId = "runtime",
)
assertEquals(listOf(normal), result.state.messages)
assertFalse(result.state.isAgentTyping)
assertFalse(result.state.isThinking)
assertEquals("", result.state.thinkingText)
assertNull(result.state.streamingMessage)
assertEquals(StreamingState(), result.streamingState)
assertNotNull(result.state.replyFailure)
assertNull(result.state.replyFailureProjection?.messageId)
}
@Test
fun duplicateRetainedFailureKeepsOneStableProjection() {
val normal = ChatMessage(id = "normal", role = MessageRole.USER, content = "Keep")
val inflight =
mapOf("assistant" to "Retained partial", "status" to "error", "error" to "Provider failed")
val first =
ChatWsEventReducer.reduceRetainedReplyFailure(
ChatUiState(messages = listOf(normal)),
inflight,
"runtime",
)
val second = ChatWsEventReducer.reduceRetainedReplyFailure(first.state, inflight, "runtime")
assertEquals(2, second.state.messages.size)
assertEquals(listOf("Keep", "Retained partial"), second.state.messages.map { it.content })
assertEquals(first.state.replyFailureProjection, second.state.replyFailureProjection)
assertEquals(first.state.replyFailure?.id, second.state.replyFailure?.id)
}
@Test
fun duplicateLiveFailureKeepsOneStableProjection() {
val normal = ChatMessage(id = "normal", role = MessageRole.USER, content = "Keep")
val partial =
ChatMessage(id = "live-partial", role = MessageRole.ASSISTANT, content = "Partial", isStreaming = true)
val event =
WsEvent.MessageComplete(
text = "Partial",
sessionId = "runtime",
completionId = "completion-1",
rawPayload = mapOf("status" to "error", "error" to "Provider failed", "partial" to true),
)
val first =
ChatWsEventReducer.reduce(
ChatUiState(messages = listOf(normal), currentSessionId = "runtime"),
StreamingState(streamingMessage = partial),
event,
"runtime",
)
val second = ChatWsEventReducer.reduce(first.state, first.streamingState, event, "runtime")
assertEquals(listOf("normal", "live-partial"), second.state.messages.map { it.id })
assertEquals(first.state.replyFailureProjection, second.state.replyFailureProjection)
assertEquals(first.state.replyFailure?.id, second.state.replyFailure?.id)
}
@Test
fun liveFailureThenResumeUpdatesSameProjection() {
val partial =
ChatMessage(
id = "live-partial",
role = MessageRole.ASSISTANT,
content = "Live partial",
isStreaming = true,
)
val live =
ChatWsEventReducer.reduce(
ChatUiState(currentSessionId = "stored"),
StreamingState(streamingMessage = partial),
WsEvent.MessageComplete(
text = "Live partial",
sessionId = "runtime",
completionId = "completion-1",
rawPayload = mapOf("status" to "error", "error" to "Provider failed", "partial" to true),
),
"runtime",
)
val resumed =
ChatWsEventReducer.reduceRetainedReplyFailure(
live.state,
mapOf("assistant" to "Retained partial", "status" to "error", "error" to "Provider failed"),
"runtime",
)
assertEquals(1, resumed.state.messages.size)
assertEquals(
"live-partial",
resumed.state.messages
.single()
.id,
)
assertEquals(
"Retained partial",
resumed.state.messages
.single()
.content,
)
assertEquals(
"completion-1",
resumed.state.messages
.single()
.completionId,
)
assertEquals(live.state.replyFailureProjection, resumed.state.replyFailureProjection)
assertEquals(live.state.replyFailure?.id, resumed.state.replyFailure?.id)
}
}
@@ -460,6 +460,104 @@ class ChatViewModelTest {
assertEquals("op-1218", operation?.opId)
}
@Test
fun sessionResume_restoresRetainedFailureAsNonDurablePartial() =
runTest {
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.switchSession("stored-session")
advanceUntilIdle()
val resumeRequestId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second
mockEventsFlow.emit(
WsEvent.RpcResult(
resumeRequestId,
mapOf(
"session_id" to "runtime-session",
"resumed" to "stored-session",
"inflight" to
mapOf(
"assistant" to "Retained partial answer",
"user" to "Private user prompt",
"streaming" to false,
"status" to "error",
"error" to "Provider failed",
"recoverable" to true,
"error_surface" to mapOf("provider" to "example", "code" to "rate_limit"),
),
),
),
)
advanceUntilIdle()
assertEquals("stored-session", viewModel.uiState.value.currentSessionId)
assertEquals(
"Retained partial answer",
viewModel.uiState.value.messages
.last()
.content,
)
assertEquals(
"code: rate_limit\nprovider: example\nProvider failed",
viewModel.uiState.value.replyFailure
?.details,
)
assertFalse(
viewModel.uiState.value.replyFailure!!
.details
.contains("Private user prompt"),
)
assertTrue(
fakeRepo.dao
.getMessagesForSession("stored-session")
.none { it.content == "Retained partial answer" },
)
}
@Test
fun staleSessionResumeFailureCannotClearHealthyCurrentStream() =
runTest {
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.switchSession("stored-old")
advanceUntilIdle()
val staleResumeId = sentRequestMethods.last { it.first == WsMethods.SESSION_RESUME }.second
viewModel.switchSession("stored-current")
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.MessageStart("stored-current"))
mockEventsFlow.emit(WsEvent.MessageToken("Healthy current stream", "stored-current"))
advanceUntilIdle()
mockEventsFlow.emit(
WsEvent.RpcResult(
staleResumeId,
mapOf(
"session_id" to "runtime-old",
"inflight" to
mapOf(
"assistant" to "Old failed partial",
"status" to "error",
"error" to "Old provider failure",
),
),
),
)
advanceUntilIdle()
assertEquals("stored-current", viewModel.uiState.value.currentSessionId)
assertNull(viewModel.uiState.value.replyFailure)
assertEquals(
"Healthy current stream",
viewModel.streamingState.value.streamingMessage
?.content,
)
assertTrue(
viewModel.uiState.value.messages
.none { it.content == "Old failed partial" },
)
}
private fun connectionOperationSnapshot(
sessionId: String,
seq: Long = 1L,
@@ -1387,6 +1485,96 @@ class ChatViewModelTest {
// ── Streaming tests ──────────────────────────────────────────────────────
@Test
fun terminalReplyFailureIsScopedDismissibleAndNeverResends() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
mockEventsFlow.emit(WsEvent.MessageToken("Partial", sessionId))
advanceUntilIdle()
mockEventsFlow.emit(
WsEvent.MessageComplete("Other error", "other-session", rawPayload = mapOf("status" to "error")),
)
advanceUntilIdle()
assertTrue(viewModel.uiState.value.isAgentTyping)
assertNull(viewModel.uiState.value.replyFailure)
assertEquals(
"Partial",
viewModel.streamingState.value.streamingMessage
?.content,
)
mockEventsFlow.emit(
WsEvent.MessageComplete("Provider error", sessionId, rawPayload = mapOf("status" to "error")),
)
advanceUntilIdle()
val failure = viewModel.uiState.value.replyFailure!!
assertFalse(viewModel.uiState.value.isAgentTyping)
assertFalse(viewModel.uiState.value.isThinking)
assertTrue(
viewModel.uiState.value.messages
.any { it.content == "Partial" },
)
assertFalse(
viewModel.uiState.value.messages
.any { it.content == "Provider error" },
)
viewModel.dismissReplyFailure("stale-card")
advanceUntilIdle()
assertEquals(failure, viewModel.uiState.value.replyFailure)
viewModel.dismissReplyFailure(failure.id)
advanceUntilIdle()
assertNull(viewModel.uiState.value.replyFailure)
verify(exactly = 0) { HermesWsClient.sendMessage(any(), any(), any(), any()) }
}
@Test
fun unscopedFailureCannotCrossSessionSwitch() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
val failure = WsEvent.MessageComplete("Error", null, rawPayload = mapOf("status" to "error"))
mockEventsFlow.emit(failure)
advanceUntilIdle()
assertNull(viewModel.uiState.value.replyFailure)
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
advanceUntilIdle()
viewModel.switchSession("another-session")
advanceUntilIdle()
// A delayed start without identity cannot authorize a failed turn on this chat.
mockEventsFlow.emit(WsEvent.MessageStart(null))
advanceUntilIdle()
mockEventsFlow.emit(failure)
advanceUntilIdle()
assertNull(viewModel.uiState.value.replyFailure)
assertFalse(
viewModel.uiState.value.messages
.any { it.content == "Error" },
)
}
@Test
fun unscopedFailureAfterForeignStartCannotUsePreviousSessionPin() =
runTest {
val (viewModel, sessionId) = createViewModelWithSession()
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
mockEventsFlow.emit(WsEvent.MessageToken("Current partial", sessionId))
advanceUntilIdle()
mockEventsFlow.emit(WsEvent.MessageStart("foreign-session"))
mockEventsFlow.emit(
WsEvent.MessageComplete("Foreign diagnostic", null, rawPayload = mapOf("status" to "error")),
)
advanceUntilIdle()
assertNull(viewModel.uiState.value.replyFailure)
assertEquals(
"Current partial",
viewModel.streamingState.value.streamingMessage
?.content,
)
assertFalse(
viewModel.uiState.value.messages
.any { it.content == "Foreign diagnostic" },
)
}
@Test
fun testMessageStreamingFlow() =
runTest {
@@ -0,0 +1,61 @@
package com.m57.hermescontrol.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ReplyFailureDetailsTest {
@Test(timeout = 2_000)
fun longNonMatchingDiagnosticDoesNotBlockUi() {
assertEquals("x".repeat(8_192), sanitizeReplyErrorDetails("x".repeat(100_000)))
}
@Test
fun redactsCredentialsAndUrlsBeforeDisplayOrExport() {
val raw =
"""
Provider failed with HTTP 429
Authorization: Bearer private-bearer
Cookie: sid=private-cookie; csrf=private-csrf
{"api_key":"private-key", "password": "private password", "access_token": "private-access"}
https://user:private-pass@example.test/path?token=private-query
OPENAI_API_KEY=private-env
-----BEGIN PRIVATE KEY-----
private-key-material
-----END PRIVATE KEY-----
""".trimIndent()
val details = sanitizeReplyErrorDetails(raw)
assertTrue(details.contains("HTTP 429"))
assertTrue(details.contains("[REDACTED]"))
assertFalse(details.contains("private-"))
assertFalse(details.contains("private password"))
}
@Test
fun limitsDetailsAndHandlesMissingMessage() {
assertTrue(sanitizeReplyErrorDetails("x".repeat(100_000)).length <= 8_192)
assertEquals("Unknown gateway error", sanitizeReplyErrorDetails(null))
assertEquals("Unknown gateway error", sanitizeReplyErrorDetails(" "))
}
@Test
fun structuredSurfaceExportsOnlyAllowedMetadataAndSanitizedError() {
val failure =
replyFailureFromPayload(
mapOf(
"status" to "error",
"error" to "HTTP 429",
"error_surface" to
mapOf(
"provider" to "example",
"code" to "rate_limit",
"unknown_field" to "private-information",
"message" to "private-conversation",
),
),
"fallback",
)
assertEquals("code: rate_limit\nprovider: example\nHTTP 429", failure?.details)
}
}