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"})])
|
||||
|
||||
@@ -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