Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() &&
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
}
Expand Down Expand Up @@ -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()
}
Expand All @@ -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()
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
Binary file added docs/evidence/pr-interrupt-reason-proof.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading