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
+139
@@ -0,0 +1,139 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.SemanticsProperties
|
||||||
|
import androidx.compose.ui.test.SemanticsMatcher
|
||||||
|
import androidx.compose.ui.test.assert
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsEnabled
|
||||||
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||||
|
import com.m57.hermescontrol.ui.chat.ReplyFailure
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ReplyErrorCardTest {
|
||||||
|
@get:Rule
|
||||||
|
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun detailsStartCollapsedAndCopyIsScrubbedWithoutAutomaticSharing() {
|
||||||
|
var copied = ""
|
||||||
|
var shared = false
|
||||||
|
compose.setContent {
|
||||||
|
HermesControlTheme {
|
||||||
|
Box(Modifier.width(320.dp)) {
|
||||||
|
ReplyErrorCard(
|
||||||
|
failure = ReplyFailure("HTTP 429\nAuthorization: Bearer private-key"),
|
||||||
|
onDismiss = {},
|
||||||
|
onOpenLogs = {},
|
||||||
|
onCopy = { copied = it },
|
||||||
|
onShare = { shared = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onNodeWithText("HTTP 429", substring = true).assertDoesNotExist()
|
||||||
|
|
||||||
|
compose.onNodeWithText(compose.activity.getString(R.string.chat_reply_failed_details)).performClick()
|
||||||
|
compose.onNodeWithText("HTTP 429", substring = true).assertIsDisplayed()
|
||||||
|
compose.onNodeWithText("private-key", substring = true).assertDoesNotExist()
|
||||||
|
compose.onNodeWithText(compose.activity.getString(R.string.chat_reply_failed_copy)).performClick()
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertEquals("HTTP 429\nAuthorization: [REDACTED]", copied)
|
||||||
|
assertFalse(shared)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sharingRequiresExpandedReviewAndUsesExactSanitizedPayload() {
|
||||||
|
var shared = ""
|
||||||
|
var logsOpened = 0
|
||||||
|
var dismissed = 0
|
||||||
|
compose.setContent {
|
||||||
|
HermesControlTheme {
|
||||||
|
ReplyErrorCard(
|
||||||
|
failure = ReplyFailure("Provider failed\nAuthorization: Bearer private-token"),
|
||||||
|
onDismiss = { dismissed++ },
|
||||||
|
onOpenLogs = { logsOpened++ },
|
||||||
|
onCopy = {},
|
||||||
|
onShare = { shared = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertEquals("", shared)
|
||||||
|
assertEquals(0, logsOpened)
|
||||||
|
assertEquals(0, dismissed)
|
||||||
|
}
|
||||||
|
val detailsLabel = compose.activity.getString(R.string.chat_reply_failed_details)
|
||||||
|
val collapsedLabel = compose.activity.getString(R.string.chat_reply_failed_details_collapsed)
|
||||||
|
val expandedLabel = compose.activity.getString(R.string.chat_reply_failed_details_expanded)
|
||||||
|
val shareLabel = compose.activity.getString(R.string.chat_reply_failed_share)
|
||||||
|
|
||||||
|
compose
|
||||||
|
.onNodeWithText(detailsLabel)
|
||||||
|
.assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, collapsedLabel))
|
||||||
|
compose.onNodeWithText(shareLabel).assertIsNotEnabled().performClick()
|
||||||
|
compose.runOnIdle { assertEquals("", shared) }
|
||||||
|
|
||||||
|
compose.onNodeWithText(detailsLabel).performClick()
|
||||||
|
compose
|
||||||
|
.onNodeWithText(detailsLabel)
|
||||||
|
.assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, expandedLabel))
|
||||||
|
compose.onNodeWithText(shareLabel).assertIsEnabled().performClick()
|
||||||
|
compose.onNodeWithText(compose.activity.getString(R.string.chat_reply_failed_logs)).performClick()
|
||||||
|
compose
|
||||||
|
.onNodeWithContentDescription(
|
||||||
|
compose.activity.getString(R.string.chat_reply_failed_dismiss),
|
||||||
|
).performClick()
|
||||||
|
compose.runOnIdle {
|
||||||
|
assertEquals("Provider failed\nAuthorization: [REDACTED]", shared)
|
||||||
|
assertEquals(1, logsOpened)
|
||||||
|
assertEquals(1, dismissed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun expandedReviewResetsForANewFailure() {
|
||||||
|
val failure = androidx.compose.runtime.mutableStateOf(ReplyFailure("First failure"))
|
||||||
|
compose.setContent {
|
||||||
|
HermesControlTheme {
|
||||||
|
ReplyErrorCard(
|
||||||
|
failure = failure.value,
|
||||||
|
onDismiss = {},
|
||||||
|
onOpenLogs = {},
|
||||||
|
onCopy = {},
|
||||||
|
onShare = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val detailsLabel = compose.activity.getString(R.string.chat_reply_failed_details)
|
||||||
|
val collapsedLabel = compose.activity.getString(R.string.chat_reply_failed_details_collapsed)
|
||||||
|
val shareLabel = compose.activity.getString(R.string.chat_reply_failed_share)
|
||||||
|
compose.onNodeWithText(detailsLabel).performClick()
|
||||||
|
compose.onNodeWithText(shareLabel).assertIsEnabled()
|
||||||
|
|
||||||
|
compose.runOnIdle { failure.value = ReplyFailure("Second failure") }
|
||||||
|
|
||||||
|
compose
|
||||||
|
.onNodeWithText(detailsLabel)
|
||||||
|
.assert(SemanticsMatcher.expectValue(SemanticsProperties.StateDescription, collapsedLabel))
|
||||||
|
compose.onNodeWithText(shareLabel).assertIsNotEnabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
|
|||||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
|
import com.m57.hermescontrol.ui.chat.replyFailureFromPayload
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -27,6 +28,46 @@ import kotlinx.coroutines.flow.collect
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
internal data class MessageCompleteNotificationPlan(
|
||||||
|
val text: String,
|
||||||
|
val sessionId: String?,
|
||||||
|
val isReplyMessage: Boolean,
|
||||||
|
val completionId: String?,
|
||||||
|
val correlationText: String?,
|
||||||
|
val allowInlineReply: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun messageCompleteNotificationPlan(
|
||||||
|
event: WsEvent.MessageComplete,
|
||||||
|
targetSessionId: String?,
|
||||||
|
newMessageText: String,
|
||||||
|
failureText: String,
|
||||||
|
): MessageCompleteNotificationPlan {
|
||||||
|
if (replyFailureFromPayload(event.rawPayload, fallback = "") != null) {
|
||||||
|
return MessageCompleteNotificationPlan(
|
||||||
|
text = failureText,
|
||||||
|
sessionId = targetSessionId,
|
||||||
|
isReplyMessage = false,
|
||||||
|
completionId = null,
|
||||||
|
correlationText = null,
|
||||||
|
allowInlineReply = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return MessageCompleteNotificationPlan(
|
||||||
|
text =
|
||||||
|
event.text
|
||||||
|
.take(100)
|
||||||
|
.replace("\n", " ")
|
||||||
|
.ifBlank { newMessageText },
|
||||||
|
sessionId = targetSessionId,
|
||||||
|
isReplyMessage = true,
|
||||||
|
completionId = event.completionId,
|
||||||
|
correlationText = event.text,
|
||||||
|
allowInlineReply = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Foreground service that keeps the WebSocket connection alive while the app
|
* Foreground service that keeps the WebSocket connection alive while the app
|
||||||
* is backgrounded and posts a notification when a new assistant reply
|
* is backgrounded and posts a notification when a new assistant reply
|
||||||
@@ -111,19 +152,22 @@ class ChatNotificationService : Service() {
|
|||||||
if (!isAppInForeground.get()) {
|
if (!isAppInForeground.get()) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is WsEvent.MessageComplete -> {
|
is WsEvent.MessageComplete -> {
|
||||||
val preview =
|
|
||||||
event.text
|
|
||||||
.take(100)
|
|
||||||
.replace("\n", " ")
|
|
||||||
.ifBlank { getString(R.string.notif_new_message) }
|
|
||||||
val targetSessionId =
|
val targetSessionId =
|
||||||
event.storedSessionId
|
event.storedSessionId
|
||||||
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId)
|
?: ActiveSessionHolder.resolveStoredSessionId(event.sessionId)
|
||||||
|
val plan =
|
||||||
|
messageCompleteNotificationPlan(
|
||||||
|
event = event,
|
||||||
|
targetSessionId = targetSessionId,
|
||||||
|
newMessageText = getString(R.string.notif_new_message),
|
||||||
|
failureText = getString(R.string.chat_reply_failed_title),
|
||||||
|
)
|
||||||
showReplyNotification(
|
showReplyNotification(
|
||||||
text = preview,
|
text = plan.text,
|
||||||
sessionId = targetSessionId,
|
sessionId = plan.sessionId,
|
||||||
isReplyMessage = true,
|
isReplyMessage = plan.isReplyMessage,
|
||||||
completionId = event.completionId,
|
completionId = plan.completionId,
|
||||||
|
allowInlineReply = plan.allowInlineReply,
|
||||||
// The durable REST row for this turn, when the
|
// The durable REST row for this turn, when the
|
||||||
// boundary armed before the prompt was submitted
|
// boundary armed before the prompt was submitted
|
||||||
// still lets us name it unambiguously. Null is a
|
// still lets us name it unambiguously. Null is a
|
||||||
@@ -132,7 +176,9 @@ class ChatNotificationService : Service() {
|
|||||||
// is strictly better than dismissing the wrong
|
// is strictly better than dismissing the wrong
|
||||||
// duplicate reply.
|
// duplicate reply.
|
||||||
serverMessageId =
|
serverMessageId =
|
||||||
coalesceTurnRow(targetSessionId, event.text),
|
plan.correlationText?.let {
|
||||||
|
coalesceTurnRow(plan.sessionId, it)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
// The wait is over — retire the foreground
|
// The wait is over — retire the foreground
|
||||||
// service. The reply notification above
|
// service. The reply notification above
|
||||||
@@ -216,6 +262,7 @@ class ChatNotificationService : Service() {
|
|||||||
isReplyMessage: Boolean = false,
|
isReplyMessage: Boolean = false,
|
||||||
completionId: String? = null,
|
completionId: String? = null,
|
||||||
serverMessageId: Int? = null,
|
serverMessageId: Int? = null,
|
||||||
|
allowInlineReply: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val builder =
|
val builder =
|
||||||
NotificationCompat
|
NotificationCompat
|
||||||
@@ -262,7 +309,7 @@ class ChatNotificationService : Service() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionId.isNullOrBlank()) {
|
if (allowInlineReply && !sessionId.isNullOrBlank()) {
|
||||||
val replyLabel = getString(R.string.notif_reply_placeholder)
|
val replyLabel = getString(R.string.notif_reply_placeholder)
|
||||||
val remoteInput =
|
val remoteInput =
|
||||||
RemoteInput
|
RemoteInput
|
||||||
|
|||||||
@@ -64,9 +64,11 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -76,8 +78,10 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.m57.hermescontrol.BuildConfig
|
||||||
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
import com.m57.hermescontrol.ExternalActivityLifecycleGuard
|
||||||
import com.m57.hermescontrol.HistoryScreen
|
import com.m57.hermescontrol.HistoryScreen
|
||||||
|
import com.m57.hermescontrol.LogsScreen
|
||||||
import com.m57.hermescontrol.NavigationController
|
import com.m57.hermescontrol.NavigationController
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
@@ -102,6 +106,7 @@ import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet
|
|||||||
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
|
import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.ReplyErrorCard
|
||||||
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
||||||
import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet
|
import com.m57.hermescontrol.ui.chat.components.SessionIntegrationsSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
|
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
|
||||||
@@ -784,6 +789,39 @@ fun ChatScreen(
|
|||||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||||
openingAttachmentPath = state.openingAttachmentPath,
|
openingAttachmentPath = state.openingAttachmentPath,
|
||||||
onImageClick = { viewingImage = it },
|
onImageClick = { viewingImage = it },
|
||||||
|
replyErrorContent =
|
||||||
|
state.replyFailure?.takeUnless { timelineState.isHistorical }?.let { failure ->
|
||||||
|
{
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
val copiedMessage = stringResource(R.string.chat_reply_failed_copied)
|
||||||
|
val shareTitle = stringResource(R.string.chat_reply_failed_share)
|
||||||
|
val shareUnavailable = stringResource(R.string.chat_reply_failed_share_unavailable)
|
||||||
|
ReplyErrorCard(
|
||||||
|
failure = failure,
|
||||||
|
onDismiss = { viewModel.dismissReplyFailure(failure.id) },
|
||||||
|
onOpenLogs = { NavigationController.navigateTo(LogsScreen) },
|
||||||
|
onCopy = { details ->
|
||||||
|
clipboard.setText(AnnotatedString(details))
|
||||||
|
scrollScope.launch { snackbarHostState.showSnackbar(copiedMessage) }
|
||||||
|
},
|
||||||
|
onShare = { details ->
|
||||||
|
val report = "Hermes Mobile ${BuildConfig.VERSION_NAME}\n\n$details"
|
||||||
|
val intent =
|
||||||
|
Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/plain"
|
||||||
|
putExtra(Intent.EXTRA_TEXT, report)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
launchExternalActivity {
|
||||||
|
context.startActivity(Intent.createChooser(intent, shareTitle))
|
||||||
|
}
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
scrollScope.launch { snackbarHostState.showSnackbar(shareUnavailable) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Loading overlay
|
// Loading overlay
|
||||||
|
|||||||
@@ -137,6 +137,10 @@ data class ChatUiState(
|
|||||||
/** Standalone streaming message — rendered after the main list. */
|
/** Standalone streaming message — rendered after the main list. */
|
||||||
val streamingMessage: ChatMessage? = null,
|
val streamingMessage: ChatMessage? = null,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
|
/** Persistent until dismissed/new turn; distinct from one-shot RPC/network snackbars. */
|
||||||
|
val replyFailure: ReplyFailure? = null,
|
||||||
|
/** Identity of the single non-durable failed assistant projection for this session lifecycle. */
|
||||||
|
val replyFailureProjection: ReplyFailureProjection? = null,
|
||||||
// Background job completion toast (issue #527) — non-blocking snackbar
|
// Background job completion toast (issue #527) — non-blocking snackbar
|
||||||
val backgroundCompleteMessage: String? = null,
|
val backgroundCompleteMessage: String? = null,
|
||||||
// Attachment feedback — surfaced as a non-blocking snackbar (issue #724)
|
// Attachment feedback — surfaced as a non-blocking snackbar (issue #724)
|
||||||
@@ -842,6 +846,23 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleWsEvent(event: WsEvent) {
|
private fun handleWsEvent(event: WsEvent) {
|
||||||
|
// Filter explicitly scoped terminal/start events before buffer resets and reduction.
|
||||||
|
// Legacy unscoped failures are ambiguous across sessions, so fail closed: gateways
|
||||||
|
// must include session_id for failed replies to be surfaced by this client.
|
||||||
|
val turnSessionId =
|
||||||
|
when (event) {
|
||||||
|
is WsEvent.MessageStart -> event.sessionId
|
||||||
|
is WsEvent.MessageComplete -> event.sessionId
|
||||||
|
is WsEvent.MessageDone -> event.sessionId
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (turnSessionId != null && !isCurrentSession(turnSessionId)) return
|
||||||
|
if (event is WsEvent.MessageComplete && event.rawPayload?.get("status") == "error" &&
|
||||||
|
event.sessionId == null
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// RpcError is reduced before ViewModel request handling. Drop stale
|
// RpcError is reduced before ViewModel request handling. Drop stale
|
||||||
// session errors here so the shared reducer cannot clear loading or
|
// session errors here so the shared reducer cannot clear loading or
|
||||||
// surface an error for a newly selected session.
|
// surface an error for a newly selected session.
|
||||||
@@ -1238,10 +1259,13 @@ class ChatViewModel(
|
|||||||
|
|
||||||
// ── Message streaming ────────────────────────────────────────────────
|
// ── Message streaming ────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
fun dismissReplyFailure(id: String) {
|
||||||
* Checks if an incoming WS event belongs to the currently active
|
_uiState.update { state ->
|
||||||
* session. Returns true if the event should be processed.
|
if (state.replyFailure?.id == id) state.copy(replyFailure = null) else state
|
||||||
*/
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks if an incoming WS event belongs to the currently active session. */
|
||||||
private fun isCurrentSession(eventSessionId: String?): Boolean {
|
private fun isCurrentSession(eventSessionId: String?): Boolean {
|
||||||
// If the event has no session ID, process it (legacy compatibility)
|
// If the event has no session ID, process it (legacy compatibility)
|
||||||
if (eventSessionId == null) return true
|
if (eventSessionId == null) return true
|
||||||
@@ -1510,20 +1534,23 @@ class ChatViewModel(
|
|||||||
|
|
||||||
WsMethods.SESSION_RESUME -> {
|
WsMethods.SESSION_RESUME -> {
|
||||||
val resultMap = result as? Map<String, Any?>
|
val resultMap = result as? Map<String, Any?>
|
||||||
|
// A resume response may carry retained terminal state. Correlate the
|
||||||
|
// exact request and stored session before binding the runtime id or
|
||||||
|
// projecting any failure; a late response must not touch the new chat.
|
||||||
|
val resumeRequest = request ?: return
|
||||||
|
val sessionId = resumeRequest.sessionId ?: return
|
||||||
|
if (sessionId != _uiState.value.currentSessionId) return
|
||||||
|
val resumedStoredId = (resultMap?.get("resumed") as? String)?.takeIf { it.isNotBlank() }
|
||||||
|
if (resumedStoredId != null && resumedStoredId != sessionId) return
|
||||||
val runtimeId = (resultMap?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
|
val runtimeId = (resultMap?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
|
||||||
if (runtimeId == null) {
|
if (runtimeId == null) {
|
||||||
val sessionId = request?.sessionId ?: _uiState.value.currentSessionId ?: return
|
handleResumeFailure(sessionId, resumeRequest.generation, "Invalid session resume response")
|
||||||
handleResumeFailure(sessionId, sessionGeneration, "Invalid session resume response")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
runtimeSessionId = runtimeId
|
runtimeSessionId = runtimeId
|
||||||
connectionOperationDelegate.bindSession(runtimeId)
|
connectionOperationDelegate.bindSession(runtimeId)
|
||||||
// Resume succeeded — the gateway confirmed the DB row.
|
// Resume succeeded — the gateway confirmed the DB row.
|
||||||
sessionHasServerPresence = true
|
sessionHasServerPresence = true
|
||||||
val sessionId =
|
|
||||||
request?.sessionId
|
|
||||||
?: (resultMap["resumed"] as? String)
|
|
||||||
?: _uiState.value.currentSessionId
|
|
||||||
|
|
||||||
// Parse session info from backend — model, provider, reasoning_effort
|
// Parse session info from backend — model, provider, reasoning_effort
|
||||||
val infoMap = resultMap["info"] as? Map<String, Any?>
|
val infoMap = resultMap["info"] as? Map<String, Any?>
|
||||||
@@ -1592,7 +1619,8 @@ class ChatViewModel(
|
|||||||
ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId)
|
ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId)
|
||||||
addSystemMessage("Session resumed")
|
addSystemMessage("Session resumed")
|
||||||
fetchContextUsage()
|
fetchContextUsage()
|
||||||
val generation = request?.generation ?: sessionGeneration
|
projectRetainedReplyFailure(resultMap["inflight"] as? Map<String, Any?>, runtimeId)
|
||||||
|
val generation = resumeRequest.generation
|
||||||
resumedGeneration = generation
|
resumedGeneration = generation
|
||||||
finishResumeWhenHydrated(generation)
|
finishResumeWhenHydrated(generation)
|
||||||
subagentsDelegate.hydrateSubagents(runtimeSessionId ?: sessionId)
|
subagentsDelegate.hydrateSubagents(runtimeSessionId ?: sessionId)
|
||||||
@@ -1663,6 +1691,25 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replays a gateway-retained failed turn through the same reducer path as a
|
||||||
|
* live terminal `message.complete(status=error)`. The assistant field is
|
||||||
|
* the partial display projection; user/transcript fields are never exported
|
||||||
|
* as diagnostics, and reducer effects intentionally do not persist it.
|
||||||
|
*/
|
||||||
|
private fun projectRetainedReplyFailure(
|
||||||
|
inflight: Map<String, Any?>?,
|
||||||
|
runtimeId: String,
|
||||||
|
) {
|
||||||
|
if (inflight?.get("status") != "error") return
|
||||||
|
val result =
|
||||||
|
ChatWsEventReducer.reduceRetainedReplyFailure(_uiState.value, inflight, runtimeId)
|
||||||
|
_uiState.value = result.state
|
||||||
|
_streamingState.value = result.streamingState
|
||||||
|
dispatchReducerEffects(result.effects)
|
||||||
|
streamingController.resetStreaming()
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
private fun handleDispatchResult(result: Any?) {
|
private fun handleDispatchResult(result: Any?) {
|
||||||
val map = result as? Map<*, *> ?: return
|
val map = result as? Map<*, *> ?: return
|
||||||
@@ -3546,6 +3593,8 @@ class ChatViewModel(
|
|||||||
hasOlderMessages = false,
|
hasOlderMessages = false,
|
||||||
streamingMessage = null,
|
streamingMessage = null,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
|
replyFailure = null,
|
||||||
|
replyFailureProjection = null,
|
||||||
openError = null,
|
openError = null,
|
||||||
clarifyRequest = null,
|
clarifyRequest = null,
|
||||||
sudoPrompt = null,
|
sudoPrompt = null,
|
||||||
|
|||||||
@@ -69,6 +69,40 @@ private fun stripSealedOrphanPrefix(
|
|||||||
* Android or ViewModel classes. Easy to unit test.
|
* Android or ViewModel classes. Easy to unit test.
|
||||||
*/
|
*/
|
||||||
object ChatWsEventReducer {
|
object ChatWsEventReducer {
|
||||||
|
/**
|
||||||
|
* Projects retained terminal state without consulting any live streaming buffers.
|
||||||
|
* Only `inflight.assistant` may become assistant prose; stale UI text, reasoning,
|
||||||
|
* and message ids are deliberately excluded.
|
||||||
|
*/
|
||||||
|
fun reduceRetainedReplyFailure(
|
||||||
|
state: ChatUiState,
|
||||||
|
inflight: Map<String, Any?>,
|
||||||
|
currentSessionId: String,
|
||||||
|
): ReducerResult {
|
||||||
|
if (inflight["status"] != "error") return ReducerResult(state, StreamingState())
|
||||||
|
val assistant = (inflight["assistant"] as? String).orEmpty()
|
||||||
|
val payload = inflight.toMutableMap().apply { put("partial", assistant.isNotBlank()) }
|
||||||
|
val cleanState =
|
||||||
|
state.copy(
|
||||||
|
isAgentTyping = false,
|
||||||
|
isThinking = false,
|
||||||
|
thinkingText = "",
|
||||||
|
streamingMessage = null,
|
||||||
|
)
|
||||||
|
return reduce(
|
||||||
|
cleanState,
|
||||||
|
StreamingState(),
|
||||||
|
WsEvent.MessageComplete(
|
||||||
|
text = assistant,
|
||||||
|
sessionId = currentSessionId,
|
||||||
|
// Retained inflight has no completion identity; suppress the event's random default.
|
||||||
|
completionId = "",
|
||||||
|
rawPayload = payload,
|
||||||
|
),
|
||||||
|
currentSessionId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun reduce(
|
fun reduce(
|
||||||
state: ChatUiState,
|
state: ChatUiState,
|
||||||
streamingState: StreamingState,
|
streamingState: StreamingState,
|
||||||
@@ -239,6 +273,18 @@ object ChatWsEventReducer {
|
|||||||
streamingState: StreamingState,
|
streamingState: StreamingState,
|
||||||
event: WsEvent.MessageStart,
|
event: WsEvent.MessageStart,
|
||||||
): ReducerResult {
|
): ReducerResult {
|
||||||
|
val previousFailureMessageId = state.replyFailureProjection?.messageId
|
||||||
|
val turnState =
|
||||||
|
state.copy(
|
||||||
|
messages =
|
||||||
|
if (previousFailureMessageId == null) {
|
||||||
|
state.messages
|
||||||
|
} else {
|
||||||
|
state.messages.filterNot { it.id == previousFailureMessageId }
|
||||||
|
},
|
||||||
|
replyFailure = null,
|
||||||
|
replyFailureProjection = null,
|
||||||
|
)
|
||||||
val msg =
|
val msg =
|
||||||
ChatMessage(
|
ChatMessage(
|
||||||
role = MessageRole.ASSISTANT,
|
role = MessageRole.ASSISTANT,
|
||||||
@@ -252,10 +298,10 @@ object ChatWsEventReducer {
|
|||||||
val effects = mutableListOf<ReducerEffect>()
|
val effects = mutableListOf<ReducerEffect>()
|
||||||
|
|
||||||
val cleanedSubagents =
|
val cleanedSubagents =
|
||||||
if (state.subagentIndicators.all { it.isComplete || it.isFailed }) {
|
if (turnState.subagentIndicators.all { it.isComplete || it.isFailed }) {
|
||||||
emptyList()
|
emptyList()
|
||||||
} else {
|
} else {
|
||||||
state.subagentIndicators
|
turnState.subagentIndicators
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build new state: finalize any orphan streaming message, then set the new one
|
// Build new state: finalize any orphan streaming message, then set the new one
|
||||||
@@ -270,13 +316,13 @@ object ChatWsEventReducer {
|
|||||||
tps = null,
|
tps = null,
|
||||||
)
|
)
|
||||||
orphan = finalized
|
orphan = finalized
|
||||||
state.copy(
|
turnState.copy(
|
||||||
messages = state.messages.upsertById(finalized),
|
messages = turnState.messages.upsertById(finalized),
|
||||||
isAgentTyping = true,
|
isAgentTyping = true,
|
||||||
subagentIndicators = cleanedSubagents,
|
subagentIndicators = cleanedSubagents,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
state.copy(
|
turnState.copy(
|
||||||
isAgentTyping = true,
|
isAgentTyping = true,
|
||||||
subagentIndicators = cleanedSubagents,
|
subagentIndicators = cleanedSubagents,
|
||||||
)
|
)
|
||||||
@@ -406,6 +452,8 @@ object ChatWsEventReducer {
|
|||||||
streamingState: StreamingState,
|
streamingState: StreamingState,
|
||||||
event: WsEvent.MessageComplete,
|
event: WsEvent.MessageComplete,
|
||||||
): ReducerResult {
|
): ReducerResult {
|
||||||
|
val failure = replyFailureFromPayload(event.rawPayload, event.text)
|
||||||
|
if (failure != null) return onReplyFailure(state, streamingState, event, failure)
|
||||||
val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot)
|
val finalSnapshot = event.rawPayload?.let(::parseUsageSnapshot)
|
||||||
val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
|
val usageState = finalSnapshot?.let { applyUsageSnapshot(state, it) } ?: state
|
||||||
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline)
|
val turnUsage = finalSnapshot?.deltaFrom(streamingState.turnUsageBaseline)
|
||||||
@@ -520,6 +568,86 @@ object ChatWsEventReducer {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep terminal diagnostics out of normal assistant messages and persistence.
|
||||||
|
private fun onReplyFailure(
|
||||||
|
state: ChatUiState,
|
||||||
|
streamingState: StreamingState,
|
||||||
|
event: WsEvent.MessageComplete,
|
||||||
|
failure: ReplyFailure,
|
||||||
|
): ReducerResult {
|
||||||
|
val usageState = event.rawPayload?.let(::parseUsageSnapshot)?.let { applyUsageSnapshot(state, it) } ?: state
|
||||||
|
val streaming = streamingState.streamingMessage
|
||||||
|
val text =
|
||||||
|
if (event.rawPayload?.get("partial") == true) {
|
||||||
|
event.text
|
||||||
|
.takeIf { it.isNotBlank() }
|
||||||
|
?.let { stripSealedOrphanPrefix(it, state.messages, streamingState.sealedOrphanIds) }
|
||||||
|
?: streaming?.content.orEmpty()
|
||||||
|
} else {
|
||||||
|
streaming?.content.orEmpty()
|
||||||
|
}
|
||||||
|
val reasoning = streamingState.reasoningText.ifBlank { streaming?.reasoningText.orEmpty() }
|
||||||
|
val existingProjection = state.replyFailureProjection
|
||||||
|
val completionId = event.completionId?.takeIf { it.isNotBlank() } ?: existingProjection?.completionId
|
||||||
|
val sessionIdentity = state.currentSessionId ?: event.sessionId.orEmpty()
|
||||||
|
val failureId =
|
||||||
|
existingProjection?.failureId
|
||||||
|
?: event.completionId?.takeIf { it.isNotBlank() }?.let { "reply-failure-$sessionIdentity-$it" }
|
||||||
|
?: failure.id
|
||||||
|
val projectionMessageId =
|
||||||
|
existingProjection?.messageId
|
||||||
|
?: streaming?.id
|
||||||
|
?: event.completionId?.takeIf { it.isNotBlank() }?.let { "reply-failure-message-$it" }
|
||||||
|
?: if (text.isNotBlank() || reasoning.isNotBlank()) "reply-failure-message-$failureId" else null
|
||||||
|
val projection = ReplyFailureProjection(projectionMessageId, failureId, completionId)
|
||||||
|
val existingProjectedMessage =
|
||||||
|
projectionMessageId?.let { id -> usageState.messages.firstOrNull { it.id == id } }
|
||||||
|
val partial =
|
||||||
|
if (text.isNotBlank() || reasoning.isNotBlank()) {
|
||||||
|
val base =
|
||||||
|
streaming
|
||||||
|
?: existingProjectedMessage
|
||||||
|
?: ChatMessage(
|
||||||
|
id = checkNotNull(projectionMessageId),
|
||||||
|
role = MessageRole.ASSISTANT,
|
||||||
|
content = "",
|
||||||
|
)
|
||||||
|
base.copy(
|
||||||
|
id = checkNotNull(projectionMessageId),
|
||||||
|
content = text,
|
||||||
|
reasoningText = reasoning,
|
||||||
|
isStreaming = false,
|
||||||
|
finishTimestamp = System.currentTimeMillis(),
|
||||||
|
completionId = completionId,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val messagesWithoutOldProjection =
|
||||||
|
existingProjection?.messageId?.let { oldId -> usageState.messages.filterNot { it.id == oldId } }
|
||||||
|
?: usageState.messages
|
||||||
|
val projectedMessages =
|
||||||
|
partial?.let { messagesWithoutOldProjection.upsertById(it) } ?: messagesWithoutOldProjection
|
||||||
|
// A failed partial is a transient projection of gateway `inflight`, not a
|
||||||
|
// successful transcript row. Persisting it would erase failure provenance on
|
||||||
|
// hydration and make a later session reopen look healthy.
|
||||||
|
val effects = listOf(ReducerEffect.RefreshSessions, ReducerEffect.RefreshContextUsage)
|
||||||
|
return ReducerResult(
|
||||||
|
state =
|
||||||
|
usageState.copy(
|
||||||
|
messages = projectedMessages,
|
||||||
|
replyFailure = failure.copy(id = failureId),
|
||||||
|
replyFailureProjection = projection,
|
||||||
|
isAgentTyping = false,
|
||||||
|
isThinking = false,
|
||||||
|
streamingMessage = null,
|
||||||
|
clarifyRequest = null,
|
||||||
|
),
|
||||||
|
streamingState = StreamingState(),
|
||||||
|
effects = effects,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── MessageDone ───────────────────────────────────────────────────
|
// ── MessageDone ───────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun onMessageDone(
|
private fun onMessageDone(
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** Session-scoped presentation, never a send receipt or a persisted transcript row. */
|
||||||
|
data class ReplyFailure(
|
||||||
|
val details: String,
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Stable identity for the one transient failed-reply projection in a session lifecycle. */
|
||||||
|
data class ReplyFailureProjection(
|
||||||
|
val messageId: String?,
|
||||||
|
val failureId: String,
|
||||||
|
val completionId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Only a terminal turn failure is a failed reply; RPC/transport errors are separate. */
|
||||||
|
internal fun replyFailureFromPayload(
|
||||||
|
payload: Map<String, Any?>?,
|
||||||
|
fallback: String,
|
||||||
|
): ReplyFailure? {
|
||||||
|
if (payload?.get("status") != "error") return null
|
||||||
|
val surface = payload["error_surface"] as? Map<*, *>
|
||||||
|
val details =
|
||||||
|
buildList {
|
||||||
|
for (key in listOf("layer", "code", "provider", "model")) {
|
||||||
|
(surface?.get(key) as? String)?.takeIf { it.isNotBlank() }?.let { add("$key: $it") }
|
||||||
|
}
|
||||||
|
val error = (payload["error"] as? String)?.takeIf { it.isNotBlank() }
|
||||||
|
add(error ?: if (payload["partial"] == true) "Unknown gateway error" else fallback)
|
||||||
|
}.joinToString("\n")
|
||||||
|
return ReplyFailure(sanitizeReplyErrorDetails(details))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort scrubbing before display/copy/share; never includes a transcript or request payload. */
|
||||||
|
internal fun sanitizeReplyErrorDetails(raw: String?): String {
|
||||||
|
if (raw.isNullOrBlank()) return "Unknown gateway error"
|
||||||
|
var safe = raw.take(8_192)
|
||||||
|
safe = PRIVATE_KEY.replace(safe, "[REDACTED]")
|
||||||
|
safe = HEADER.replace(safe) { "${it.groupValues[1]}[REDACTED]" }
|
||||||
|
safe = URL.replace(safe, "[REDACTED URL]")
|
||||||
|
safe = SECRET_ASSIGNMENT.replace(safe) { "${it.groupValues[1]}[REDACTED]" }
|
||||||
|
safe = BEARER.replace(safe, "Bearer [REDACTED]")
|
||||||
|
safe = PROVIDER_KEY.replace(safe, "[REDACTED]")
|
||||||
|
return safe.take(8_192)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PRIVATE_KEY = Regex("-----BEGIN [^-]*PRIVATE KEY-----[\\s\\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)")
|
||||||
|
private val HEADER = Regex("(?im)^([ \\t]*(?:authorization|proxy-authorization|set-cookie|cookie)\\s*:\\s*)[^\\r\\n]*")
|
||||||
|
private val URL = Regex("(?i)(?:https?|wss?)://[^\\s<>\\\"']+")
|
||||||
|
private val SECRET_ASSIGNMENT =
|
||||||
|
Regex(
|
||||||
|
"(?i)((?<![\\w-])[\\\"']?[\\w-]{0,128}" +
|
||||||
|
"(?:token|secret|password|passwd|api[_-]?key|authorization|cookie)[\\w-]{0,128}" +
|
||||||
|
"[\\\"']?\\s*[:=]\\s*)(?:\\\"[^\\\"]*\\\"?|'[^']*'?|[^\\r\\n,;}]+)",
|
||||||
|
)
|
||||||
|
private val BEARER = Regex("(?i)\\bBearer\\s+[A-Za-z0-9._~+/=-]+")
|
||||||
|
private val PROVIDER_KEY = Regex("\\b(?:sk-[A-Za-z0-9_-]+|gh[pousr]_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+)\\b")
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ErrorOutline
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.LiveRegionMode
|
||||||
|
import androidx.compose.ui.semantics.liveRegion
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
|
import com.m57.hermescontrol.ui.chat.ReplyFailure
|
||||||
|
import com.m57.hermescontrol.ui.chat.sanitizeReplyErrorDetails
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ReplyErrorCard(
|
||||||
|
failure: ReplyFailure,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onOpenLogs: () -> Unit,
|
||||||
|
onCopy: (String) -> Unit,
|
||||||
|
onShare: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by rememberSaveable(failure.id) { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val details = remember(failure.details) { sanitizeReplyErrorDetails(failure.details) }
|
||||||
|
val detailsStateDescription =
|
||||||
|
stringResource(
|
||||||
|
if (expanded) {
|
||||||
|
R.string.chat_reply_failed_details_expanded
|
||||||
|
} else {
|
||||||
|
R.string.chat_reply_failed_details_collapsed
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val colors = LocalHermesStatusColors.current
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(12.dp).testTag("reply_error_card"),
|
||||||
|
shape = MaterialTheme.shapes.medium,
|
||||||
|
color = colors.errorContainer,
|
||||||
|
contentColor = colors.onErrorContainer,
|
||||||
|
border = BorderStroke(1.dp, colors.error),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.ErrorOutline, contentDescription = null)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.chat_reply_failed_title),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.weight(
|
||||||
|
1f,
|
||||||
|
).padding(start = 8.dp)
|
||||||
|
.semantics { liveRegion = LiveRegionMode.Polite },
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
)
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.chat_reply_failed_dismiss))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.chat_reply_failed_description), style = MaterialTheme.typography.bodyMedium)
|
||||||
|
TextButton(
|
||||||
|
onClick = { expanded = !expanded },
|
||||||
|
modifier = Modifier.semantics { stateDescription = detailsStateDescription },
|
||||||
|
) {
|
||||||
|
Icon(if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, contentDescription = null)
|
||||||
|
Text(stringResource(R.string.chat_reply_failed_details))
|
||||||
|
}
|
||||||
|
if (expanded) {
|
||||||
|
SelectionContainer {
|
||||||
|
Text(
|
||||||
|
details,
|
||||||
|
modifier = Modifier.heightIn(max = 200.dp).verticalScroll(rememberScrollState()),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.chat_reply_failed_share_note), style = MaterialTheme.typography.bodySmall)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(onClick = onOpenLogs) { Text(stringResource(R.string.chat_reply_failed_logs)) }
|
||||||
|
OutlinedButton(onClick = { onCopy(details) }) { Text(stringResource(R.string.chat_reply_failed_copy)) }
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onShare(details) },
|
||||||
|
enabled = expanded,
|
||||||
|
) { Text(stringResource(R.string.chat_reply_failed_share)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,8 +104,9 @@ fun FullBleedChatList(
|
|||||||
hasOlderMessages: Boolean = false,
|
hasOlderMessages: Boolean = false,
|
||||||
pagingSessionId: String? = null,
|
pagingSessionId: String? = null,
|
||||||
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
|
onLoadOlder: () -> Unit = viewModel::loadOlderMessages,
|
||||||
|
replyErrorContent: (@Composable () -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
if (messages.isEmpty() && !isLoading && !isAgentTyping) {
|
if (messages.isEmpty() && !isLoading && !isAgentTyping && replyErrorContent == null) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
@@ -145,6 +146,7 @@ fun FullBleedChatList(
|
|||||||
// A single ordered definition supplies both emitted tail rows and anchor keys.
|
// A single ordered definition supplies both emitted tail rows and anchor keys.
|
||||||
val tailItems =
|
val tailItems =
|
||||||
buildMap<String, @Composable () -> Unit> {
|
buildMap<String, @Composable () -> Unit> {
|
||||||
|
replyErrorContent?.let { put("reply_error", it) }
|
||||||
agentStatus?.let { status ->
|
agentStatus?.let { status ->
|
||||||
put("agent_status") {
|
put("agent_status") {
|
||||||
AgentStatusIndicator(status = status)
|
AgentStatusIndicator(status = status)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="chat_reply_failed_title">Hermes couldn\'t finish this reply</string>
|
||||||
|
<string name="chat_reply_failed_description">Something went wrong while Hermes was replying. Open the details or logs to understand what happened.</string>
|
||||||
|
<string name="chat_reply_failed_details">Details</string>
|
||||||
|
<string name="chat_reply_failed_details_collapsed">Collapsed</string>
|
||||||
|
<string name="chat_reply_failed_details_expanded">Expanded</string>
|
||||||
|
<string name="chat_reply_failed_copy">Copy error details</string>
|
||||||
|
<string name="chat_reply_failed_logs">Open logs</string>
|
||||||
|
<string name="chat_reply_failed_share">Share diagnostics</string>
|
||||||
|
<string name="chat_reply_failed_share_note">Review details before sharing. Diagnostics include this error and the app version, not your conversation or attachments.</string>
|
||||||
|
<string name="chat_reply_failed_dismiss">Dismiss error</string>
|
||||||
|
|
||||||
|
<string name="chat_reply_failed_copied">Error details copied</string>
|
||||||
|
<string name="chat_reply_failed_share_unavailable">No app available to share diagnostics</string>
|
||||||
|
</resources>
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
package com.m57.hermescontrol.notification
|
package com.m57.hermescontrol.notification
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,4 +108,59 @@ class ChatNotificationServiceTest {
|
|||||||
atomicBoolean.get(),
|
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)
|
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(
|
private fun connectionOperationSnapshot(
|
||||||
sessionId: String,
|
sessionId: String,
|
||||||
seq: Long = 1L,
|
seq: Long = 1L,
|
||||||
@@ -1387,6 +1485,96 @@ class ChatViewModelTest {
|
|||||||
|
|
||||||
// ── Streaming tests ──────────────────────────────────────────────────────
|
// ── 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
|
@Test
|
||||||
fun testMessageStreamingFlow() =
|
fun testMessageStreamingFlow() =
|
||||||
runTest {
|
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