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:
tao.chen
2026-07-27 17:00:16 +08:00
co-authored by Claude
parent 0bd04a825d
commit 4f8bbaf09f
12 changed files with 2532 additions and 243 deletions
+164 -8
View File
@@ -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,