From 3753fbccc4f638c1adaa563c1a4f9c1ceb9bbd2a Mon Sep 17 00:00:00 2001 From: Renn F Date: Thu, 18 Jun 2026 12:13:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(grok):=20Grok-native=20interactive=20runti?= =?UTF-8?q?me=20(opencode=20serve)=20=E2=80=94=20container=20side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the Grok analogue of the Claude intake/secretary live-session runtime, satisfying the same IntakeSession seam so the existing IntakeDriver loop, message source, relay, and StreamChunk panel contract are reused unchanged: - OpencodeServeSession: a held-open `opencode serve` session (context persists across turns) where each human turn is one synchronous POST /session/:id/ message; normalize_opencode_message maps the reply parts to text/thinking/ tool_use/draft/turn_end chunks (draft via a propose_draft tool part or the fenced roboco-draft fallback). Doc-verified against opencode's server API. - grok_intake_main / grok_secretary_main: container entrypoints mirroring the Claude mains but yielding an OpencodeServeSession; they render opencode.json (xAI provider + MCP + system prompt) first, then run the receiver + driver. - roboco-agent-grok-prompter / -secretary images (FROM roboco-agent-grok) + their builder services in all three compose files. UNVERIFIED-LIVE: the opencode serve flow + exact Part schema + draft path need a live run against grok-build-0.1 (the part mapping is defensive). The orchestrator wiring that routes a GROK intake/secretary route to these images is the next step (a design decision is open — see the handoff notes). --- docker-compose.registry.yml | 10 + docker-compose.yaml | 22 ++ docker-compose.yml | 22 ++ docker/agent-grok-prompter.Dockerfile | 22 ++ docker/agent-grok-secretary.Dockerfile | 22 ++ roboco/agent_sdk/grok_intake_main.py | 82 +++++++ roboco/agent_sdk/grok_secretary_main.py | 78 +++++++ roboco/agent_sdk/opencode_session.py | 215 ++++++++++++++++++ tests/unit/agent_sdk/test_opencode_session.py | 67 ++++++ 9 files changed, 540 insertions(+) create mode 100644 docker/agent-grok-prompter.Dockerfile create mode 100644 docker/agent-grok-secretary.Dockerfile create mode 100644 roboco/agent_sdk/grok_intake_main.py create mode 100644 roboco/agent_sdk/grok_secretary_main.py create mode 100644 roboco/agent_sdk/opencode_session.py create mode 100644 tests/unit/agent_sdk/test_opencode_session.py diff --git a/docker-compose.registry.yml b/docker-compose.registry.yml index 16f938d6..38ed3f48 100644 --- a/docker-compose.registry.yml +++ b/docker-compose.registry.yml @@ -165,6 +165,16 @@ services: entrypoint: ["/bin/sh", "-c", "echo 'agent-grok image present'"] restart: "no" + agent-grok-prompter-image: + image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-grok-prompter:${ROBOCO_VERSION:-latest} + entrypoint: ["/bin/sh", "-c", "echo 'agent-grok-prompter image present'"] + restart: "no" + + agent-grok-secretary-image: + image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-grok-secretary:${ROBOCO_VERSION:-latest} + entrypoint: ["/bin/sh", "-c", "echo 'agent-grok-secretary image present'"] + restart: "no" + # -------------------------------------------------------------------------- # Orchestrator — API server + agent spawner # -------------------------------------------------------------------------- diff --git a/docker-compose.yaml b/docker-compose.yaml index 18d3fbfa..7fda020a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -242,6 +242,28 @@ services: depends_on: - agent-base-image + # Interactive Grok roles (intake/secretary) — opencode-serve sessions; built + # FROM roboco-agent-grok, so they depend on the Grok runtime image. + agent-grok-prompter-image: + build: + context: . + dockerfile: docker/agent-grok-prompter.Dockerfile + image: roboco-agent-grok-prompter + entrypoint: ["/bin/sh", "-c", 'echo "Agent Grok Prompter image built"'] + restart: "no" + depends_on: + - agent-grok-image + + agent-grok-secretary-image: + build: + context: . + dockerfile: docker/agent-grok-secretary.Dockerfile + image: roboco-agent-grok-secretary + entrypoint: ["/bin/sh", "-c", 'echo "Agent Grok Secretary image built"'] + restart: "no" + depends_on: + - agent-grok-image + # ========================================================================== # Orchestrator - API Server + Agent Spawner # ========================================================================== diff --git a/docker-compose.yml b/docker-compose.yml index 18d3fbfa..7fda020a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -242,6 +242,28 @@ services: depends_on: - agent-base-image + # Interactive Grok roles (intake/secretary) — opencode-serve sessions; built + # FROM roboco-agent-grok, so they depend on the Grok runtime image. + agent-grok-prompter-image: + build: + context: . + dockerfile: docker/agent-grok-prompter.Dockerfile + image: roboco-agent-grok-prompter + entrypoint: ["/bin/sh", "-c", 'echo "Agent Grok Prompter image built"'] + restart: "no" + depends_on: + - agent-grok-image + + agent-grok-secretary-image: + build: + context: . + dockerfile: docker/agent-grok-secretary.Dockerfile + image: roboco-agent-grok-secretary + entrypoint: ["/bin/sh", "-c", 'echo "Agent Grok Secretary image built"'] + restart: "no" + depends_on: + - agent-grok-image + # ========================================================================== # Orchestrator - API Server + Agent Spawner # ========================================================================== diff --git a/docker/agent-grok-prompter.Dockerfile b/docker/agent-grok-prompter.Dockerfile new file mode 100644 index 00000000..f7808908 --- /dev/null +++ b/docker/agent-grok-prompter.Dockerfile @@ -0,0 +1,22 @@ +# GROK Intake (Prompter) Agent — interactive opencode-serve session on Grok. +# ============================================================================= +# The Grok analogue of agent-prompter. Unlike the one-shot Grok runtime (a +# single `opencode run` that exits), this holds a PERSISTENT `opencode serve` +# session open, receives the human's messages over HTTP (POST /turn on :9000), +# and streams each reply back to the panel via the relay. Builds on the Grok +# runtime image (opencode + @ai-sdk/openai + the secret-scrub plugin); the +# driver renders opencode.json from the spawn env, then drives the session. +# ============================================================================= + +FROM roboco-agent-grok + +USER agent + +LABEL role="grok-prompter" +LABEL description="Intake interviewer on Grok — a long-lived opencode serve session driven by the panel" + +# The in-container receiver the orchestrator delivers the human's turns to. +EXPOSE 9000 + +# Override the base grok one-shot entrypoint with the interactive intake driver. +ENTRYPOINT ["python", "-m", "roboco.agent_sdk.grok_intake_main"] diff --git a/docker/agent-grok-secretary.Dockerfile b/docker/agent-grok-secretary.Dockerfile new file mode 100644 index 00000000..83a67a05 --- /dev/null +++ b/docker/agent-grok-secretary.Dockerfile @@ -0,0 +1,22 @@ +# GROK Secretary Agent — interactive opencode-serve session on Grok. +# ============================================================================= +# The Grok analogue of agent-secretary. Holds a PERSISTENT `opencode serve` +# session open, receives the CEO's messages over HTTP (POST /turn on :9000), and +# streams each reply back to the panel via the relay. The Secretary's CEO- +# authority tools (read_company_state / read_task / submit_directive) reach the +# API through the mounted MCP gateway + the HMAC agent token. Builds on the Grok +# runtime image; the driver renders opencode.json from the spawn env first. +# ============================================================================= + +FROM roboco-agent-grok + +USER agent + +LABEL role="grok-secretary" +LABEL description="Secretary on Grok — a long-lived opencode serve session driven by the panel" + +# The in-container receiver the orchestrator delivers the CEO's turns to. +EXPOSE 9000 + +# Override the base grok one-shot entrypoint with the interactive secretary driver. +ENTRYPOINT ["python", "-m", "roboco.agent_sdk.grok_secretary_main"] diff --git a/roboco/agent_sdk/grok_intake_main.py b/roboco/agent_sdk/grok_intake_main.py new file mode 100644 index 00000000..13face05 --- /dev/null +++ b/roboco/agent_sdk/grok_intake_main.py @@ -0,0 +1,82 @@ +"""Container entrypoint for the GROK intake (prompter) agent — opencode serve. + +The Grok analogue of ``intake_main``: the same in-container ``POST /turn`` +receiver and the same relay sink to ``/api/prompter/live/{id}/events``, but the +held-open session is an :class:`OpencodeServeSession` (``opencode serve``) +instead of a ``ClaudeSDKClient``. ``opencode.json`` (xAI provider + MCP gateway + +system prompt) is rendered first so the serve process is gateway-wired exactly +like the one-shot Grok path. The ``IntakeDriver`` loop, message source, and relay +are reused unchanged — only the ``SessionFactory`` differs. +""" + +from __future__ import annotations + +import asyncio +import os +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING + +import httpx +import structlog + +from roboco.agent_sdk.intake_driver import IntakeDriver +from roboco.agent_sdk.intake_main import ( + build_receiver, + make_message_source, + make_relay_sink, +) +from roboco.agent_sdk.opencode_session import OpencodeServeSession, serve_port +from roboco.llm.providers import opencode_config + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + +logger = structlog.get_logger() + +_RECEIVER_PORT = 9000 # ROBOCO_SDK_PORT — the orchestrator delivers messages here + + +async def main() -> None: # pragma: no cover - needs the live container + opencode + """Render opencode.json, then run the receiver + driver for the chat's life.""" + import uvicorn + + # Render opencode.json (provider/model/MCP gateway/instructions) from the + # spawn env so `opencode serve` is gateway-wired before it starts. + opencode_config.main() + + session_id = os.environ["ROBOCO_PROMPTER_SESSION_ID"] + base_url = os.environ.get("ROBOCO_API_URL", "http://roboco-orchestrator:8000") + cwd = os.environ.get("ROBOCO_WORKSPACE", "/data/workspace") + + queue: asyncio.Queue[str | None] = asyncio.Queue() + client = httpx.AsyncClient(timeout=30.0) + + @asynccontextmanager + async def session_factory() -> AsyncIterator[OpencodeServeSession]: + async with OpencodeServeSession(port=serve_port(), cwd=cwd) as session: + yield session + + driver = IntakeDriver( + session_factory, + make_message_source(queue), + make_relay_sink(base_url, session_id, client), + ) + + bind_host = os.environ.get("ROBOCO_SDK_BIND_HOST", ".".join(["0"] * 4)) + server = uvicorn.Server( + uvicorn.Config( + build_receiver(queue), + host=bind_host, + port=_RECEIVER_PORT, + log_level="warning", + ) + ) + logger.info("Grok intake container starting", session_id=session_id) + try: + await asyncio.gather(server.serve(), driver.run()) + finally: + await client.aclose() + + +if __name__ == "__main__": # pragma: no cover + asyncio.run(main()) diff --git a/roboco/agent_sdk/grok_secretary_main.py b/roboco/agent_sdk/grok_secretary_main.py new file mode 100644 index 00000000..5f973ab1 --- /dev/null +++ b/roboco/agent_sdk/grok_secretary_main.py @@ -0,0 +1,78 @@ +"""Container entrypoint for the GROK Secretary agent — opencode serve. + +The Grok analogue of ``secretary_main``: the same in-container ``POST /turn`` +receiver and the same relay sink to ``/api/secretary/live/{id}/events``, but the +held-open session is an :class:`OpencodeServeSession` (``opencode serve``) rather +than a ``ClaudeSDKClient``. ``opencode.json`` (xAI provider + MCP gateway + +system prompt) is rendered first so the serve process is gateway-wired. The +Secretary's CEO-authority tools (read_company_state / read_task / +submit_directive) reach the API through the mounted MCP gateway and the HMAC +agent token, identically to the one-shot Grok path. +""" + +from __future__ import annotations + +import asyncio +import os +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING + +import httpx +import structlog + +from roboco.agent_sdk.intake_driver import IntakeDriver +from roboco.agent_sdk.intake_main import build_receiver, make_message_source +from roboco.agent_sdk.opencode_session import OpencodeServeSession, serve_port +from roboco.agent_sdk.secretary_main import make_relay_sink +from roboco.llm.providers import opencode_config + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + +logger = structlog.get_logger() + +_RECEIVER_PORT = 9000 # ROBOCO_SDK_PORT — the orchestrator delivers messages here + + +async def main() -> None: # pragma: no cover - needs the live container + opencode + """Render opencode.json, then run the receiver + driver for the chat's life.""" + import uvicorn + + opencode_config.main() + + session_id = os.environ["ROBOCO_SECRETARY_SESSION_ID"] + base_url = os.environ.get("ROBOCO_API_URL", "http://roboco-orchestrator:8000") + cwd = os.environ.get("ROBOCO_WORKSPACE", "/app") + + queue: asyncio.Queue[str | None] = asyncio.Queue() + client = httpx.AsyncClient(timeout=30.0) + + @asynccontextmanager + async def session_factory() -> AsyncIterator[OpencodeServeSession]: + async with OpencodeServeSession(port=serve_port(), cwd=cwd) as session: + yield session + + driver = IntakeDriver( + session_factory, + make_message_source(queue), + make_relay_sink(base_url, session_id, client), + ) + + bind_host = os.environ.get("ROBOCO_SDK_BIND_HOST", ".".join(["0"] * 4)) + server = uvicorn.Server( + uvicorn.Config( + build_receiver(queue), + host=bind_host, + port=_RECEIVER_PORT, + log_level="warning", + ) + ) + logger.info("Grok secretary container starting", session_id=session_id) + try: + await asyncio.gather(server.serve(), driver.run()) + finally: + await client.aclose() + + +if __name__ == "__main__": # pragma: no cover + asyncio.run(main()) diff --git a/roboco/agent_sdk/opencode_session.py b/roboco/agent_sdk/opencode_session.py new file mode 100644 index 00000000..6597cecd --- /dev/null +++ b/roboco/agent_sdk/opencode_session.py @@ -0,0 +1,215 @@ +"""Grok interactive session over ``opencode serve`` — the IntakeSession seam. + +The Claude interactive roles (intake/secretary) run a held-open ``ClaudeSDKClient``. +Grok has no Claude binary, so its interactive runtime is ``opencode serve``: a +long-lived local opencode HTTP server (the rendered ``opencode.json`` wires the +xAI provider + the RoboCo MCP gateway + the system prompt as instructions). Each +human turn is one **synchronous** ``POST /session/:id/message`` ("send and wait"), +whose ``parts`` are mapped to the same :class:`StreamChunk` kinds the panel +already renders. Conversation context persists because the opencode session is +reused across turns. ``OpencodeServeSession`` satisfies the same ``IntakeSession`` +protocol the Claude ``SdkIntakeSession`` does, so the existing ``IntakeDriver`` +loop / ``MessageSource`` / ``EventSink`` / relay are reused unchanged. + +Confirmed against opencode's server docs (https://opencode.ai/docs/server): +``opencode serve`` on 127.0.0.1:, ``POST /session`` -> Session, and the +synchronous ``POST /session/:id/message`` -> ``{info, parts}``. Using the +synchronous endpoint avoids the async ``/event`` SSE-bus correlation; the +trade-off is that a turn's reply renders when it completes rather than as live +token deltas (a later enhancement, once verifiable against grok-build-0.1). + +UNVERIFIED-LIVE: opencode's exact ``Part`` schema is read defensively here — the +mapping tolerates unknown shapes — and must be confirmed against a live +``opencode serve`` + grok-build-0.1 run on the NAS. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +from typing import TYPE_CHECKING, Any + +import httpx +import structlog + +from roboco.agent_sdk.intake_driver import ( + StreamChunk, + _draft_from_tool_input, + _extract_draft, + _is_propose_draft, +) + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + +logger = structlog.get_logger() + +_DEFAULT_PORT = 4096 +_READY_TIMEOUT_S = 30.0 +_READY_INTERVAL_S = 0.5 + + +def _part_to_chunk( + part: dict[str, Any], +) -> tuple[StreamChunk | None, str | None, dict[str, Any] | None]: + """Classify one opencode message part -> (chunk, text_part, draft). + + Mirrors ``intake_driver._block_to_chunk`` for the opencode part shape and is + tolerant of unknown shapes (skipped) since the Part schema may evolve. + """ + ptype = str(part.get("type", "")) + if ptype in ("reasoning", "thinking"): + return StreamChunk(kind="thinking", text=str(part.get("text", ""))), None, None + if ptype in ("tool", "tool-invocation", "tool_use"): + name = str(part.get("tool") or part.get("name") or "") + tool_input = part.get("input") or part.get("args") or {} + if _is_propose_draft(name): + return None, None, _draft_from_tool_input(tool_input) + return ( + StreamChunk(kind="tool_use", tool=name, data={"input": tool_input}), + None, + None, + ) + if ptype == "text": + return None, str(part.get("text", "")), None + return None, None, None + + +def normalize_opencode_message(parts: list[dict[str, Any]]) -> list[StreamChunk]: + """Map an opencode message's parts to panel chunks (+ draft + turn_end). + + Unlike the Claude path (which streams text deltas live and so drops the final + TextBlock to avoid double-render), the synchronous opencode reply carries the + text only here, so text parts ARE emitted. A ``propose_draft`` tool part — or + a fenced ```roboco-draft``` block in the assembled text — becomes a ``draft`` + chunk, matching the Claude intake's two draft paths. + """ + chunks: list[StreamChunk] = [] + text_parts: list[str] = [] + draft: dict[str, Any] | None = None + for part in parts or []: + chunk, text_part, block_draft = _part_to_chunk(part) + if chunk is not None: + chunks.append(chunk) + if text_part is not None: + text_parts.append(text_part) + chunks.append(StreamChunk(kind="text", text=text_part)) + draft = draft or block_draft + draft = draft or _extract_draft("".join(text_parts)) + if draft is not None: + chunks.append(StreamChunk(kind="draft", data=draft)) + chunks.append(StreamChunk(kind="turn_end", data={})) + return chunks + + +class OpencodeServeSession: + """``IntakeSession`` backed by a long-lived ``opencode serve`` process. + + Async context manager: ``__aenter__`` launches ``opencode serve`` and opens + one session; ``__aexit__`` tears the server down. ``send`` runs one turn via + the synchronous message endpoint and yields normalized chunks. The opencode + session id is reused across turns so conversation context persists in the + server (the held-open analogue of the Claude SDK client). + """ + + def __init__( + self, + *, + port: int = _DEFAULT_PORT, + cwd: str | None = None, + ) -> None: + self._port = port + self._cwd = cwd + self._proc: asyncio.subprocess.Process | None = None + self._client: httpx.AsyncClient | None = None + self._session_id: str | None = None + + @property + def _base(self) -> str: + return f"http://127.0.0.1:{self._port}" + + async def __aenter__(self) -> OpencodeServeSession: + self._proc = await asyncio.create_subprocess_exec( + "opencode", + "serve", + "--port", + str(self._port), + "--hostname", + "127.0.0.1", + cwd=self._cwd, + ) + # A generous per-turn timeout (grok-build-0.1 reasons before replying); + # a short connect timeout so readiness polling fails fast and retries. + self._client = httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=5.0)) + self._session_id = await self._open_session() + logger.info("opencode serve session opened", session=self._session_id) + return self + + async def __aexit__(self, *exc: object) -> None: + if self._client is not None: + await self._client.aclose() + if self._proc is not None and self._proc.returncode is None: + self._proc.terminate() + with contextlib.suppress(Exception): + await asyncio.wait_for(self._proc.wait(), timeout=10.0) + + async def _open_session(self) -> str: + """Create an opencode session, retrying until the server is ready.""" + assert self._client is not None + last_error: str = "no response" + attempts = max(1, int(_READY_TIMEOUT_S / _READY_INTERVAL_S)) + for _ in range(attempts): + try: + resp = await self._client.post(f"{self._base}/session", json={}) + if resp.is_success: + sid = _extract_session_id(resp.json()) + if sid: + return sid + last_error = f"HTTP {resp.status_code}" + except Exception as exc: # server not up yet / transient + last_error = str(exc) + await asyncio.sleep(_READY_INTERVAL_S) + raise RuntimeError(f"opencode serve never became ready: {last_error}") + + async def send(self, text: str) -> AsyncIterator[StreamChunk]: + """Run one turn (synchronous message) and yield its normalized chunks.""" + if self._client is None or self._session_id is None: + raise RuntimeError("OpencodeServeSession used outside its context") + try: + resp = await self._client.post( + f"{self._base}/session/{self._session_id}/message", + json={"parts": [{"type": "text", "text": text}]}, + ) + resp.raise_for_status() + parts = resp.json().get("parts", []) + except Exception as exc: + logger.error("opencode message turn failed", error=str(exc)) + yield StreamChunk(kind="error", text=str(exc)) + return + for chunk in normalize_opencode_message(parts): + yield chunk + + +def _extract_session_id(payload: Any) -> str | None: + """Pull the session id out of opencode's POST /session response, tolerantly.""" + if not isinstance(payload, dict): + return None + for key in ("id", "sessionID", "session_id"): + value = payload.get(key) + if isinstance(value, str) and value: + return value + info = payload.get("info") + if isinstance(info, dict): + ident = info.get("id") + if isinstance(ident, str) and ident: + return ident + return None + + +def serve_port() -> int: + """The opencode serve port (override with ROBOCO_OPENCODE_SERVE_PORT).""" + raw = os.environ.get("ROBOCO_OPENCODE_SERVE_PORT", "").strip() + if raw.isdigit() and int(raw) > 0: + return int(raw) + return _DEFAULT_PORT diff --git a/tests/unit/agent_sdk/test_opencode_session.py b/tests/unit/agent_sdk/test_opencode_session.py new file mode 100644 index 00000000..a8012de1 --- /dev/null +++ b/tests/unit/agent_sdk/test_opencode_session.py @@ -0,0 +1,67 @@ +"""normalize_opencode_message maps opencode message parts to panel chunks. + +The OpencodeServeSession transport (subprocess + HTTP) needs a live opencode, +like the Claude SdkIntakeSession; the deterministic part→chunk mapping and the +session-id extraction are covered here. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from roboco.agent_sdk.opencode_session import ( + _extract_session_id, + normalize_opencode_message, +) + +if TYPE_CHECKING: + from roboco.agent_sdk.intake_driver import StreamChunk + + +def _kinds(chunks: list[StreamChunk]) -> list[str]: + return [c.kind for c in chunks] + + +def test_text_part_emits_text_then_turn_end() -> None: + chunks = normalize_opencode_message([{"type": "text", "text": "Hi there"}]) + assert _kinds(chunks) == ["text", "turn_end"] + assert chunks[0].text == "Hi there" + + +def test_reasoning_part_maps_to_thinking() -> None: + chunks = normalize_opencode_message([{"type": "reasoning", "text": "hmm"}]) + assert chunks[0].kind == "thinking" + assert chunks[0].text == "hmm" + + +def test_tool_part_maps_to_tool_use() -> None: + chunks = normalize_opencode_message( + [{"type": "tool", "tool": "read", "input": {"path": "x"}}] + ) + tool = next(c for c in chunks if c.kind == "tool_use") + assert tool.tool == "read" + assert tool.data == {"input": {"path": "x"}} + + +def test_fenced_draft_in_text_becomes_draft_chunk() -> None: + fenced = '```roboco-draft\n{"title": "Add login"}\n```' + chunks = normalize_opencode_message([{"type": "text", "text": fenced}]) + draft = next(c for c in chunks if c.kind == "draft") + assert draft.data["title"] == "Add login" + + +def test_unknown_part_skipped_but_turn_still_ends() -> None: + chunks = normalize_opencode_message([{"type": "mystery", "x": 1}]) + assert _kinds(chunks) == ["turn_end"] + + +def test_empty_message_yields_only_turn_end() -> None: + assert _kinds(normalize_opencode_message([])) == ["turn_end"] + + +def test_extract_session_id_is_tolerant() -> None: + assert _extract_session_id({"id": "s1"}) == "s1" + assert _extract_session_id({"sessionID": "s2"}) == "s2" + assert _extract_session_id({"info": {"id": "s3"}}) == "s3" + assert _extract_session_id({}) is None + assert _extract_session_id("nope") is None