Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 39 additions & 12 deletions opencode-sms-bridge/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,18 +480,24 @@ def __init__(self, settings: Settings):
basic = base64.b64encode(f"{settings.opencode_username}:{settings.opencode_password}".encode()).decode()
self.headers = {"Authorization": f"Basic {basic}", "Content-Type": "application/json"}

def _request(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
def _request(self, path: str, payload: dict[str, Any] | None = None, method: str = "POST") -> dict[str, Any]:
request = Request(
f"{self.settings.opencode_base_url}{path}",
data=json.dumps(payload).encode(),
method="POST",
data=None if payload is None else json.dumps(payload).encode(),
method=method,
headers=self.headers,
)
try:
with build_opener(NoRedirect).open(request, timeout=self.settings.opencode_timeout_seconds) as response:
return json.loads(response.read().decode())
except (HTTPError, URLError, OSError, ValueError, json.JSONDecodeError) as error:
body = response.read()
except (HTTPError, URLError, OSError, ValueError) as error:
raise BridgeError("OpenCode request failed") from error
if not body:
return {}
try:
return json.loads(body.decode())
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise BridgeError("OpenCode response was invalid") from error

def create_session(self, agent: str) -> str:
response = self._request("/api/session", {"agent": agent})
Expand All @@ -502,13 +508,34 @@ def create_session(self, agent: str) -> str:
return session_id

def prompt(self, session_id: str, parts: list[dict[str, str]]) -> str:
response = self._request(f"/session/{session_id}/message", {"parts": parts})
data = response.get("data", response)
candidates = data.get("parts", []) if isinstance(data, dict) else []
text = "".join(part.get("text", "") for part in candidates if part.get("type") == "text")
if not text.strip():
raise BridgeError("OpenCode response did not contain text")
return text.strip()
if any(part.get("type") != "text" for part in parts):
raise UnsupportedMedia("V2 file prompt mapping is not implemented")
text = "\n".join(part.get("text", "") for part in parts).strip()
if not text:
raise BridgeError("OpenCode prompt has no text")
admission = self._request(f"/api/session/{session_id}/prompt", {"prompt": {"text": text}})
data = admission.get("data")
if not isinstance(data, dict) or not isinstance(data.get("id"), str):
raise BridgeError("OpenCode prompt admission was invalid")
self._request(f"/api/session/{session_id}/wait")
response = self._request(f"/api/session/{session_id}/message?order=desc&limit=200", method="GET")
messages = response.get("data")
if not isinstance(messages, list):
raise BridgeError("OpenCode messages response was invalid")
for message in messages:
if not isinstance(message, dict) or message.get("type") != "assistant":
continue
content = message.get("content")
if not isinstance(content, list):
continue
reply = "".join(
part.get("text", "")
for part in content
if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str)
)
if reply.strip():
return reply.strip()
raise BridgeError("OpenCode response did not contain text")


def build_parts(settings: Settings, payload: dict[str, Any]) -> list[dict[str, str]]:
Expand Down
32 changes: 26 additions & 6 deletions opencode-sms-bridge/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from unittest.mock import call, patch

from cryptography.fernet import Fernet
from fastapi.testclient import TestClient
Expand All @@ -17,6 +17,7 @@
Routing,
SQLiteStore,
Settings,
UnsupportedMedia,
create_ingress_app,
load_routing,
normalize_e164,
Expand Down Expand Up @@ -132,12 +133,31 @@ def test_ingress_ignores_unapproved_sender_before_queueing(self):
enqueue.assert_not_called()
self.assertIsNone(self.store.claim())

def test_prompt_uses_compatible_session_message_route(self):
parts = [{"type": "text", "text": "hello"}]
def test_prompt_uses_v2_admission_wait_and_message_flow(self):
client = OpenCodeClient(self.settings)
with patch.object(client, "_request", return_value={"parts": [{"type": "text", "text": "reply"}]}) as request:
self.assertEqual(client.prompt("ses_123", parts), "reply")
request.assert_called_once_with("/session/ses_123/message", {"parts": parts})
with patch.object(
client,
"_request",
side_effect=[
{"data": {"id": "in_123"}},
{},
{"data": [{"type": "assistant", "content": [{"type": "text", "text": "reply"}]}]},
],
) as request:
self.assertEqual(client.prompt("ses_123", [{"type": "text", "text": "hello"}]), "reply")
request.assert_has_calls(
[
call("/api/session/ses_123/prompt", {"prompt": {"text": "hello"}}),
call("/api/session/ses_123/wait"),
call("/api/session/ses_123/message?order=desc&limit=200", method="GET"),
]
)
self.assertEqual(request.call_count, 3)

def test_prompt_rejects_unmapped_file_parts(self):
client = OpenCodeClient(self.settings)
with self.assertRaises(UnsupportedMedia):
client.prompt("ses_123", [{"type": "file", "mime": "image/png", "filename": "image", "url": "data:image/png;base64,"}])

def test_image_sanitization_removes_exif(self):
image = Image.new("RGB", (8, 8), color="red")
Expand Down
Loading