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:
@@ -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"})])
|
||||
|
||||
Reference in New Issue
Block a user