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:
@@ -12,10 +12,6 @@ class FakeOpenCodeClient:
|
||||
self.session_id = "fake-session-123"
|
||||
self.health_response = {"healthy": True, "version": "0.1.0"}
|
||||
self.providers_response = {"providers": [{"id": "anthropic", "models": [{"id": "claude"}]}]}
|
||||
self.message_response = {
|
||||
"info": {"id": "msg-1"},
|
||||
"parts": [{"type": "text", "text": "def foo():\n return 42\n"}],
|
||||
}
|
||||
# Canned session-messages list response (list_session_messages).
|
||||
# Default: one user + one assistant message, mixed text parts.
|
||||
self.messages_response = [
|
||||
@@ -45,9 +41,13 @@ class FakeOpenCodeClient:
|
||||
self.calls.append(("create_session", title))
|
||||
return {"id": self.session_id, "title": title}
|
||||
|
||||
async def send_message_sync(self, session_id, parts, provider_id=None, model_id=None, system=None):
|
||||
self.calls.append(("send_message_sync", session_id, parts, system))
|
||||
return self.message_response
|
||||
async def send_message_async(
|
||||
self, session_id, parts, provider_id=None, model_id=None, system=None
|
||||
):
|
||||
self.calls.append(
|
||||
("send_message_async", session_id, parts, system, provider_id, model_id)
|
||||
)
|
||||
return None
|
||||
|
||||
async def delete_session(self, session_id):
|
||||
self.calls.append(("delete_session", session_id))
|
||||
@@ -57,6 +57,19 @@ class FakeOpenCodeClient:
|
||||
self.calls.append(("list_session_messages", session_id))
|
||||
return self.messages_response
|
||||
|
||||
async def reply_permission(self, session_id, permission_id, response):
|
||||
self.calls.append(("reply_permission", session_id, permission_id, response))
|
||||
return True
|
||||
|
||||
async def reply_question(self, session_id, question_id, answer):
|
||||
self.calls.append(("reply_question", session_id, question_id, answer))
|
||||
return True
|
||||
|
||||
async def stream_global_events(self):
|
||||
"""Async generator — tests can monkey-patch this to control the stream."""
|
||||
if False: # pragma: no cover - never executed, just makes this an asyncgen
|
||||
yield {}
|
||||
|
||||
|
||||
class FakeSessionManager:
|
||||
"""Drop-in replacement for SessionManager with recording."""
|
||||
@@ -131,6 +144,12 @@ async def test_providers_handler(monkeypatch, jp_fetch):
|
||||
|
||||
|
||||
async def test_edit_handler(monkeypatch, jp_fetch):
|
||||
"""POST /opencode-bridge/edit fires prompt_async and returns sessionId only.
|
||||
|
||||
The async path returns immediately with `{ok, sessionId, notebookPath}` —
|
||||
the actual LLM response is consumed via the /events SSE endpoint, not
|
||||
embedded in this response.
|
||||
"""
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
@@ -159,31 +178,68 @@ async def test_edit_handler(monkeypatch, jp_fetch):
|
||||
)
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload["ok"] is True
|
||||
# Server now returns the AI reply as raw markdown (```fences``` kept
|
||||
# so the frontend marked.parse renders code blocks).
|
||||
assert payload["markdown"] == "def foo():\n return 42"
|
||||
assert payload["sessionId"] == "fake-session-123"
|
||||
assert payload["notebookPath"] == "test.ipynb"
|
||||
assert payload == {
|
||||
"ok": True,
|
||||
"sessionId": "fake-session-123",
|
||||
"notebookPath": "test.ipynb",
|
||||
}
|
||||
# Async path: no `markdown` field. The LLM reply is consumed via SSE.
|
||||
assert "markdown" not in payload
|
||||
|
||||
# Session manager was used, not direct create/delete on client
|
||||
# Session manager was used (not direct create/delete on client).
|
||||
call_names = [c[0] for c in fake.calls]
|
||||
assert "create_session" not in call_names
|
||||
assert "delete_session" not in call_names
|
||||
assert "send_message_sync" in call_names
|
||||
assert "send_message_async" in call_names
|
||||
assert "send_message_sync" not in call_names # legacy sync path is gone
|
||||
|
||||
# send_message_sync received the session ID from the manager
|
||||
send_call = [c for c in fake.calls if c[0] == "send_message_sync"][0]
|
||||
# send_message_async received the session ID from the manager.
|
||||
send_call = [c for c in fake.calls if c[0] == "send_message_async"][0]
|
||||
assert send_call[1] == "fake-session-123"
|
||||
# system prompt was passed (v3+ allows markdown/fences in the reply)
|
||||
# system prompt was passed.
|
||||
assert "你是一个代码助手" in send_call[3]
|
||||
|
||||
# SessionManager.get_or_create was called with the notebook path
|
||||
# SessionManager.get_or_create was called with the notebook path.
|
||||
sm_call_names = [c[0] for c in fake_sm.calls]
|
||||
assert sm_call_names == ["get_or_create"]
|
||||
assert fake_sm.calls[0][1] == "test.ipynb"
|
||||
|
||||
|
||||
async def test_edit_handler_includes_model_when_provider_and_model_given(
|
||||
monkeypatch, jp_fetch
|
||||
):
|
||||
"""providerId/modelId are forwarded into the async send body when both set."""
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
monkeypatch.setattr(
|
||||
"opencode_bridge.routes.get_session_manager",
|
||||
lambda h: FakeSessionManager(),
|
||||
)
|
||||
|
||||
body = json.dumps({
|
||||
"prompt": "x",
|
||||
"context": {
|
||||
"notebookPath": "n.ipynb",
|
||||
"cellId": "c",
|
||||
"language": "python",
|
||||
"cellIndex": 0,
|
||||
"totalCells": 1,
|
||||
"source": "",
|
||||
"previousCode": None,
|
||||
"error": None,
|
||||
},
|
||||
"providerId": "anthropic",
|
||||
"modelId": "claude-sonnet-4-20250514",
|
||||
})
|
||||
response = await jp_fetch("opencode-bridge", "edit", method="POST", body=body)
|
||||
assert response.code == 200
|
||||
|
||||
send_call = [c for c in fake.calls if c[0] == "send_message_async"][0]
|
||||
# (send_message_async, sid, parts, system, provider_id, model_id)
|
||||
assert send_call[4] == "anthropic"
|
||||
assert send_call[5] == "claude-sonnet-4-20250514"
|
||||
|
||||
|
||||
async def test_session_list_handler(monkeypatch, jp_fetch):
|
||||
fake_sm = FakeSessionManager()
|
||||
fake_sm._sessions = {
|
||||
@@ -287,3 +343,119 @@ async def test_session_release_handler(monkeypatch, jp_fetch):
|
||||
assert payload["notebookPath"] == "foo.ipynb"
|
||||
assert payload["deleted"] is True
|
||||
assert ("release", "foo.ipynb") in fake_sm.calls
|
||||
|
||||
|
||||
async def test_permission_reply_forwards_response(monkeypatch, jp_fetch):
|
||||
"""POST /opencode-bridge/permissions/:permId?session=:sid forwards to client."""
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
response = await jp_fetch(
|
||||
"opencode-bridge", "permissions", "perm-42",
|
||||
method="POST",
|
||||
params={"session": "ses-1"},
|
||||
body=json.dumps({"response": "once"}),
|
||||
)
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload == {
|
||||
"ok": True,
|
||||
"permissionId": "perm-42",
|
||||
"response": "once",
|
||||
}
|
||||
assert ("reply_permission", "ses-1", "perm-42", "once") in fake.calls
|
||||
|
||||
|
||||
async def test_permission_reply_rejects_invalid_response(monkeypatch, jp_fetch):
|
||||
"""response must be one of once/always/reject — 400 otherwise."""
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
|
||||
await jp_fetch(
|
||||
"opencode-bridge", "permissions", "perm-1",
|
||||
method="POST",
|
||||
params={"session": "ses-1"},
|
||||
body=json.dumps({"response": "yes"}),
|
||||
)
|
||||
assert exc_info.value.code == 400
|
||||
assert "yes" not in fake.calls and fake.calls == []
|
||||
|
||||
|
||||
async def test_permission_reply_requires_session(monkeypatch, jp_fetch):
|
||||
"""Missing ?session=... is a 400."""
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
|
||||
await jp_fetch(
|
||||
"opencode-bridge", "permissions", "perm-1",
|
||||
method="POST",
|
||||
body=json.dumps({"response": "once"}),
|
||||
)
|
||||
assert exc_info.value.code == 400
|
||||
|
||||
|
||||
async def test_question_reply_forwards_answer(monkeypatch, jp_fetch):
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
response = await jp_fetch(
|
||||
"opencode-bridge", "questions", "q-7", "reply",
|
||||
method="POST",
|
||||
params={"session": "ses-2"},
|
||||
body=json.dumps({"answer": "use Python 3.12"}),
|
||||
)
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload == {"ok": True, "questionId": "q-7"}
|
||||
assert ("reply_question", "ses-2", "q-7", "use Python 3.12") in fake.calls
|
||||
|
||||
|
||||
async def test_question_reply_rejects_empty_answer(monkeypatch, jp_fetch):
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
|
||||
await jp_fetch(
|
||||
"opencode-bridge", "questions", "q-1", "reply",
|
||||
method="POST",
|
||||
params={"session": "ses-1"},
|
||||
body=json.dumps({"answer": " "}),
|
||||
)
|
||||
assert exc_info.value.code == 400
|
||||
assert fake.calls == []
|
||||
|
||||
|
||||
async def test_global_event_handler_forwards_all_events_unfiltered(
|
||||
monkeypatch, jp_fetch
|
||||
):
|
||||
"""Server-side SSE proxy must NOT filter by session — it forwards
|
||||
everything so the client can decide. (Previously a too-eager server
|
||||
filter silently dropped events the client would have accepted.)
|
||||
|
||||
We mock stream_global_events to push three events with different
|
||||
sessionID shapes (one matching, one not, one missing). The proxy
|
||||
should pass all three through as data: lines.
|
||||
"""
|
||||
fake = FakeOpenCodeClient()
|
||||
|
||||
async def fake_stream():
|
||||
yield {"type": "session.next.text.delta", "properties": {"sessionID": "ses-1", "delta": "A"}}
|
||||
yield {"type": "session.next.text.delta", "properties": {"sessionID": "ses-OTHER", "delta": "B"}}
|
||||
yield {"type": "server.connected", "properties": {}}
|
||||
|
||||
fake.stream_global_events = fake_stream
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
# Use ?session=ses-1 to confirm the param is accepted but doesn't filter.
|
||||
response = await jp_fetch(
|
||||
"opencode-bridge", "events",
|
||||
params={"session": "ses-1"},
|
||||
)
|
||||
assert response.code == 200
|
||||
body = response.body.decode("utf-8")
|
||||
# All three events should appear in the body, regardless of sessionID.
|
||||
assert '"delta": "A"' in body
|
||||
assert '"delta": "B"' in body
|
||||
assert '"server.connected"' in body
|
||||
# The body should use the SSE format: each event as a `data:` line.
|
||||
assert body.count("data: ") == 3
|
||||
|
||||
Reference in New Issue
Block a user