fix(grok): make the live interactive path work — store perms, error surfacing, variant

Found by actually running opencode serve locally (the path was doc-verified but
never executed). Three fixes:

1. EACCES on the opencode store mount (the live intake crash): on Linux docker
   auto-creates a missing bind source as root:root, so the non-root agent user
   could not mkdir/write in /home/agent/.local/share/opencode and opencode died
   at boot. _ensure_opencode_data_dir pre-creates the per-agent dir 0777 before
   the mount (one-shot via the _GrokHost seam, interactive in both spawns).

2. Silent blank reply on a model error: opencode reports a turn failure in
   info.error with parts=[], NOT as a part — verified live (a bad xAI key
   returns info.error APIError). send() / normalize_opencode_message now surface
   it as an "error" StreamChunk so a failed turn is never blank (the original
   intake bug class). Confirmed live: the error now renders.

3. Reasoning variant on the serve path: the live OpenAPI shows the message body
   accepts a "variant" field (it is NOT CLI-only, as the docs implied), so the
   pin is unblocked. send() passes ROBOCO_GROK_VARIANT as the per-turn variant;
   the orchestrator sets it per-role (_reasoning_effort_for) for interactive
   Grok, the same lever as the one-shot --variant.

opencode serve startup, POST /session, session-id extraction, the part-type
mapping (text/reasoning/tool), and the error path are all validated against a
live opencode 1.17.8. A real successful grok reply still needs a funded key.
This commit is contained in:
Renn F
2026-06-18 13:30:54 +02:00
parent 61c8faa8da
commit 997a19e074
6 changed files with 167 additions and 21 deletions
+37 -12
View File
@@ -1,8 +1,8 @@
"""normalize_opencode_message maps opencode message parts to panel chunks.
"""normalize_opencode_message maps an opencode message reply 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.
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
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
from roboco.agent_sdk.opencode_session import (
_extract_session_id,
_message_error,
normalize_opencode_message,
)
@@ -23,20 +24,20 @@ def _kinds(chunks: list[StreamChunk]) -> list[str]:
def test_text_part_emits_text_then_turn_end() -> None:
chunks = normalize_opencode_message([{"type": "text", "text": "Hi there"}])
chunks = normalize_opencode_message({"parts": [{"type": "text", "text": "Hi"}]})
assert _kinds(chunks) == ["text", "turn_end"]
assert chunks[0].text == "Hi there"
assert chunks[0].text == "Hi"
def test_reasoning_part_maps_to_thinking() -> None:
chunks = normalize_opencode_message([{"type": "reasoning", "text": "hmm"}])
chunks = normalize_opencode_message({"parts": [{"type": "reasoning", "text": "x"}]})
assert chunks[0].kind == "thinking"
assert chunks[0].text == "hmm"
assert chunks[0].text == "x"
def test_tool_part_maps_to_tool_use() -> None:
chunks = normalize_opencode_message(
[{"type": "tool", "tool": "read", "input": {"path": "x"}}]
{"parts": [{"type": "tool", "tool": "read", "input": {"path": "x"}}]}
)
tool = next(c for c in chunks if c.kind == "tool_use")
assert tool.tool == "read"
@@ -45,18 +46,42 @@ def test_tool_part_maps_to_tool_use() -> None:
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}])
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"
def test_unknown_part_skipped_but_turn_still_ends() -> None:
chunks = normalize_opencode_message([{"type": "mystery", "x": 1}])
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([])) == ["turn_end"]
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:
+4
View File
@@ -55,6 +55,7 @@ class _FakeHost:
self.removed: list[str] = []
self.spawn_args: tuple[object, ...] | None = None
self.mount_config: OrchestratorAgentConfig | None = None
self.opencode_dirs_ensured: list[str] = []
async def _spawn_container(
self,
@@ -68,6 +69,9 @@ class _FakeHost:
async def _remove_container(self, container_name: str) -> None:
self.removed.append(container_name)
def _ensure_opencode_data_dir(self, agent_id: str) -> None:
self.opencode_dirs_ensured.append(agent_id)
def _resolve_host_paths(
self, config: OrchestratorAgentConfig, agent_settings_path: Path | None
) -> dict[str, str | None]:
@@ -24,7 +24,11 @@ _HOSTS: dict[str, str | None] = {
def _intake_spec(
provider_type: str, *, base_url: str | None, token: str | None
provider_type: str,
*,
base_url: str | None,
token: str | None,
grok_variant: str | None = None,
) -> _IntakeRunSpec:
return _IntakeRunSpec(
container_name="roboco-agent-intake-1",
@@ -40,23 +44,38 @@ def _intake_spec(
provider_auth_token=token,
provider_type=provider_type,
model="grok-build-0.1",
grok_variant=grok_variant,
)
def test_intake_grok_uses_openai_env_and_opencode_mount() -> None:
cmd = AgentOrchestrator._build_intake_run_cmd(
_intake_spec("grok", base_url="https://api.x.ai/v1", token="xai-key")
_intake_spec(
"grok",
base_url="https://api.x.ai/v1",
token="xai-key",
grok_variant="minimal",
)
)
assert "OPENAI_BASE_URL=https://api.x.ai/v1" in cmd
assert "OPENAI_API_KEY=xai-key" in cmd
assert "ROBOCO_AGENT_MODEL=grok-build-0.1" in cmd
assert "ROBOCO_SYSTEM_PROMPT=/app/system-prompt.md" in cmd
assert "/h/oc/intake-1:/home/agent/.local/share/opencode" in cmd
# Per-role reasoning effort reaches the container for the serve driver.
assert "ROBOCO_GROK_VARIANT=minimal" in cmd
assert cmd[-1] == GROK_PROMPTER_IMAGE
# The xAI endpoint is never mislabelled as Anthropic.
assert not any(c.startswith("ANTHROPIC_") for c in cmd)
def test_intake_grok_omits_variant_when_unset() -> None:
cmd = AgentOrchestrator._build_intake_run_cmd(
_intake_spec("grok", base_url="https://api.x.ai/v1", token="xai-key")
)
assert not any(c.startswith("ROBOCO_GROK_VARIANT=") for c in cmd)
def test_intake_anthropic_keeps_anthropic_env() -> None:
cmd = AgentOrchestrator._build_intake_run_cmd(
_intake_spec("anthropic", base_url="https://api.anthropic.com", token="sk-ant")