242 lines
8.8 KiB
Python
242 lines
8.8 KiB
Python
"""Tests for the OpenCode native executor turn lifecycle."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from omnigent import opencode_http_transport as transport_mod
|
|
from omnigent.inner.executor import ExecutorError, TurnComplete
|
|
from omnigent.inner.opencode_native_executor import OpenCodeNativeExecutor
|
|
from omnigent.opencode_native_bridge import (
|
|
OPENCODE_NATIVE_REQUEST_SESSION_ID_ENV_VAR,
|
|
OpenCodeNativeBridgeState,
|
|
write_bridge_state,
|
|
)
|
|
from omnigent.opencode_native_client import OpenCodeClient
|
|
|
|
_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" # noqa: E501
|
|
_PNG_DATA_URI = f"data:image/png;base64,{_PNG_B64}"
|
|
|
|
|
|
class _FakeServer:
|
|
"""Records the requests a fake OpenCode HTTP server receives."""
|
|
|
|
def __init__(self) -> None:
|
|
self.requests: list[tuple[str, str, dict[str, Any]]] = []
|
|
|
|
def handler(self, request: httpx.Request) -> httpx.Response:
|
|
body: dict[str, Any] = {}
|
|
if request.content:
|
|
try:
|
|
body = json.loads(request.content)
|
|
except json.JSONDecodeError:
|
|
body = {}
|
|
self.requests.append((request.method, request.url.path, body))
|
|
if request.url.path.endswith("/abort"):
|
|
return httpx.Response(200, json=True)
|
|
return httpx.Response(200, json={})
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_server(monkeypatch: pytest.MonkeyPatch) -> _FakeServer:
|
|
"""Patch the transport's client factory to talk to a fake server."""
|
|
server = _FakeServer()
|
|
|
|
def fake_client_for_state(
|
|
*, base_url: str, auth_secret: str | None, directory: str | None = None
|
|
) -> OpenCodeClient:
|
|
mock = httpx.AsyncClient(
|
|
base_url="http://opencode.test",
|
|
transport=httpx.MockTransport(server.handler),
|
|
)
|
|
return OpenCodeClient("http://opencode.test", client=mock)
|
|
|
|
monkeypatch.setattr(transport_mod, "client_for_state", fake_client_for_state)
|
|
return server
|
|
|
|
|
|
def _seed_state(
|
|
bridge_dir: Path,
|
|
*,
|
|
session_id: str = "conv_1",
|
|
opencode_session_id: str = "ses_1",
|
|
model_override: str | None = None,
|
|
) -> None:
|
|
write_bridge_state(
|
|
bridge_dir,
|
|
OpenCodeNativeBridgeState(
|
|
session_id=session_id,
|
|
server_base_url="http://127.0.0.1:49231",
|
|
opencode_session_id=opencode_session_id,
|
|
auth_secret="pw",
|
|
model_override=model_override,
|
|
),
|
|
)
|
|
|
|
|
|
def _executor(
|
|
bridge_dir: Path, monkeypatch: pytest.MonkeyPatch, *, request_id: str = "conv_1"
|
|
) -> OpenCodeNativeExecutor:
|
|
monkeypatch.setenv(OPENCODE_NATIVE_REQUEST_SESSION_ID_ENV_VAR, request_id)
|
|
executor = OpenCodeNativeExecutor(bridge_dir=bridge_dir)
|
|
executor._boot_poll_attempts = 1
|
|
executor._boot_poll_delay = 0.0
|
|
return executor
|
|
|
|
|
|
async def _run(executor: OpenCodeNativeExecutor, content: Any) -> list[Any]:
|
|
events: list[Any] = []
|
|
async for event in executor.run_turn([{"role": "user", "content": content}], [], ""):
|
|
events.append(event)
|
|
return events
|
|
|
|
|
|
async def test_run_turn_injects_prompt_and_completes(
|
|
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_seed_state(tmp_path)
|
|
executor = _executor(tmp_path, monkeypatch)
|
|
events = await _run(executor, "hello")
|
|
assert [type(e) for e in events] == [TurnComplete]
|
|
prompt_reqs = [r for r in fake_server.requests if r[1].endswith("/prompt_async")]
|
|
assert len(prompt_reqs) == 1
|
|
parts = prompt_reqs[0][2]["parts"]
|
|
assert parts == [{"type": "text", "text": "hello"}]
|
|
|
|
|
|
async def test_run_turn_with_blocks(
|
|
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_seed_state(tmp_path)
|
|
executor = _executor(tmp_path, monkeypatch)
|
|
events = await _run(
|
|
executor,
|
|
[
|
|
{"type": "input_text", "text": "what is this?"},
|
|
{"type": "input_image", "image_url": _PNG_DATA_URI},
|
|
],
|
|
)
|
|
assert [type(e) for e in events] == [TurnComplete]
|
|
parts = fake_server.requests[0][2]["parts"]
|
|
text_parts = [p for p in parts if p["type"] == "text"]
|
|
file_parts = [p for p in parts if p["type"] == "file"]
|
|
assert text_parts[0]["text"] == "what is this?"
|
|
assert len(file_parts) == 1
|
|
assert file_parts[0]["url"] == _PNG_DATA_URI
|
|
assert file_parts[0]["mime"] == "image/png"
|
|
# No inline base64 in any text part.
|
|
assert all(_PNG_B64 not in p.get("text", "") for p in parts)
|
|
|
|
|
|
async def test_run_turn_pins_resolved_model_on_prompt(
|
|
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The session's resolved model reaches the prompt body from turn one.
|
|
|
|
OpenCode's session-create body cannot carry a model, so the override is
|
|
applied per prompt as ``model: {providerID, modelID}``. This pins the
|
|
first injected turn (which OpenCode then persists as the session
|
|
default), so the override governs the run from the start — not only a
|
|
later web turn.
|
|
"""
|
|
_seed_state(tmp_path, model_override="anthropic/claude-opus-4")
|
|
executor = _executor(tmp_path, monkeypatch)
|
|
events = await _run(executor, "hello")
|
|
assert [type(e) for e in events] == [TurnComplete]
|
|
prompt_reqs = [r for r in fake_server.requests if r[1].endswith("/prompt_async")]
|
|
assert len(prompt_reqs) == 1
|
|
body = prompt_reqs[0][2]
|
|
assert body["model"] == {"providerID": "anthropic", "modelID": "claude-opus-4"}
|
|
|
|
|
|
async def test_run_turn_omits_model_when_no_override(
|
|
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""With no model_override the prompt carries no model (OpenCode default)."""
|
|
_seed_state(tmp_path)
|
|
executor = _executor(tmp_path, monkeypatch)
|
|
await _run(executor, "hello")
|
|
prompt_reqs = [r for r in fake_server.requests if r[1].endswith("/prompt_async")]
|
|
assert "model" not in prompt_reqs[0][2]
|
|
|
|
|
|
async def test_run_turn_no_user_content_errors(
|
|
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_seed_state(tmp_path)
|
|
executor = _executor(tmp_path, monkeypatch)
|
|
events = await _run(executor, "")
|
|
assert [type(e) for e in events] == [ExecutorError]
|
|
assert fake_server.requests == []
|
|
|
|
|
|
async def test_run_turn_missing_bridge_state_errors(
|
|
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# No state written; resolve never returns a session id.
|
|
executor = _executor(tmp_path, monkeypatch)
|
|
events = await _run(executor, "hi")
|
|
assert [type(e) for e in events] == [ExecutorError]
|
|
|
|
|
|
async def test_run_turn_session_mismatch_errors(
|
|
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_seed_state(tmp_path, session_id="conv_OTHER")
|
|
executor = _executor(tmp_path, monkeypatch, request_id="conv_1")
|
|
events = await _run(executor, "hi")
|
|
assert [type(e) for e in events] == [ExecutorError]
|
|
|
|
|
|
async def test_interrupt_calls_abort(
|
|
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_seed_state(tmp_path)
|
|
executor = _executor(tmp_path, monkeypatch)
|
|
assert await executor.interrupt_session("k") is True
|
|
abort_reqs = [r for r in fake_server.requests if r[1].endswith("/abort")]
|
|
assert len(abort_reqs) == 1
|
|
|
|
|
|
async def test_enqueue_message_injects_prompt(
|
|
fake_server: _FakeServer, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
_seed_state(tmp_path)
|
|
executor = _executor(tmp_path, monkeypatch)
|
|
assert await executor.enqueue_session_message("k", "steer me") is True
|
|
prompt_reqs = [r for r in fake_server.requests if r[1].endswith("/prompt_async")]
|
|
assert prompt_reqs[0][2]["parts"] == [{"type": "text", "text": "steer me"}]
|
|
|
|
|
|
def test_capabilities(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_seed_state(tmp_path)
|
|
executor = _executor(tmp_path, monkeypatch)
|
|
assert executor.supports_streaming() is False
|
|
assert executor.handles_tools_internally() is True
|
|
assert executor.supports_live_message_queue() is True
|
|
|
|
|
|
def test_harness_create_app_builds_fastapi() -> None:
|
|
"""The ``opencode-native`` harness module builds a FastAPI app (lazy executor)."""
|
|
from fastapi import FastAPI
|
|
|
|
from omnigent.inner.opencode_native_harness import create_app
|
|
|
|
assert isinstance(create_app(), FastAPI)
|
|
|
|
|
|
def test_harness_executor_factory_builds_from_env(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""The harness executor factory constructs an executor from the spawn env."""
|
|
from omnigent.inner.opencode_native_harness import _build_opencode_native_executor
|
|
from omnigent.opencode_native_bridge import OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR
|
|
|
|
monkeypatch.setenv(OPENCODE_NATIVE_BRIDGE_DIR_ENV_VAR, str(tmp_path))
|
|
assert isinstance(_build_opencode_native_executor(), OpenCodeNativeExecutor)
|