a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
543 lines
19 KiB
Python
Executable File
543 lines
19 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Deterministic mock jcode gateway for end-to-end iOS app testing.
|
|
|
|
Speaks the exact wire protocol from `crates/jcode-base/src/gateway.rs` on a
|
|
single TCP port, peeking the request line to route like the real gateway:
|
|
- GET /health -> {status, version, gateway}
|
|
- POST /pair -> {token, server_name, server_version}
|
|
- GET /ws -> WebSocket upgrade; newline-delimited JSON event protocol
|
|
|
|
It does NOT call an LLM. A `message` request triggers a scripted, deterministic
|
|
stream (reasoning, text deltas, a tool-call lifecycle, tokens, done) so the app
|
|
can be exercised and visually validated without network or provider cost. This
|
|
is the iOS equivalent of the removed Rust simulator: one source of honest,
|
|
repeatable behavior to develop the client against.
|
|
|
|
Self-contained: no third-party deps. Minimal hand-rolled WebSocket framing.
|
|
|
|
Run: python3 mock_gateway.py [--port 7643] [--code 123456]
|
|
"""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import struct
|
|
import sys
|
|
|
|
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
SERVER_VERSION = "mock-0.32.0"
|
|
SERVER_NAME = "mock-jcode"
|
|
DEFAULT_MODELS = [
|
|
"claude-api:claude-fable-5",
|
|
"claude-api:claude-sonnet-4",
|
|
"openai:gpt-5",
|
|
"gemini:gemini-2.5-pro",
|
|
]
|
|
|
|
|
|
class GatewayState:
|
|
def __init__(self, code, token):
|
|
self.code = code
|
|
self.token = token
|
|
self.session_id = "mock-session-0001"
|
|
self.title = "Mock session"
|
|
self.model = DEFAULT_MODELS[0]
|
|
self.messages = []
|
|
self.token_input = 0
|
|
self.token_output = 0
|
|
self.reasoning_effort = "high"
|
|
self.push_demo = False
|
|
|
|
|
|
def scenario_messages(name):
|
|
"""Pre-seeded transcripts for the layout matrix. Each is a deterministic
|
|
content state so UI efficiency can be measured across the real range."""
|
|
bash_tool = {
|
|
"id": "t1", "name": "bash",
|
|
"input": '{"command": "echo hello"}',
|
|
"output": "hello\n", "error": None,
|
|
}
|
|
if name == "empty":
|
|
return []
|
|
if name == "short":
|
|
return [
|
|
{"role": "user", "content": "hi"},
|
|
{"role": "assistant", "content": "Hello! How can I help?"},
|
|
]
|
|
if name == "tool":
|
|
return [
|
|
{"role": "user", "content": "run echo hello"},
|
|
{"role": "assistant", "content": "Done. Output above.", "tool_data": bash_tool},
|
|
]
|
|
if name == "long":
|
|
turns = []
|
|
for i in range(6):
|
|
turns.append({"role": "user", "content": f"Question number {i + 1} about the codebase?"})
|
|
turns.append({
|
|
"role": "assistant",
|
|
"content": (
|
|
f"Answer {i + 1}: here is a reasonably detailed paragraph that "
|
|
"wraps across multiple lines to simulate a real assistant reply "
|
|
"with enough text to fill vertical space and exercise scrolling."
|
|
),
|
|
"tool_data": bash_tool if i % 2 == 0 else None,
|
|
})
|
|
return turns
|
|
if name == "code":
|
|
return [
|
|
{"role": "user", "content": "show me a python snippet"},
|
|
{"role": "assistant", "content": (
|
|
"Sure:\n\n```python\ndef fib(n):\n a, b = 0, 1\n for _ in range(n):\n"
|
|
" a, b = b, a + b\n return a\n```\n\nThat is iterative and O(n)."
|
|
)},
|
|
]
|
|
return []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WebSocket framing (server side)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def ws_accept_key(key: str) -> str:
|
|
digest = hashlib.sha1((key + WS_GUID).encode()).digest()
|
|
return base64.b64encode(digest).decode()
|
|
|
|
|
|
def encode_text_frame(text: str) -> bytes:
|
|
payload = text.encode("utf-8")
|
|
header = bytearray([0x81]) # FIN + text opcode
|
|
length = len(payload)
|
|
if length < 126:
|
|
header.append(length)
|
|
elif length < 65536:
|
|
header.append(126)
|
|
header += struct.pack(">H", length)
|
|
else:
|
|
header.append(127)
|
|
header += struct.pack(">Q", length)
|
|
return bytes(header) + payload
|
|
|
|
|
|
def encode_control_frame(opcode: int, payload: bytes = b"") -> bytes:
|
|
header = bytearray([0x80 | opcode, len(payload)])
|
|
return bytes(header) + payload
|
|
|
|
|
|
async def read_frame(reader: asyncio.StreamReader):
|
|
"""Returns (opcode, payload_bytes) or None on EOF/close."""
|
|
try:
|
|
b = await reader.readexactly(2)
|
|
except asyncio.IncompleteReadError:
|
|
return None
|
|
opcode = b[0] & 0x0F
|
|
masked = (b[1] & 0x80) != 0
|
|
length = b[1] & 0x7F
|
|
if length == 126:
|
|
ext = await reader.readexactly(2)
|
|
length = struct.unpack(">H", ext)[0]
|
|
elif length == 127:
|
|
ext = await reader.readexactly(8)
|
|
length = struct.unpack(">Q", ext)[0]
|
|
mask = await reader.readexactly(4) if masked else b"\x00\x00\x00\x00"
|
|
data = await reader.readexactly(length) if length else b""
|
|
if masked:
|
|
data = bytes(data[i] ^ mask[i % 4] for i in range(len(data)))
|
|
return opcode, data
|
|
|
|
|
|
class WSConn:
|
|
"""Minimal server-side WebSocket connection wrapper."""
|
|
|
|
def __init__(self, writer: asyncio.StreamWriter):
|
|
self.writer = writer
|
|
self._lock = asyncio.Lock()
|
|
|
|
async def send(self, text: str):
|
|
async with self._lock:
|
|
self.writer.write(encode_text_frame(text))
|
|
await self.writer.drain()
|
|
|
|
async def pong(self, payload: bytes):
|
|
async with self._lock:
|
|
self.writer.write(encode_control_frame(0xA, payload))
|
|
await self.writer.drain()
|
|
|
|
async def close(self):
|
|
try:
|
|
async with self._lock:
|
|
self.writer.write(encode_control_frame(0x8))
|
|
await self.writer.drain()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Protocol behavior
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def jline(obj):
|
|
return json.dumps(obj)
|
|
|
|
|
|
def chunk_text(text, size):
|
|
for i in range(0, len(text), size):
|
|
yield text[i : i + size]
|
|
|
|
|
|
async def send_event(ws: WSConn, obj):
|
|
await ws.send(jline(obj))
|
|
|
|
|
|
async def stream_response(ws, state, user_text, req_id):
|
|
await send_event(ws, {"type": "ack", "id": req_id})
|
|
|
|
for chunk in ["Looking at ", "the request", "..."]:
|
|
await send_event(ws, {"type": "reasoning_delta", "text": chunk})
|
|
await asyncio.sleep(0.05)
|
|
await send_event(ws, {"type": "reasoning_done", "duration_secs": 0.4})
|
|
|
|
intro = f"You said: {user_text}\n\nRunning a quick tool to demonstrate.\n\n"
|
|
for ch in chunk_text(intro, 6):
|
|
await send_event(ws, {"type": "text_delta", "text": ch})
|
|
await asyncio.sleep(0.02)
|
|
|
|
tool_id = f"tool-{req_id}"
|
|
await send_event(ws, {"type": "tool_start", "id": tool_id, "name": "bash"})
|
|
for piece in ['{"command":', ' "echo ', 'hello"}']:
|
|
await send_event(ws, {"type": "tool_input", "delta": piece})
|
|
await asyncio.sleep(0.03)
|
|
await send_event(ws, {"type": "tool_exec", "id": tool_id, "name": "bash"})
|
|
await asyncio.sleep(0.2)
|
|
await send_event(ws, {
|
|
"type": "tool_done", "id": tool_id, "name": "bash",
|
|
"output": "hello\n", "error": None,
|
|
})
|
|
|
|
answer = (
|
|
"Done. Here is a code block:\n\n"
|
|
"```python\nprint('hello from mock gateway')\n```\n\n"
|
|
"And a **bold** word plus `inline code`."
|
|
)
|
|
for ch in chunk_text(answer, 8):
|
|
await send_event(ws, {"type": "text_delta", "text": ch})
|
|
await asyncio.sleep(0.02)
|
|
|
|
await send_event(ws, {"type": "message_end"})
|
|
|
|
state.token_input += 120 + len(user_text)
|
|
state.token_output += 240
|
|
await send_event(ws, {"type": "tokens", "input": state.token_input, "output": state.token_output})
|
|
|
|
state.messages.append({"role": "user", "content": user_text})
|
|
state.messages.append({
|
|
"role": "assistant", "content": answer,
|
|
"tool_data": {
|
|
"id": tool_id, "name": "bash",
|
|
"input": '{"command": "echo hello"}',
|
|
"output": "hello\n", "error": None,
|
|
},
|
|
})
|
|
|
|
await send_event(ws, {"type": "done", "id": req_id})
|
|
|
|
|
|
def history_payload(state, req_id):
|
|
return {
|
|
"type": "history",
|
|
"id": req_id,
|
|
"session_id": state.session_id,
|
|
"messages": state.messages,
|
|
"provider_name": "anthropic-api",
|
|
"provider_model": state.model,
|
|
"available_models": DEFAULT_MODELS,
|
|
"total_tokens": [state.token_input, state.token_output],
|
|
"all_sessions": [state.session_id, "mock-session-0002"],
|
|
"server_version": SERVER_VERSION,
|
|
"display_title": state.title,
|
|
"reasoning_effort": state.reasoning_effort,
|
|
}
|
|
|
|
|
|
async def handle_request(ws, state, raw):
|
|
try:
|
|
msg = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return
|
|
req_type = msg.get("type")
|
|
req_id = int(msg.get("id", 0))
|
|
print(f"[ws] <- {req_type} id={req_id}", file=sys.stderr)
|
|
|
|
if req_type == "subscribe":
|
|
await send_event(ws, {"type": "ack", "id": req_id})
|
|
await send_event(ws, {"type": "session", "session_id": state.session_id})
|
|
await send_event(ws, {
|
|
"type": "state", "id": req_id, "session_id": state.session_id,
|
|
"message_count": len(state.messages), "is_processing": False,
|
|
})
|
|
elif req_type == "get_history":
|
|
await send_event(ws, history_payload(state, req_id))
|
|
elif req_type == "message":
|
|
await stream_response(ws, state, msg.get("content", ""), req_id)
|
|
elif req_type == "soft_interrupt":
|
|
await send_event(ws, {"type": "ack", "id": req_id})
|
|
# Mirror the real server: confirm the queued message was injected
|
|
# before streaming the (echoed) response it participates in.
|
|
await send_event(ws, {
|
|
"type": "soft_interrupt_injected",
|
|
"content": msg.get("content", ""),
|
|
"display_role": "user",
|
|
"point": "immediate",
|
|
"tools_skipped": 0,
|
|
})
|
|
await stream_response(ws, state, msg.get("content", ""), req_id)
|
|
elif req_type == "cancel":
|
|
await send_event(ws, {"type": "interrupted"})
|
|
await send_event(ws, {"type": "done", "id": req_id})
|
|
elif req_type == "ping":
|
|
await send_event(ws, {"type": "pong", "id": req_id})
|
|
elif req_type == "set_model":
|
|
state.model = msg.get("model", state.model)
|
|
await send_event(ws, {"type": "model_changed", "id": req_id, "model": state.model, "error": None})
|
|
await send_event(ws, {"type": "available_models_updated", "available_models": DEFAULT_MODELS, "provider_model": state.model})
|
|
elif req_type == "set_reasoning_effort":
|
|
state.reasoning_effort = msg.get("effort", state.reasoning_effort)
|
|
await send_event(ws, {"type": "reasoning_effort_changed", "id": req_id, "effort": state.reasoning_effort, "error": None})
|
|
elif req_type == "compact":
|
|
await send_event(ws, {"type": "compact_result", "id": req_id, "message": "Compacted context (2048 tokens saved)", "success": True})
|
|
elif req_type == "rename_session":
|
|
state.title = msg.get("title") or "Untitled"
|
|
await send_event(ws, {"type": "session_renamed", "session_id": state.session_id, "display_title": state.title})
|
|
elif req_type == "resume_session":
|
|
sid = msg.get("session_id", state.session_id)
|
|
state.session_id = sid
|
|
state.messages = []
|
|
await send_event(ws, {"type": "session", "session_id": sid})
|
|
await send_event(ws, history_payload(state, req_id))
|
|
elif req_type == "clear":
|
|
state.messages = []
|
|
await send_event(ws, history_payload(state, req_id))
|
|
elif req_type == "cancel_soft_interrupts":
|
|
await send_event(ws, {"type": "ack", "id": req_id})
|
|
elif req_type == "_notify":
|
|
# Test-only: synthesize a push notification + a compaction notice.
|
|
await send_event(ws, {"type": "notification", "from_name": "swarm", "message": "build finished"})
|
|
await send_event(ws, {"type": "compaction", "trigger": "manual", "tokens_saved": 4096})
|
|
else:
|
|
print(f"[ws] (ignored unknown request {req_type})", file=sys.stderr)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HTTP + routing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def http_response(status_line, body):
|
|
body_bytes = body.encode()
|
|
head = (
|
|
f"HTTP/1.1 {status_line}\r\n"
|
|
"Content-Type: application/json\r\n"
|
|
f"Content-Length: {len(body_bytes)}\r\n"
|
|
"Connection: close\r\n"
|
|
"Access-Control-Allow-Origin: *\r\n"
|
|
"\r\n"
|
|
)
|
|
return head.encode() + body_bytes
|
|
|
|
|
|
async def read_http_request(reader):
|
|
"""Reads headers (and body per Content-Length). Returns (method, path, headers, body)."""
|
|
header_data = b""
|
|
while b"\r\n\r\n" not in header_data:
|
|
chunk = await reader.read(4096)
|
|
if not chunk:
|
|
break
|
|
header_data += chunk
|
|
if len(header_data) > 65536:
|
|
break
|
|
if b"\r\n\r\n" not in header_data:
|
|
return None
|
|
head, _, rest = header_data.partition(b"\r\n\r\n")
|
|
lines = head.decode("latin1").split("\r\n")
|
|
request_line = lines[0]
|
|
parts = request_line.split()
|
|
method, path = (parts[0], parts[1]) if len(parts) >= 2 else ("", "")
|
|
headers = {}
|
|
for line in lines[1:]:
|
|
if ":" in line:
|
|
k, v = line.split(":", 1)
|
|
headers[k.strip().lower()] = v.strip()
|
|
body = rest
|
|
content_length = int(headers.get("content-length", "0") or "0")
|
|
while len(body) < content_length:
|
|
chunk = await reader.read(content_length - len(body))
|
|
if not chunk:
|
|
break
|
|
body += chunk
|
|
return method, path, headers, body
|
|
|
|
|
|
async def handle_connection(reader, writer, state):
|
|
parsed = await read_http_request(reader)
|
|
if parsed is None:
|
|
writer.close()
|
|
return
|
|
method, path, headers, body = parsed
|
|
path_base = path.split("?")[0]
|
|
print(f"[http] {method} {path_base}", file=sys.stderr)
|
|
|
|
if headers.get("upgrade", "").lower() == "websocket" and path_base == "/ws":
|
|
await serve_websocket(reader, writer, headers, state)
|
|
return
|
|
|
|
if method == "GET" and path_base == "/health":
|
|
body_str = jline({"status": "ok", "version": SERVER_VERSION, "gateway": True})
|
|
writer.write(http_response("200 OK", body_str))
|
|
elif method == "OPTIONS":
|
|
writer.write(
|
|
b"HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\n"
|
|
b"Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n"
|
|
b"Access-Control-Allow-Headers: Content-Type, Authorization\r\n"
|
|
b"Content-Length: 0\r\nConnection: close\r\n\r\n"
|
|
)
|
|
elif method == "POST" and path_base == "/pair":
|
|
try:
|
|
payload = json.loads(body.decode() or "{}")
|
|
except Exception:
|
|
payload = {}
|
|
if payload.get("code", "") == state.code:
|
|
resp = jline({"token": state.token, "server_name": SERVER_NAME, "server_version": SERVER_VERSION})
|
|
writer.write(http_response("200 OK", resp))
|
|
else:
|
|
resp = jline({"error": "Invalid or expired pairing code"})
|
|
writer.write(http_response("401 Unauthorized", resp))
|
|
else:
|
|
writer.write(http_response("404 Not Found", jline({"error": "Not found"})))
|
|
|
|
try:
|
|
await writer.drain()
|
|
except Exception:
|
|
pass
|
|
writer.close()
|
|
|
|
|
|
async def serve_websocket(reader, writer, headers, state):
|
|
key = headers.get("sec-websocket-key")
|
|
auth = headers.get("authorization", "")
|
|
if not key:
|
|
writer.close()
|
|
return
|
|
accept = ws_accept_key(key)
|
|
handshake = (
|
|
"HTTP/1.1 101 Switching Protocols\r\n"
|
|
"Upgrade: websocket\r\n"
|
|
"Connection: Upgrade\r\n"
|
|
f"Sec-WebSocket-Accept: {accept}\r\n"
|
|
"\r\n"
|
|
)
|
|
writer.write(handshake.encode())
|
|
await writer.drain()
|
|
ws = WSConn(writer)
|
|
ok = auth == f"Bearer {state.token}"
|
|
print(f"[ws] client connected (auth_ok={ok})", file=sys.stderr)
|
|
|
|
keepalive = asyncio.create_task(keepalive_loop(ws))
|
|
push_demo = None
|
|
if getattr(state, "push_demo", False):
|
|
push_demo = asyncio.create_task(push_demo_loop(ws))
|
|
try:
|
|
buffered = ""
|
|
while True:
|
|
frame = await read_frame(reader)
|
|
if frame is None:
|
|
break
|
|
opcode, data = frame
|
|
if opcode == 0x8: # close
|
|
break
|
|
if opcode == 0x9: # ping
|
|
await ws.pong(data)
|
|
continue
|
|
if opcode in (0x1, 0x2):
|
|
buffered += data.decode("utf-8", errors="replace")
|
|
while "\n" in buffered:
|
|
line, buffered = buffered.split("\n", 1)
|
|
line = line.strip()
|
|
if line:
|
|
await handle_request(ws, state, line)
|
|
if buffered.strip():
|
|
await handle_request(ws, state, buffered.strip())
|
|
buffered = ""
|
|
except (asyncio.IncompleteReadError, ConnectionResetError):
|
|
pass
|
|
finally:
|
|
keepalive.cancel()
|
|
if push_demo:
|
|
push_demo.cancel()
|
|
await ws.close()
|
|
writer.close()
|
|
print("[ws] client disconnected", file=sys.stderr)
|
|
|
|
|
|
async def push_demo_loop(ws):
|
|
"""Spontaneously push out-of-band notices to validate the toast UI."""
|
|
try:
|
|
await asyncio.sleep(2.5)
|
|
await send_event(ws, {"type": "notification", "from_name": "swarm", "message": "build finished"})
|
|
await asyncio.sleep(1.5)
|
|
await send_event(ws, {"type": "compaction", "trigger": "manual", "tokens_saved": 4096})
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def keepalive_loop(ws):
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(20)
|
|
async with ws._lock:
|
|
ws.writer.write(encode_control_frame(0x9)) # ping
|
|
await ws.writer.drain()
|
|
except asyncio.CancelledError:
|
|
pass
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
async def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--port", type=int, default=7643)
|
|
parser.add_argument("--host", default="127.0.0.1")
|
|
parser.add_argument("--code", default="123456")
|
|
parser.add_argument("--token", default="mocktoken0123456789abcdef")
|
|
parser.add_argument("--push-demo", action="store_true",
|
|
help="spontaneously push notification + compaction notices after connect")
|
|
parser.add_argument("--scenario", default="",
|
|
help="pre-seed transcript: empty|short|tool|long|code")
|
|
args = parser.parse_args()
|
|
|
|
state = GatewayState(args.code, args.token)
|
|
state.push_demo = args.push_demo
|
|
if args.scenario:
|
|
state.messages = scenario_messages(args.scenario)
|
|
|
|
server = await asyncio.start_server(
|
|
lambda r, w: handle_connection(r, w, state),
|
|
args.host,
|
|
args.port,
|
|
)
|
|
print(
|
|
f"mock gateway: http+ws on {args.host}:{args.port} "
|
|
f"(code={args.code}, token={args.token})",
|
|
file=sys.stderr,
|
|
)
|
|
async with server:
|
|
await server.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
asyncio.run(main())
|
|
except KeyboardInterrupt:
|
|
pass
|