Mercurial
comparison mrjunejune/inference/mock_sidecar.py @ 261:b401627fc49e
Add JRPG mock flows and interactive previews
Add scripted mock SSE commands, custom event forwarding, animated chat turns, full-height message navigation, and a cyberpunk resume dossier.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <mrjunejune@users.noreply.github.com> |
|---|---|
| date | Wed, 05 Aug 2026 20:38:32 -0700 |
| parents | |
| children | 056790c4fb0d |
comparison
equal
deleted
inserted
replaced
| 260:1f9877b637e9 | 261:b401627fc49e |
|---|---|
| 1 from __future__ import annotations | |
| 2 | |
| 3 import argparse | |
| 4 import asyncio | |
| 5 import json | |
| 6 import os | |
| 7 import pathlib | |
| 8 import re | |
| 9 import sys | |
| 10 from dataclasses import dataclass | |
| 11 from typing import Any, Awaitable, Callable | |
| 12 | |
| 13 | |
| 14 JsonObject = dict[str, Any] | |
| 15 Emit = Callable[[JsonObject], Awaitable[None]] | |
| 16 COMMAND_PATTERN = re.compile(r"^![a-z][a-z0-9_-]*$") | |
| 17 EVENT_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") | |
| 18 RESERVED_EVENT_TYPES = {"bridge.closed", "ready", "turn.accepted", "turn.done"} | |
| 19 RESERVED_EVENT_FIELDS = { | |
| 20 "conversation_id", | |
| 21 "mock", | |
| 22 "mock_command", | |
| 23 "request_id", | |
| 24 } | |
| 25 MAX_DELAY_MS = 60_000 | |
| 26 | |
| 27 | |
| 28 @dataclass(frozen=True) | |
| 29 class MockEvent: | |
| 30 event_type: str | |
| 31 delay_ms: int | None | |
| 32 payload: JsonObject | |
| 33 | |
| 34 | |
| 35 @dataclass(frozen=True) | |
| 36 class MockCommand: | |
| 37 name: str | |
| 38 description: str | |
| 39 events: tuple[MockEvent, ...] | |
| 40 failed: bool | |
| 41 | |
| 42 | |
| 43 @dataclass(frozen=True) | |
| 44 class MockConfig: | |
| 45 delay_ms: int | |
| 46 fallback: str | |
| 47 commands: dict[str, MockCommand] | |
| 48 | |
| 49 def select(self, prompt: str) -> MockCommand: | |
| 50 first_token = prompt.strip().split(maxsplit=1)[0].lower() | |
| 51 return self.commands.get(first_token, self.commands[self.fallback]) | |
| 52 | |
| 53 | |
| 54 @dataclass | |
| 55 class MockTurn: | |
| 56 request_id: str | |
| 57 task: asyncio.Task[None] | |
| 58 | |
| 59 | |
| 60 def _require_non_negative_integer(value: Any, description: str) -> int: | |
| 61 if not isinstance(value, int) or isinstance(value, bool) or value < 0: | |
| 62 raise ValueError(f"{description} must be a non-negative integer") | |
| 63 if value > MAX_DELAY_MS: | |
| 64 raise ValueError(f"{description} must not exceed {MAX_DELAY_MS}") | |
| 65 return value | |
| 66 | |
| 67 | |
| 68 def _validate_event(command_name: str, index: int, raw: Any) -> MockEvent: | |
| 69 if not isinstance(raw, dict): | |
| 70 raise ValueError(f"{command_name} event {index} must be an object") | |
| 71 event_type = raw.get("type") | |
| 72 if not isinstance(event_type, str) or not EVENT_PATTERN.fullmatch(event_type): | |
| 73 raise ValueError( | |
| 74 f"{command_name} event {index} requires a safe event type" | |
| 75 ) | |
| 76 if event_type in RESERVED_EVENT_TYPES: | |
| 77 raise ValueError( | |
| 78 f"{command_name} cannot script lifecycle event {event_type}" | |
| 79 ) | |
| 80 reserved = set(raw) & RESERVED_EVENT_FIELDS | |
| 81 if reserved: | |
| 82 raise ValueError( | |
| 83 f"{command_name} event {index} uses reserved fields: " | |
| 84 f"{', '.join(sorted(reserved))}" | |
| 85 ) | |
| 86 delay_ms = raw.get("delay_ms") | |
| 87 if delay_ms is not None: | |
| 88 delay_ms = _require_non_negative_integer( | |
| 89 delay_ms, | |
| 90 f"{command_name} event {index} delay_ms", | |
| 91 ) | |
| 92 payload = { | |
| 93 key: value | |
| 94 for key, value in raw.items() | |
| 95 if key not in {"delay_ms", "type"} | |
| 96 } | |
| 97 if event_type == "assistant.delta": | |
| 98 if not isinstance(payload.get("delta"), str) or not payload["delta"]: | |
| 99 raise ValueError( | |
| 100 f"{command_name} assistant.delta requires non-empty delta" | |
| 101 ) | |
| 102 elif event_type == "assistant.completed": | |
| 103 if not isinstance(payload.get("content"), str): | |
| 104 raise ValueError( | |
| 105 f"{command_name} assistant.completed requires content" | |
| 106 ) | |
| 107 elif event_type == "assistant.usage": | |
| 108 usage = payload.get("usage") | |
| 109 if not isinstance(usage, dict): | |
| 110 raise ValueError(f"{command_name} assistant.usage requires usage") | |
| 111 for key in ("input_tokens", "output_tokens"): | |
| 112 _require_non_negative_integer( | |
| 113 usage.get(key), | |
| 114 f"{command_name} assistant.usage {key}", | |
| 115 ) | |
| 116 elif event_type == "turn.error": | |
| 117 error = payload.get("error") | |
| 118 if not isinstance(error, dict): | |
| 119 raise ValueError(f"{command_name} turn.error requires error") | |
| 120 if ( | |
| 121 not isinstance(error.get("code"), str) | |
| 122 or not error["code"] | |
| 123 or not isinstance(error.get("message"), str) | |
| 124 or not error["message"] | |
| 125 ): | |
| 126 raise ValueError( | |
| 127 f"{command_name} turn.error requires error code and message" | |
| 128 ) | |
| 129 return MockEvent(event_type, delay_ms, payload) | |
| 130 | |
| 131 | |
| 132 def _validate_command(name: str, raw: Any) -> MockCommand: | |
| 133 if not COMMAND_PATTERN.fullmatch(name): | |
| 134 raise ValueError(f"invalid mock command: {name}") | |
| 135 if not isinstance(raw, dict): | |
| 136 raise ValueError(f"{name} must contain an object") | |
| 137 unknown = set(raw) - {"description", "events"} | |
| 138 if unknown: | |
| 139 raise ValueError( | |
| 140 f"{name} has unknown keys: {', '.join(sorted(unknown))}" | |
| 141 ) | |
| 142 description = raw.get("description", "") | |
| 143 if not isinstance(description, str): | |
| 144 raise ValueError(f"{name} description must be a string") | |
| 145 raw_events = raw.get("events") | |
| 146 if not isinstance(raw_events, list) or not raw_events: | |
| 147 raise ValueError(f"{name} events must be a non-empty array") | |
| 148 if len(raw_events) > 256: | |
| 149 raise ValueError(f"{name} has too many events") | |
| 150 events = tuple( | |
| 151 _validate_event(name, index, event) | |
| 152 for index, event in enumerate(raw_events) | |
| 153 ) | |
| 154 errors = [event for event in events if event.event_type == "turn.error"] | |
| 155 completed = [ | |
| 156 event for event in events if event.event_type == "assistant.completed" | |
| 157 ] | |
| 158 if errors: | |
| 159 if len(errors) != 1 or errors[0] is not events[-1]: | |
| 160 raise ValueError(f"{name} turn.error must be the final scripted event") | |
| 161 if completed: | |
| 162 raise ValueError(f"{name} cannot complete and fail the same turn") | |
| 163 elif len(completed) != 1: | |
| 164 raise ValueError(f"{name} requires one assistant.completed event") | |
| 165 | |
| 166 deltas = "".join( | |
| 167 event.payload["delta"] | |
| 168 for event in events | |
| 169 if event.event_type == "assistant.delta" | |
| 170 ) | |
| 171 if completed: | |
| 172 completed_index = events.index(completed[0]) | |
| 173 if any( | |
| 174 event.event_type == "assistant.delta" | |
| 175 for event in events[completed_index + 1:] | |
| 176 ): | |
| 177 raise ValueError( | |
| 178 f"{name} assistant.completed must follow all deltas" | |
| 179 ) | |
| 180 if completed and deltas and deltas != completed[0].payload["content"]: | |
| 181 raise ValueError(f"{name} deltas must join to completed content") | |
| 182 return MockCommand(name, description, events, failed=bool(errors)) | |
| 183 | |
| 184 | |
| 185 def load_mock_config(path: str | os.PathLike[str]) -> MockConfig: | |
| 186 with open(path, encoding="utf-8") as config_file: | |
| 187 payload = json.load(config_file) | |
| 188 if not isinstance(payload, dict): | |
| 189 raise ValueError("mock response file must contain a JSON object") | |
| 190 unknown = set(payload) - {"commands", "delay_ms", "fallback"} | |
| 191 if unknown: | |
| 192 raise ValueError(f"unknown mock config keys: {', '.join(sorted(unknown))}") | |
| 193 delay_ms = _require_non_negative_integer( | |
| 194 payload.get("delay_ms", 55), | |
| 195 "delay_ms", | |
| 196 ) | |
| 197 fallback = payload.get("fallback") | |
| 198 if not isinstance(fallback, str) or not COMMAND_PATTERN.fullmatch(fallback): | |
| 199 raise ValueError("fallback must be a !command") | |
| 200 raw_commands = payload.get("commands") | |
| 201 if not isinstance(raw_commands, dict) or not raw_commands: | |
| 202 raise ValueError("commands must be a non-empty object") | |
| 203 commands = { | |
| 204 name.lower(): _validate_command(name.lower(), raw) | |
| 205 for name, raw in raw_commands.items() | |
| 206 } | |
| 207 if fallback.lower() not in commands: | |
| 208 raise ValueError(f"fallback command is not defined: {fallback}") | |
| 209 return MockConfig(delay_ms, fallback.lower(), commands) | |
| 210 | |
| 211 | |
| 212 class MockSidecar: | |
| 213 def __init__(self, emit: Emit, config: MockConfig) -> None: | |
| 214 self._emit = emit | |
| 215 self._config = config | |
| 216 self._active: dict[str, MockTurn] = {} | |
| 217 self.shutting_down = False | |
| 218 | |
| 219 async def announce_ready(self) -> None: | |
| 220 await self._send("ready", None, None, status="ok", mock=True) | |
| 221 | |
| 222 async def dispatch(self, command: JsonObject) -> None: | |
| 223 command_name = command.get("command") | |
| 224 request_id = command.get("request_id") | |
| 225 conversation_id = command.get("conversation_id") | |
| 226 if not isinstance(request_id, str) or not request_id: | |
| 227 await self._fail( | |
| 228 request_id if isinstance(request_id, str) else None, | |
| 229 conversation_id if isinstance(conversation_id, str) else None, | |
| 230 "invalid_request", | |
| 231 "request_id is required", | |
| 232 ) | |
| 233 return | |
| 234 if command_name == "shutdown": | |
| 235 await self._shutdown(request_id, conversation_id) | |
| 236 return | |
| 237 if command_name == "health": | |
| 238 await self._send( | |
| 239 "ready", | |
| 240 request_id, | |
| 241 conversation_id if isinstance(conversation_id, str) else None, | |
| 242 status="ok", | |
| 243 mock=True, | |
| 244 ) | |
| 245 return | |
| 246 if not isinstance(conversation_id, str) or not conversation_id: | |
| 247 await self._fail( | |
| 248 request_id, | |
| 249 None, | |
| 250 "invalid_request", | |
| 251 "conversation_id is required", | |
| 252 ) | |
| 253 return | |
| 254 if command_name == "turn.start": | |
| 255 prompt = command.get("prompt") | |
| 256 if not isinstance(prompt, str) or not prompt: | |
| 257 await self._fail( | |
| 258 request_id, | |
| 259 conversation_id, | |
| 260 "invalid_request", | |
| 261 "prompt is required", | |
| 262 ) | |
| 263 return | |
| 264 await self._start_turn(request_id, conversation_id, prompt) | |
| 265 elif command_name == "turn.abort": | |
| 266 await self._abort_turn(request_id, conversation_id) | |
| 267 elif command_name == "conversation.delete": | |
| 268 await self._delete_conversation(request_id, conversation_id) | |
| 269 else: | |
| 270 await self._fail( | |
| 271 request_id, | |
| 272 conversation_id, | |
| 273 "unknown_command", | |
| 274 f"unsupported command: {command_name!r}", | |
| 275 ) | |
| 276 | |
| 277 async def wait_for_idle(self) -> None: | |
| 278 tasks = [turn.task for turn in self._active.values()] | |
| 279 if tasks: | |
| 280 await asyncio.gather(*tasks, return_exceptions=True) | |
| 281 | |
| 282 async def _start_turn( | |
| 283 self, | |
| 284 request_id: str, | |
| 285 conversation_id: str, | |
| 286 prompt: str, | |
| 287 ) -> None: | |
| 288 if conversation_id in self._active: | |
| 289 await self._fail( | |
| 290 request_id, | |
| 291 conversation_id, | |
| 292 "turn_in_progress", | |
| 293 "the conversation already has an active turn", | |
| 294 ) | |
| 295 return | |
| 296 scripted_command = self._config.select(prompt) | |
| 297 await self._send( | |
| 298 "turn.accepted", | |
| 299 request_id, | |
| 300 conversation_id, | |
| 301 mock=True, | |
| 302 mock_command=scripted_command.name, | |
| 303 ) | |
| 304 task = asyncio.create_task( | |
| 305 self._stream_turn(request_id, conversation_id, scripted_command) | |
| 306 ) | |
| 307 self._active[conversation_id] = MockTurn(request_id, task) | |
| 308 | |
| 309 async def _stream_turn( | |
| 310 self, | |
| 311 request_id: str, | |
| 312 conversation_id: str, | |
| 313 scripted_command: MockCommand, | |
| 314 ) -> None: | |
| 315 try: | |
| 316 for event in scripted_command.events: | |
| 317 delay = ( | |
| 318 self._config.delay_ms | |
| 319 if event.delay_ms is None | |
| 320 else event.delay_ms | |
| 321 ) | |
| 322 await asyncio.sleep(delay / 1000) | |
| 323 await self._send( | |
| 324 event.event_type, | |
| 325 request_id, | |
| 326 conversation_id, | |
| 327 **event.payload, | |
| 328 mock=True, | |
| 329 mock_command=scripted_command.name, | |
| 330 ) | |
| 331 await self._send( | |
| 332 "turn.done", | |
| 333 request_id, | |
| 334 conversation_id, | |
| 335 failed=scripted_command.failed, | |
| 336 mock=True, | |
| 337 mock_command=scripted_command.name, | |
| 338 ) | |
| 339 except asyncio.CancelledError: | |
| 340 await self._send( | |
| 341 "turn.done", | |
| 342 request_id, | |
| 343 conversation_id, | |
| 344 aborted=True, | |
| 345 mock=True, | |
| 346 mock_command=scripted_command.name, | |
| 347 ) | |
| 348 except Exception as error: | |
| 349 await self._send( | |
| 350 "turn.error", | |
| 351 request_id, | |
| 352 conversation_id, | |
| 353 error={ | |
| 354 "code": "mock_script_failed", | |
| 355 "message": str(error), | |
| 356 }, | |
| 357 mock_command=scripted_command.name, | |
| 358 ) | |
| 359 await self._send( | |
| 360 "turn.done", | |
| 361 request_id, | |
| 362 conversation_id, | |
| 363 failed=True, | |
| 364 mock=True, | |
| 365 mock_command=scripted_command.name, | |
| 366 ) | |
| 367 finally: | |
| 368 active = self._active.get(conversation_id) | |
| 369 if active is not None and active.request_id == request_id: | |
| 370 self._active.pop(conversation_id, None) | |
| 371 | |
| 372 async def _abort_turn( | |
| 373 self, | |
| 374 request_id: str, | |
| 375 conversation_id: str, | |
| 376 ) -> None: | |
| 377 active = self._active.get(conversation_id) | |
| 378 if active is None: | |
| 379 await self._fail( | |
| 380 request_id, | |
| 381 conversation_id, | |
| 382 "no_active_turn", | |
| 383 "no active turn to abort", | |
| 384 ) | |
| 385 return | |
| 386 await self._send( | |
| 387 "turn.accepted", | |
| 388 request_id, | |
| 389 conversation_id, | |
| 390 action="abort", | |
| 391 target_request_id=active.request_id, | |
| 392 mock=True, | |
| 393 ) | |
| 394 active.task.cancel() | |
| 395 await asyncio.gather(active.task, return_exceptions=True) | |
| 396 await self._send( | |
| 397 "turn.done", | |
| 398 request_id, | |
| 399 conversation_id, | |
| 400 action="abort", | |
| 401 target_request_id=active.request_id, | |
| 402 mock=True, | |
| 403 ) | |
| 404 | |
| 405 async def _delete_conversation( | |
| 406 self, | |
| 407 request_id: str, | |
| 408 conversation_id: str, | |
| 409 ) -> None: | |
| 410 active = self._active.get(conversation_id) | |
| 411 if active is not None: | |
| 412 active.task.cancel() | |
| 413 await asyncio.gather(active.task, return_exceptions=True) | |
| 414 await self._send( | |
| 415 "turn.done", | |
| 416 request_id, | |
| 417 conversation_id, | |
| 418 action="conversation.delete", | |
| 419 mock=True, | |
| 420 ) | |
| 421 | |
| 422 async def _shutdown( | |
| 423 self, | |
| 424 request_id: str, | |
| 425 conversation_id: Any, | |
| 426 ) -> None: | |
| 427 self.shutting_down = True | |
| 428 tasks = [turn.task for turn in self._active.values()] | |
| 429 for task in tasks: | |
| 430 task.cancel() | |
| 431 if tasks: | |
| 432 await asyncio.gather(*tasks, return_exceptions=True) | |
| 433 await self._send( | |
| 434 "turn.done", | |
| 435 request_id, | |
| 436 conversation_id if isinstance(conversation_id, str) else None, | |
| 437 action="shutdown", | |
| 438 mock=True, | |
| 439 ) | |
| 440 | |
| 441 async def _fail( | |
| 442 self, | |
| 443 request_id: str | None, | |
| 444 conversation_id: str | None, | |
| 445 code: str, | |
| 446 message: str, | |
| 447 ) -> None: | |
| 448 await self._send( | |
| 449 "turn.error", | |
| 450 request_id, | |
| 451 conversation_id, | |
| 452 error={"code": code, "message": message}, | |
| 453 ) | |
| 454 await self._send( | |
| 455 "turn.done", | |
| 456 request_id, | |
| 457 conversation_id, | |
| 458 failed=True, | |
| 459 mock=True, | |
| 460 ) | |
| 461 | |
| 462 async def _send( | |
| 463 self, | |
| 464 event_type: str, | |
| 465 request_id: str | None, | |
| 466 conversation_id: str | None, | |
| 467 **fields: Any, | |
| 468 ) -> None: | |
| 469 await self._emit( | |
| 470 { | |
| 471 "type": event_type, | |
| 472 "request_id": request_id, | |
| 473 "conversation_id": conversation_id, | |
| 474 **fields, | |
| 475 } | |
| 476 ) | |
| 477 | |
| 478 | |
| 479 async def run(config: MockConfig) -> None: | |
| 480 async def emit(payload: JsonObject) -> None: | |
| 481 sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n") | |
| 482 sys.stdout.flush() | |
| 483 | |
| 484 sidecar = MockSidecar(emit, config) | |
| 485 await sidecar.announce_ready() | |
| 486 while not sidecar.shutting_down: | |
| 487 line = await asyncio.to_thread(sys.stdin.readline) | |
| 488 if not line: | |
| 489 break | |
| 490 try: | |
| 491 command = json.loads(line) | |
| 492 if not isinstance(command, dict): | |
| 493 raise ValueError("command must be a JSON object") | |
| 494 except (json.JSONDecodeError, ValueError) as error: | |
| 495 await sidecar._fail(None, None, "invalid_json", str(error)) | |
| 496 continue | |
| 497 await sidecar.dispatch(command) | |
| 498 await sidecar.wait_for_idle() | |
| 499 | |
| 500 | |
| 501 def main() -> None: | |
| 502 packaged_responses = pathlib.Path(__file__).with_name("mock_responses.json") | |
| 503 parser = argparse.ArgumentParser(description="Scripted JRPG mock sidecar") | |
| 504 parser.add_argument("copilot_cli", nargs="?") | |
| 505 parser.add_argument("--responses", default=str(packaged_responses)) | |
| 506 args = parser.parse_args() | |
| 507 responses_path = os.environ.get("MRJUNEJUNE_MOCK_RESPONSES") or args.responses | |
| 508 config = load_mock_config(responses_path) | |
| 509 delay_override = os.environ.get("MRJUNEJUNE_MOCK_DELAY_MS") | |
| 510 if delay_override is not None: | |
| 511 try: | |
| 512 delay_ms = int(delay_override) | |
| 513 except ValueError as error: | |
| 514 raise ValueError( | |
| 515 "MRJUNEJUNE_MOCK_DELAY_MS must be a non-negative integer" | |
| 516 ) from error | |
| 517 delay_ms = _require_non_negative_integer( | |
| 518 delay_ms, | |
| 519 "MRJUNEJUNE_MOCK_DELAY_MS", | |
| 520 ) | |
| 521 config = MockConfig(delay_ms, config.fallback, config.commands) | |
| 522 asyncio.run(run(config)) | |
| 523 | |
| 524 | |
| 525 if __name__ == "__main__": | |
| 526 main() |