feat(chat): show session-scoped reply failure details (#1255)
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user