diff --git a/CHANGELOG.md b/CHANGELOG.md index b92a06f9c..271c10d2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ## [Unreleased] +### Fixed + +- Android Chat shows a server-provided interrupt explanation when a turn is cancelled, instead of a bare “Response interrupted” / “Run interrupted” label when content, error, `interrupt_reason`, or `turn_exit_reason` is present. + ## [Android 1.19.0] - 2026-10-20 ### Added diff --git a/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/ChatHandler.kt b/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/ChatHandler.kt index 650551f0c..34a0b50ae 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/ChatHandler.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/ChatHandler.kt @@ -19,6 +19,7 @@ import com.hermesandroid.relay.network.shared.LocalDispatchResult import com.hermesandroid.relay.network.upstream.models.MessageItem import com.hermesandroid.relay.network.upstream.models.RelayStreamEventEnvelope import com.hermesandroid.relay.network.upstream.models.SessionItem +import com.hermesandroid.relay.network.upstream.models.isBareInterruptPlaceholder import com.hermesandroid.relay.voice.RealtimeTurnSyncBuilder import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -365,7 +366,12 @@ class ChatHandler { } "assistant.completed" -> { if (boolField("interrupted") == true) { - onStreamError("Response interrupted") + val reason = listOfNotNull( + textField("interrupt_reason", "error", "content", "output", "final_response", "text", "message"), + textField("turn_exit_reason")?.let { "Operation interrupted: ${it.replace('_', ' ')}." }, + ).map { it.trim() }.firstOrNull { it.isNotEmpty() && !isBareInterruptPlaceholder(it) } + ?: "Response interrupted" + onStreamError(reason) } else { onTurnComplete(currentMessageId) } diff --git a/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/GatewayEventMapper.kt b/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/GatewayEventMapper.kt index a7cce9567..ee83bb191 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/GatewayEventMapper.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/GatewayEventMapper.kt @@ -287,10 +287,17 @@ class GatewayEventMapper( "message.complete" -> { // Non-streaming servers (or error turns) deliver everything // here; backfill whatever never streamed. - val failed = payload.string("status").equals(ERROR_STATUS_KIND, ignoreCase = true) + val statusKind = payload.string("status").orEmpty() + val failed = statusKind.equals(ERROR_STATUS_KIND, ignoreCase = true) + val interrupted = statusKind.equals("interrupted", ignoreCase = true) || + statusKind.equals("cancelled", ignoreCase = true) val error = payload.string("error") + val turnExit = payload.string("turn_exit_reason") val text = payload.string("text") - ?: error?.takeIf { failed }?.let { "Error: $it" } + ?: error?.takeIf { failed || interrupted }?.let { if (failed) "Error: $it" else it } + ?: turnExit?.takeIf { interrupted }?.let { + "Operation interrupted: ${it.replace('_', ' ')}." + } val reconcilesInterim = !text.isNullOrEmpty() && previewedText?.let { preview -> preview.isNotEmpty() && @@ -309,17 +316,25 @@ class GatewayEventMapper( callbacks.onThinkingDelta(reasoning) } callbacks.onUsage(parseGatewayUsage(payload?.get("usage") as? JsonObject)) - if (failed) { + if (failed || interrupted) { + val failMsg = error?.takeIf { it.isNotBlank() } + ?: text.orEmpty().ifBlank { + if (interrupted) { + turnExit?.let { "Operation interrupted: ${it.replace('_', ' ')}." } + ?: "Run interrupted" + } else { + "Turn failed" + } + } callbacks.onFailure( GatewayTurnFailure( - error = error?.takeIf { it.isNotBlank() } - ?: text.orEmpty().ifBlank { "Turn failed" }, - recoverable = payload.boolean("recoverable") == true, + error = failMsg, + recoverable = payload.boolean("recoverable") == true || interrupted, ), ) callbacks.onStatusUpdate( - ERROR_STATUS_KIND, - error?.takeIf { it.isNotBlank() } ?: text.orEmpty().ifBlank { "Turn failed" }, + if (interrupted) "interrupted" else ERROR_STATUS_KIND, + failMsg, ) } turnEnded = true diff --git a/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/HermesApiClient.kt b/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/HermesApiClient.kt index e9068869e..781767b18 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/HermesApiClient.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/HermesApiClient.kt @@ -1296,7 +1296,7 @@ class HermesApiClient( } } else if (event.interrupted == true) { if (completeCalled.compareAndSet(false, true)) { - onError("Response interrupted") + onError(event.resolveInterruptMessage(preferRun = false)) } } else { onTurnComplete() @@ -1315,7 +1315,7 @@ class HermesApiClient( if (!runtimeMatches) { onError("Server response did not confirm the selected model route.") } else if (event.interrupted == true) { - onError("Run interrupted") + onError(event.resolveInterruptMessage(preferRun = true)) } else { onComplete() } @@ -1802,7 +1802,7 @@ class HermesApiClient( mainHandler.post { onUsage(event.usage) if (event.interrupted == true) { - onError("Run interrupted") + onError(event.resolveInterruptMessage(preferRun = true)) } else { onComplete() } @@ -1815,7 +1815,7 @@ class HermesApiClient( onUsage(event.usage) if (event.interrupted == true) { if (completeCalled.compareAndSet(false, true)) { - onError("Response interrupted") + onError(event.resolveInterruptMessage(preferRun = false)) } } else { onTurnComplete() @@ -1827,7 +1827,7 @@ class HermesApiClient( mainHandler.post { onUsage(event.usage) if (event.interrupted == true) { - onError("Run interrupted") + onError(event.resolveInterruptMessage(preferRun = true)) } else { onComplete() } diff --git a/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/models/SessionModels.kt b/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/models/SessionModels.kt index e959b50b6..26f6c71f8 100644 --- a/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/models/SessionModels.kt +++ b/app/src/main/kotlin/com/hermesandroid/relay/network/upstream/models/SessionModels.kt @@ -543,6 +543,10 @@ data class HermesSseEvent( val partial: Boolean? = null, val interrupted: Boolean? = null, @SerialName("api_calls") val apiCalls: Int? = null, + /** Machine exit reason from hermes-agent terminal_run_status (cancelled/failed runs). */ + @SerialName("turn_exit_reason") val turnExitReason: String? = null, + /** Optional dedicated interrupt reason field when servers set it. */ + @SerialName("interrupt_reason") val interruptReason: String? = null, // session.created val title: String? = null, // run.started — user_message is an object @@ -572,6 +576,39 @@ data class HermesSseEvent( /** Extract message as string (returns null if message is an object, not a string). */ val messageText: String? get() = (message as? JsonPrimitive)?.takeIf { it.isString }?.content + + /** + * User-visible interrupt explanation from a terminal SSE event. + * Never returns a bare "Response/Run interrupted" without a server reason when one exists. + */ + fun resolveInterruptMessage( + responseFallback: String = "Response interrupted", + runFallback: String = "Run interrupted", + preferRun: Boolean = false, + ): String { + val candidates = listOfNotNull( + interruptReason, + error, + content, + output, + finalResponse, + text, + messageText, + turnExitReason?.let { "Operation interrupted: ${it.replace('_', ' ')}." }, + ).map { it.trim() }.filter { it.isNotEmpty() && !isBareInterruptPlaceholder(it) } + if (candidates.isNotEmpty()) return candidates.first() + return if (preferRun) runFallback else responseFallback + } +} + +/** True for legacy bare placeholders that carry no reason. */ +internal fun isBareInterruptPlaceholder(text: String): Boolean { + val t = text.trim() + if (t.isEmpty()) return true + return t.equals("Operation interrupted.", ignoreCase = true) || + t.equals("Operation interrupted", ignoreCase = true) || + t.equals("Response interrupted", ignoreCase = true) || + t.equals("Run interrupted", ignoreCase = true) } @Serializable diff --git a/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/models/InterruptReasonResolveTest.kt b/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/models/InterruptReasonResolveTest.kt new file mode 100644 index 000000000..a2f521c00 --- /dev/null +++ b/app/src/test/kotlin/com/hermesandroid/relay/network/upstream/models/InterruptReasonResolveTest.kt @@ -0,0 +1,48 @@ +package com.hermesandroid.relay.network.upstream.models + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class InterruptReasonResolveTest { + @Test + fun barePlaceholdersDetected() { + assertTrue(isBareInterruptPlaceholder("Operation interrupted.")) + assertTrue(isBareInterruptPlaceholder("Response interrupted")) + assertTrue(isBareInterruptPlaceholder("Run interrupted")) + assertFalse(isBareInterruptPlaceholder("Operation interrupted: stopped by user.")) + } + + @Test + fun prefersErrorOverFallback() { + val event = HermesSseEvent( + interrupted = true, + error = "Operation interrupted: client disconnected.", + content = "Operation interrupted.", + ) + assertEquals( + "Operation interrupted: client disconnected.", + event.resolveInterruptMessage(preferRun = true), + ) + } + + @Test + fun usesTurnExitReasonWhenNoProse() { + val event = HermesSseEvent( + interrupted = true, + turnExitReason = "interrupted_by_user", + ) + assertEquals( + "Operation interrupted: interrupted by user.", + event.resolveInterruptMessage(), + ) + } + + @Test + fun fallsBackOnlyWhenNothingAvailable() { + val event = HermesSseEvent(interrupted = true) + assertEquals("Run interrupted", event.resolveInterruptMessage(preferRun = true)) + assertEquals("Response interrupted", event.resolveInterruptMessage(preferRun = false)) + } +} diff --git a/docs/evidence/pr-interrupt-reason-proof.jpg b/docs/evidence/pr-interrupt-reason-proof.jpg new file mode 100644 index 000000000..523409c21 Binary files /dev/null and b/docs/evidence/pr-interrupt-reason-proof.jpg differ