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  boxes (provider + model), a textarea, send/cancel
+ * buttons, and a scrollable history area. The history area serves two modes:
+ *
+ * 1. **Static** — `setMessages(messages)` renders the session's stored
+ *    history (user/assistant markdown) when the panel is first opened.
+ * 2. **Streaming** — after the user submits a prompt, the owning cell
+ *    action subscribes to `/opencode-bridge/events` and forwards every
+ *    `OpenCodeEvent` to `applyEvent(event)`, which dispatches to the
+ *    appropriate DOM helper (text deltas, reasoning blocks, tool blocks,
+ *    permission/question prompts, system lines). On `session.idle` the
+ *    stream pointers reset.
+ *
+ * The model select's default for the chosen provider is `default[providerID]`
+ * from the /config/providers response.
+ *
+ * v4: cell source is NOT replaced. The AI reply is rendered into this
+ * history area. The user can still Copy / Insert / Replace individual
+ * code blocks from a finished assistant message.
  */
 import { CodeCell } from '@jupyterlab/cells';
 import { Notification } from '@jupyterlab/apputils';
 import { Widget } from '@lumino/widgets';
 import { marked } from 'marked';
 
-import type { OpenCodeMessage, OpenCodeProvidersResponse } from '../types';
+import type {
+  OpenCodeEvent,
+  OpenCodeMessage,
+  OpenCodeProvidersResponse
+} from '../types';
 
 export interface IOpenCodeInlinePromptOptions {
   onSubmit: (text: string, providerId?: string, modelId?: string) => void;
   onCancel: () => void;
   disabled: boolean;
   providers: OpenCodeProvidersResponse | null;
+  /**
+   * Respond to a `permission.asked` event. Returns a promise that resolves
+   * to true on success, false on failure (the prompt updates the UI in
+   * both cases).
+   */
+  onPermissionReply?: (
+    permissionId: string,
+    response: 'once' | 'always' | 'reject'
+  ) => Promise;
+  /** Reply to a `question.asked` event with the user's freeform answer. */
+  onQuestionReply?: (questionId: string, answer: string) => Promise;
+  /**
+   * Fired when the prompt detects the agent has finished its turn
+   * (any of: `session.idle`, `session.status` with `status.type === 'idle'`,
+   * or the same shapes nested under `payload`). Centralizing this in
+   * the prompt keeps the cell action from re-parsing event types.
+   */
+  onStreamEnd?: () => void;
 }
 
 interface FlatProvider {
@@ -65,8 +98,14 @@ function pickDefaultModelId(
   return keys[0];
 }
 
+interface BlockEntry {
+  details: HTMLDetailsElement;
+  content: HTMLDivElement;
+}
+
 export class OpenCodeInlinePrompt extends Widget {
   private _cell: CodeCell;
+  private _options: IOpenCodeInlinePromptOptions;
   private _textarea: HTMLTextAreaElement;
   private _sendBtn: HTMLButtonElement;
   private _cancelBtn: HTMLButtonElement;
@@ -76,12 +115,28 @@ export class OpenCodeInlinePrompt extends Widget {
   private _defaultMap: { [pid: string]: string };
   private _historyEl: HTMLDivElement;
 
+  // Streaming state. _sessionId is set by the cell action after a successful
+  // /edit response; applyEvent() drops events that don't belong to it.
+  private _sessionId: string | null = null;
+  // The current assistant message DOM node (re-rendered as markdown on
+  // every text delta so the user sees a properly formatted response in
+  // real-time, not raw ```fence``` source).
+  private _currentAssistantEl: HTMLDivElement | null = null;
+  // Accumulator for the current assistant turn's raw markdown source.
+  // Each text delta appends here; the whole string is then re-rendered
+  // through marked.parse to refresh the DOM. Using the raw source as
+  // truth (not innerHTML) avoids lossy round-trips through partial HTML.
+  private _currentAssistantText: string = '';
+  // Active collapsible blocks (reasoning / tool / etc.), keyed by type.
+  private _activeBlocks: { [key: string]: BlockEntry } = {};
+
   constructor(
     cell: CodeCell,
     options: IOpenCodeInlinePromptOptions
   ) {
     super();
     this._cell = cell;
+    this._options = options;
     this.addClass('opencode-inline-prompt');
 
     const flat = flattenProviders(options.providers);
@@ -125,8 +180,6 @@ export class OpenCodeInlinePrompt extends Widget {
       const modelId = this._modelSelect?.value || undefined;
       options.onSubmit(text, providerId, modelId);
     });
-    // Chat-input convention: Enter submits, Shift+Enter / Ctrl+Enter
-    // insert a newline (textarea default).
     this._textarea.addEventListener('keydown', (e) => {
       if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
         e.preventDefault();
@@ -167,8 +220,8 @@ export class OpenCodeInlinePrompt extends Widget {
       this.node.appendChild(row);
     }
 
-    // Scrollable history of the current session's messages. Empty until
-    // setMessages() is called by the owning cell action.
+    // Scrollable history (static + streaming). Empty until setMessages() /
+    // applyEvent() populate it.
     this._historyEl = document.createElement('div');
     this._historyEl.className = 'opencode-inline-history';
     this.node.appendChild(this._historyEl);
@@ -219,20 +272,27 @@ export class OpenCodeInlinePrompt extends Widget {
     }
   }
 
-  /**
-   * Clear the user input textarea (called by the cell action after a
-   * successful response, so the user can type a follow-up message
-   * without manually deleting the previous prompt).
-   */
+  /** Clear the user input textarea. */
   clearInput(): void {
     this._textarea.value = '';
   }
 
   /**
-   * Render the current session's messages into the scrollable history
-   * area. User messages are plain text; assistant messages are rendered
-   * as markdown via marked. Auto-scrolls to the bottom so the most
-   * recent message is visible.
+   * Bind the prompt to a specific OpenCode session id. Events arriving via
+   * `applyEvent` for a different session are dropped. Pass null to clear.
+   */
+  setSessionId(sessionId: string | null): void {
+    this._sessionId = sessionId;
+    if (sessionId) {
+      // Fresh turn: drop any in-progress stream DOM from a previous session.
+      this._resetStreamPointers();
+    }
+  }
+
+  /**
+   * Render the current session's static messages (loaded once from
+   * /session-messages when the panel opens). Subsequent assistant turns
+   * during the same session are appended live via applyEvent().
    */
   setMessages(messages: OpenCodeMessage[]): void {
     this._historyEl.textContent = '';
@@ -242,74 +302,501 @@ export class OpenCodeInlinePrompt extends Widget {
       if (msg.role === 'user') {
         wrap.textContent = msg.content;
       } else {
-        // Assistant: render the markdown, then for every fenced code
-        // block (
) attach a small toolbar with Copy / Insert /
-        // Replace buttons that act on THAT block's code (not the whole
-        // message). Messages without code fences get no toolbar.
-        const rendered = document.createElement('div');
-        rendered.className = 'opencode-msg-content';
-        rendered.innerHTML = marked.parse(msg.content) as string;
-
-        const codeBlocks = rendered.querySelectorAll('pre');
-        codeBlocks.forEach(pre => {
-          const code = pre.querySelector('code');
-          if (!code) {
-            return;
-          }
-          // Snapshot the code text before we mutate the DOM so the
-          // buttons act on the exact code inside this block (no
-          // surrounding ```fences```, no toolbar label text).
-          const codeText = code.textContent ?? '';
-          const wrap = document.createElement('div');
-          wrap.className = 'opencode-code-block';
-          const toolbar = document.createElement('div');
-          toolbar.className = 'opencode-code-toolbar';
-          const mkBtn = (
-            label: string,
-            title: string,
-            onClick: () => void
-          ) => {
-            const b = document.createElement('button');
-            b.type = 'button';
-            b.className = 'opencode-code-btn';
-            b.textContent = label;
-            b.title = title;
-            b.addEventListener('click', onClick);
-            return b;
-          };
-          toolbar.appendChild(
-            mkBtn('📋 复制', '复制代码', () => {
-              void this._copyToClipboard(codeText);
-            })
-          );
-          toolbar.appendChild(
-            mkBtn('↪ 插入', '在光标位置插入', () => {
-              this._insertAtCursor(codeText);
-            })
-          );
-          toolbar.appendChild(
-            mkBtn('⟳ 替换', '替换单元格内容', () => {
-              this._replaceCell(codeText);
-            })
-          );
-          wrap.appendChild(toolbar);
-          // Move the original 
 into the wrapper.
-          pre.parentNode!.insertBefore(wrap, pre);
-          wrap.appendChild(pre);
-        });
-
-        wrap.appendChild(rendered);
+        this._renderAssistantMarkdown(wrap, msg.content);
       }
       this._historyEl.appendChild(wrap);
     }
-    this._historyEl.scrollTop = this._historyEl.scrollHeight;
+    this._scrollToBottom();
   }
 
   /**
-   * Copy the given text to the clipboard. Prefers navigator.clipboard
-   * (available in secure contexts incl. JupyterLab). Falls back to a
-   * hidden textarea + execCommand for older / non-secure contexts.
+   * Apply one OpenCode SSE event to the streaming UI. Mirrors
+   * demo.html's handleGlobalEvent / processSessionEvent dispatch.
    */
+  applyEvent(event: OpenCodeEvent): void {
+    const type = event.type || (event.payload && event.payload.type) || '';
+    const props =
+      event.properties ||
+      (event.payload && event.payload.properties) ||
+      {};
+    const sessionID =
+      props.sessionID ||
+      props.sessionId ||
+      (event.syncEvent && event.syncEvent.aggregateID) ||
+      (event.payload &&
+        event.payload.syncEvent &&
+        event.payload.syncEvent.aggregateID);
+
+    // Drop events for other sessions (defensive; matches demo.html
+    // handleGlobalEvent). Only drop when the event has an explicit
+    // sessionID — events without one (e.g. system events) are still
+    // processed and may be dropped by _isSystemEvent below.
+    if (sessionID && this._sessionId && sessionID !== this._sessionId) {
+      return;
+    }
+
+    // System / workspace / pty / etc. events are not displayed.
+    if (this._isSystemEvent(type)) {
+      return;
+    }
+
+    // For text/reasoning/tool/idle/permission/question we expect to match.
+    // If none of the branches below fire, log a warning so the missing
+    // event type can be diagnosed in DevTools.
+    const handled = this._dispatchContentEvent(event, type, props);
+    if (!handled) {
+      console.warn(
+        'opencode_bridge: unrecognized SSE event type (dropped):',
+        type,
+        event
+      );
+    }
+  }
+
+  private _dispatchContentEvent(
+    event: OpenCodeEvent,
+    type: string,
+    props: { [k: string]: any }
+  ): boolean {
+
+    if (type === 'permission.asked') {
+      const permId = String(props.id || props.permissionID || '');
+      this._createPermissionBlock(
+        permId,
+        String(
+          props.description || props.title || '系统请求执行权限(命令行/文件修改)'
+        )
+      );
+      return true;
+    }
+    if (type === 'question.asked') {
+      const qId = String(props.id || props.questionID || '');
+      this._createQuestionBlock(
+        qId,
+        String(props.question || props.title || 'Agent 正在等待确认…')
+      );
+      return true;
+    }
+
+    if (
+      type === 'message.part.delta' &&
+      props.field === 'text' &&
+      typeof props.delta === 'string'
+    ) {
+      this._appendToAssistantText(props.delta);
+      return true;
+    }
+    if (
+      (type === 'message.part.delta' && props.field === 'reasoning') ||
+      type === 'session.next.reasoning.delta'
+    ) {
+      this._appendToBlock(
+        'reasoning',
+        '🧠 思考与推理过程',
+        String(props.delta || props.text || '')
+      );
+      return true;
+    }
+    if (type === 'session.next.text.delta' && typeof props.delta === 'string') {
+      this._appendToAssistantText(props.delta);
+      return true;
+    }
+
+    if (type === 'session.next.reasoning.started') {
+      this._getOrCreateBlock('reasoning', '🧠 思考与推理过程');
+      return true;
+    }
+
+    if (
+      type === 'session.next.tool.input.started' ||
+      type === 'session.next.tool.called'
+    ) {
+      const toolName = String(props.name || props.tool || '未知工具');
+      this._getOrCreateBlock('tool', `🛠️ 调用工具: ${toolName}`);
+      return true;
+    }
+    if (type === 'session.next.tool.input.delta') {
+      this._appendToBlock('tool', '🛠️ 工具输入与参数', String(props.delta || ''));
+      return true;
+    }
+    if (type === 'session.next.tool.progress') {
+      this._appendToBlock(
+        'tool',
+        '🛠️ 工具执行中…',
+        `\n[Progress]: ${String(props.message || '')}`
+      );
+      return true;
+    }
+    if (type === 'session.next.tool.success') {
+      this._appendToBlock(
+        'tool',
+        '🛠️ 工具执行完毕',
+        `\n[执行成功结果]: ${JSON.stringify(props.result || {}, null, 2)}`
+      );
+      return true;
+    }
+    if (type === 'session.next.tool.failed') {
+      this._appendToBlock(
+        'tool',
+        '🛠️ 工具执行失败',
+        `\n[执行失败异常]: ${String(props.error || 'Unknown')}`
+      );
+      return true;
+    }
+
+    if (this._isIdleEvent(event, type, props)) {
+      this._resetStreamPointers();
+      this._options.onStreamEnd?.();
+      return true;
+    }
+
+    return false;
+  }
+
+  /**
+   * Detect "agent is now idle" across the multiple shapes OpenCode has
+   * emitted across versions: top-level `session.idle`, top-level
+   * `session.status` with `status.type === 'idle'`, and the same shapes
+   * nested under `payload`.
+   */
+  private _isIdleEvent(
+    event: OpenCodeEvent,
+    topType: string,
+    topProps: { [k: string]: any }
+  ): boolean {
+    const check = (t: string, p: { [k: string]: any }): boolean => {
+      if (t === 'session.idle') {
+        return true;
+      }
+      if (t === 'session.status' && p && p.status && p.status.type === 'idle') {
+        return true;
+      }
+      return false;
+    };
+    if (check(topType, topProps)) {
+      return true;
+    }
+    const payload = event.payload;
+    if (payload) {
+      return check(
+        payload.type || topType,
+        payload.properties || topProps
+      );
+    }
+    return false;
+  }
+
+  // ---------- Private streaming helpers ----------
+
+  private _isSystemEvent(type: string): boolean {
+    return (
+      type.startsWith('server.') ||
+      type.startsWith('workspace.') ||
+      type.startsWith('worktree.') ||
+      type.startsWith('lsp.') ||
+      type.startsWith('mcp.') ||
+      type.startsWith('pty.') ||
+      type.startsWith('installation.') ||
+      type === 'global.disposed' ||
+      // Session-level control events are not part of the assistant reply
+      // and would be confusing as separate lines in the history.
+      type === 'session.next.agent.switched' ||
+      type === 'session.next.model.switched' ||
+      type === 'file.edited'
+    );
+  }
+
+  private _appendToAssistantText(text: string): void {
+    if (!this._currentAssistantEl) {
+      this._currentAssistantEl = this._createMessageElement('assistant', '');
+    }
+    // Accumulate the raw markdown source, then re-render the whole
+    // message through marked.parse. This way the user sees properly
+    // formatted output (code blocks as 
, headings, lists)
+    // in real-time as text deltas arrive — NOT the raw ```fence```
+    // source that an append-only .textContent would show.
+    this._currentAssistantText += text;
+    this._renderAssistantMarkdown(
+      this._currentAssistantEl,
+      this._currentAssistantText
+    );
+    this._scrollToBottom();
+  }
+
+  private _getOrCreateBlock(key: string, title: string): BlockEntry {
+    let entry = this._activeBlocks[key];
+    if (entry) {
+      return entry;
+    }
+    const details = document.createElement('details');
+    details.className = `opencode-msg-block opencode-msg-block-${key}`;
+    details.open = true;
+    const summary = document.createElement('summary');
+    summary.textContent = title;
+    const content = document.createElement('div');
+    content.className = 'opencode-msg-block-content';
+    details.appendChild(summary);
+    details.appendChild(content);
+    this._historyEl.appendChild(details);
+    entry = { details, content };
+    this._activeBlocks[key] = entry;
+    this._scrollToBottom();
+    return entry;
+  }
+
+  private _appendToBlock(
+    key: string,
+    defaultTitle: string,
+    text: string
+  ): void {
+    const block = this._getOrCreateBlock(key, defaultTitle);
+    block.content.textContent = (block.content.textContent || '') + text;
+    this._scrollToBottom();
+  }
+
+  private _createPermissionBlock(permId: string, description: string): void {
+    if (!permId) {
+      return;
+    }
+    const domId = `opencode-perm-${permId}`;
+    if (this._historyEl.querySelector('#' + domId)) {
+      return;
+    }
+    const details = document.createElement('details');
+    details.id = domId;
+    details.className =
+      'opencode-msg-block opencode-msg-block-interaction';
+    details.open = true;
+    const summary = document.createElement('summary');
+    summary.textContent = '🔐 权限请求等待处理';
+    const content = document.createElement('div');
+    content.className = 'opencode-msg-block-content opencode-perm-body';
+
+    const desc = document.createElement('div');
+    desc.className = 'opencode-perm-desc';
+    desc.textContent = `请求内容:${description}`;
+    content.appendChild(desc);
+
+    const btnGroup = document.createElement('div');
+    btnGroup.className = 'opencode-perm-buttons';
+    const mkBtn = (
+      label: string,
+      cls: string,
+      resp: 'once' | 'always' | 'reject'
+    ): HTMLButtonElement => {
+      const b = document.createElement('button');
+      b.type = 'button';
+      b.className = cls;
+      b.textContent = label;
+      b.addEventListener('click', () => {
+        this._handlePermissionResponse(permId, resp, btnGroup, details);
+      });
+      return b;
+    };
+    btnGroup.appendChild(
+      mkBtn('允许一次 (Once)', 'opencode-perm-btn-allow', 'once')
+    );
+    btnGroup.appendChild(
+      mkBtn('总是允许 (Always)', 'opencode-perm-btn-allow', 'always')
+    );
+    btnGroup.appendChild(
+      mkBtn('拒绝 (Reject)', 'opencode-perm-btn-reject', 'reject')
+    );
+    content.appendChild(btnGroup);
+
+    details.appendChild(summary);
+    details.appendChild(content);
+    this._historyEl.appendChild(details);
+    this._scrollToBottom();
+  }
+
+  private async _handlePermissionResponse(
+    permId: string,
+    response: 'once' | 'always' | 'reject',
+    btnGroup: HTMLElement,
+    details: HTMLElement
+  ): Promise {
+    if (!this._options.onPermissionReply) {
+      btnGroup.textContent = '(未配置 onPermissionReply 回调)';
+      return;
+    }
+    btnGroup.textContent = '正在提交…';
+    try {
+      const ok = await this._options.onPermissionReply(permId, response);
+      const labels: Record = {
+        once: '✓ 已授权 (仅一次)',
+        always: '✓ 已授权 (总是允许)',
+        reject: '✕ 已拒绝操作'
+      };
+      const color = response === 'reject' ? '#ef4444' : '#10b981';
+      btnGroup.innerHTML = `${
+        labels[response]
+      }${ok ? '' : ' (后端失败)'}`;
+    } catch (e) {
+      btnGroup.innerHTML = `提交失败: ${(e as Error).message}`;
+    }
+  }
+
+  private _createQuestionBlock(questionId: string, questionText: string): void {
+    if (!questionId) {
+      return;
+    }
+    const domId = `opencode-q-${questionId}`;
+    if (this._historyEl.querySelector('#' + domId)) {
+      return;
+    }
+    const details = document.createElement('details');
+    details.id = domId;
+    details.className =
+      'opencode-msg-block opencode-msg-block-interaction';
+    details.open = true;
+    const summary = document.createElement('summary');
+    summary.textContent = '❓ Agent 提问等待回复';
+    const content = document.createElement('div');
+    content.className = 'opencode-msg-block-content opencode-q-body';
+
+    const desc = document.createElement('div');
+    desc.className = 'opencode-q-desc';
+    desc.textContent = `问题:${questionText}`;
+    content.appendChild(desc);
+
+    const inputRow = document.createElement('div');
+    inputRow.className = 'opencode-q-input-row';
+    const input = document.createElement('input');
+    input.type = 'text';
+    input.className = 'opencode-q-input';
+    input.placeholder = '请输入你的回答…';
+    const submit = document.createElement('button');
+    submit.type = 'button';
+    submit.className = 'opencode-q-submit';
+    submit.textContent = '提交回答';
+    submit.addEventListener('click', () => {
+      void this._handleQuestionResponse(
+        questionId,
+        input,
+        inputRow,
+        details
+      );
+    });
+    input.addEventListener('keydown', (e) => {
+      if (e.key === 'Enter') {
+        e.preventDefault();
+        submit.click();
+      }
+    });
+    inputRow.appendChild(input);
+    inputRow.appendChild(submit);
+    content.appendChild(inputRow);
+
+    details.appendChild(summary);
+    details.appendChild(content);
+    this._historyEl.appendChild(details);
+    this._scrollToBottom();
+    input.focus();
+  }
+
+  private async _handleQuestionResponse(
+    questionId: string,
+    input: HTMLInputElement,
+    inputRow: HTMLElement,
+    _details: HTMLElement
+  ): Promise {
+    const answer = input.value.trim();
+    if (!answer) {
+      return;
+    }
+    if (!this._options.onQuestionReply) {
+      inputRow.innerHTML = '(未配置 onQuestionReply 回调)';
+      return;
+    }
+    inputRow.innerHTML = '正在提交…';
+    try {
+      const ok = await this._options.onQuestionReply(questionId, answer);
+      const color = ok ? '#10b981' : '#ef4444';
+      const text = ok ? `✓ 已回答:${answer}` : `提交失败 (后端 ok=false)`;
+      inputRow.innerHTML = `${text}`;
+    } catch (e) {
+      inputRow.innerHTML = `提交失败: ${(e as Error).message}`;
+    }
+  }
+
+  private _resetStreamPointers(): void {
+    this._currentAssistantEl = null;
+    this._currentAssistantText = '';
+    this._activeBlocks = {};
+  }
+
+  // ---------- Static-message rendering (used by setMessages + streaming) ----------
+
+  private _renderAssistantMarkdown(wrap: HTMLElement, content: string): void {
+    // Idempotent: clear existing content first so this can be called
+    // repeatedly with the same `wrap` element (used by streaming —
+    // every text delta re-renders the whole message).
+    wrap.textContent = '';
+    const rendered = document.createElement('div');
+    rendered.className = 'opencode-msg-content';
+    rendered.innerHTML = marked.parse(content) as string;
+    const codeBlocks = rendered.querySelectorAll('pre');
+    codeBlocks.forEach(pre => {
+      const code = pre.querySelector('code');
+      if (!code) {
+        return;
+      }
+      const codeText = code.textContent ?? '';
+      const blockWrap = document.createElement('div');
+      blockWrap.className = 'opencode-code-block';
+      const toolbar = document.createElement('div');
+      toolbar.className = 'opencode-code-toolbar';
+      const mkBtn = (
+        label: string,
+        title: string,
+        onClick: () => void
+      ): HTMLButtonElement => {
+        const b = document.createElement('button');
+        b.type = 'button';
+        b.className = 'opencode-code-btn';
+        b.textContent = label;
+        b.title = title;
+        b.addEventListener('click', onClick);
+        return b;
+      };
+      toolbar.appendChild(
+        mkBtn('📋 复制', '复制代码', () => {
+          void this._copyToClipboard(codeText);
+        })
+      );
+      toolbar.appendChild(
+        mkBtn('↪ 插入', '在光标位置插入', () => {
+          this._insertAtCursor(codeText);
+        })
+      );
+      toolbar.appendChild(
+        mkBtn('⟳ 替换', '替换单元格内容', () => {
+          this._replaceCell(codeText);
+        })
+      );
+      blockWrap.appendChild(toolbar);
+      pre.parentNode!.insertBefore(blockWrap, pre);
+      blockWrap.appendChild(pre);
+    });
+    wrap.appendChild(rendered);
+  }
+
+  private _createMessageElement(role: string, text: string): HTMLDivElement {
+    const div = document.createElement('div');
+    div.className = `opencode-msg opencode-msg-${role}`;
+    div.textContent = text;
+    this._historyEl.appendChild(div);
+    this._scrollToBottom();
+    return div;
+  }
+
+  private _scrollToBottom(): void {
+    this._historyEl.scrollTop = this._historyEl.scrollHeight;
+  }
+
+  // ---------- Cell-edit actions (Copy / Insert / Replace) ----------
+
   private async _copyToClipboard(text: string): Promise {
     try {
       if (
@@ -335,11 +822,6 @@ export class OpenCodeInlinePrompt extends Widget {
     }
   }
 
-  /**
-   * Insert text at the current editor cursor position. If the cell has
-   * no editor (e.g. a non-Code cell) or the cursor can't be read, falls
-   * back to appending at the end of the cell.
-   */
   private _insertAtCursor(text: string): void {
     const cell = this._cell;
     if (!cell || !cell.model || !cell.model.sharedModel) {
@@ -367,7 +849,6 @@ export class OpenCodeInlinePrompt extends Widget {
         offset += col;
       }
     } catch {
-      // Fall back to append on any error reading the cursor.
       offset = source.length;
     }
     const newSource = source.slice(0, offset) + text + source.slice(offset);
@@ -375,7 +856,6 @@ export class OpenCodeInlinePrompt extends Widget {
     Notification.info('已插入到光标位置');
   }
 
-  /** Replace the entire cell source with the given text. */
   private _replaceCell(text: string): void {
     const cell = this._cell;
     if (!cell || !cell.model || !cell.model.sharedModel) {
diff --git a/src/types.ts b/src/types.ts
index 1f8a416..bc116ff 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,12 +1,13 @@
 /**
  * Shared types for the opencode-bridge frontend.
  *
- * These mirror the Python server's `CellContext` / `OpenCodeRequest` / `OpenCodeResponse`
- * shapes in `opencode_bridge/routes.py`. Keep them in sync.
+ * Mirrors the Python server's response shapes in `opencode_bridge/routes.py`
+ * and the OpenCode Serve SSE event shape documented in
+ * `/Users/taochen/temp/demo.html` (`handleGlobalEvent` / `processSessionEvent`).
  *
- * v3-final: the JupyterLab plugin settings have been removed entirely.
- * All connection config lives in startup environment variables (see
- * `opencode_bridge/config.py`); the inline model picker is fully dynamic.
+ * v4: POST /edit is async (returns immediately with sessionId). The LLM
+ * reply is consumed via the GET /events SSE stream. Each SSE event is
+ * a JSON object with a `type` string; consumers route on `type`.
  */
 
 export interface ErrorOutput {
@@ -33,21 +34,41 @@ export interface OpenCodeRequest {
   modelId?: string;
 }
 
-export interface OpenCodeSuccess {
+/**
+ * Response from POST /opencode-bridge/edit (async).
+ * The actual assistant reply arrives via the /events SSE stream and is
+ * NOT embedded in this response.
+ */
+export interface OpenCodeEditSuccess {
   ok: true;
-  /** The AI's reply as markdown (code in ```fences```, optional explanation).
-   *  Render with marked. The cell source is NOT replaced. */
-  markdown: string;
   sessionId: string;
   notebookPath: string;
 }
 
-export interface OpenCodeFailure {
+export interface OpenCodeEditFailure {
   ok: false;
   error: string;
 }
 
-export type OpenCodeResponse = OpenCodeSuccess | OpenCodeFailure;
+export type OpenCodeEditResponse = OpenCodeEditSuccess | OpenCodeEditFailure;
+
+/**
+ * One SSE event from GET /opencode-bridge/events (proxied from OpenCode
+ * Serve's /global/event). The shape is loosely typed because OpenCode's
+ * payload envelope is not strictly documented; we follow demo.html's
+ * defensive property lookup pattern (try top-level then nested under
+ * `payload`).
+ */
+export interface OpenCodeEvent {
+  type: string;
+  payload?: {
+    type?: string;
+    properties?: { [k: string]: any };
+    syncEvent?: { aggregateID?: string };
+  };
+  properties?: { [k: string]: any };
+  syncEvent?: { aggregateID?: string };
+}
 
 /** Response from GET /opencode-bridge/session-messages?notebook=... .
  *  Projected from OpenCode's {info, parts}[] by the server into a
diff --git a/style/base.css b/style/base.css
index 6c23365..cb66b59 100644
--- a/style/base.css
+++ b/style/base.css
@@ -210,3 +210,132 @@
   opacity: 0.4;
   cursor: default;
 }
+
+/* ------------------------------------------------------------------
+   Streaming event blocks (v4+ — see opencode_inline_prompt.applyEvent)
+   ------------------------------------------------------------------ */
+
+/* System / status line rendered for server.*, workspace.*, lsp.*, etc. */
+.opencode-inline-prompt .opencode-msg-system {
+  font-size: var(--jp-ui-font-size0, 11px);
+  color: var(--jp-ui-font-color2, #888);
+  padding: 2px 4px;
+  font-family: var(--jp-code-font-family, monospace);
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+
+/* Base collapsible block used for reasoning / tool / permission / question. */
+.opencode-inline-prompt .opencode-msg-block {
+  margin: 4px 0;
+  border: 1px solid var(--jp-border-color2, #ddd);
+  border-radius: 3px;
+  background: var(--jp-layout-color1, #fafafa);
+  font-size: var(--jp-ui-font-size0, 11px);
+  overflow: hidden;
+}
+
+.opencode-inline-prompt .opencode-msg-block > summary {
+  padding: 2px 6px;
+  font-weight: 600;
+  cursor: pointer;
+  background: var(--jp-layout-color2, #f1f5f9);
+  color: var(--jp-ui-font-color2, #64748b);
+  user-select: none;
+  font-size: var(--jp-ui-font-size0, 11px);
+}
+
+.opencode-inline-prompt .opencode-msg-block-content {
+  padding: 4px 8px;
+  font-family: var(--jp-code-font-family, monospace);
+  white-space: pre-wrap;
+  word-break: break-all;
+  color: var(--jp-ui-font-color1, #334155);
+  max-height: 240px;
+  overflow-y: auto;
+}
+
+/* Reasoning: green tint (matches demo.html). */
+.opencode-inline-prompt .opencode-msg-block-reasoning > summary {
+  background: #f0fdf4;
+  color: #15803d;
+}
+
+/* Tool call: yellow tint. */
+.opencode-inline-prompt .opencode-msg-block-tool > summary {
+  background: #fef3c7;
+  color: #b45309;
+}
+
+/* Permission / question: pink tint. */
+.opencode-inline-prompt .opencode-msg-block-interaction > summary {
+  background: #fce7f3;
+  color: #be185d;
+}
+
+/* Permission block: description + 3 buttons. */
+.opencode-inline-prompt .opencode-perm-desc {
+  margin-bottom: 6px;
+  font-size: var(--jp-ui-font-size0, 11px);
+  color: var(--jp-ui-font-color1, #334155);
+}
+.opencode-inline-prompt .opencode-perm-buttons {
+  display: flex;
+  gap: 6px;
+  flex-wrap: wrap;
+  margin-top: 4px;
+}
+.opencode-inline-prompt .opencode-perm-btn-allow,
+.opencode-inline-prompt .opencode-perm-btn-reject {
+  border: none;
+  padding: 4px 10px;
+  border-radius: 3px;
+  cursor: pointer;
+  font-size: var(--jp-ui-font-size0, 11px);
+  color: white;
+  font-weight: 500;
+}
+.opencode-inline-prompt .opencode-perm-btn-allow {
+  background: #10b981;
+}
+.opencode-inline-prompt .opencode-perm-btn-allow:hover {
+  background: #059669;
+}
+.opencode-inline-prompt .opencode-perm-btn-reject {
+  background: #ef4444;
+}
+.opencode-inline-prompt .opencode-perm-btn-reject:hover {
+  background: #dc2626;
+}
+
+/* Question block: text + input + submit. */
+.opencode-inline-prompt .opencode-q-desc {
+  margin-bottom: 6px;
+  font-size: var(--jp-ui-font-size0, 11px);
+  color: var(--jp-ui-font-color1, #334155);
+}
+.opencode-inline-prompt .opencode-q-input-row {
+  display: flex;
+  gap: 6px;
+  margin-top: 4px;
+}
+.opencode-inline-prompt .opencode-q-input {
+  flex: 1;
+  padding: 3px 6px;
+  border: 1px solid var(--jp-border-color2, #ccc);
+  border-radius: 3px;
+  font-size: var(--jp-ui-font-size0, 11px);
+  font-family: inherit;
+  background: var(--jp-layout-color1, #fff);
+}
+.opencode-inline-prompt .opencode-q-submit {
+  border: 1px solid var(--jp-border-color2, #ccc);
+  background: var(--jp-layout-color2, #f0f0f0);
+  padding: 3px 10px;
+  border-radius: 3px;
+  cursor: pointer;
+  font-size: var(--jp-ui-font-size0, 11px);
+}
+.opencode-inline-prompt .opencode-q-submit:hover {
+  background: var(--jp-layout-color3, #e0e0e0);
+}