HTTP fallback leg (docs/19): POST /v1/frame + SSE /v1/events + long-poll

Second, short-lived-connection transport next to the WS: same frames,
same outbox/cursor, same token, served over plain HTTP (stdlib
ThreadingHTTPServer bridged into the asyncio loop; zero new deps).

- http_server.py: /v1/health (unauthenticated), POST /v1/frame
  (accept-and-ack; validation rejections as 4xx error frames), SSE
  /v1/events (outbox catch-up with id=cursor, event: hello, 15s
  heartbeat, bounded-queue backpressure), long-poll /v1/poll (25s hold).
  Bearer token + X-Iris-Device (same allowlist as WS hello), 64 KiB body
  cap, per-device rate limit, optional TLS, non-fatal bind failure.
- ws_server.py: inbound dispatch chain extracted to shared
  dispatch_frame() used by both transports.
- adapter.py: ANDROID_HTTP_PORT/CERT/KEY config; start/stop next to the
  WS; delivery counting in _broadcast_or_log (an SSE subscriber is a
  live subscriber -> no push, docs/19 19.8); _reply() routes
  point-to-point replies into the in-flight HTTP response (reply sink)
  or broadcasts when the device has no live WS (19.7); status/typing/
  channel events fan out to both transports.
- ws_probe.py: --http mode (health + POST + SSE turn drive, same
  assertion flags); tests/README updated.
- Tests: hermes-agent/tests/gateway/test_android_http.py (23 tests,
  incl. the 19.8 delivery-counting regression); test_android.py (74)
  still green.
This commit is contained in:
ARIA committed 2026-08-22 14:10:14 +02:00
1 parent 524ed8ce53
commit e5c7d690b8
7 files changed
+1315 -89

No files matched your search

+9 -1
View File
@@ -43,6 +43,12 @@ Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
- `--offer-grace S` — with `--pull-offer`, keep listening S seconds after
the final message for a `media.offer` (offers are emitted post-turn,
right after the final; default 15).
- `--http [--http-url http://host:port]` — docs/19: drive the turn over
the **HTTP fallback leg** instead of WS: `GET /v1/health`,
`POST /v1/frame` (the `message.send`), receive over SSE `GET
/v1/events`. The same assertion flags apply. The base URL defaults to
the `--url` host with scheme `ws(s)` → `http(s)` and port 8791
(`ANDROID_HTTP_PORT`).
Exit codes: `0` ok (incl. SKIP for absent M7 frames), `2` connect fail,
`3` no hello.ack, `4` expected hello.ack, `5` authfail expected but
@@ -51,7 +57,9 @@ acked, `6` timeout, `7` no final message, `8` upload/sync fail,
`12` assert-tools fail, `13` assert-commentary fail, `14` search fail
(error or zero hits), `15` channel.create/list fail, `16` channel.delete
fail, `17` watch timeout, `18` read.receipt arrived before the sent
message, `19` status frame with empty payload.
message, `19` status frame with empty payload, `20` `--http` health
check failed, `21` `--http` SSE open failed, `22` `--http`
`POST /v1/frame` rejected (4xx).
## E2E driver (`e2e.py`)
+144
View File
@@ -56,6 +56,13 @@ Request modes (no turn driven unless --send/--upload also given):
--watch CHAT_ID wait up to --timeout for a message to land in
CHAT_ID (cron delivery E2E, scenario 7)
HTTP fallback leg (docs/19):
--http drive the turn over the HTTP leg instead of WS:
GET /v1/health, POST /v1/frame (message.send), receive
over SSE /v1/events. The same assertion flags apply.
--http-url http://host:port base for --http (default: derived
from --url, ws(s) -> http(s), port 8791)
Exit codes:
0 ok (incl. SKIP for absent M7 frames)
2 connect failed
@@ -76,6 +83,9 @@ Exit codes:
17 --watch timed out (no message landed in the channel)
18 --assert-read-receipt failed (frame arrived before the sent message)
19 --assert-status failed (status frame arrived with an empty payload)
20 --http: health check failed
21 --http: SSE open failed
22 --http: POST /v1/frame rejected (4xx)
"""
import argparse
@@ -690,6 +700,124 @@ async def run(args) -> int:
return 0
def run_http(args, base: str) -> int:
"""docs/19: drive a turn over the HTTP fallback leg — GET /v1/health,
POST /v1/frame (message.send), receive over SSE /v1/events. Blocking
(stdlib http.client); the same assertion flags apply as the WS leg."""
from http.client import HTTPConnection
from urllib.parse import urlparse
u = urlparse(base)
host = u.hostname or "127.0.0.1"
port = u.port or (443 if u.scheme == "https" else 80)
headers = {
"Authorization": f"Bearer {args.token}",
"X-Iris-Device": args.device,
}
# 1. health (unauthenticated liveness probe).
try:
conn = HTTPConnection(host, port, timeout=5)
conn.request("GET", "/v1/health")
r = conn.getresponse()
body = r.read()
conn.close()
except Exception as e:
print(f"!! health check failed: {e}")
return 20
if r.status != 200:
print(f"!! health check failed: HTTP {r.status} {body[:200]!r}")
return 20
print(f"== health ok: {body!r}")
# 2. open the SSE stream.
sse = HTTPConnection(host, port, timeout=args.timeout)
sse.request("GET", "/v1/events", headers=headers)
resp = sse.getresponse()
if resp.status != 200:
print(f"!! SSE open failed: HTTP {resp.status}")
return 21
print("== SSE open (/v1/events)")
# 3. POST the message.send frame (accept-and-ack).
if args.send:
frame = {
"v": 1, "id": 1, "type": "message.send",
"chat_id": "android:default", "payload": {"text": args.send},
}
conn = HTTPConnection(host, port, timeout=30)
conn.request(
"POST", "/v1/frame", body=json.dumps(frame),
headers={**headers, "Content-Type": "application/json"},
)
r = conn.getresponse()
body = r.read()
conn.close()
print(f"== POST /v1/frame -> {r.status} {body[:200]!r}")
if r.status >= 400:
print("!! POST /v1/frame rejected")
return 22
# 4. read SSE until the final assistant message (same final-detection
# logic as the WS leg).
st = _TurnState()
got_final = False
seen_final_frame = False
deadline = time.time() + args.timeout
cur_data: list[str] = []
def feed(line: str) -> bool:
nonlocal cur_data, got_final, seen_final_frame
line = line.rstrip("\r\n")
if line == "":
if cur_data:
data = _print_frame("\n".join(cur_data))
if data is not None:
ftype = data.get("type")
payload = data.get("payload") or {}
st.track(ftype, payload)
if ftype == "message" and payload.get("role") == "assistant":
got_final = True
if ftype == "message.stop":
seen_final_frame = True
if ftype == "typing" and payload.get("on") is False and seen_final_frame:
got_final = True
cur_data = []
return got_final
if line.startswith(":"):
return got_final # heartbeat comment
field, _, value = line.partition(":")
if value.startswith(" "):
value = value[1:]
if field == "data":
cur_data.append(value)
return got_final
sock = getattr(getattr(resp.fp, "raw", None), "_sock", None)
try:
while time.time() < deadline and not got_final:
if sock is not None:
sock.settimeout(max(0.1, deadline - time.time()))
line = resp.fp.readline()
if not line:
break
if feed(line.decode("utf-8")):
break
finally:
sse.close()
if not got_final:
print(f"!! no final assistant message (HTTP leg, {args.timeout:.0f}s)")
return 7
print("== final assistant message received (via SSE)")
for code, ok, msg in _evaluate_assertions(args, st):
if not ok:
print(f"!! {msg}")
return code
return 0
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--url", default=os.getenv("ANDROID_WS_URL", "ws://127.0.0.1:8790/ws"))
@@ -739,12 +867,28 @@ def main() -> int:
p.add_argument("--offer-grace", type=float, default=15.0,
help="seconds to wait for a media.offer after the final "
"message when --pull-offer (default 15)")
p.add_argument("--http", action="store_true",
help="docs/19: drive the turn over the HTTP fallback leg "
"(health + POST /v1/frame + SSE /v1/events) instead of WS")
p.add_argument("--http-url", default="",
help="docs/19: http(s)://host:port base for --http "
"(default: derived from --url, port 8791)")
args = p.parse_args()
if not args.token and not args.authfail:
p.error("--token (or $ANDROID_TOKEN) is required")
if args.assert_read_receipt and not args.send:
p.error("--assert-read-receipt requires --send (the receipt must follow "
"the sent message)")
if args.http:
if args.http_url:
base = args.http_url
else:
from urllib.parse import urlparse
u = urlparse(args.url)
scheme = "https" if u.scheme == "wss" else "http"
base = f"{scheme}://{u.hostname or '127.0.0.1'}:8791"
return run_http(args, base)
return asyncio.run(run(args))