F1: add correlated OpenCode HTTP and SSE task adapter

This commit is contained in:
Xin Wang
2026-10-23 15:57:33 +08:00
parent 82d479f9e1
commit bae5ec3c58
3 changed files with 202 additions and 1 deletions
@@ -0,0 +1,146 @@
"""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[-1], ensure_ascii=False)[:8000]}
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[-16000:]}
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 = []
+54
View File
@@ -0,0 +1,54 @@
import json
import unittest
import httpx
from services.background_tasks.opencode import OpenCodeExecutor
class OpenCodeTaskTest(unittest.IsolatedAsyncioTestCase):
async def test_correlated_completion_ignores_initial_idle_and_other_sessions(self):
handle = {"session_id": "ses_test", "message_id": "msg_test"}
submitted = False
snapshots = 0
updates = []
async def respond(request):
nonlocal submitted, snapshots
self.assertEqual(request.url.params["directory"], "/workspace/demo")
if request.url.path == "/event":
events = [
{"type": "session.idle", "properties": {"sessionID": "ses_other"}},
{"type": "session.idle", "properties": {"sessionID": "ses_test"}},
{"type": "message.updated", "properties": {"info": {"sessionID": "ses_test"}}},
]
return httpx.Response(200, text="".join("data: " + json.dumps(e) + "\n\n" for e in events))
if request.url.path.endswith("prompt_async"):
self.assertFalse(submitted)
self.assertEqual(json.loads(request.content)["messageID"], "msg_test")
submitted = True
return httpx.Response(204)
if request.url.path.endswith("/message"):
snapshots += 1
parent = "old_message" if snapshots == 1 else "msg_test"
return httpx.Response(200, json=[{"info": {"role": "assistant", "parentID": parent, "time": {"completed": 1}}, "parts": [{"type": "text", "text": "测试通过"}]}])
if request.url.path == "/session/status":
return httpx.Response(200, json={})
raise AssertionError(request.url)
executor = OpenCodeExecutor({"url": "https://remote.test", "directory": "/workspace/demo"}, {}, transport=httpx.MockTransport(respond))
async def emit(event):
updates.append(event)
result = await executor.run(handle, "修复测试", emit)
self.assertEqual(result["status"], "succeeded")
self.assertEqual(result["result"], "测试通过")
self.assertEqual(snapshots, 2)
self.assertEqual(updates[0]["status"], "submitted")
async def test_resume_reads_results_without_resubmitting(self):
async def respond(request):
self.assertEqual(request.method, "GET")
if request.url.path.endswith("/message"):
return httpx.Response(200, json=[{"info": {"role": "assistant", "parentID": "msg_test", "time": {"completed": 1}}, "parts": []}])
return httpx.Response(200, json={})
executor = OpenCodeExecutor({"url": "https://remote.test", "directory": "/demo"}, {}, transport=httpx.MockTransport(respond))
async def emit(event):
self.fail("completed task should not restart")
state = await executor.run({"session_id": "ses_test", "message_id": "msg_test"}, "ignored", emit, resume=True)
self.assertEqual(state["status"], "succeeded")
+2 -1
View File
@@ -3,13 +3,14 @@
| Rung | Feature | Acceptance check | Tier | Status |
|------|---------|------------------|------|--------|
| F0 | 原生Job主线、断线独立与取消 | backend/.venv/bin/python -m unittest discover -s backend/tests -p test_background_task_native.py | MUST | passed |
| F1 | OpenCode协议适配与状态关联 | cd backend && .venv/bin/python -m unittest tests.test_background_task_native tests.test_opencode_tasks | MUST | pending |
| F1 | OpenCode协议适配与状态关联 | cd backend && .venv/bin/python -m unittest tests.test_background_task_native tests.test_opencode_tasks | MUST | passed |
| F2 | 持久记录、管理API、应用宿主 | cd backend && .venv/bin/python -m unittest tests.test_background_tasks | MUST | pending |
| F3 | 提示词绑定、任务通知、网页 | cd frontend && npx tsc --noEmit | MUST | pending |
| F4 | 回归、文档与假设审阅 | make test | MUST | pending |
## Run record
- F0: PYTHONPATH=backend backend/.venv/bin/python -m unittest discover -s backend/tests -p test_background_task_native.py → exit 0; native-jobs-smoke.json。
- F1: 原生 Job + httpx 模拟远端协议共 3 项测试通过;未调用真实模型。
## Deferred
- 真实远程 OpenCode + 模型联调:未提供服务地址/凭证。
- 多进程分布式总线及通用插件发现:超出单进程 MVP。