From b7089519fd0dc071299715edb8181675afef1042 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:00:16 +0800 Subject: [PATCH] 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
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
---
design.md | 30 +-
opencode_bridge/opencode_client.py | 172 ++++-
opencode_bridge/routes.py | 205 +++++-
opencode_bridge/tests/test_client.py | 130 +++-
opencode_bridge/tests/test_routes.py | 210 ++++++-
src/__tests__/opencode_cell_actions.spec.ts | 615 +++++++++++++++++-
src/__tests__/opencode_client.spec.ts | 174 +++++-
src/api/opencode_client.ts | 255 +++++++-
src/components/opencode_cell_actions.ts | 154 +++--
src/components/opencode_inline_prompt.ts | 658 +++++++++++++++++---
src/types.ts | 43 +-
style/base.css | 129 ++++
12 files changed, 2532 insertions(+), 243 deletions(-)
diff --git a/design.md b/design.md
index 32f9a0f..454ab0e 100644
--- a/design.md
+++ b/design.md
@@ -62,7 +62,7 @@
## 🔌 3. 接口契约设计 (API Contract)
-### 接口:`POST /opencode-bridge/edit`(v2 — 无 `mode`)
+### 接口:`POST /opencode-bridge/edit`(v4 — 异步 + SSE)
**Request Payload**(前端 `OpenCodeCellActions` 在用户提交 inline 输入框时发出):
@@ -84,20 +84,40 @@
}
```
-> **v2 变化**:body 不再含 `mode` / `action` 字段。前端只把 cell 的 input/output/error 收集到 `context`,加用户的自然语言 `prompt` 一起发出;后端用统一的 `UNIFIED_SYSTEM_PROMPT` + LLM 自己判断优化/排错/编辑。`providerId` / `modelId` 来自 JupyterLab settings 透传。
+> body 不含 `mode` / `action`。`providerId` / `modelId` 来自 inline 输入框动态选择,无 settings 持久化。
-**Response**(当前 v2 实现:单次 JSON;SSE 流式属于 Slice 4 后续工作):
+**Response**(v4 异步):后端调用 OpenCode `/session/:id/prompt_async` 后**立即返回**——不等 LLM 完成。
```json
{
"ok": true,
- "finalSource": "import plotly.express as px\nfig = px.line(x=[1, 2, 3], y=[1, 2, 3])\nfig.show()",
"sessionId": "ses-abc123",
"notebookPath": "analysis/demo.ipynb"
}
```
-> 成功时前端用 `finalSource` 就地替换 cell 源码(`cell.model.sharedModel.setSource`)。Session 由服务端 `SessionManager` 按 `notebookPath` 复用(1 notebook 1 sid),404 / `session not found` 时服务端自动 invalidate 并在下次请求重建。
+> 响应中**无** `markdown` 字段。AI 的回复通过独立的 SSE 端点(`/opencode-bridge/events`)实时推送给前端,前端在 inline prompt 的 history 区里增量渲染文本流 / 推理 / 工具调用 / 权限提问等。Session 仍由 `SessionManager` 按 `notebookPath` 复用(1 notebook 1 sid),404 / `session not found` 时服务端自动 invalidate 并在下次请求重建。cell 源码**不**被替换;用户可对 AI 消息中的每个代码块使用 Copy / Insert / Replace 按钮。
+
+### 接口:`GET /opencode-bridge/events?session=`(v4 新增 — SSE 代理)
+
+服务端长连接到 OpenCode Serve 的 `GET /global/event`,按 `session=` 过滤后以 `text/event-stream` 透传给前端。OpenCode 事件 schema 较松散,事件示例:
+
+```json
+{"type": "session.next.text.delta", "properties": {"sessionID": "ses-abc123", "delta": "hel"}}
+{"type": "session.next.text.delta", "properties": {"sessionID": "ses-abc123", "delta": "lo"}}
+{"type": "session.next.reasoning.delta", "properties": {"sessionID": "ses-abc123", "delta": "thinking..."}}
+{"type": "session.next.tool.called", "properties": {"sessionID": "ses-abc123", "name": "bash"}}
+{"type": "session.next.tool.input.delta", "properties": {"sessionID": "ses-abc123", "delta": "{\"cmd\": \"ls\"}"}}
+{"type": "session.next.tool.success", "properties": {"sessionID": "ses-abc123", "result": {"exitCode": 0}}}
+{"type": "session.next.agent.switched", "properties": {"sessionID": "ses-abc123", "agentID": "build"}}
+{"type": "session.next.model.switched", "properties": {"sessionID": "ses-abc123", "modelID": "claude-..."}}
+{"type": "file.edited", "properties": {"sessionID": "ses-abc123", "path": "foo.py"}}
+{"type": "permission.asked", "properties": {"sessionID": "ses-abc123", "id": "perm-1", "description": "rm -rf"}}
+{"type": "question.asked", "properties": {"sessionID": "ses-abc123", "id": "q-1", "question": "Which env?"}}
+{"type": "session.idle", "properties": {"sessionID": "ses-abc123"}}
+```
+
+> v4 前端 `OpenCodeInlinePrompt.applyEvent()` 按 `type` 路由到不同渲染器:text/reasoning/tool 块用 `` 可折叠,permission/question 提示独立块(响应需要 OpenCode Serve 提供对应 API;本扩展 v4 仅渲染,不实现 allow/answer 回调路由)。`session.idle` 触发流指针重置、关闭 SSE 订阅、重新启用输入框。System 事件(`server.*` / `workspace.*` / `lsp.*` / `mcp.*` / `pty.*` / `installation.*`)以日志行形式展示,不参与 session 过滤。
---
diff --git a/opencode_bridge/opencode_client.py b/opencode_bridge/opencode_client.py
index 99b48a0..d1df205 100644
--- a/opencode_bridge/opencode_client.py
+++ b/opencode_bridge/opencode_client.py
@@ -1,8 +1,22 @@
-"""Async HTTP client for the local OpenCode server."""
+"""Async HTTP client for the local OpenCode server.
+Two interaction modes:
+
+- **Request/response** (one-shot) - ``health``, ``list_providers``,
+ ``create_session``, ``send_message_async``, ``abort``, ``delete_session``,
+ ``list_session_messages``. ``send_message_async`` fires the prompt and
+ returns immediately; the actual LLM response is consumed via the SSE
+ stream (next mode).
+- **Streaming** (long-lived) - :meth:`stream_global_events` is an async
+ generator over OpenCode Serve's ``GET /global/event`` SSE feed. It
+ yields each event as a parsed JSON dict until the upstream closes.
+"""
+
+import asyncio
import atexit
import json
-from typing import Any, Optional
+import logging
+from typing import Any, AsyncIterator, Optional
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
from tornado.httputil import HTTPHeaders
@@ -10,6 +24,9 @@ from tornado.httputil import HTTPHeaders
from .config import OpenCodeConfig
+log = logging.getLogger("opencode_bridge.opencode_client")
+
+
class OpenCodeError(Exception):
"""Raised when an OpenCode API request fails."""
@@ -64,22 +81,28 @@ class OpenCodeClient:
async def create_session(self, title: str) -> dict[str, Any]:
return await self._request("POST", "/session", {"title": title})
- async def send_message_sync(
+ async def send_message_async(
self,
session_id: str,
parts: list[dict[str, Any]],
provider_id: Optional[str] = None,
model_id: Optional[str] = None,
system: Optional[str] = None,
- ) -> dict[str, Any]:
+ ) -> None:
+ """Fire-and-forget POST /session/:id/prompt_async.
+
+ Returns once OpenCode has accepted the prompt and queued it for
+ processing - NOT when the LLM is done. Subscribe to
+ :meth:`stream_global_events` to follow the agent's progress.
+ """
body: dict[str, Any] = {"parts": parts}
if provider_id is not None and model_id is not None:
body["model"] = {"providerID": provider_id, "modelID": model_id}
if system is not None:
body["system"] = system
- return await self._request(
+ await self._request(
"POST",
- "/session/%s/message" % session_id,
+ "/session/%s/prompt_async" % session_id,
body,
)
@@ -87,6 +110,41 @@ class OpenCodeClient:
result = await self._request("POST", "/session/%s/abort" % session_id)
return result is not None
+ async def reply_permission(
+ self,
+ session_id: str,
+ permission_id: str,
+ response: str,
+ ) -> bool:
+ """Respond to a `permission.asked` event.
+
+ ``response`` must be one of ``"once"`` / ``"always"`` / ``"reject"``,
+ matching the OpenCode Serve API. Returns True on success.
+ """
+ result = await self._request(
+ "POST",
+ "/session/%s/permissions/%s" % (session_id, permission_id),
+ {"response": response},
+ )
+ return result is not None
+
+ async def reply_question(
+ self,
+ session_id: str,
+ question_id: str,
+ answer: str,
+ ) -> bool:
+ """Reply to a `question.asked` event with the user's freeform answer.
+
+ Returns True on success.
+ """
+ result = await self._request(
+ "POST",
+ "/session/%s/question/%s/reply" % (session_id, question_id),
+ {"answer": answer},
+ )
+ return result is not None
+
async def delete_session(self, session_id: str) -> bool:
result = await self._request("DELETE", "/session/%s" % session_id)
return result is not None
@@ -95,9 +153,9 @@ class OpenCodeClient:
"""List all messages in the given OpenCode session.
Returns the raw OpenCode response: a list of
- `{ info: { role: "user"|"assistant", ... }, parts: [...] }`.
+ ``{ info: { role: "user"|"assistant", ... }, parts: [...] }``.
The server extension is responsible for projecting this into a
- frontend-friendly `{role, content}[]` shape.
+ frontend-friendly ``{role, content}[]`` shape.
"""
return await self._request("GET", "/session/%s/message" % session_id)
@@ -105,6 +163,104 @@ class OpenCodeClient:
def endpoint(self) -> str:
return self._config.url
+ async def stream_global_events(self) -> AsyncIterator[dict[str, Any]]:
+ """Stream OpenCode Serve's ``GET /global/event`` SSE feed.
+
+ Yields one parsed JSON dict per ``data: {...}`` event block. The
+ stream runs until:
+
+ - the upstream disconnects (end of stream),
+ - the consumer stops iterating (caller cancels this generator),
+ - or a network error occurs (logged and the generator returns).
+
+ The consumer is responsible for filtering events by session ID
+ (the upstream pushes events for ALL sessions, plus system events).
+ """
+ queue: asyncio.Queue = asyncio.Queue()
+ end_sentinel = object()
+
+ def on_chunk(chunk: bytes) -> None:
+ if chunk:
+ queue.put_nowait(chunk)
+
+ headers = HTTPHeaders({
+ "Accept": "text/event-stream",
+ "Cache-Control": "no-cache",
+ })
+ request_kwargs: dict[str, Any] = {
+ "method": "GET",
+ "headers": headers,
+ "request_timeout": 0, # 0 = no timeout for long-lived streams
+ # streaming_callback must live on the HTTPRequest itself; tornado
+ # rejects any fetch() kwargs when an HTTPRequest is passed.
+ "streaming_callback": on_chunk,
+ }
+ if self._config.auth is not None:
+ request_kwargs["auth_username"] = self._config.auth[0]
+ request_kwargs["auth_password"] = self._config.auth[1]
+
+ request = HTTPRequest(self._config.url + "/global/event", **request_kwargs)
+
+ async def _run_fetch() -> None:
+ try:
+ await self._http_client.fetch(request, raise_error=False)
+ except Exception as e:
+ log.warning("OpenCode /global/event fetch error: %s", e)
+ finally:
+ queue.put_nowait(end_sentinel)
+
+ runner_task = asyncio.create_task(_run_fetch())
+
+ buffer = ""
+ try:
+ while True:
+ chunk = await queue.get()
+ if chunk is end_sentinel:
+ break
+ if not isinstance(chunk, bytes):
+ continue
+ buffer += chunk.decode("utf-8", errors="replace")
+ while "\n\n" in buffer:
+ block, buffer = buffer.split("\n\n", 1)
+ parsed = self._parse_sse_block(block)
+ if parsed is not None:
+ yield parsed
+ # Drain any trailing data after the last \n\n
+ if buffer.strip():
+ parsed = self._parse_sse_block(buffer)
+ if parsed is not None:
+ yield parsed
+ finally:
+ if not runner_task.done():
+ runner_task.cancel()
+ try:
+ await runner_task
+ except (asyncio.CancelledError, Exception):
+ pass
+
+ @staticmethod
+ def _parse_sse_block(block: str) -> Optional[dict[str, Any]]:
+ """Parse a single SSE event block into a JSON dict (or None).
+
+ SSE format reminder: a block is one or more ``field: value`` lines
+ separated by newlines; multiple ``data:`` lines are concatenated
+ with ``\\n``. We only care about ``data:``.
+ """
+ data_parts: list[str] = []
+ for line in block.split("\n"):
+ if line.startswith("data:"):
+ data_parts.append(line[5:].lstrip())
+ if not data_parts:
+ return None
+ data = "\n".join(data_parts).strip()
+ if not data:
+ return None
+ try:
+ result = json.loads(data)
+ except json.JSONDecodeError:
+ return None
+ return result if isinstance(result, dict) else None
+
async def _request(
self,
method: str,
diff --git a/opencode_bridge/routes.py b/opencode_bridge/routes.py
index 582537f..c7f2489 100644
--- a/opencode_bridge/routes.py
+++ b/opencode_bridge/routes.py
@@ -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/ and /questions//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)
diff --git a/opencode_bridge/tests/test_client.py b/opencode_bridge/tests/test_client.py
index a6dec27..824b23c 100644
--- a/opencode_bridge/tests/test_client.py
+++ b/opencode_bridge/tests/test_client.py
@@ -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"})])
diff --git a/opencode_bridge/tests/test_routes.py b/opencode_bridge/tests/test_routes.py
index 7bf78c3..bb9dcea 100644
--- a/opencode_bridge/tests/test_routes.py
+++ b/opencode_bridge/tests/test_routes.py
@@ -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
diff --git a/src/__tests__/opencode_cell_actions.spec.ts b/src/__tests__/opencode_cell_actions.spec.ts
index 9ac2e0c..64c4413 100644
--- a/src/__tests__/opencode_cell_actions.spec.ts
+++ b/src/__tests__/opencode_cell_actions.spec.ts
@@ -109,7 +109,10 @@ beforeAll(() => {
jest.mock('../api/opencode_client', () => ({
callOpenCodeEdit: jest.fn(),
callOpenCodeProviders: jest.fn(),
- callOpenCodeSessionMessages: jest.fn().mockResolvedValue({ messages: [] })
+ callOpenCodeSessionMessages: jest.fn().mockResolvedValue({ messages: [] }),
+ callOpenCodeReplyPermission: jest.fn().mockResolvedValue({ ok: true }),
+ callOpenCodeReplyQuestion: jest.fn().mockResolvedValue({ ok: true }),
+ subscribeOpenCodeEvents: jest.fn().mockReturnValue({ close: jest.fn() })
}));
import {
@@ -119,6 +122,20 @@ import {
} from '../components/opencode_cell_actions';
import { OpenCodeInlinePrompt } from '../components/opencode_inline_prompt';
import { CodeCell } from '@jupyterlab/cells';
+import {
+ callOpenCodeEdit,
+ subscribeOpenCodeEvents
+} from '../api/opencode_client';
+import { Notification } from '@jupyterlab/apputils';
+
+const mockedCallOpenCodeEdit = callOpenCodeEdit as jest.MockedFunction<
+ typeof callOpenCodeEdit
+>;
+const mockedSubscribeOpenCodeEvents =
+ subscribeOpenCodeEvents as jest.MockedFunction<
+ typeof subscribeOpenCodeEvents
+ >;
+const mockedNotification = Notification as jest.Mocked;
function makeFakeOutputs(items: any[]): any {
return {
@@ -395,58 +412,197 @@ describe('OpenCodeCellActions', () => {
).toBeNull();
});
- it('on a successful response, does NOT replace the cell source and triggers a history refresh', () => {
+ it('on submit: posts to /edit, binds SSE, does NOT replace cell source, clears input', async () => {
setOpenCodeProviders(null);
- const cell = makeFakeCell('x = 1');
- const actions = new OpenCodeCellActions(cell);
- // Click once to create the inline prompt (so the history refresh
- // has a target and the cell source check has a baseline).
- Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
- (actions as any).onAfterAttach({} as any);
- const btn = actions.node.querySelector('button') as HTMLButtonElement;
- btn.click();
-
- // The history refresh on show is async; spy on _refreshHistory to
- // confirm it gets called (without depending on the network).
- const refreshSpy = jest.spyOn(actions as any, '_refreshHistory');
-
- (actions as any)._handleResponse({
+ mockedCallOpenCodeEdit.mockResolvedValue({
ok: true,
- markdown: '# AI says hi\n\n```python\nprint("hi")\n```',
sessionId: 'ses-abc',
notebookPath: 'foo.ipynb'
});
+ const subscribeCalls: any[] = [];
+ mockedSubscribeOpenCodeEvents.mockImplementation(
+ ((sid: string, _s: any, handlers: any) => {
+ subscribeCalls.push({ sid, handlers });
+ return { close: jest.fn() };
+ }) as any
+ );
- // The cell source must remain unchanged.
- expect((cell as any).model.sharedModel.setSource).not.toHaveBeenCalled();
- // A history refresh was triggered (to pick up the new assistant msg).
- expect(refreshSpy).toHaveBeenCalledWith('foo.ipynb');
- });
-
- it('clears the input textarea after a successful response', () => {
- setOpenCodeProviders(null);
const cell = makeFakeCell('x = 1');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
-
- // Open the prompt and type something into the textarea.
const aiBtn = actions.node.querySelector('button') as HTMLButtonElement;
aiBtn.click();
+
+ // Type into the textarea, then submit.
const prompt = (actions as any)._prompt;
- expect(prompt).not.toBeNull();
const textarea = prompt._textarea as HTMLTextAreaElement;
textarea.value = 'make it faster';
+ (prompt._sendBtn as HTMLButtonElement).click();
- // A successful response clears the input so the user can type a
- // follow-up message without manually deleting the previous prompt.
- (actions as any)._handleResponse({
+ // Let the async _onSubmit run.
+ await new Promise(r => setTimeout(r, 5));
+
+ // callOpenCodeEdit was called with the right body.
+ expect(mockedCallOpenCodeEdit).toHaveBeenCalledTimes(1);
+ expect(mockedCallOpenCodeEdit.mock.calls[0][0].prompt).toBe('make it faster');
+ expect(mockedCallOpenCodeEdit.mock.calls[0][0].context.notebookPath).toBe('foo.ipynb');
+
+ // SSE subscription was opened with the returned sessionId.
+ expect(subscribeCalls).toHaveLength(1);
+ expect(subscribeCalls[0].sid).toBe('ses-abc');
+ expect(typeof subscribeCalls[0].handlers.onEvent).toBe('function');
+
+ // The cell source must remain unchanged.
+ expect((cell as any).model.sharedModel.setSource).not.toHaveBeenCalled();
+
+ // The textarea is cleared so the user can type a follow-up.
+ expect(textarea.value).toBe('');
+ });
+
+ it('on submit: forwards SSE events to the prompt and closes the stream on session.idle', async () => {
+ setOpenCodeProviders(null);
+ mockedCallOpenCodeEdit.mockResolvedValue({
ok: true,
- markdown: 'ok',
sessionId: 'ses-1',
notebookPath: 'foo.ipynb'
});
- expect(textarea.value).toBe('');
+
+ const fakeSse = { close: jest.fn() };
+ let captured: any = null;
+ mockedSubscribeOpenCodeEvents.mockImplementation(
+ ((_sid: string, _s: any, handlers: any) => {
+ captured = handlers;
+ return fakeSse;
+ }) as any
+ );
+
+ const cell = makeFakeCell('x = 1');
+ const actions = new OpenCodeCellActions(cell);
+ Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
+ (actions as any).onAfterAttach({} as any);
+ const aiBtn = actions.node.querySelector('button') as HTMLButtonElement;
+ aiBtn.click();
+ const prompt = (actions as any)._prompt;
+ const textarea = prompt._textarea as HTMLTextAreaElement;
+ textarea.value = 'go';
+ (prompt._sendBtn as HTMLButtonElement).click();
+
+ await new Promise(r => setTimeout(r, 5));
+ expect(captured).not.toBeNull();
+
+ // Simulate a text-delta event arriving.
+ const applySpy = jest.spyOn(prompt, 'applyEvent');
+ captured.onEvent({
+ type: 'session.next.text.delta',
+ properties: { sessionID: 'ses-1', delta: 'hel' }
+ });
+ expect(applySpy).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'session.next.text.delta' })
+ );
+
+ // session.idle should close the stream and re-enable the input.
+ captured.onEvent({ type: 'session.idle', properties: { sessionID: 'ses-1' } });
+ expect(fakeSse.close).toHaveBeenCalled();
+ expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
+ });
+
+ it('on submit: session.status with status.type=idle also closes the stream (idle shape variants)', async () => {
+ setOpenCodeProviders(null);
+ mockedCallOpenCodeEdit.mockResolvedValue({
+ ok: true,
+ sessionId: 'ses-1',
+ notebookPath: 'foo.ipynb'
+ });
+ const fakeSse = { close: jest.fn() };
+ let captured: any = null;
+ mockedSubscribeOpenCodeEvents.mockImplementation(
+ ((_sid: string, _s: any, handlers: any) => {
+ captured = handlers;
+ return fakeSse;
+ }) as any
+ );
+
+ const cell = makeFakeCell('x = 1');
+ const actions = new OpenCodeCellActions(cell);
+ Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
+ (actions as any).onAfterAttach({} as any);
+ (actions.node.querySelector('button') as HTMLButtonElement).click();
+ const prompt = (actions as any)._prompt;
+ (prompt._textarea as HTMLTextAreaElement).value = 'go';
+ (prompt._sendBtn as HTMLButtonElement).click();
+ await new Promise(r => setTimeout(r, 5));
+
+ // session.status with status.type === 'idle' should also close the
+ // stream (some OpenCode versions emit this shape instead of
+ // session.idle).
+ captured.onEvent({
+ type: 'session.status',
+ properties: { sessionID: 'ses-1', status: { type: 'idle' } }
+ });
+ expect(fakeSse.close).toHaveBeenCalled();
+ expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
+ });
+
+ it('on submit: idle nested under payload also closes the stream', async () => {
+ setOpenCodeProviders(null);
+ mockedCallOpenCodeEdit.mockResolvedValue({
+ ok: true,
+ sessionId: 'ses-1',
+ notebookPath: 'foo.ipynb'
+ });
+ const fakeSse = { close: jest.fn() };
+ let captured: any = null;
+ mockedSubscribeOpenCodeEvents.mockImplementation(
+ ((_sid: string, _s: any, handlers: any) => {
+ captured = handlers;
+ return fakeSse;
+ }) as any
+ );
+
+ const cell = makeFakeCell('x = 1');
+ const actions = new OpenCodeCellActions(cell);
+ Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
+ (actions as any).onAfterAttach({} as any);
+ (actions.node.querySelector('button') as HTMLButtonElement).click();
+ const prompt = (actions as any)._prompt;
+ (prompt._textarea as HTMLTextAreaElement).value = 'go';
+ (prompt._sendBtn as HTMLButtonElement).click();
+ await new Promise(r => setTimeout(r, 5));
+
+ // Some OpenCode events have type at top level AND in payload.
+ captured.onEvent({
+ type: 'unknown.wrapper',
+ payload: {
+ type: 'session.idle',
+ properties: { sessionID: 'ses-1' }
+ }
+ });
+ expect(fakeSse.close).toHaveBeenCalled();
+ expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
+ });
+
+ it('on submit failure: re-enables the input and shows an error notification', async () => {
+ setOpenCodeProviders(null);
+ mockedCallOpenCodeEdit.mockResolvedValue({
+ ok: false,
+ error: 'opencode down'
+ });
+
+ const cell = makeFakeCell('x = 1');
+ const actions = new OpenCodeCellActions(cell);
+ Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
+ (actions as any).onAfterAttach({} as any);
+ const aiBtn = actions.node.querySelector('button') as HTMLButtonElement;
+ aiBtn.click();
+ const prompt = (actions as any)._prompt;
+ const textarea = prompt._textarea as HTMLTextAreaElement;
+ textarea.value = 'go';
+ (prompt._sendBtn as HTMLButtonElement).click();
+
+ await new Promise(r => setTimeout(r, 5));
+ expect(mockedNotification.error).toHaveBeenCalledWith('OpenCode 错误: opencode down');
+ expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
});
});
@@ -674,4 +830,397 @@ describe('OpenCodeInlinePrompt', () => {
);
expect(onSubmit).not.toHaveBeenCalled();
});
+
+ it('applyEvent: text deltas accumulate into a single assistant element (real-time streaming)', () => {
+ const cell = makeFakeCell('x = 1');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ prompt.setSessionId('s1');
+
+ // Many deltas — they must all append to the SAME DOM node, not
+ // create a new one each time (that would defeat real-time rendering).
+ const deltas = ['hel', 'lo', ' ', 'wor', 'ld', '!', ' ', 'It works.'];
+ for (const d of deltas) {
+ prompt.applyEvent({
+ type: 'session.next.text.delta',
+ properties: { sessionID: 's1', delta: d }
+ });
+ }
+ const all = prompt.node.querySelectorAll(
+ '.opencode-inline-prompt .opencode-msg-assistant'
+ );
+ // Exactly one element — the streaming text is appended, not forked.
+ expect(all.length).toBe(1);
+ expect(all[0].textContent).toBe('hello world! It works.');
+ });
+
+ it('applyEvent: unrecognized event types are logged but never throw', () => {
+ const cell = makeFakeCell('x = 1');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ prompt.setSessionId('s1');
+ // An event type the dispatcher doesn't recognize.
+ expect(() =>
+ prompt.applyEvent({
+ type: 'some.future.event',
+ properties: { sessionID: 's1', payload: 'whatever' }
+ })
+ ).not.toThrow();
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining('unrecognized SSE event type'),
+ 'some.future.event',
+ expect.anything()
+ );
+ warnSpy.mockRestore();
+ });
+
+ it('applyEvent: streaming markdown is rendered as HTML in real-time (not raw ```fence``` source)', () => {
+ // Regression: previously the streaming path appended deltas to
+ // .textContent, so the user saw raw ```py\n...\n``` source while
+ // the model was still typing. Only after closing+reopening the
+ // panel (which goes through setMessages + marked.parse) did the
+ // code block render as . The fix: re-render via
+ // marked.parse on every delta.
+ const cell = makeFakeCell('x = 1');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ prompt.setSessionId('s1');
+ // Simulate a streaming reply that contains a fenced code block.
+ const deltas = [
+ 'Here is the fix:\n\n',
+ '```python\n',
+ 'import pandas as pd\n',
+ 'import numpy as np\n',
+ '```\n'
+ ];
+ for (const d of deltas) {
+ prompt.applyEvent({
+ type: 'session.next.text.delta',
+ properties: { sessionID: 's1', delta: d }
+ });
+ }
+ // The block is rendered DURING streaming, not just at
+ // the end. The raw ```fence``` characters must NOT appear as visible
+ // text content in the message body.
+ const pre = prompt.node.querySelector(
+ '.opencode-msg-assistant .opencode-msg-content pre'
+ ) as HTMLElement;
+ expect(pre).not.toBeNull();
+ const code = pre.querySelector('code') as HTMLElement;
+ expect(code).not.toBeNull();
+ expect(code.textContent).toBe('import pandas as pd\nimport numpy as np');
+ // The Copy / Insert / Replace toolbar is attached to the
+ // block during streaming, same as in setMessages.
+ const toolbar = pre.parentElement!.querySelector(
+ '.opencode-code-toolbar'
+ ) as HTMLElement;
+ expect(toolbar).not.toBeNull();
+ expect(toolbar.querySelectorAll('button').length).toBe(3);
+ // The wrapper still has a single assistant element (not one per delta).
+ expect(
+ prompt.node.querySelectorAll(
+ '.opencode-inline-prompt .opencode-msg-assistant'
+ ).length
+ ).toBe(1);
+ });
+
+ it('applyEvent: message.part.delta with field=text routes to the assistant message', () => {
+ const cell = makeFakeCell('x = 1');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ prompt.setSessionId('s1');
+ prompt.applyEvent({
+ type: 'message.part.delta',
+ properties: { sessionID: 's1', field: 'text', delta: 'world' }
+ });
+ const asst = prompt.node.querySelector(
+ '.opencode-inline-prompt .opencode-msg-assistant'
+ ) as HTMLElement;
+ expect(asst.textContent).toBe('world');
+ });
+
+ it('applyEvent: reasoning deltas render into a collapsible green block', () => {
+ const cell = makeFakeCell('x = 1');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ prompt.setSessionId('s1');
+ prompt.applyEvent({
+ type: 'session.next.reasoning.delta',
+ properties: { sessionID: 's1', delta: 'thinking...' }
+ });
+ const block = prompt.node.querySelector(
+ '.opencode-inline-prompt .opencode-msg-block-reasoning'
+ ) as HTMLElement;
+ expect(block).not.toBeNull();
+ const content = block.querySelector(
+ '.opencode-msg-block-content'
+ ) as HTMLElement;
+ expect(content.textContent).toBe('thinking...');
+ });
+
+ it('applyEvent: tool-call lifecycle creates and updates a tool block', () => {
+ const cell = makeFakeCell('x = 1');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ prompt.setSessionId('s1');
+ prompt.applyEvent({
+ type: 'session.next.tool.called',
+ properties: { sessionID: 's1', name: 'bash' }
+ });
+ prompt.applyEvent({
+ type: 'session.next.tool.input.delta',
+ properties: { sessionID: 's1', delta: '{"cmd":' }
+ });
+ prompt.applyEvent({
+ type: 'session.next.tool.success',
+ properties: { sessionID: 's1', result: { ok: true } }
+ });
+ const block = prompt.node.querySelector(
+ '.opencode-inline-prompt .opencode-msg-block-tool'
+ ) as HTMLElement;
+ expect(block).not.toBeNull();
+ const summary = block.querySelector('summary') as HTMLElement;
+ expect(summary.textContent).toContain('bash');
+ const content = block.querySelector(
+ '.opencode-msg-block-content'
+ ) as HTMLElement;
+ expect(content.textContent).toContain('{"cmd":');
+ expect(content.textContent).toContain('"ok": true');
+ });
+
+ it('applyEvent: session.idle resets stream pointers (next text delta starts a fresh assistant element)', () => {
+ const cell = makeFakeCell('x = 1');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ prompt.setSessionId('s1');
+ prompt.applyEvent({
+ type: 'session.next.text.delta',
+ properties: { sessionID: 's1', delta: 'turn1' }
+ });
+ prompt.applyEvent({
+ type: 'session.idle',
+ properties: { sessionID: 's1' }
+ });
+ prompt.applyEvent({
+ type: 'session.next.text.delta',
+ properties: { sessionID: 's1', delta: 'turn2' }
+ });
+ const asstBlocks = prompt.node.querySelectorAll(
+ '.opencode-inline-prompt .opencode-msg-assistant'
+ );
+ expect(asstBlocks.length).toBe(2);
+ expect(asstBlocks[0].textContent).toBe('turn1');
+ expect(asstBlocks[1].textContent).toBe('turn2');
+ });
+
+ it('applyEvent: drops events for a different session (defensive filter)', () => {
+ const cell = makeFakeCell('x = 1');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ prompt.setSessionId('s1');
+ prompt.applyEvent({
+ type: 'session.next.text.delta',
+ properties: { sessionID: 's2', delta: 'should be ignored' }
+ });
+ const asst = prompt.node.querySelector(
+ '.opencode-inline-prompt .opencode-msg-assistant'
+ );
+ expect(asst).toBeNull();
+ });
+
+ it('applyEvent: permission.asked creates a permission block with 3 buttons', () => {
+ const cell = makeFakeCell('x = 1');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ prompt.setSessionId('s1');
+ prompt.applyEvent({
+ type: 'permission.asked',
+ properties: { sessionID: 's1', id: 'perm-1', description: 'rm -rf' }
+ });
+ const block = prompt.node.querySelector(
+ '.opencode-inline-prompt .opencode-msg-block-interaction'
+ ) as HTMLElement;
+ expect(block).not.toBeNull();
+ expect(block.id).toBe('opencode-perm-perm-1');
+ expect(block.textContent).toContain('rm -rf');
+ // Three buttons: once / always / reject.
+ const buttons = block.querySelectorAll('button');
+ expect(buttons.length).toBe(3);
+ expect(buttons[0].textContent).toContain('Once');
+ expect(buttons[1].textContent).toContain('Always');
+ expect(buttons[2].textContent).toContain('Reject');
+ });
+
+ it('applyEvent: clicking a permission button calls onPermissionReply and shows status', async () => {
+ const cell = makeFakeCell('x = 1');
+ const onPermissionReply = jest.fn().mockResolvedValue(true);
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null,
+ onPermissionReply
+ });
+ prompt.setSessionId('s1');
+ prompt.applyEvent({
+ type: 'permission.asked',
+ properties: { sessionID: 's1', id: 'perm-9', description: 'sudo' }
+ });
+ const rejectBtn = prompt.node.querySelector(
+ '.opencode-perm-btn-reject'
+ ) as HTMLButtonElement;
+ rejectBtn.click();
+ await new Promise(r => setTimeout(r, 5));
+ expect(onPermissionReply).toHaveBeenCalledWith('perm-9', 'reject');
+ // The button group now shows the success status.
+ const block = prompt.node.querySelector(
+ '#opencode-perm-perm-9'
+ ) as HTMLElement;
+ expect(block.textContent).toContain('已拒绝');
+ });
+
+ it('applyEvent: question.asked creates a question block with input + submit', () => {
+ const cell = makeFakeCell('x = 1');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ prompt.setSessionId('s1');
+ prompt.applyEvent({
+ type: 'question.asked',
+ properties: { sessionID: 's1', id: 'q-1', question: 'which env?' }
+ });
+ const block = prompt.node.querySelector(
+ '#opencode-q-q-1'
+ ) as HTMLElement;
+ expect(block).not.toBeNull();
+ expect(block.textContent).toContain('which env?');
+ expect(block.querySelector('input.opencode-q-input')).not.toBeNull();
+ expect(block.querySelector('button.opencode-q-submit')).not.toBeNull();
+ });
+
+ it('applyEvent: question submit calls onQuestionReply with the typed answer', async () => {
+ const cell = makeFakeCell('x = 1');
+ const onQuestionReply = jest.fn().mockResolvedValue(true);
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null,
+ onQuestionReply
+ });
+ prompt.setSessionId('s1');
+ prompt.applyEvent({
+ type: 'question.asked',
+ properties: { sessionID: 's1', id: 'q-2', question: 'which version?' }
+ });
+ const input = prompt.node.querySelector(
+ '.opencode-q-input'
+ ) as HTMLInputElement;
+ const submit = prompt.node.querySelector(
+ '.opencode-q-submit'
+ ) as HTMLButtonElement;
+ input.value = 'use 3.12';
+ submit.click();
+ await new Promise(r => setTimeout(r, 5));
+ expect(onQuestionReply).toHaveBeenCalledWith('q-2', 'use 3.12');
+ const block = prompt.node.querySelector('#opencode-q-q-2') as HTMLElement;
+ expect(block.textContent).toContain('已回答');
+ });
+
+ it('applyEvent: question submit with empty input is a no-op', async () => {
+ const cell = makeFakeCell('x = 1');
+ const onQuestionReply = jest.fn();
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null,
+ onQuestionReply
+ });
+ prompt.setSessionId('s1');
+ prompt.applyEvent({
+ type: 'question.asked',
+ properties: { sessionID: 's1', id: 'q-3', question: 'color?' }
+ });
+ const submit = prompt.node.querySelector(
+ '.opencode-q-submit'
+ ) as HTMLButtonElement;
+ submit.click();
+ await new Promise(r => setTimeout(r, 5));
+ expect(onQuestionReply).not.toHaveBeenCalled();
+ });
+
+ it('applyEvent: system events (server.* etc.) are NOT rendered', () => {
+ const cell = makeFakeCell('x = 1');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ prompt.setSessionId('s1');
+ const systemTypes = [
+ 'server.connected',
+ 'workspace.updated',
+ 'lsp.updated',
+ 'mcp.tools.changed',
+ 'pty.created',
+ 'session.next.agent.switched',
+ 'session.next.model.switched',
+ 'file.edited'
+ ];
+ for (const t of systemTypes) {
+ prompt.applyEvent({ type: t, properties: { sessionID: 's1' } });
+ }
+ // No system lines, no message blocks, nothing rendered.
+ expect(
+ prompt.node.querySelector('.opencode-msg-system')
+ ).toBeNull();
+ expect(
+ prompt.node.querySelectorAll('.opencode-msg').length
+ ).toBe(0);
+ expect(
+ prompt.node.querySelectorAll('.opencode-msg-block').length
+ ).toBe(0);
+ });
});
diff --git a/src/__tests__/opencode_client.spec.ts b/src/__tests__/opencode_client.spec.ts
index 5785aeb..f77dc40 100644
--- a/src/__tests__/opencode_client.spec.ts
+++ b/src/__tests__/opencode_client.spec.ts
@@ -1,6 +1,12 @@
import { ServerConnection } from '@jupyterlab/services';
-import { callOpenCodeEdit, callOpenCodeProviders } from '../api/opencode_client';
+import {
+ callOpenCodeEdit,
+ callOpenCodeProviders,
+ callOpenCodeReplyPermission,
+ callOpenCodeReplyQuestion,
+ subscribeOpenCodeEvents
+} from '../api/opencode_client';
import type { OpenCodeRequest } from '../types';
jest.mock('@jupyterlab/services', () => {
@@ -82,10 +88,9 @@ describe('callOpenCodeEdit', () => {
mockedMakeRequest.mockReset();
});
- it('POSTs to /opencode-bridge/edit with JSON body', async () => {
+ it('POSTs to /opencode-bridge/edit and returns async {ok, sessionId, notebookPath}', async () => {
const respBody = {
ok: true,
- markdown: '```python\ndef foo(x: int) -> int: return x\n```',
sessionId: 'sid',
notebookPath: 'foo.ipynb',
};
@@ -107,7 +112,10 @@ describe('callOpenCodeEdit', () => {
);
expect(resp.ok).toBe(true);
if (resp.ok) {
- expect(resp.markdown).toContain('int');
+ // Async response has no `markdown` field — the LLM reply arrives
+ // via the SSE stream, not embedded in this response.
+ expect(resp.sessionId).toBe('sid');
+ expect((resp as any).markdown).toBeUndefined();
}
});
@@ -219,3 +227,161 @@ describe('callOpenCodeProviders', () => {
).rejects.toThrow(/network error/);
});
});
+
+describe('callOpenCodeReplyPermission', () => {
+ beforeEach(() => {
+ mockedMakeRequest.mockReset();
+ });
+
+ it('POSTs to /opencode-bridge/permissions/:permId with {response}', async () => {
+ mockedMakeRequest.mockResolvedValue(
+ mockResponse({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ text: JSON.stringify({ ok: true, permissionId: 'perm-1', response: 'once' })
+ })
+ );
+
+ const r = await callOpenCodeReplyPermission(
+ 's1',
+ 'perm-1',
+ 'once',
+ mockServerSettings()
+ );
+ expect(r).toEqual({ ok: true, permissionId: 'perm-1', response: 'once' });
+ expect(mockedMakeRequest).toHaveBeenCalledWith(
+ 'http://localhost:8888/opencode-bridge/permissions/perm-1?session=s1',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ response: 'once' })
+ }),
+ expect.anything()
+ );
+ });
+
+ it('throws on non-2xx', async () => {
+ mockedMakeRequest.mockResolvedValue(
+ mockResponse({
+ ok: false,
+ status: 400,
+ statusText: 'Bad Request',
+ text: 'bad response'
+ })
+ );
+ await expect(
+ callOpenCodeReplyPermission('s1', 'p', 'reject', mockServerSettings())
+ ).rejects.toThrow(/400/);
+ });
+});
+
+describe('callOpenCodeReplyQuestion', () => {
+ beforeEach(() => {
+ mockedMakeRequest.mockReset();
+ });
+
+ it('POSTs to /opencode-bridge/questions/:qId/reply with {answer}', async () => {
+ mockedMakeRequest.mockResolvedValue(
+ mockResponse({
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ text: JSON.stringify({ ok: true, questionId: 'q-1' })
+ })
+ );
+
+ const r = await callOpenCodeReplyQuestion(
+ 's1',
+ 'q-1',
+ 'use 3.12',
+ mockServerSettings()
+ );
+ expect(r).toEqual({ ok: true, questionId: 'q-1' });
+ expect(mockedMakeRequest).toHaveBeenCalledWith(
+ 'http://localhost:8888/opencode-bridge/questions/q-1/reply?session=s1',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ answer: 'use 3.12' })
+ }),
+ expect.anything()
+ );
+ });
+});
+
+/**
+ * subscribeOpenCodeEvents uses the global `fetch` (not ServerConnection),
+ * so we mock fetch instead of ServerConnection.makeRequest.
+ */
+describe('subscribeOpenCodeEvents', () => {
+ let originalFetch: typeof fetch;
+ let mockReader: { read: jest.Mock };
+ let capturedUrl: string | undefined;
+ let capturedInit: RequestInit | undefined;
+
+ beforeEach(() => {
+ originalFetch = globalThis.fetch;
+ mockReader = {
+ read: jest
+ .fn()
+ // First call returns a chunk; second returns done.
+ .mockResolvedValueOnce({
+ value: new TextEncoder().encode(
+ 'data: {"type":"session.next.text.delta","properties":{"sessionID":"sid","delta":"hello"}}\n\n' +
+ 'data: {"type":"session.idle","properties":{"sessionID":"sid"}}\n\n'
+ ),
+ done: false
+ })
+ .mockResolvedValueOnce({ value: undefined, done: true })
+ };
+ globalThis.fetch = jest.fn(async (url: any, init?: RequestInit) => {
+ capturedUrl = String(url);
+ capturedInit = init;
+ return {
+ ok: true,
+ status: 200,
+ statusText: 'OK',
+ body: {
+ getReader: () => mockReader
+ }
+ } as unknown as Response;
+ }) as unknown as typeof fetch;
+ });
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch;
+ });
+
+ it('GETs /opencode-bridge/events?session= and dispatches parsed events', async () => {
+ const onEvent = jest.fn();
+ const settings = mockServerSettings();
+ const sub = subscribeOpenCodeEvents('sid', settings, { onEvent });
+
+ // Let the async fetch + reader resolve.
+ await new Promise(r => setTimeout(r, 10));
+ sub.close();
+
+ expect(capturedUrl).toBe(
+ 'http://localhost:8888/opencode-bridge/events?session=sid'
+ );
+ expect(capturedInit?.method).toBe('GET');
+ expect((capturedInit?.headers as any).Accept).toBe('text/event-stream');
+ expect((capturedInit?.headers as any)['X-XSRFToken']).toBe('test');
+
+ expect(onEvent).toHaveBeenCalledTimes(2);
+ expect(onEvent.mock.calls[0][0].type).toBe('session.next.text.delta');
+ expect(onEvent.mock.calls[0][0].properties.delta).toBe('hello');
+ expect(onEvent.mock.calls[1][0].type).toBe('session.idle');
+ });
+
+ it('close() is idempotent and aborts the in-flight fetch', async () => {
+ const onEvent = jest.fn();
+ const sub = subscribeOpenCodeEvents('sid', mockServerSettings(), { onEvent });
+ // Let the async fetch actually run and capture the signal.
+ await new Promise(r => setTimeout(r, 5));
+ sub.close();
+ sub.close(); // second call must not throw
+ const signal = capturedInit?.signal as AbortSignal | undefined;
+ expect(signal).toBeDefined();
+ expect(signal?.aborted).toBe(true);
+ });
+});
diff --git a/src/api/opencode_client.ts b/src/api/opencode_client.ts
index c2b74f6..7a11204 100644
--- a/src/api/opencode_client.ts
+++ b/src/api/opencode_client.ts
@@ -1,19 +1,32 @@
/**
* HTTP client for the opencode-bridge server extension.
+ *
+ * Two flows:
+ * - one-shot: POST /edit (async — returns immediately with sessionId),
+ * GET /providers, GET /session-messages.
+ * - streaming: subscribeOpenCodeEvents() opens a fetch+reader connection
+ * to GET /events and yields parsed OpenCodeEvent values.
*/
import { URLExt } from '@jupyterlab/coreutils';
import { ServerConnection } from '@jupyterlab/services';
-import type { OpenCodeProvidersResponse, OpenCodeRequest, OpenCodeResponse, OpenCodeMessagesResponse } from '../types';
+import type {
+ OpenCodeEditResponse,
+ OpenCodeEvent,
+ OpenCodeMessagesResponse,
+ OpenCodeProvidersResponse,
+ OpenCodeRequest
+} from '../types';
/**
- * Call POST /opencode-bridge/edit. Returns parsed response (success or failure).
- * Throws on network error or non-JSON response.
+ * Call POST /opencode-bridge/edit. The endpoint is async — it fires
+ * prompt_async at OpenCode and returns once the prompt is queued. The
+ * actual LLM response is consumed via the SSE stream.
*/
export async function callOpenCodeEdit(
request: OpenCodeRequest,
serverSettings: ServerConnection.ISettings
-): Promise {
+): Promise {
const url = URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
@@ -23,7 +36,7 @@ export async function callOpenCodeEdit(
const init: RequestInit = {
method: 'POST',
body: JSON.stringify(request),
- headers: { 'Content-Type': 'application/json' },
+ headers: { 'Content-Type': 'application/json' }
};
let response: Response;
@@ -51,7 +64,7 @@ export async function callOpenCodeEdit(
);
}
- return parsed as OpenCodeResponse;
+ return parsed as OpenCodeEditResponse;
}
/**
@@ -115,3 +128,233 @@ export async function callOpenCodeProviders(
return (await response.json()) as OpenCodeProvidersResponse;
}
+
+/**
+ * Respond to a `permission.asked` event. `response` must be one of
+ * "once" | "always" | "reject". Returns the parsed JSON body.
+ */
+export async function callOpenCodeReplyPermission(
+ sessionId: string,
+ permissionId: string,
+ response: 'once' | 'always' | 'reject',
+ serverSettings: ServerConnection.ISettings
+): Promise<{ ok: boolean; permissionId: string; response: string; error?: string }> {
+ const url =
+ URLExt.join(
+ serverSettings.baseUrl,
+ 'opencode-bridge',
+ 'permissions',
+ encodeURIComponent(permissionId)
+ ) +
+ '?session=' +
+ encodeURIComponent(sessionId);
+
+ const init: RequestInit = {
+ method: 'POST',
+ body: JSON.stringify({ response }),
+ headers: { 'Content-Type': 'application/json' }
+ };
+
+ let res: Response;
+ try {
+ res = await ServerConnection.makeRequest(url, init, serverSettings);
+ } catch (error) {
+ throw new Error(
+ `network error calling opencode-bridge/permissions: ${(error as Error).message}`
+ );
+ }
+ const text = await res.text();
+ if (!res.ok) {
+ throw new Error(
+ `opencode-bridge/permissions failed: ${res.status} ${text}`
+ );
+ }
+ return JSON.parse(text);
+}
+
+/**
+ * Reply to a `question.asked` event with the user's freeform answer.
+ */
+export async function callOpenCodeReplyQuestion(
+ sessionId: string,
+ questionId: string,
+ answer: string,
+ serverSettings: ServerConnection.ISettings
+): Promise<{ ok: boolean; questionId: string; error?: string }> {
+ const url =
+ URLExt.join(
+ serverSettings.baseUrl,
+ 'opencode-bridge',
+ 'questions',
+ encodeURIComponent(questionId),
+ 'reply'
+ ) +
+ '?session=' +
+ encodeURIComponent(sessionId);
+
+ const init: RequestInit = {
+ method: 'POST',
+ body: JSON.stringify({ answer }),
+ headers: { 'Content-Type': 'application/json' }
+ };
+
+ let res: Response;
+ try {
+ res = await ServerConnection.makeRequest(url, init, serverSettings);
+ } catch (error) {
+ throw new Error(
+ `network error calling opencode-bridge/questions: ${(error as Error).message}`
+ );
+ }
+ const text = await res.text();
+ if (!res.ok) {
+ throw new Error(
+ `opencode-bridge/questions failed: ${res.status} ${text}`
+ );
+ }
+ return JSON.parse(text);
+}
+
+export interface OpenCodeEventHandlers {
+ onEvent: (event: OpenCodeEvent) => void;
+ onError?: (err: Error) => void;
+ onOpen?: () => void;
+}
+
+export interface OpenCodeEventSubscription {
+ /** Cancel the stream. Safe to call multiple times. */
+ close: () => void;
+}
+
+/**
+ * Subscribe to GET /opencode-bridge/events?session= as an SSE stream.
+ *
+ * Mirrors demo.html `initSSE`: opens a fetch(), reads the response body
+ * with a TextDecoder, splits on `\n\n` event boundaries, and parses each
+ * `data: {...}` line as JSON. Use `close()` to cancel.
+ *
+ * ServerConnection.makeRequest is unsuitable for SSE (it buffers the
+ * entire response), so we use plain fetch with the XSRF token injected
+ * from `serverSettings.token` when present.
+ */
+export function subscribeOpenCodeEvents(
+ sessionId: string,
+ serverSettings: ServerConnection.ISettings,
+ handlers: OpenCodeEventHandlers
+): OpenCodeEventSubscription {
+ const baseUrl = serverSettings.baseUrl.replace(/\/$/, '');
+ const url =
+ URLExt.join(baseUrl, 'opencode-bridge', 'events') +
+ '?session=' +
+ encodeURIComponent(sessionId);
+
+ const headers: Record = {
+ Accept: 'text/event-stream'
+ };
+ if (serverSettings.token) {
+ headers['X-XSRFToken'] = serverSettings.token;
+ }
+
+ const controller = new AbortController();
+ let closed = false;
+
+ const close = (): void => {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ try {
+ controller.abort();
+ } catch {
+ // ignore
+ }
+ };
+
+ void (async () => {
+ let response: Response;
+ try {
+ response = await fetch(url, { method: 'GET', headers, signal: controller.signal });
+ } catch (err) {
+ if ((err as Error).name === 'AbortError') {
+ return;
+ }
+ handlers.onError?.(err as Error);
+ return;
+ }
+ if (!response.ok) {
+ handlers.onError?.(
+ new Error(
+ `opencode-bridge/events failed: ${response.status} ${response.statusText}`
+ )
+ );
+ return;
+ }
+ if (!response.body) {
+ handlers.onError?.(new Error('opencode-bridge/events: no response body'));
+ return;
+ }
+ handlers.onOpen?.();
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder('utf-8');
+ let buffer = '';
+
+ try {
+ while (!closed) {
+ const { value, done } = await reader.read();
+ if (done) {
+ break;
+ }
+ buffer += decoder.decode(value, { stream: true });
+ let sepIdx: number;
+ while ((sepIdx = buffer.indexOf('\n\n')) !== -1) {
+ const block = buffer.slice(0, sepIdx);
+ buffer = buffer.slice(sepIdx + 2);
+ for (const line of block.split('\n')) {
+ if (!line.startsWith('data:')) {
+ continue;
+ }
+ const jsonStr = line.slice(5).trim();
+ if (!jsonStr) {
+ continue;
+ }
+ try {
+ const event = JSON.parse(jsonStr) as OpenCodeEvent;
+ handlers.onEvent(event);
+ } catch (e) {
+ console.warn(
+ 'opencode_bridge: SSE JSON parse error:',
+ e,
+ jsonStr
+ );
+ }
+ }
+ }
+ }
+ // Drain trailing block (no final \n\n).
+ if (buffer.trim()) {
+ for (const line of buffer.split('\n')) {
+ if (!line.startsWith('data:')) {
+ continue;
+ }
+ const jsonStr = line.slice(5).trim();
+ if (!jsonStr) {
+ continue;
+ }
+ try {
+ handlers.onEvent(JSON.parse(jsonStr) as OpenCodeEvent);
+ } catch {
+ // ignore
+ }
+ }
+ }
+ } catch (err) {
+ if ((err as Error).name === 'AbortError') {
+ return;
+ }
+ handlers.onError?.(err as Error);
+ }
+ })();
+
+ return { close };
+}
diff --git a/src/components/opencode_cell_actions.ts b/src/components/opencode_cell_actions.ts
index deea633..916c7cb 100644
--- a/src/components/opencode_cell_actions.ts
+++ b/src/components/opencode_cell_actions.ts
@@ -1,14 +1,20 @@
/**
* Per-cell AI action button rendered inside JupyterLab's native Cell toolbar
- * (top-right of the active cell). v2: a single icon button. Clicking it
- * toggles an OpenCodeInlinePrompt panel attached to the cell's DOM.
+ * (top-right of the active cell). Clicking it toggles an
+ * OpenCodeInlinePrompt panel attached to the cell's DOM.
*
* Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...).
*
- * v3-final: the frontend does NO semantic processing. It gathers the cell's
- * source / outputs / error, attaches the user's freeform instruction, and
- * POSTs to the server. The chosen provider/model comes from the inline
- * picker (no settings-based default).
+ * v4 message flow (matches demo.html):
+ * 1. User clicks 🪄 -> panel appears, shows session history (setMessages)
+ * 2. User submits prompt -> callOpenCodeEdit returns immediately with
+ * sessionId
+ * 3. We subscribe to GET /opencode-bridge/events?session= and
+ * forward each event to the prompt's applyEvent() dispatcher
+ * 4. On session.idle we close the stream and re-enable the input
+ *
+ * The frontend does NO semantic processing of the LLM reply. The chosen
+ * provider/model comes from the inline picker (no settings-based default).
*/
import type { CodeCell } from '@jupyterlab/cells';
import { Notification } from '@jupyterlab/apputils';
@@ -16,13 +22,18 @@ import type { NotebookPanel } from '@jupyterlab/notebook';
import { ServerConnection } from '@jupyterlab/services';
import { Widget } from '@lumino/widgets';
-import { callOpenCodeEdit, callOpenCodeSessionMessages } from '../api/opencode_client';
+import {
+ callOpenCodeEdit,
+ callOpenCodeReplyPermission,
+ callOpenCodeReplyQuestion,
+ callOpenCodeSessionMessages,
+ subscribeOpenCodeEvents,
+ type OpenCodeEventSubscription
+} from '../api/opencode_client';
import { extractCellContext } from '../context/cell_context';
import type {
CellContext,
- OpenCodeProvidersResponse,
- OpenCodeRequest,
- OpenCodeResponse
+ OpenCodeProvidersResponse
} from '../types';
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
@@ -50,6 +61,7 @@ export class OpenCodeCellActions extends Widget {
private _context: CellContext | null = null;
private _status: Status = 'idle';
private _prompt: OpenCodeInlinePrompt | null = null;
+ private _sseSub: OpenCodeEventSubscription | null = null;
constructor(cell: CodeCell) {
super();
@@ -65,6 +77,7 @@ export class OpenCodeCellActions extends Widget {
if (this.isDisposed) {
return;
}
+ this._closeSse();
this._cell.model.contentChanged.disconnect(this._onModelChange, this);
this._cell.model.stateChanged.disconnect(this._onModelChange, this);
this._hidePrompt();
@@ -119,14 +132,61 @@ export class OpenCodeCellActions extends Widget {
},
onCancel: () => {
this._hidePrompt();
+ },
+ onPermissionReply: async (permId, response) => {
+ if (!_serverSettings) {
+ return false;
+ }
+ const sessionId = (this._prompt as any)._sessionId as string | null;
+ if (!sessionId) {
+ return false;
+ }
+ try {
+ const r = await callOpenCodeReplyPermission(
+ sessionId,
+ permId,
+ response,
+ _serverSettings
+ );
+ return r.ok;
+ } catch (e) {
+ console.warn('opencode_bridge: permission reply failed', e);
+ return false;
+ }
+ },
+ onQuestionReply: async (qId, answer) => {
+ if (!_serverSettings) {
+ return false;
+ }
+ const sessionId = (this._prompt as any)._sessionId as string | null;
+ if (!sessionId) {
+ return false;
+ }
+ try {
+ const r = await callOpenCodeReplyQuestion(
+ sessionId,
+ qId,
+ answer,
+ _serverSettings
+ );
+ return r.ok;
+ } catch (e) {
+ console.warn('opencode_bridge: question reply failed', e);
+ return false;
+ }
+ },
+ onStreamEnd: () => {
+ // Centralized: the prompt detected "idle" (any shape) and tells
+ // us to close the SSE stream + restore the input. We no longer
+ // parse event types here.
+ this._closeSse();
}
});
Widget.attach(prompt, this._cell.node);
this._prompt = prompt;
- // Fetch the current session's message history and render it into
- // the prompt's scrollable history area. Empty list (no session yet)
- // is a normal no-op render.
+ // Fetch the current session's static history and render it before
+ // streaming begins. Empty list is a normal no-op.
const notebookPath = this._context?.notebookPath;
if (notebookPath) {
void this._refreshHistory(notebookPath);
@@ -144,8 +204,6 @@ export class OpenCodeCellActions extends Widget {
);
this._prompt.setMessages(resp.messages);
} catch (e) {
- // Non-fatal: the history is a convenience. The user can still
- // send new prompts; just the scrollback won't update.
console.warn('opencode_bridge: failed to fetch session messages', e);
}
}
@@ -170,7 +228,7 @@ export class OpenCodeCellActions extends Widget {
return;
}
- const request: OpenCodeRequest = {
+ const request = {
prompt: text,
context: this._context,
providerId,
@@ -180,41 +238,55 @@ export class OpenCodeCellActions extends Widget {
this._status = 'loading';
if (this._prompt) {
this._prompt.setDisabled(true);
+ this._prompt.setSessionId(null);
}
+ let resp;
try {
- const resp = await callOpenCodeEdit(request, _serverSettings);
- this._handleResponse(resp);
+ resp = await callOpenCodeEdit(request, _serverSettings);
} catch (e) {
this._status = 'idle';
- if (this._prompt) {
- this._prompt.setDisabled(false);
- }
+ this._prompt?.setDisabled(false);
Notification.error(`OpenCode 失败: ${(e as Error).message}`);
+ return;
}
+
+ if (!resp.ok) {
+ this._status = 'idle';
+ this._prompt?.setDisabled(false);
+ Notification.error(`OpenCode 错误: ${resp.error}`);
+ return;
+ }
+
+ // Async edit accepted. Bind the prompt to the returned session, clear
+ // the input, then subscribe to the SSE event stream for this session.
+ this._prompt?.setSessionId(resp.sessionId);
+ this._prompt?.clearInput();
+ this._sseSub = subscribeOpenCodeEvents(
+ resp.sessionId,
+ _serverSettings,
+ {
+ onEvent: ev => {
+ this._prompt?.applyEvent(ev);
+ // Stream-end is detected by the prompt and signalled via
+ // onStreamEnd (see _showPrompt); no idle parsing here.
+ },
+ onError: err => {
+ console.warn('opencode_bridge: SSE error', err);
+ // Don't flip status here: the server may just have hiccuped.
+ // session.idle (if it comes through) will re-enable the input.
+ }
+ }
+ );
}
- private _handleResponse(resp: OpenCodeResponse): void {
- this._status = 'idle';
- if (resp.ok) {
- // Refetch the (now-updated) session history and render the new
- // assistant message as the last item in the scrollable history.
- // The cell source is NOT replaced.
- const notebookPath = this._context?.notebookPath;
- if (notebookPath) {
- void this._refreshHistory(notebookPath);
- }
- // Clear the input so the user can type a follow-up message.
- this._prompt?.clearInput();
- Notification.info(
- `OpenCode 完成 (${resp.markdown.length} chars). Session: ${resp.sessionId.slice(0, 8)}…`
- );
- } else {
- if (this._prompt) {
- this._prompt.setDisabled(false);
- }
- Notification.error(`OpenCode 错误: ${resp.error}`);
+ private _closeSse(): void {
+ if (this._sseSub) {
+ this._sseSub.close();
+ this._sseSub = null;
}
+ this._status = 'idle';
+ this._prompt?.setDisabled(false);
}
}
diff --git a/src/components/opencode_inline_prompt.ts b/src/components/opencode_inline_prompt.ts
index f7328e0..b29ba49 100644
--- a/src/components/opencode_inline_prompt.ts
+++ b/src/components/opencode_inline_prompt.ts
@@ -1,26 +1,59 @@
/**
* Inline prompt widget attached to a cell's DOM when the AI button is clicked.
- * Renders two