HTTP fallback leg (docs/19, app): State.HttpFallback + SSE/long-poll receive

When the WS is down but the gateway is reachable over HTTP, the app
stays sendable instead of waiting out the WS redial backoff:

- HttpGateway.kt: OkHttp client for the HTTP leg — GET /v1/health
  (2 s probe), POST /v1/frame (accept-and-ack; 4xx error frames parsed),
  SSE GET /v1/events (hand-rolled line parser: event/id/data, comments,
  multi-line data, Last-Event-ID bookkeeping), long-poll GET /v1/poll.
  Per-purpose call timeouts (SSE heartbeat 15 s / poll hold 25 s exceed
  OkHttp's 10 s default read timeout). deriveHttpUrl: ws(s)://host:port
  -> http(s)://host:8791 (pure, unit-tested).
- GatewayClient.kt: new State.HttpFallback (sibling of Connected, both
  implement State.HelloInfo). connectLoop races the WS dial against the
  health probe (sendable in < 1 s on a dead WS port); on WS loss it
  enters fallback immediately (no backoff gate on the send path); on WS
  reconnect it stops the HTTP leg (media available again). sendMessage/
  sendFrame route to POST in fallback (mediaRefs dropped — media is
  WS-only in v1); SSE frames feed the same _events flow, so request-id
  correlation is unchanged. The SSE hello is treated like hello.ack
  (caps/channels/lastPushedCursor + onHelloAck fast path). After two
  consecutive SSE open failures the receive loop switches to long-poll
  until the next full (re)connect.
- IrisController.kt: onHelloAck/onConnectedLane take State.HelloInfo;
  state collector + deep-link/commands-catalog gates accept fallback.
- ChatScreen.kt: send gate accepts fallback; attach button disabled in
  fallback (media needs the live connection); status pill shows
  'connected · http' (green).
- Tests: HttpGatewayTest (URL derivation + SSE parser, 8 tests); full
  :shared desktop + android-host suites green.
This commit is contained in:
ARIA committed 2026-08-22 14:31:46 +02:00
1 parent e5c7d690b8
commit 7f936fa596
5 files changed
+723 -25

No files matched your search

@@ -0,0 +1,203 @@
package iris.net
import iris.protocol.Frame
import iris.protocol.IrisJson
import iris.protocol.MessagePayload
import iris.protocol.TYPE_MESSAGE
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* docs/19: unit tests for the HTTP fallback leg client.
*
* The SSE line parser is the trickiest pure logic (event/id/data fields,
* comments, multi-line data, Last-Event-ID bookkeeping), so it is factored
* into [SseParser] and tested directly. The WS->HTTP URL derivation is a
* pure function (unit-tested). Full transport behavior (health, POST, SSE
* catch-up, long-poll, delivery counting) is covered by the gateway-side
* Python tests (hermes-agent/tests/gateway/test_android_http.py).
*/
class HttpGatewayTest {
// ── URL derivation ────────────────────────────────────────────────────
@Test
fun deriveHttpUrlReplacesSchemeAndPort() {
assertEquals("http://192.168.1.10:8791", HttpGateway.deriveHttpUrl("ws://192.168.1.10:8790/ws"))
assertEquals("http://127.0.0.1:8791", HttpGateway.deriveHttpUrl("ws://127.0.0.1:8790/ws"))
assertEquals("https://gw.example.com:8791", HttpGateway.deriveHttpUrl("wss://gw.example.com:8790/ws"))
// No explicit port on the WS URL: still the HTTP leg's default port.
assertEquals("http://gw.example.com:8791", HttpGateway.deriveHttpUrl("ws://gw.example.com/ws"))
}
@Test
fun deriveHttpUrlPassesThroughHttpUrls() {
assertEquals("http://1.2.3.4:9000", HttpGateway.deriveHttpUrl("http://1.2.3.4:9000"))
assertEquals("https://a.b", HttpGateway.deriveHttpUrl("https://a.b"))
}
// ── SSE parser ────────────────────────────────────────────────────────
@Test
fun sseParsesHelloAndFrames() {
val parser = SseParser()
val hello = """{"v":1,"type":"hello.ack","payload":{"sync_cursor":5}}"""
val frame = """{"v":1,"type":"$TYPE_MESSAGE","payload":{"text":"hi"}}"""
val lines =
listOf(
"event: hello",
"data: $hello",
"",
"id: 7",
"event: frame",
"data: $frame",
"",
)
var helloCount = 0
var frameCount = 0
var lastCursor: Long? = null
for (line in lines) {
parser.feed(
line,
onHello = { helloCount++ },
onFrame = { frameCount++ },
onCursor = { lastCursor = it },
)
}
assertEquals(1, helloCount)
assertEquals(1, frameCount)
assertEquals(7L, lastCursor)
}
@Test
fun sseIgnoresHeartbeatComments() {
val parser = SseParser()
var frames = 0
parser.feed(": hb", onHello = {}, onFrame = { frames++ }, onCursor = {})
parser.feed("", onHello = {}, onFrame = { frames++ }, onCursor = {})
assertEquals(0, frames)
}
@Test
fun sseMultiLineDataJoinsWithNewline() {
val parser = SseParser()
var frame: Frame? = null
// SSE data may span multiple `data:` lines; the parser must join
// them with "\n" so the reassembled JSON still decodes. Split at a
// legal JSON whitespace point (right after a comma, between tokens).
val full =
"""{"v":1,"type":"$TYPE_MESSAGE","payload":{"message_id":"m1","role":"assistant","text":"a\nb"}}"""
val cut = full.indexOf("\"m1\",") + "\"m1\",".length
val l1 = full.substring(0, cut)
val l2 = full.substring(cut)
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("data: $l1", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("data: $l2", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("", onHello = {}, onFrame = { frame = it }, onCursor = {})
assertEquals("a\nb", frame?.payloadAsText())
}
@Test
fun sseTracksLastEventIdAcrossFrames() {
val parser = SseParser()
val ids = mutableListOf<Long>()
val frame = """{"v":1,"type":"$TYPE_MESSAGE","payload":{}}"""
for (id in listOf(1L, 2L, 3L)) {
parser.feed("id: $id", onHello = {}, onFrame = {}, onCursor = { ids.add(it) })
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("data: $frame", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("", onHello = {}, onFrame = {}, onCursor = {})
}
assertEquals(listOf(1L, 2L, 3L), ids)
assertEquals(3L, parser.lastEventId)
}
@Test
fun sseMalformedLineDoesNotThrow() {
val parser = SseParser()
parser.feed("garbage without colon", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("id: notanumber", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("data: {not json", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("", onHello = {}, onFrame = {}, onCursor = {})
// No exception, no frame emitted for the malformed data.
}
@Test
fun sseDecodesFramePayload() {
val parser = SseParser()
var text: String? = null
val frame =
"""{"v":1,"type":"$TYPE_MESSAGE","payload":{"message_id":"m1","role":"assistant","text":"hello"}}"""
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("data: $frame", onHello = {}, onFrame = {}, onCursor = {})
parser.feed("", onHello = {}, onFrame = { f -> text = f.payloadAsText() }, onCursor = {})
assertEquals("hello", text)
}
}
// ── SSE line parser (pure; shared by the live reader + tests) ─────────────
/**
* Incremental SSE parser (docs/19 §19.5). Feed raw lines (without
* terminators); a blank line dispatches the buffered event. [lastEventId]
* is the most recent `id:` field (the outbox cursor) — the resume point for
* a reconnect.
*/
class SseParser {
var lastEventId: Long? = null
private set
private var eventId: String? = null
private val dataLines = mutableListOf<String>()
fun feed(
line: String,
onHello: (String) -> Unit,
onFrame: (Frame) -> Unit,
onCursor: (Long) -> Unit,
) {
when {
line.isEmpty() -> {
if (dataLines.isNotEmpty()) {
val data = dataLines.joinToString("\n")
val frame =
try {
IrisJson.instance.decodeFromString(Frame.serializer(), data)
} catch (e: Exception) {
null
}
if (frame != null) {
when (eventId) {
"hello" -> onHello(data)
else -> onFrame(frame)
}
}
}
eventId = null
dataLines.clear()
}
line.startsWith(":") -> {
Unit
}
// comment / heartbeat
line.startsWith("id:") -> {
val id = line.removePrefix("id:").trim().toLongOrNull()
if (id != null) {
lastEventId = id
onCursor(id)
}
}
line.startsWith("event:") -> {
eventId = line.removePrefix("event:").trim()
}
line.startsWith("data:") -> {
dataLines.add(line.removePrefix("data:").removePrefix(" "))
}
}
}
}
private fun Frame.payloadAsText(): String? = payloadAs<MessagePayload>()?.text