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/__init__.py b/opencode_bridge/__init__.py index 773a605..079df3e 100644 --- a/opencode_bridge/__init__.py +++ b/opencode_bridge/__init__.py @@ -3,12 +3,24 @@ try: except ImportError: # Fallback when using the package in dev mode without installing # in editable mode with pip. It is highly recommended to install - # the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs + # the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-install/ import warnings warnings.warn("Importing 'opencode_bridge' outside a proper installation.") __version__ = "dev" +from .config import OpencodeConfig from .routes import setup_route_handlers +# Re-export OpencodeConfig at module level so the JupyterApp CLI parser +# (which auto-discovers Configurable subclasses from extension-point +# modules) recognises ``--OpencodeConfig.`` flags. +__all__ = [ + "OpencodeConfig", + "setup_route_handlers", + "_jupyter_labextension_paths", + "_jupyter_server_extension_points", + "_load_jupyter_server_extension", +] + def _jupyter_labextension_paths(): return [{ @@ -26,11 +38,25 @@ def _jupyter_server_extension_points(): def _load_jupyter_server_extension(server_app): """Registers the API handler to receive HTTP requests from the frontend extension. + Also instantiates the :class:`OpencodeConfig` configurable (with the + server app as parent so CLI / config-file overrides take effect) and + exposes it to request handlers via + ``web_app.settings["opencode_bridge"]``. + Parameters ---------- server_app: jupyterlab.labapp.LabApp JupyterLab application instance """ + # Belt-and-suspenders: ensure the CLI parser recognises --OpencodeConfig.* + # even on JupyterApp versions that don't auto-discover Configurable + # subclasses from extension-point modules. + if OpencodeConfig not in server_app.classes: + server_app.classes.append(OpencodeConfig) + + cfg = OpencodeConfig(parent=server_app) + server_app.web_app.settings["opencode_bridge"] = cfg + setup_route_handlers(server_app.web_app) name = "opencode_bridge" server_app.log.info(f"Registered {name} server extension") diff --git a/opencode_bridge/config.py b/opencode_bridge/config.py index 3267ec4..0c62f95 100644 --- a/opencode_bridge/config.py +++ b/opencode_bridge/config.py @@ -1,10 +1,22 @@ """Configuration resolution for the opencode-bridge extension. -All connection fields (URL / user / password / request timeout) are resolved -exclusively from environment variables + built-in defaults at server startup. -They are NOT stored in the JupyterLab plugin settings. +Connection fields (URL / user / password / request timeout) are resolved +in the following priority order, highest first: + +1. ``--OpencodeConfig.`` CLI flag (e.g. ``--OpencodeConfig.url=...``) +2. ``c.OpencodeConfig.`` line in a Jupyter config file +3. ``OPENCODE_BRIDGE_*`` environment variable +4. Built-in default + +The ``OpencodeConfig`` class is a ``traitlets.config.Configurable``; the +Jupyter server extension instantiates it with ``parent=server_app`` and +stashes it on ``web_app.settings["opencode_bridge"]`` so handlers can pick +it up via ``resolve_config``. + +Env vars (kept for backward compatibility — also the default-value source +for the traitlets so a fresh ``OpencodeConfig()`` with no overrides still +honours them): -Env vars: OPENCODE_BRIDGE_URL — OpenCode Serve base URL (default http://127.0.0.1:4096) OPENCODE_BRIDGE_USER — HTTP Basic Auth username (default 'opencode') OPENCODE_BRIDGE_PASSWORD — HTTP Basic Auth password (default '' = no auth) @@ -15,6 +27,9 @@ from __future__ import annotations import os from typing import NamedTuple, Optional, Tuple +from traitlets import Int, Unicode, default +from traitlets.config import Configurable + ENV_URL = "OPENCODE_BRIDGE_URL" ENV_USER = "OPENCODE_BRIDGE_USER" @@ -40,19 +55,106 @@ class OpenCodeConfig(NamedTuple): return (self.user, self.password) -def resolve_config(jupyter_settings: dict) -> OpenCodeConfig: - """Resolve OpenCode connection config from environment variables + defaults. +class OpencodeConfig(Configurable): + """Traitlets-configurable connection settings for the opencode-bridge server extension. - The `jupyter_settings` argument is accepted for API compatibility but no - fields are read from it — all connection config is now startup-only - (environment variables). + Exposed to ``jupyter lab`` / ``jupyter server`` as ``--OpencodeConfig.`` + and to ``jupyter_server_config.py`` as ``c.OpencodeConfig.``. Defaults + fall back to the ``OPENCODE_BRIDGE_*`` env vars and then to built-in defaults, + so the legacy env-var-only flow keeps working unchanged. """ - _ = jupyter_settings # intentionally unused; see module docstring + + url = Unicode( + DEFAULT_URL, + config=True, + help=( + "Base URL of the locally-running OpenCode Serve process. " + "Overrides the OPENCODE_BRIDGE_URL env var." + ), + ) + + user = Unicode( + DEFAULT_USER, + config=True, + help=( + "HTTP Basic Auth username for OpenCode Serve. " + "Overrides the OPENCODE_BRIDGE_USER env var." + ), + ) + + password = Unicode( + "", + config=True, + help=( + "HTTP Basic Auth password for OpenCode Serve (empty = no auth). " + "Overrides the OPENCODE_BRIDGE_PASSWORD env var." + ), + ) + + request_timeout_seconds = Int( + DEFAULT_REQUEST_TIMEOUT, + config=True, + help=( + "Per-request timeout in seconds when calling OpenCode Serve. " + "Overrides the OPENCODE_BRIDGE_TIMEOUT env var." + ), + ) + + @default("url") + def _default_url(self) -> str: + return os.environ.get(ENV_URL) or DEFAULT_URL + + @default("user") + def _default_user(self) -> str: + return os.environ.get(ENV_USER) or DEFAULT_USER + + @default("password") + def _default_password(self) -> str: + return os.environ.get(ENV_PASSWORD) or "" + + @default("request_timeout_seconds") + def _default_request_timeout_seconds(self) -> int: + return int(os.environ.get(ENV_TIMEOUT) or DEFAULT_REQUEST_TIMEOUT) + + +def _to_opencode_config(bridge) -> OpenCodeConfig: + """Project an :class:`OpencodeConfig` (or duck-typed equivalent) to the + internal ``OpenCodeConfig`` NamedTuple consumed by ``OpenCodeClient``. + """ + return OpenCodeConfig( + url=bridge.url, + user=bridge.user, + password=bridge.password, + request_timeout_seconds=bridge.request_timeout_seconds, + ) + + +def resolve_config(jupyter_settings) -> OpenCodeConfig: + """Resolve OpenCode connection config into an ``OpenCodeConfig`` NamedTuple. + + Accepts either a settings dict (legacy) or an :class:`OpencodeConfig` + instance directly, because request handlers call + ``resolve_config(handler.settings.get("opencode_bridge", {}))`` and pass + the inner value (which is now an ``OpencodeConfig`` instance after the + server extension's load hook populates it). + + Source order (highest priority first): + + 1. The argument itself is an :class:`OpencodeConfig` instance. + 2. ``jupyter_settings["opencode_bridge"]`` is an :class:`OpencodeConfig` + instance (legacy call style). + 3. Otherwise fall back to env vars + built-in defaults. + """ + if isinstance(jupyter_settings, OpencodeConfig): + return _to_opencode_config(jupyter_settings) + bridge = (jupyter_settings or {}).get("opencode_bridge") + if isinstance(bridge, OpencodeConfig): + return _to_opencode_config(bridge) return OpenCodeConfig( url=os.environ.get(ENV_URL) or DEFAULT_URL, user=os.environ.get(ENV_USER) or DEFAULT_USER, password=os.environ.get(ENV_PASSWORD) or "", request_timeout_seconds=int( os.environ.get(ENV_TIMEOUT) or DEFAULT_REQUEST_TIMEOUT - ) + ), ) 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__/model_selection.spec.ts b/src/__tests__/model_selection.spec.ts new file mode 100644 index 0000000..5afcd54 --- /dev/null +++ b/src/__tests__/model_selection.spec.ts @@ -0,0 +1,99 @@ +/** + * Tests for the localStorage-backed model selection persistence helper. + * jsdom provides a real (per-test-class) localStorage. + */ +import { + clearModelSelection, + loadModelSelection, + saveModelSelection +} from '../components/model_selection'; + +describe('loadModelSelection', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('returns null when no entry exists', () => { + expect(loadModelSelection('foo.ipynb')).toBeNull(); + }); + + it('returns null for an empty notebookPath (defensive)', () => { + expect(loadModelSelection('')).toBeNull(); + }); + + it('returns the parsed entry when storage is valid', () => { + localStorage.setItem( + 'opencode_bridge:model-selection:foo.ipynb', + JSON.stringify({ providerId: 'anthropic', modelId: 'claude-sonnet-4-20250514' }) + ); + expect(loadModelSelection('foo.ipynb')).toEqual({ + providerId: 'anthropic', + modelId: 'claude-sonnet-4-20250514' + }); + }); + + it('returns null for malformed JSON (does not throw)', () => { + localStorage.setItem( + 'opencode_bridge:model-selection:foo.ipynb', + '{not json' + ); + expect(loadModelSelection('foo.ipynb')).toBeNull(); + }); + + it('returns null when the entry is missing required fields', () => { + localStorage.setItem( + 'opencode_bridge:model-selection:foo.ipynb', + JSON.stringify({ providerId: 'anthropic' }) + ); + expect(loadModelSelection('foo.ipynb')).toBeNull(); + }); + + it('keys entries independently per notebookPath', () => { + saveModelSelection('a.ipynb', { providerId: 'p1', modelId: 'm1' }); + saveModelSelection('b.ipynb', { providerId: 'p2', modelId: 'm2' }); + expect(loadModelSelection('a.ipynb')).toEqual({ + providerId: 'p1', + modelId: 'm1' + }); + expect(loadModelSelection('b.ipynb')).toEqual({ + providerId: 'p2', + modelId: 'm2' + }); + }); +}); + +describe('saveModelSelection + clearModelSelection', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('writes a JSON entry that can be re-read', () => { + saveModelSelection('foo.ipynb', { providerId: 'p1', modelId: 'm1' }); + expect(loadModelSelection('foo.ipynb')).toEqual({ + providerId: 'p1', + modelId: 'm1' + }); + }); + + it('overwrites an existing entry', () => { + saveModelSelection('foo.ipynb', { providerId: 'p1', modelId: 'm1' }); + saveModelSelection('foo.ipynb', { providerId: 'p2', modelId: 'm2' }); + expect(loadModelSelection('foo.ipynb')).toEqual({ + providerId: 'p2', + modelId: 'm2' + }); + }); + + it('clearModelSelection removes the entry', () => { + saveModelSelection('foo.ipynb', { providerId: 'p1', modelId: 'm1' }); + clearModelSelection('foo.ipynb'); + expect(loadModelSelection('foo.ipynb')).toBeNull(); + }); + + it('no-op for an empty notebookPath (defensive)', () => { + expect(() => + saveModelSelection('', { providerId: 'p1', modelId: 'm1' }) + ).not.toThrow(); + expect(() => clearModelSelection('')).not.toThrow(); + }); +}); diff --git a/src/__tests__/opencode_cell_actions.spec.ts b/src/__tests__/opencode_cell_actions.spec.ts index 9ac2e0c..6c139e9 100644 --- a/src/__tests__/opencode_cell_actions.spec.ts +++ b/src/__tests__/opencode_cell_actions.spec.ts @@ -103,13 +103,22 @@ beforeAll(() => { }); }); +beforeEach(() => { + // Each test starts with a clean selection store so persistence tests + // are deterministic. + localStorage.clear(); +}); + // Mock the network-touching API module so jsdom tests don't try to hit // a real notebook server (and to keep the refresh-history logic decoupled // from the network). The cell_actions tests focus on widget behavior. 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 +128,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 { @@ -306,6 +329,122 @@ describe('OpenCodeCellActions', () => { setOpenCodeProviders(null); }); + it('restores the user\'s previously-saved (provider, model) when still valid in the providers payload', () => { + // Seed localStorage with a prior selection that IS valid. + localStorage.setItem( + 'opencode_bridge:model-selection:foo.ipynb', + JSON.stringify({ providerId: 'openai', modelId: 'gpt-5' }) + ); + setOpenCodeProviders({ + providers: [ + { id: 'anthropic', models: { 'claude-sonnet-4-20250514': { id: 'x' } } }, + { id: 'openai', models: { 'gpt-5': { id: 'x' }, 'gpt-4': { id: 'x' } } } + ], + default: { anthropic: 'claude-sonnet-4-20250514' } + }); + const cell = makeFakeCell('x = 1', [], 'foo.ipynb'); + 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; + const providerSel = prompt.node.querySelector( + '.opencode-provider-select' + ) as HTMLSelectElement; + const modelSel = prompt.node.querySelector( + '.opencode-model-select' + ) as HTMLSelectElement; + // Stored selection was applied, NOT the default (first provider). + expect(providerSel.value).toBe('openai'); + expect(modelSel.value).toBe('gpt-5'); + setOpenCodeProviders(null); + }); + + it('falls back to default when the stored provider is no longer present', () => { + localStorage.setItem( + 'opencode_bridge:model-selection:foo.ipynb', + JSON.stringify({ providerId: 'removed-provider', modelId: 'm1' }) + ); + setOpenCodeProviders({ + providers: [ + { id: 'anthropic', models: { 'claude-sonnet-4-20250514': { id: 'x' } } } + ] + }); + const cell = makeFakeCell('x = 1', [], 'foo.ipynb'); + 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 providerSel = (actions as any)._prompt.node.querySelector( + '.opencode-provider-select' + ) as HTMLSelectElement; + // Provider is gone → fall back to first available. + expect(providerSel.value).toBe('anthropic'); + setOpenCodeProviders(null); + }); + + it('falls back to default model when the stored model is no longer present under the same provider', () => { + localStorage.setItem( + 'opencode_bridge:model-selection:foo.ipynb', + JSON.stringify({ providerId: 'openai', modelId: 'gpt-99-removed' }) + ); + setOpenCodeProviders({ + providers: [ + { id: 'openai', models: { 'gpt-5': { id: 'x' }, 'gpt-4': { id: 'x' } } } + ], + default: { openai: 'gpt-5' } + }); + const cell = makeFakeCell('x = 1', [], 'foo.ipynb'); + 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 modelSel = (actions as any)._prompt.node.querySelector( + '.opencode-model-select' + ) as HTMLSelectElement; + // Model is gone → fall back to default[openai] = gpt-5. + expect(modelSel.value).toBe('gpt-5'); + setOpenCodeProviders(null); + }); + + it('saves the new selection to localStorage when the model select changes', () => { + setOpenCodeProviders({ + providers: [ + { id: 'anthropic', models: { 'claude-sonnet-4-20250514': { id: 'x' } } }, + { id: 'openai', models: { 'gpt-5': { id: 'x' } } } + ] + }); + const cell = makeFakeCell('x = 1', [], 'foo.ipynb'); + 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; + const providerSel = prompt.node.querySelector( + '.opencode-provider-select' + ) as HTMLSelectElement; + const modelSel = prompt.node.querySelector( + '.opencode-model-select' + ) as HTMLSelectElement; + + // Switch provider to openai (whose model list includes gpt-5). + providerSel.value = 'openai'; + providerSel.dispatchEvent(new Event('change')); + // Now change the model select to gpt-5. + modelSel.value = 'gpt-5'; + modelSel.dispatchEvent(new Event('change')); + + const stored = JSON.parse( + localStorage.getItem('opencode_bridge:model-selection:foo.ipynb') || 'null' + ); + expect(stored).toEqual({ providerId: 'openai', modelId: 'gpt-5' }); + setOpenCodeProviders(null); + }); + it('falls back to the first model when default[provider] is not in models', () => { setOpenCodeProviders({ providers: [ @@ -395,58 +534,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 +952,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/model_selection.ts b/src/components/model_selection.ts
new file mode 100644
index 0000000..82f0912
--- /dev/null
+++ b/src/components/model_selection.ts
@@ -0,0 +1,82 @@
+/**
+ * Persistence of the user's (provider, model) selection in the inline
+ * prompt, keyed by notebook path. Stored in localStorage so the
+ * preference survives panel close/reopen and JupyterLab restart.
+ *
+ * The selection is ALWAYS validated against the current providers
+ * payload before being applied: if the stored provider (or the model
+ * under it) is no longer present, the caller falls back to its
+ * default-selection logic. We never silently apply a stale value that
+ * would result in an empty / disabled  boxes (provider + model), a textarea, send/cancel
- * buttons, and a scrollable history area that shows the current session's
- * messages (user + assistant). The model select's default for the chosen
- * provider is `default[providerID]` from the /config/providers response.
  *
- * v3-final: no settings — picks come from the OpenCode Serve providers
- * cache; history comes from /session-messages; cell source is NOT replaced
- * (the AI reply is rendered as markdown inside this history area).
+ * Renders two