mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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.
93 lines
3.2 KiB
Python
93 lines
3.2 KiB
Python
"""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
|
|
|
|
from roboco.agent_sdk.opencode_session import (
|
|
_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"
|
|
|
|
|
|
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
|