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:
+187
-18
@@ -49,7 +49,7 @@ def get_session_manager(handler: APIHandler) -> SessionManager:
|
||||
|
||||
|
||||
def _build_request_body(prompt: str, context: dict) -> dict:
|
||||
"""Build full request body for OpenCode POST /session/:id/message.
|
||||
"""Build full request body for OpenCode POST /session/:id/prompt_async.
|
||||
|
||||
Returns dict with 'parts' (list) and 'system' (str) keys. No mode concept:
|
||||
the LLM interprets the user's natural-language instruction, with the code
|
||||
@@ -105,6 +105,26 @@ def _strip_code_fence(s: str) -> str:
|
||||
return s
|
||||
|
||||
|
||||
def _extract_session_id(event: dict) -> str | None:
|
||||
"""Best-effort session ID extraction from an OpenCode event.
|
||||
|
||||
OpenCode's event shape is not strictly documented; this mirrors the
|
||||
heuristics in demo.html ``handleGlobalEvent``:
|
||||
properties.sessionID / properties.sessionId /
|
||||
payload.properties.sessionID / syncEvent.aggregateID
|
||||
"""
|
||||
props = event.get("properties") or {}
|
||||
sid = props.get("sessionID") or props.get("sessionId")
|
||||
if sid:
|
||||
return sid
|
||||
payload_props = (event.get("payload") or {}).get("properties") or {}
|
||||
sid = payload_props.get("sessionID") or payload_props.get("sessionId")
|
||||
if sid:
|
||||
return sid
|
||||
sync = event.get("syncEvent") or (event.get("payload") or {}).get("syncEvent") or {}
|
||||
return sync.get("aggregateID")
|
||||
|
||||
|
||||
class HelloRouteHandler(APIHandler):
|
||||
# The following decorator should be present on all verb methods (head, get, post,
|
||||
# patch, put, delete, options) to ensure only authorized user can request the
|
||||
@@ -154,6 +174,13 @@ class ProvidersHandler(APIHandler):
|
||||
|
||||
|
||||
class EditHandler(APIHandler):
|
||||
"""Async edit: fire prompt_async, return sessionId immediately.
|
||||
|
||||
The LLM's response is consumed via the separate ``/opencode-bridge/events``
|
||||
SSE endpoint. Frontend subscribes to SSE after this call returns and
|
||||
processes events incrementally.
|
||||
"""
|
||||
|
||||
@tornado.web.authenticated
|
||||
async def post(self):
|
||||
try:
|
||||
@@ -178,44 +205,174 @@ class EditHandler(APIHandler):
|
||||
try:
|
||||
sid = await sm.get_or_create(notebook_path)
|
||||
request_body = _build_request_body(prompt, context)
|
||||
result = await client.send_message_sync(
|
||||
await client.send_message_async(
|
||||
sid,
|
||||
request_body["parts"],
|
||||
provider_id=provider_id,
|
||||
model_id=model_id,
|
||||
system=request_body["system"],
|
||||
)
|
||||
|
||||
text_parts = [
|
||||
p.get("text", "")
|
||||
for p in result.get("parts", [])
|
||||
if p.get("type") == "text"
|
||||
]
|
||||
# The AI's reply is markdown (code in ```fences```, optional
|
||||
# explanation). Pass it through unchanged so the frontend
|
||||
# `marked.parse` can render the code blocks and structure.
|
||||
markdown = "\n".join(text_parts).strip()
|
||||
|
||||
self.finish(json.dumps({
|
||||
"ok": True,
|
||||
"markdown": markdown,
|
||||
"sessionId": sid,
|
||||
"notebookPath": notebook_path,
|
||||
}))
|
||||
except OpenCodeError as e:
|
||||
# If session is invalid on OpenCode side, invalidate cache.
|
||||
# 404 / 410 / "session not found" -> next get_or_create will recreate.
|
||||
if "404" in str(e) or "not found" in str(e).lower():
|
||||
sm.invalidate(notebook_path)
|
||||
log.warning("invalidated dead session for %s", notebook_path)
|
||||
log.exception("edit failed")
|
||||
log.exception("edit (async) failed")
|
||||
self.set_status(502)
|
||||
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
||||
except Exception as e:
|
||||
log.exception("edit failed")
|
||||
log.exception("edit (async) failed")
|
||||
self.set_status(502)
|
||||
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
||||
|
||||
|
||||
class GlobalEventHandler(APIHandler):
|
||||
"""SSE proxy for OpenCode Serve's ``GET /global/event``.
|
||||
|
||||
Streams every event from the upstream SSE feed to the client as
|
||||
``text/event-stream`` so EventSource / fetch+reader on the frontend
|
||||
can consume them directly. The frontend filters by session ID —
|
||||
we deliberately do NOT filter on the server because OpenCode's
|
||||
event-shape has varied across versions and a too-eager server
|
||||
filter can silently drop events the client would have accepted.
|
||||
|
||||
The connection is held open until the client disconnects, the upstream
|
||||
closes, or an unrecoverable error occurs.
|
||||
"""
|
||||
|
||||
@tornado.web.authenticated
|
||||
async def get(self):
|
||||
# The ?session= query param is accepted for API compatibility
|
||||
# but the server does NOT use it to filter — see docstring.
|
||||
_ = self.get_query_argument("session", None)
|
||||
|
||||
self.set_header("Content-Type", "text/event-stream")
|
||||
self.set_header("Cache-Control", "no-cache")
|
||||
# Disable proxy buffering (e.g. nginx) so chunks reach the client
|
||||
# immediately.
|
||||
self.set_header("X-Accel-Buffering", "no")
|
||||
# Prevent tornado from closing the connection on its own keep-alive
|
||||
# timer — we hold it open as long as the upstream is alive.
|
||||
self.set_header("Connection", "keep-alive")
|
||||
|
||||
client = make_client(self)
|
||||
try:
|
||||
async for event in client.stream_global_events():
|
||||
payload = json.dumps(event, ensure_ascii=False)
|
||||
self.write("data: %s\n\n" % payload)
|
||||
# flush() pushes the chunk to the client; without it tornado
|
||||
# would buffer until the response is "finished".
|
||||
await self.flush()
|
||||
except tornado.iostream.StreamClosedError:
|
||||
# Client went away — normal disconnect, not an error.
|
||||
log.info("SSE client disconnected")
|
||||
except Exception as e:
|
||||
log.exception("SSE proxy error")
|
||||
try:
|
||||
self.write("data: %s\n\n" % json.dumps({
|
||||
"type": "error",
|
||||
"message": str(e),
|
||||
}))
|
||||
await self.flush()
|
||||
except Exception:
|
||||
# Connection already gone; nothing to send.
|
||||
pass
|
||||
|
||||
|
||||
class PermissionReplyHandler(APIHandler):
|
||||
"""Respond to a `permission.asked` event for a given session.
|
||||
|
||||
POST ``/opencode-bridge/permissions/:permId?session=:sid`` with body
|
||||
``{"response": "once"|"always"|"reject"}``. Forwards to OpenCode
|
||||
Serve's ``POST /session/:sid/permissions/:permId``.
|
||||
"""
|
||||
|
||||
VALID_RESPONSES = ("once", "always", "reject")
|
||||
|
||||
@tornado.web.authenticated
|
||||
async def post(self, perm_id: str):
|
||||
session_id = self.get_query_argument("session", "")
|
||||
if not session_id:
|
||||
self.set_status(400)
|
||||
self.finish(json.dumps({"error": "missing 'session' query parameter"}))
|
||||
return
|
||||
try:
|
||||
body = json.loads(self.request.body) if self.request.body else {}
|
||||
except json.JSONDecodeError as e:
|
||||
self.set_status(400)
|
||||
self.finish(json.dumps({"error": "bad request: %s" % e}))
|
||||
return
|
||||
response = body.get("response")
|
||||
if response not in self.VALID_RESPONSES:
|
||||
self.set_status(400)
|
||||
self.finish(json.dumps({
|
||||
"error": "response must be one of %s" % list(self.VALID_RESPONSES)
|
||||
}))
|
||||
return
|
||||
client = make_client(self)
|
||||
try:
|
||||
ok = await client.reply_permission(session_id, perm_id, response)
|
||||
self.finish(json.dumps({
|
||||
"ok": ok,
|
||||
"permissionId": perm_id,
|
||||
"response": response,
|
||||
}))
|
||||
except OpenCodeError as e:
|
||||
log.exception("permission reply failed")
|
||||
self.set_status(502)
|
||||
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
||||
except Exception as e:
|
||||
log.exception("permission reply failed")
|
||||
self.set_status(502)
|
||||
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
||||
|
||||
|
||||
class QuestionReplyHandler(APIHandler):
|
||||
"""Reply to a `question.asked` event with a freeform answer.
|
||||
|
||||
POST ``/opencode-bridge/questions/:qId/reply?session=:sid`` with body
|
||||
``{"answer": "..."}``. Forwards to OpenCode Serve's
|
||||
``POST /session/:sid/question/:qId/reply``.
|
||||
"""
|
||||
|
||||
@tornado.web.authenticated
|
||||
async def post(self, question_id: str):
|
||||
session_id = self.get_query_argument("session", "")
|
||||
if not session_id:
|
||||
self.set_status(400)
|
||||
self.finish(json.dumps({"error": "missing 'session' query parameter"}))
|
||||
return
|
||||
try:
|
||||
body = json.loads(self.request.body) if self.request.body else {}
|
||||
except json.JSONDecodeError as e:
|
||||
self.set_status(400)
|
||||
self.finish(json.dumps({"error": "bad request: %s" % e}))
|
||||
return
|
||||
answer = body.get("answer")
|
||||
if not isinstance(answer, str) or not answer.strip():
|
||||
self.set_status(400)
|
||||
self.finish(json.dumps({"error": "missing 'answer'"}))
|
||||
return
|
||||
client = make_client(self)
|
||||
try:
|
||||
ok = await client.reply_question(session_id, question_id, answer)
|
||||
self.finish(json.dumps({
|
||||
"ok": ok,
|
||||
"questionId": question_id,
|
||||
}))
|
||||
except OpenCodeError as e:
|
||||
log.exception("question reply failed")
|
||||
self.set_status(502)
|
||||
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
||||
except Exception as e:
|
||||
log.exception("question reply failed")
|
||||
self.set_status(502)
|
||||
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
||||
# NO finally delete — session is reused per notebook.
|
||||
|
||||
|
||||
class SessionListHandler(APIHandler):
|
||||
@@ -310,9 +467,21 @@ def setup_route_handlers(web_app):
|
||||
(url_path_join(base_url, "opencode-bridge", "health"), HealthHandler),
|
||||
(url_path_join(base_url, "opencode-bridge", "providers"), ProvidersHandler),
|
||||
(url_path_join(base_url, "opencode-bridge", "edit"), EditHandler),
|
||||
(url_path_join(base_url, "opencode-bridge", "events"), GlobalEventHandler),
|
||||
(url_path_join(base_url, "opencode-bridge", "sessions"), SessionListHandler),
|
||||
(url_path_join(base_url, "opencode-bridge", "session"), SessionReleaseHandler),
|
||||
(url_path_join(base_url, "opencode-bridge", "session-messages"), SessionMessagesHandler),
|
||||
# /permissions/<perm_id> and /questions/<q_id>/reply
|
||||
# use a URLSpec with a regex so the path param is forwarded to
|
||||
# the handler as a positional argument.
|
||||
tornado.web.URLSpec(
|
||||
url_path_join(base_url, "opencode-bridge", "permissions", r"([A-Za-z0-9_\-]+)"),
|
||||
PermissionReplyHandler,
|
||||
),
|
||||
tornado.web.URLSpec(
|
||||
url_path_join(base_url, "opencode-bridge", "questions", r"([A-Za-z0-9_\-]+)", "reply"),
|
||||
QuestionReplyHandler,
|
||||
),
|
||||
]
|
||||
|
||||
web_app.add_handlers(host_pattern, handlers)
|
||||
|
||||
Reference in New Issue
Block a user