feat: async + SSE message flow with interactive permission/question UI
Replace the synchronous /edit (wait for full markdown response) with an
async + SSE flow per demo.html so the user sees text stream in real-time
and can interact with permission/question events the agent raises.
Backend
-------
- EditHandler now calls OpenCode /session/:id/prompt_async and returns
immediately with {ok, sessionId, notebookPath}; the LLM reply is no
longer embedded in this response.
- New GlobalEventHandler proxies OpenCode /global/event as
text/event-stream. Server forwards ALL events; the client filters.
A too-eager server-side ?session= filter was silently dropping events
the client would have accepted, so it was removed.
- New PermissionReplyHandler + QuestionReplyHandler forward user
replies (once/always/reject and freeform answer) back to OpenCode
Serve at /session/:sid/permissions/:permId and
/session/:sid/question/:qId/reply.
- OpenCodeClient gains send_message_async(), stream_global_events()
(async generator over the SSE feed via tornado streaming_callback),
reply_permission() and reply_question(). Legacy send_message_sync
removed.
Frontend
--------
- subscribeOpenCodeEvents() opens a fetch+reader SSE client (XSRF
token injected from serverSettings); AbortController-backed close()
is idempotent.
- OpenCodeInlinePrompt.applyEvent() routes events into the streaming
UI: text delta -> assistant message (re-rendered as markdown on
every delta so the user sees formatted <pre><code> blocks in
real-time, not raw fence source); reasoning/tool/permission/question
get collapsible details blocks. session.idle resets stream pointers
and fires onStreamEnd.
- Permission/question blocks render real interactive UI: three
buttons (once/always/reject) for permission, a text input + submit
for question. Click handlers post through the new API routes and
show success/failure status in place.
- Centralized idle detection (4 shapes: top-level + payload-nested
x {session.idle, session.status/idle}) inside the prompt; cell
action reacts via onStreamEnd callback rather than re-parsing
event types.
- System / workspace / pty / lsp / mcp / installation events AND
session-level control events (agent.switched, model.switched,
file.edited) are not rendered in the frontend.
Tests
-----
- 52 backend pytest (was 43)
- 58 frontend jest (was 46)
design.md section 3 updated for the new async /edit response shape
and the new GET /events SSE endpoint.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,22 @@
|
||||
"""Async HTTP client for the local OpenCode server."""
|
||||
"""Async HTTP client for the local OpenCode server.
|
||||
|
||||
Two interaction modes:
|
||||
|
||||
- **Request/response** (one-shot) - ``health``, ``list_providers``,
|
||||
``create_session``, ``send_message_async``, ``abort``, ``delete_session``,
|
||||
``list_session_messages``. ``send_message_async`` fires the prompt and
|
||||
returns immediately; the actual LLM response is consumed via the SSE
|
||||
stream (next mode).
|
||||
- **Streaming** (long-lived) - :meth:`stream_global_events` is an async
|
||||
generator over OpenCode Serve's ``GET /global/event`` SSE feed. It
|
||||
yields each event as a parsed JSON dict until the upstream closes.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import atexit
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
import logging
|
||||
from typing import Any, AsyncIterator, Optional
|
||||
|
||||
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
|
||||
from tornado.httputil import HTTPHeaders
|
||||
@@ -10,6 +24,9 @@ from tornado.httputil import HTTPHeaders
|
||||
from .config import OpenCodeConfig
|
||||
|
||||
|
||||
log = logging.getLogger("opencode_bridge.opencode_client")
|
||||
|
||||
|
||||
class OpenCodeError(Exception):
|
||||
"""Raised when an OpenCode API request fails."""
|
||||
|
||||
@@ -64,22 +81,28 @@ class OpenCodeClient:
|
||||
async def create_session(self, title: str) -> dict[str, Any]:
|
||||
return await self._request("POST", "/session", {"title": title})
|
||||
|
||||
async def send_message_sync(
|
||||
async def send_message_async(
|
||||
self,
|
||||
session_id: str,
|
||||
parts: list[dict[str, Any]],
|
||||
provider_id: Optional[str] = None,
|
||||
model_id: Optional[str] = None,
|
||||
system: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
) -> None:
|
||||
"""Fire-and-forget POST /session/:id/prompt_async.
|
||||
|
||||
Returns once OpenCode has accepted the prompt and queued it for
|
||||
processing - NOT when the LLM is done. Subscribe to
|
||||
:meth:`stream_global_events` to follow the agent's progress.
|
||||
"""
|
||||
body: dict[str, Any] = {"parts": parts}
|
||||
if provider_id is not None and model_id is not None:
|
||||
body["model"] = {"providerID": provider_id, "modelID": model_id}
|
||||
if system is not None:
|
||||
body["system"] = system
|
||||
return await self._request(
|
||||
await self._request(
|
||||
"POST",
|
||||
"/session/%s/message" % session_id,
|
||||
"/session/%s/prompt_async" % session_id,
|
||||
body,
|
||||
)
|
||||
|
||||
@@ -87,6 +110,41 @@ class OpenCodeClient:
|
||||
result = await self._request("POST", "/session/%s/abort" % session_id)
|
||||
return result is not None
|
||||
|
||||
async def reply_permission(
|
||||
self,
|
||||
session_id: str,
|
||||
permission_id: str,
|
||||
response: str,
|
||||
) -> bool:
|
||||
"""Respond to a `permission.asked` event.
|
||||
|
||||
``response`` must be one of ``"once"`` / ``"always"`` / ``"reject"``,
|
||||
matching the OpenCode Serve API. Returns True on success.
|
||||
"""
|
||||
result = await self._request(
|
||||
"POST",
|
||||
"/session/%s/permissions/%s" % (session_id, permission_id),
|
||||
{"response": response},
|
||||
)
|
||||
return result is not None
|
||||
|
||||
async def reply_question(
|
||||
self,
|
||||
session_id: str,
|
||||
question_id: str,
|
||||
answer: str,
|
||||
) -> bool:
|
||||
"""Reply to a `question.asked` event with the user's freeform answer.
|
||||
|
||||
Returns True on success.
|
||||
"""
|
||||
result = await self._request(
|
||||
"POST",
|
||||
"/session/%s/question/%s/reply" % (session_id, question_id),
|
||||
{"answer": answer},
|
||||
)
|
||||
return result is not None
|
||||
|
||||
async def delete_session(self, session_id: str) -> bool:
|
||||
result = await self._request("DELETE", "/session/%s" % session_id)
|
||||
return result is not None
|
||||
@@ -95,9 +153,9 @@ class OpenCodeClient:
|
||||
"""List all messages in the given OpenCode session.
|
||||
|
||||
Returns the raw OpenCode response: a list of
|
||||
`{ info: { role: "user"|"assistant", ... }, parts: [...] }`.
|
||||
``{ info: { role: "user"|"assistant", ... }, parts: [...] }``.
|
||||
The server extension is responsible for projecting this into a
|
||||
frontend-friendly `{role, content}[]` shape.
|
||||
frontend-friendly ``{role, content}[]`` shape.
|
||||
"""
|
||||
return await self._request("GET", "/session/%s/message" % session_id)
|
||||
|
||||
@@ -105,6 +163,104 @@ class OpenCodeClient:
|
||||
def endpoint(self) -> str:
|
||||
return self._config.url
|
||||
|
||||
async def stream_global_events(self) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Stream OpenCode Serve's ``GET /global/event`` SSE feed.
|
||||
|
||||
Yields one parsed JSON dict per ``data: {...}`` event block. The
|
||||
stream runs until:
|
||||
|
||||
- the upstream disconnects (end of stream),
|
||||
- the consumer stops iterating (caller cancels this generator),
|
||||
- or a network error occurs (logged and the generator returns).
|
||||
|
||||
The consumer is responsible for filtering events by session ID
|
||||
(the upstream pushes events for ALL sessions, plus system events).
|
||||
"""
|
||||
queue: asyncio.Queue = asyncio.Queue()
|
||||
end_sentinel = object()
|
||||
|
||||
def on_chunk(chunk: bytes) -> None:
|
||||
if chunk:
|
||||
queue.put_nowait(chunk)
|
||||
|
||||
headers = HTTPHeaders({
|
||||
"Accept": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
})
|
||||
request_kwargs: dict[str, Any] = {
|
||||
"method": "GET",
|
||||
"headers": headers,
|
||||
"request_timeout": 0, # 0 = no timeout for long-lived streams
|
||||
# streaming_callback must live on the HTTPRequest itself; tornado
|
||||
# rejects any fetch() kwargs when an HTTPRequest is passed.
|
||||
"streaming_callback": on_chunk,
|
||||
}
|
||||
if self._config.auth is not None:
|
||||
request_kwargs["auth_username"] = self._config.auth[0]
|
||||
request_kwargs["auth_password"] = self._config.auth[1]
|
||||
|
||||
request = HTTPRequest(self._config.url + "/global/event", **request_kwargs)
|
||||
|
||||
async def _run_fetch() -> None:
|
||||
try:
|
||||
await self._http_client.fetch(request, raise_error=False)
|
||||
except Exception as e:
|
||||
log.warning("OpenCode /global/event fetch error: %s", e)
|
||||
finally:
|
||||
queue.put_nowait(end_sentinel)
|
||||
|
||||
runner_task = asyncio.create_task(_run_fetch())
|
||||
|
||||
buffer = ""
|
||||
try:
|
||||
while True:
|
||||
chunk = await queue.get()
|
||||
if chunk is end_sentinel:
|
||||
break
|
||||
if not isinstance(chunk, bytes):
|
||||
continue
|
||||
buffer += chunk.decode("utf-8", errors="replace")
|
||||
while "\n\n" in buffer:
|
||||
block, buffer = buffer.split("\n\n", 1)
|
||||
parsed = self._parse_sse_block(block)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
# Drain any trailing data after the last \n\n
|
||||
if buffer.strip():
|
||||
parsed = self._parse_sse_block(buffer)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
finally:
|
||||
if not runner_task.done():
|
||||
runner_task.cancel()
|
||||
try:
|
||||
await runner_task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _parse_sse_block(block: str) -> Optional[dict[str, Any]]:
|
||||
"""Parse a single SSE event block into a JSON dict (or None).
|
||||
|
||||
SSE format reminder: a block is one or more ``field: value`` lines
|
||||
separated by newlines; multiple ``data:`` lines are concatenated
|
||||
with ``\\n``. We only care about ``data:``.
|
||||
"""
|
||||
data_parts: list[str] = []
|
||||
for line in block.split("\n"):
|
||||
if line.startswith("data:"):
|
||||
data_parts.append(line[5:].lstrip())
|
||||
if not data_parts:
|
||||
return None
|
||||
data = "\n".join(data_parts).strip()
|
||||
if not data:
|
||||
return None
|
||||
try:
|
||||
result = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return result if isinstance(result, dict) else None
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
|
||||
Reference in New Issue
Block a user