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
+121 -9
View File
@@ -98,28 +98,30 @@ async def test_auth_absent_when_no_password(base_config: OpenCodeConfig) -> None
@pytest.mark.asyncio
async def test_send_message_sync_includes_model_when_provider_and_model_given(
async def test_send_message_async_includes_model_when_provider_and_model_given(
base_config: OpenCodeConfig,
) -> None:
"""send_message_async posts to /prompt_async with provider/model when both set."""
client, mock = _make_client(base_config, [(200, {"done": True})])
parts = [{"type": "text", "text": "hello"}]
result = await client.send_message_sync("s1", parts, provider_id="p1", model_id="m1")
assert result == {"done": True}
result = await client.send_message_async("s1", parts, provider_id="p1", model_id="m1")
assert result is None
call = mock.calls[0]
assert call["request"].url == "http://127.0.0.1:4096/session/s1/message"
assert call["request"].url == "http://127.0.0.1:4096/session/s1/prompt_async"
assert call["request"].method == "POST"
body = json.loads(call["request"].body.decode("utf-8"))
assert body["parts"] == parts
assert body["model"] == {"providerID": "p1", "modelID": "m1"}
@pytest.mark.asyncio
async def test_send_message_sync_omits_model_when_provider_or_model_missing(
async def test_send_message_async_omits_model_when_provider_or_model_missing(
base_config: OpenCodeConfig,
) -> None:
client, mock = _make_client(base_config, [(200, {"done": True})])
parts = [{"type": "text", "text": "hello"}]
result = await client.send_message_sync("s1", parts)
assert result == {"done": True}
result = await client.send_message_async("s1", parts)
assert result is None
call = mock.calls[0]
body = json.loads(call["request"].body.decode("utf-8"))
assert body["parts"] == parts
@@ -127,7 +129,7 @@ async def test_send_message_sync_omits_model_when_provider_or_model_missing(
@pytest.mark.asyncio
async def test_send_message_sync_includes_system_when_provided() -> None:
async def test_send_message_async_includes_system_when_provided() -> None:
"""system param is forwarded into the request body when not None."""
config = OpenCodeConfig(
url="http://x:1", user="u", password="", request_timeout_seconds=120
@@ -136,7 +138,7 @@ async def test_send_message_sync_includes_system_when_provided() -> None:
mock.responses.append((200, {"info": {}, "parts": []}))
client = OpenCodeClient(config, http_client=mock)
await client.send_message_sync("sid", [{"type": "text", "text": "hi"}], system="be brief")
await client.send_message_async("sid", [{"type": "text", "text": "hi"}], system="be brief")
assert len(mock.calls) == 1
body = json.loads(mock.calls[0]["request"].body.decode("utf-8"))
@@ -144,6 +146,83 @@ async def test_send_message_sync_includes_system_when_provided() -> None:
assert body["parts"] == [{"type": "text", "text": "hi"}]
@pytest.mark.asyncio
async def test_stream_global_events_yields_parsed_events(
base_config: OpenCodeConfig,
) -> None:
"""stream_global_events yields one dict per `data: {...}` SSE block."""
class _StreamingMock(MockHTTPClient):
async def fetch(self, request, **kwargs):
self.calls.append({"request": request, "kwargs": kwargs})
# Simulate OpenCode's /global/event: three events back-to-back.
chunks = [
b"data: {\"type\": \"session.next.text.delta\", \"properties\": "
b"{\"sessionID\": \"s1\", \"delta\": \"hello \"}}\n\n",
b"data: {\"type\": \"session.next.text.delta\", \"properties\": "
b"{\"sessionID\": \"s1\", \"delta\": \"world\"}}\n\n",
b"data: {\"type\": \"session.idle\", \"properties\": "
b"{\"sessionID\": \"s1\"}}\n\n",
]
# streaming_callback now lives on the HTTPRequest (per tornado
# API — fetch() rejects kwargs when an HTTPRequest is passed).
cb = getattr(request, "streaming_callback", None)
if cb is not None:
for c in chunks:
cb(c)
# Empty body, code 200 to terminate normally.
return tornado.httpclient.HTTPResponse(
request=request,
code=200,
headers=tornado.httputil.HTTPHeaders(),
buffer=BytesIO(b""),
)
mock = _StreamingMock()
client = OpenCodeClient(base_config, http_client=mock)
events = []
async for ev in client.stream_global_events():
events.append(ev)
assert len(events) == 3
assert events[0]["type"] == "session.next.text.delta"
assert events[0]["properties"]["delta"] == "hello "
assert events[1]["properties"]["delta"] == "world"
assert events[2]["type"] == "session.idle"
# Stream used the SSE accept header and no timeout.
call = mock.calls[0]
assert call["kwargs"] == {"raise_error": False}
assert call["request"].method == "GET"
assert call["request"].url == "http://127.0.0.1:4096/global/event"
assert call["request"].request_timeout == 0
assert call["request"].headers.get("Accept") == "text/event-stream"
assert call["request"].streaming_callback is not None
def test_parse_sse_block_handles_concatenated_data_lines() -> None:
"""Multi-line `data:` blocks are joined with \n (per SSE spec)."""
parsed = OpenCodeClient._parse_sse_block(
"data: {\"a\": 1,\ndata: \"b\": 2}"
)
assert parsed == {"a": 1, "b": 2}
def test_parse_sse_block_ignores_non_data_fields() -> None:
parsed = OpenCodeClient._parse_sse_block("event: ping\ndata: {\"k\": \"v\"}\n\n")
assert parsed == {"k": "v"}
def test_parse_sse_block_returns_none_on_empty() -> None:
assert OpenCodeClient._parse_sse_block("") is None
assert OpenCodeClient._parse_sse_block("event: ping\n\n") is None
assert OpenCodeClient._parse_sse_block(": heartbeat\n\n") is None
def test_parse_sse_block_returns_none_on_bad_json() -> None:
assert OpenCodeClient._parse_sse_block("data: not-json\n\n") is None
@pytest.mark.asyncio
async def test_delete_session_returns_bool_by_status(base_config: OpenCodeConfig) -> None:
client, mock = _make_client(base_config, [(200, True), (404, False)])
@@ -154,6 +233,39 @@ async def test_delete_session_returns_bool_by_status(base_config: OpenCodeConfig
assert mock.calls[0]["request"].url == "http://127.0.0.1:4096/session/s1"
@pytest.mark.asyncio
async def test_reply_permission_posts_response(base_config: OpenCodeConfig) -> None:
"""reply_permission forwards the response string to /session/:id/permissions/:permId."""
client, mock = _make_client(base_config, [(200, True)])
ok = await client.reply_permission("s1", "perm-1", "once")
assert ok is True
call = mock.calls[0]
assert call["request"].url == "http://127.0.0.1:4096/session/s1/permissions/perm-1"
assert call["request"].method == "POST"
body = json.loads(call["request"].body.decode("utf-8"))
assert body == {"response": "once"}
@pytest.mark.asyncio
async def test_reply_permission_returns_false_on_404(
base_config: OpenCodeConfig,
) -> None:
client, mock = _make_client(base_config, [(404, False)])
assert await client.reply_permission("s1", "perm-1", "reject") is False
@pytest.mark.asyncio
async def test_reply_question_posts_answer(base_config: OpenCodeConfig) -> None:
"""reply_question forwards the answer to /session/:id/question/:qid/reply."""
client, mock = _make_client(base_config, [(200, True)])
ok = await client.reply_question("s1", "q-7", "use 3.12")
assert ok is True
call = mock.calls[0]
assert call["request"].url == "http://127.0.0.1:4096/session/s1/question/q-7/reply"
body = json.loads(call["request"].body.decode("utf-8"))
assert body == {"answer": "use 3.12"}
@pytest.mark.asyncio
async def test_non_2xx_response_raises_with_body(base_config: OpenCodeConfig) -> None:
client, mock = _make_client(base_config, [(500, {"error": "boom"})])
+191 -19
View File
@@ -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