"""OpenCode HTTP/SSE adapter. No Pipecat, database or browser dependencies.""" import json from collections.abc import AsyncIterator, Awaitable, Callable from uuid import uuid4 import httpx Emit = Callable[[dict], Awaitable[None]] class RemoteTaskError(RuntimeError): """A remote task failed, without including connection credentials.""" class OpenCodeExecutor: def __init__(self, config: dict, secrets: dict, *, transport=None): self.config = config self._secrets = secrets self._transport = transport def _client(self): password = str(self._secrets.get("password") or "") return httpx.AsyncClient( base_url=self.config["url"].rstrip("/") + "/", params={"directory": self.config["directory"]}, auth=httpx.BasicAuth(self.config.get("username", "opencode"), password) if password else None, timeout=httpx.Timeout(20, read=45), follow_redirects=False, transport=self._transport, ) async def prepare(self) -> dict: async with self._client() as client: response = await client.post("session", json={"title": "语音助手后台任务"}) response.raise_for_status() session_id = response.json()["id"] # Stored before prompt submission, so reconnects never create a second run. return {"session_id": session_id, "message_id": "msg_" + uuid4().hex} @staticmethod async def _snapshot(client, handle) -> dict: response = await client.get(f"session/{handle['session_id']}/message") response.raise_for_status() messages = response.json() status = await client.get("session/status") status.raise_for_status() remote_status = status.json().get(handle["session_id"], {"type": "idle"}) matched = [item for item in messages if item.get("info", {}).get("role") == "assistant" and item.get("info", {}).get("parentID") == handle["message_id"]] errors = [item["info"]["error"] for item in matched if item["info"].get("error")] if errors: return {"status": "failed", "summary": "远端执行失败", "result": json.dumps(errors, ensure_ascii=False)} completed = [item for item in matched if item["info"].get("time", {}).get("completed")] if remote_status.get("type") == "idle" and completed: text = "\n".join(part.get("text", "") for item in completed for part in item.get("parts", []) if part.get("type") == "text") return {"status": "succeeded", "summary": "任务已完成", "result": text} return {"status": "running", "summary": "远端任务执行中", "result": ""} async def snapshot(self, handle: dict) -> dict: async with self._client() as client: return await self._snapshot(client, handle) async def run(self, handle: dict, instruction: str, emit: Emit, *, resume=False) -> dict: """Subscribe before submitting; only correlated completed messages finish a run. Reconnect/restart never resubmits a prompt. The host calls this with resume=True after a lost stream and reconciles from the messages API. """ async with self._client() as client: if resume: state = await self._snapshot(client, handle) if state["status"] in {"succeeded", "failed"}: return state async with client.stream("GET", "event") as stream: stream.raise_for_status() if not resume: response = await client.post( f"session/{handle['session_id']}/prompt_async", json={"messageID": handle["message_id"], "parts": [{"type": "text", "text": instruction}]}, ) response.raise_for_status() await emit({"status": "submitted", "summary": "远端已接收,等待执行"}) async for event in self._events(stream): props = event.get("properties") or {} info = props.get("info") or {} part = props.get("part") or {} session_id = props.get("sessionID") or info.get("sessionID") or part.get("sessionID") if session_id != handle["session_id"]: continue kind = event.get("type") if kind == "session.error": return {"status": "failed", "summary": "远端执行失败", "result": "请检查 OpenCode 会话中的错误信息。"} if kind in {"permission.asked", "question.asked"}: request = {key: props[key] for key in ("id", "permission", "patterns", "questions") if key in props} request["kind"] = "permission" if kind.startswith("permission") else "question" await emit({"status": "waiting_input", "summary": "远端等待确认或补充信息", "input_request": request}) elif kind == "message.part.updated" and part.get("type") == "tool": state = part.get("state") or {} await emit({"status": "running", "summary": f"{part.get('tool', '工具')} · {state.get('status', '执行中')}"[:300], "input_request": {}}) elif kind == "session.status" and (props.get("status") or {}).get("type") == "busy": await emit({"status": "running", "summary": "远端任务执行中"}) if kind in {"session.idle", "session.status", "message.updated"}: state = await self._snapshot(client, handle) if state["status"] in {"succeeded", "failed"}: return state raise RemoteTaskError("远端事件流已关闭,需要重新连接") async def cancel(self, handle: dict) -> bool: async with self._client() as client: response = await client.post(f"session/{handle['session_id']}/abort") response.raise_for_status() status = await client.get("session/status") status.raise_for_status() value = status.json().get(handle["session_id"], {"type": "idle"}) return value.get("type") == "idle" async def send_input(self, request: dict, value: dict): async with self._client() as client: if request.get("kind") == "permission": reply = value.get("reply") if reply not in {"once", "reject"}: raise ValueError("请选择允许本次或拒绝") response = await client.post(f"permission/{request['id']}/reply", json={"reply": reply}) elif request.get("kind") == "question": answers = value.get("answers") if not isinstance(answers, list) or len(answers) != len(request.get("questions") or []): raise ValueError("请回答远端提出的每一个问题") response = await client.post(f"question/{request['id']}/reply", json={"answers": answers}) else: raise ValueError("当前任务没有待回答的问题") response.raise_for_status() @staticmethod async def _events(stream) -> AsyncIterator[dict]: data = [] async for line in stream.aiter_lines(): if line.startswith("data:"): data.append(line[5:].lstrip()) elif not line and data: try: yield json.loads("\n".join(data)) except json.JSONDecodeError: pass data = []