diff --git a/roboco/agent_sdk/opencode_session.py b/roboco/agent_sdk/opencode_session.py index b5e59615..93408ebf 100644 --- a/roboco/agent_sdk/opencode_session.py +++ b/roboco/agent_sdk/opencode_session.py @@ -206,6 +206,22 @@ class OpencodeServeSession: """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") + # If the serve subprocess has died, the session is gone — every turn + # would otherwise fail with an opaque httpx connection error while the + # container lingers as a zombie. Surface it clearly (the panel shows a + # real message) and end the turn; the idle watchdog / a human reap tears + # the container down. + if self._proc is not None and self._proc.returncode is not None: + logger.error("opencode serve exited", returncode=self._proc.returncode) + yield StreamChunk( + kind="error", + text=( + f"opencode serve exited (rc={self._proc.returncode}); this " + "chat session ended — please start a new chat." + ), + ) + yield StreamChunk(kind="turn_end", data={}) + return body: dict[str, Any] = {"parts": [{"type": "text", "text": text}]} # Per-role reasoning effort: the orchestrator sets ROBOCO_GROK_VARIANT on # the container; the serve message endpoint accepts a `variant` field diff --git a/tests/unit/agent_sdk/test_opencode_session.py b/tests/unit/agent_sdk/test_opencode_session.py index 1de33bf9..29913077 100644 --- a/tests/unit/agent_sdk/test_opencode_session.py +++ b/tests/unit/agent_sdk/test_opencode_session.py @@ -9,7 +9,9 @@ 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, @@ -51,6 +53,21 @@ def test_fenced_draft_in_text_becomes_draft_chunk() -> None: 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 —