M1+M2: gateway core loop + agent transparency
M1 (gateway core loop / text round-trip): - WS server (ws_server.py): bind, hello auth (constant-time), hello.ack, heartbeat, connection registry - pairing.py: token generation, pairing store, QR payload - adapter.py: send() -> message frame; inbound message.send -> MessageEvent -> handle_message - app: Connect screen, GatewayClient (connect + reconnect), ChatScreen send/render, SecureStore (Android/Desktop) - tests/ws_probe.py: probe harness driving a real turn M2 (streaming + reasoning + tools + commentary): - protocol.py: M2 frame types (message.start/update/stop, tool.start/progress/end, commentary) - adapter.py: per-chat turn-state machine; classify outbound into frames; _split_reasoning; tool-line parsing - reasoning in streaming: capture via on_stream_delta hook (kind=reasoning, gated by plugins.stream_reasoning_deltas) with a FIFO barrier, attach to message.stop - app: live streaming bubble, ReasoningBlock (collapse + copy), ToolCard (Everything/Truncated/Nothing), dimmed commentary, typing - docs/14-milestones.md: M1/M2 marked done; reasoning note corrected
This commit is contained in:
1 parent
59acf66c89
commit
218c50d688
21 files changed
+3437
-129
No files matched your search
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
"""WS test-client harness (docs/13-testing.md §13.2).
|
||||
|
||||
Connects to the REAL running gateway and drives a turn, printing every
|
||||
frame. This is how we empirically confirm the exact frame shapes before /
|
||||
while building the Kotlin client.
|
||||
|
||||
Usage::
|
||||
|
||||
hermes gateway & # with the android plugin
|
||||
python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> \
|
||||
--send "hello"
|
||||
|
||||
Options:
|
||||
--url ws://host:port/ws (default ws://127.0.0.1:8790/ws)
|
||||
--token ANDROID_TOKEN (default: $ANDROID_TOKEN)
|
||||
--device device_id (default: probe-<rand>)
|
||||
--send TEXT send this message after pairing (default: "hello")
|
||||
--timeout S seconds to wait for the final reply (default 120)
|
||||
--authfail expect an auth rejection (wrong token) and exit 0 on it
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
try:
|
||||
import websockets
|
||||
except ImportError: # pragma: no cover
|
||||
sys.stderr.write("websockets is required (hermes core dep); run inside the hermes venv\n")
|
||||
raise
|
||||
|
||||
|
||||
def _print_frame(raw):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
print(f" <- {raw!r}")
|
||||
return None
|
||||
ftype = data.get("type", "?")
|
||||
chat = data.get("chat_id")
|
||||
fid = data.get("id")
|
||||
payload = data.get("payload", {})
|
||||
# Compact one-line summary + full payload for the interesting frames.
|
||||
extra = ""
|
||||
if ftype == "message":
|
||||
text = (payload.get("text") or "")
|
||||
extra = f" role={payload.get('role')} id={payload.get('message_id')} text={text[:120]!r}"
|
||||
if payload.get("reasoning"):
|
||||
extra += f" reasoning={payload['reasoning'][:80]!r}"
|
||||
elif ftype == "message.start":
|
||||
extra = f" id={payload.get('message_id')} role={payload.get('role')}"
|
||||
elif ftype == "message.update":
|
||||
text = (payload.get("text") or "")
|
||||
extra = f" id={payload.get('message_id')} text={text[:100]!r}"
|
||||
elif ftype == "message.stop":
|
||||
text = (payload.get("final_text") or "")
|
||||
extra = f" id={payload.get('message_id')} text={text[:120]!r}"
|
||||
if payload.get("reasoning"):
|
||||
extra += f" reasoning={payload['reasoning'][:80]!r}"
|
||||
elif ftype == "tool.start":
|
||||
extra = (f" idx={payload.get('index')} name={payload.get('name')!r} "
|
||||
f"preview={str(payload.get('preview'))[:80]!r}")
|
||||
elif ftype == "tool.progress":
|
||||
extra = f" idx={payload.get('index')} name={payload.get('name')!r} note={payload.get('note')!r}"
|
||||
elif ftype == "tool.end":
|
||||
extra = (f" idx={payload.get('index')} name={payload.get('name')!r} "
|
||||
f"ok={payload.get('ok')} dur={payload.get('duration')}")
|
||||
elif ftype == "commentary":
|
||||
extra = f" id={payload.get('message_id')} text={(payload.get('text') or '')[:120]!r}"
|
||||
elif ftype == "hello.ack":
|
||||
extra = f" caps={payload.get('server_caps')} cursor={payload.get('sync_cursor')}"
|
||||
elif ftype == "error":
|
||||
extra = f" code={payload.get('code')} msg={payload.get('message')!r}"
|
||||
elif ftype == "typing":
|
||||
extra = f" on={payload.get('on')}"
|
||||
elif ftype == "pong":
|
||||
extra = ""
|
||||
scope = f" chat={chat}" if chat else ""
|
||||
idpart = f" id={fid}" if fid is not None else ""
|
||||
print(f" <- {ftype}{idpart}{scope}{extra}")
|
||||
return data
|
||||
|
||||
|
||||
async def run(args) -> int:
|
||||
url = args.url
|
||||
token = args.token
|
||||
device_id = args.device
|
||||
|
||||
print(f"== ws_probe: connecting {url} device={device_id}")
|
||||
try:
|
||||
ws = await websockets.connect(url, open_timeout=10)
|
||||
except Exception as e:
|
||||
print(f"!! connect failed: {e}")
|
||||
return 2
|
||||
|
||||
hello = {
|
||||
"v": 1,
|
||||
"type": "hello",
|
||||
"payload": {
|
||||
"token": token,
|
||||
"device_id": device_id,
|
||||
"device_name": "ws-probe",
|
||||
"caps": {"min_protocol": 1},
|
||||
},
|
||||
}
|
||||
await ws.send(json.dumps(hello))
|
||||
print(" -> hello")
|
||||
|
||||
# First response must be hello.ack (or an auth error).
|
||||
try:
|
||||
first = await asyncio.wait_for(ws.recv(), timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
print("!! no hello.ack within 10s")
|
||||
await ws.close()
|
||||
return 3
|
||||
data = _print_frame(first)
|
||||
if data is None or data.get("type") != "hello.ack":
|
||||
if args.authfail:
|
||||
print("== auth rejected as expected")
|
||||
await ws.close()
|
||||
return 0
|
||||
print("!! expected hello.ack")
|
||||
await ws.close()
|
||||
return 4
|
||||
if args.authfail:
|
||||
print("!! expected auth rejection but got hello.ack")
|
||||
await ws.close()
|
||||
return 5
|
||||
|
||||
if not args.send:
|
||||
print("== paired OK (no --send; exiting)")
|
||||
await ws.close()
|
||||
return 0
|
||||
|
||||
# Drive a turn.
|
||||
msg_id = 1
|
||||
send_frame = {
|
||||
"v": 1,
|
||||
"id": msg_id,
|
||||
"type": "message.send",
|
||||
"chat_id": "android:default",
|
||||
"payload": {"text": args.send},
|
||||
}
|
||||
await ws.send(json.dumps(send_frame))
|
||||
print(f" -> message.send id={msg_id} text={args.send!r}")
|
||||
|
||||
deadline = time.time() + args.timeout
|
||||
got_final = False
|
||||
seen_final_frame = False
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
raw = await asyncio.wait_for(ws.recv(), timeout=deadline - time.time())
|
||||
except asyncio.TimeoutError:
|
||||
print(f"!! timeout after {args.timeout}s waiting for final message")
|
||||
await ws.close()
|
||||
return 6
|
||||
data = _print_frame(raw)
|
||||
if data is None:
|
||||
continue
|
||||
ftype = data.get("type")
|
||||
payload = data.get("payload") or {}
|
||||
# A standalone assistant `message` (non-streaming) is immediately final.
|
||||
if ftype == "message" and payload.get("role") == "assistant":
|
||||
got_final = True
|
||||
break
|
||||
# A `message.stop` finalizes a streaming segment; the turn is done once
|
||||
# typing stops afterwards (multi-segment turns have several stops).
|
||||
if ftype == "message.stop":
|
||||
seen_final_frame = True
|
||||
if ftype == "typing" and payload.get("on") is False and seen_final_frame:
|
||||
got_final = True
|
||||
break
|
||||
await ws.close()
|
||||
if got_final:
|
||||
print("== final assistant message received")
|
||||
return 0
|
||||
print("!! no final assistant message")
|
||||
return 7
|
||||
|
||||
|
||||
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"))
|
||||
p.add_argument("--token", default=os.getenv("ANDROID_TOKEN", ""))
|
||||
p.add_argument("--device", default=f"probe-{uuid.uuid4().hex[:8]}")
|
||||
p.add_argument("--send", default="hello")
|
||||
p.add_argument("--timeout", type=float, default=120.0)
|
||||
p.add_argument("--authfail", action="store_true",
|
||||
help="expect an auth rejection (wrong token)")
|
||||
args = p.parse_args()
|
||||
if not args.token and not args.authfail:
|
||||
p.error("--token (or $ANDROID_TOKEN) is required")
|
||||
return asyncio.run(run(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user