feat(grok): convert interactive intake/secretary to the grok CLI; delete opencode

Move the last Grok runtime off opencode onto xAI's official `grok` CLI, for full
parity with the Claude path. The intake/secretary chat now runs per-turn headless
`grok -p` invocations that resume one session id (proven live: context carries
across runs), with streaming-json deltas mapped to the existing panel StreamChunk
kinds — the IntakeDriver loop, message source, relay, and idle reaper are reused
unchanged; only the SessionFactory differs (GrokCliSession replaces the
opencode-serve session).

- GrokCliSession + a pure, unit-tested streaming-json -> StreamChunk assembler
  (thought coalesced to one block, text streamed live, end captures the session
  id for -r, fenced-draft fallback, clear errors incl. rate-limit).
- intake propose_draft and secretary read_company_state/read_task/submit_directive
  are now FastMCP servers (roboco-intake / roboco-secretary) wired into
  ~/.grok/config.toml, launched via `uv run --directory /app` to resolve the
  installed package. The secretary tools reuse the shared backend helpers.
- Orchestrator: interactive spawn mounts the subscription auth + per-agent usage
  dir (no metered xAI key, no permission env — grok flags carry per-role perms);
  usage/cost now read a captured usage.json (drop the opencode.db reader, the
  _opencode_db_path/_grok_usage_from_opencode methods, and the cost-cap's
  opencode read). hosts["opencode"] -> hosts["grok_usage"]; OPENCODE_DATA_DIR ->
  GROK_USAGE_DATA_DIR.
- Fix one-shot usage capture: `-s` does not pin the session id (grok generates
  its own), so the entrypoint now reads the real id back from the JSON run log
  and the reader uses it; usage is captured per-turn on the interactive path.
- Delete the opencode layer: opencode_config/opencode_usage/opencode_session, the
  docker/grok/*.js plugins, the old one-shot entrypoint, and their tests.
- Compose (all three files), .env.example, and stale comments updated to the
  grok-CLI runtime; add the SuperGrok auth mount + grok-usage dir.

Gate green: ruff, mypy (296 files), xenon, tests. NAS build/verify pending.
This commit is contained in:
Renn F
2026-06-19 04:42:25 +02:00
parent 499f6fc509
commit a88045aacf
40 changed files with 1307 additions and 2200 deletions
@@ -0,0 +1,95 @@
"""grok_cli_session — the pure streaming-json → StreamChunk mapper.
The subprocess runner (``GrokCliSession``) needs the live grok binary, so it is
not gate-covered; the turn-mapping logic lives in the pure ``_StreamAssembler``
and is fully exercised here by feeding it parsed events.
"""
from __future__ import annotations
import json
from roboco.agent_sdk.grok_cli_session import (
_classify_failure,
_parse_event,
_StreamAssembler,
)
def _kinds(chunks: list) -> list[str]:
return [c.kind for c in chunks]
def test_thought_deltas_coalesce_into_one_thinking_block() -> None:
a = _StreamAssembler()
out: list = []
for piece in ("Let", " me", " think"):
out += a.feed({"type": "thought", "data": piece})
# Nothing emitted until the answer starts (reasoning shown as one block).
assert out == []
out += a.feed({"type": "text", "data": "Hello"})
assert _kinds(out) == ["thinking", "text"]
assert out[0].text == "Let me think"
assert out[1].text == "Hello"
def test_text_deltas_stream_live() -> None:
a = _StreamAssembler()
out: list = []
for piece in ("a", "b", "c"):
out += a.feed({"type": "text", "data": piece})
assert _kinds(out) == ["text", "text", "text"]
assert "".join(c.text for c in out) == "abc"
def test_end_captures_session_id_and_emits_turn_end() -> None:
a = _StreamAssembler()
a.feed({"type": "text", "data": "hi"})
out = a.feed({"type": "end", "sessionId": "sid-9", "stopReason": "EndTurn"})
assert _kinds(out) == ["turn_end"]
assert a.session_id == "sid-9"
assert a.saw_end is True
assert out[-1].data["session_id"] == "sid-9"
def test_end_flushes_pending_thinking_before_turn_end() -> None:
a = _StreamAssembler()
a.feed({"type": "thought", "data": "reasoning only"})
out = a.feed({"type": "end", "sessionId": "s", "stopReason": "EndTurn"})
assert _kinds(out) == ["thinking", "turn_end"]
def test_fenced_draft_is_surfaced_as_a_draft_chunk() -> None:
a = _StreamAssembler()
draft = {"title": "Build X", "objective": "do it"}
a.feed({"type": "text", "data": "Here:\n```roboco-draft\n"})
a.feed({"type": "text", "data": json.dumps(draft)})
a.feed({"type": "text", "data": "\n```\n"})
out = a.feed({"type": "end", "sessionId": "s", "stopReason": "EndTurn"})
assert "draft" in _kinds(out)
draft_chunk = next(c for c in out if c.kind == "draft")
assert draft_chunk.data["title"] == "Build X"
def test_unknown_event_types_are_ignored() -> None:
a = _StreamAssembler()
assert a.feed({"type": "tool", "name": "whatever"}) == []
assert a.feed({"type": "", "data": "x"}) == []
def test_parse_event_is_tolerant() -> None:
assert _parse_event('{"type":"text","data":"x"}') == {"type": "text", "data": "x"}
assert _parse_event("not json") is None
assert _parse_event("[1,2,3]") is None # not a dict
def test_classify_failure_detects_rate_limit() -> None:
msg = _classify_failure(1, "xAI error: 429 too many requests")
assert "rate-limited" in msg.lower()
def test_classify_failure_generic_uses_last_stderr_line() -> None:
msg = _classify_failure(2, "warming up\nboom: the model exploded")
assert "boom: the model exploded" in msg
# With no stderr, the exit code is surfaced.
assert "exit code 2" in _classify_failure(2, "")
@@ -1,130 +0,0 @@
"""normalize_opencode_message maps an opencode message reply to panel chunks.
The OpencodeServeSession transport (subprocess + HTTP) is exercised live against
a real `opencode serve`; the deterministic message→chunk mapping, the
turn-level error surfacing, and session-id extraction are covered here.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from roboco.agent_sdk.opencode_session import (
OpencodeServeSession,
_extract_session_id,
_message_error,
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({"parts": [{"type": "text", "text": "Hi"}]})
assert _kinds(chunks) == ["text", "turn_end"]
assert chunks[0].text == "Hi"
def test_reasoning_part_maps_to_thinking() -> None:
chunks = normalize_opencode_message({"parts": [{"type": "reasoning", "text": "x"}]})
assert chunks[0].kind == "thinking"
assert chunks[0].text == "x"
def test_tool_part_maps_to_tool_use() -> None:
chunks = normalize_opencode_message(
{"parts": [{"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({"parts": [{"type": "text", "text": fenced}]})
draft = next(c for c in chunks if c.kind == "draft")
assert draft.data["title"] == "Add login"
@pytest.mark.asyncio
async def test_send_on_dead_serve_yields_clear_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# A crashed `opencode serve` must surface a clear error + end the turn, not
# hang the chat with opaque connection errors while the container zombies.
sess = OpencodeServeSession()
monkeypatch.setattr(sess, "_session_id", "ses-1")
monkeypatch.setattr(sess, "_client", object()) # unused: dead-proc guard wins
monkeypatch.setattr(sess, "_proc", type("P", (), {"returncode": 1})())
chunks = [c async for c in sess.send("hi")]
assert [c.kind for c in chunks] == ["error", "turn_end"]
assert "exited" in chunks[0].text
def test_propose_draft_tool_part_becomes_draft_chunk() -> None:
# The intake-tools.js propose_draft tool call (its input nested under
# `draft`) is intercepted into a draft chunk — NOT rendered as a tool_use —
# so the panel shows the draft card. This is the primary Grok-intake path.
chunks = normalize_opencode_message(
{
"parts": [
{
"type": "tool",
"tool": "propose_draft",
"input": {"draft": {"title": "Add login", "team": "backend"}},
}
]
}
)
assert "tool_use" not in _kinds(chunks)
draft = next(c for c in chunks if c.kind == "draft")
assert draft.data["title"] == "Add login"
assert draft.data["team"] == "backend"
def test_unknown_part_skipped_but_turn_still_ends() -> None:
chunks = normalize_opencode_message({"parts": [{"type": "mystery", "x": 1}]})
assert _kinds(chunks) == ["turn_end"]
def test_empty_message_yields_only_turn_end() -> None:
assert _kinds(normalize_opencode_message({"parts": []})) == ["turn_end"]
def test_turn_level_error_is_surfaced_not_blank() -> None:
# A model failure lands in info.error with parts=[]; it must NOT render blank.
msg = {
"info": {
"role": "assistant",
"error": {
"name": "APIError",
"data": {"message": "Incorrect API key provided"},
},
},
"parts": [],
}
chunks = normalize_opencode_message(msg)
assert _kinds(chunks) == ["error", "turn_end"]
assert "Incorrect API key" in chunks[0].text
def test_message_error_extraction() -> None:
assert _message_error({"info": {"error": {"data": {"message": "boom"}}}}) == "boom"
assert _message_error({"info": {"error": {"name": "APIError"}}}) == "APIError"
assert _message_error({"info": {}}) is None
assert _message_error({"parts": []}) is None
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