Merge pull request 'Feat/async sse streaming' (#1) from feat/async-sse-streaming into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -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=<sid>`(v4 新增 — SSE 代理)
|
||||
|
||||
服务端长连接到 OpenCode Serve 的 `GET /global/event`,按 `session=<sid>` 过滤后以 `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 块用 `<details>` 可折叠,permission/question 提示独立块(响应需要 OpenCode Serve 提供对应 API;本扩展 v4 仅渲染,不实现 allow/answer 回调路由)。`session.idle` 触发流指针重置、关闭 SSE 订阅、重新启用输入框。System 事件(`server.*` / `workspace.*` / `lsp.*` / `mcp.*` / `pty.*` / `installation.*`)以日志行形式展示,不参与 session 过滤。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.<trait>`` 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")
|
||||
|
||||
+113
-11
@@ -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.<trait>`` CLI flag (e.g. ``--OpencodeConfig.url=...``)
|
||||
2. ``c.OpencodeConfig.<trait>`` 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.<trait>``
|
||||
and to ``jupyter_server_config.py`` as ``c.OpencodeConfig.<trait>``. 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
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
+187
-18
@@ -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/<perm_id> and /questions/<q_id>/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)
|
||||
|
||||
@@ -98,28 +98,30 @@ async def test_auth_absent_when_no_password(base_config: OpenCodeConfig) -> None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_sync_includes_model_when_provider_and_model_given(
|
||||
async def test_send_message_async_includes_model_when_provider_and_model_given(
|
||||
base_config: OpenCodeConfig,
|
||||
) -> None:
|
||||
"""send_message_async posts to /prompt_async with provider/model when both set."""
|
||||
client, mock = _make_client(base_config, [(200, {"done": True})])
|
||||
parts = [{"type": "text", "text": "hello"}]
|
||||
result = await client.send_message_sync("s1", parts, provider_id="p1", model_id="m1")
|
||||
assert result == {"done": True}
|
||||
result = await client.send_message_async("s1", parts, provider_id="p1", model_id="m1")
|
||||
assert result is None
|
||||
call = mock.calls[0]
|
||||
assert call["request"].url == "http://127.0.0.1:4096/session/s1/message"
|
||||
assert call["request"].url == "http://127.0.0.1:4096/session/s1/prompt_async"
|
||||
assert call["request"].method == "POST"
|
||||
body = json.loads(call["request"].body.decode("utf-8"))
|
||||
assert body["parts"] == parts
|
||||
assert body["model"] == {"providerID": "p1", "modelID": "m1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_sync_omits_model_when_provider_or_model_missing(
|
||||
async def test_send_message_async_omits_model_when_provider_or_model_missing(
|
||||
base_config: OpenCodeConfig,
|
||||
) -> None:
|
||||
client, mock = _make_client(base_config, [(200, {"done": True})])
|
||||
parts = [{"type": "text", "text": "hello"}]
|
||||
result = await client.send_message_sync("s1", parts)
|
||||
assert result == {"done": True}
|
||||
result = await client.send_message_async("s1", parts)
|
||||
assert result is None
|
||||
call = mock.calls[0]
|
||||
body = json.loads(call["request"].body.decode("utf-8"))
|
||||
assert body["parts"] == parts
|
||||
@@ -127,7 +129,7 @@ async def test_send_message_sync_omits_model_when_provider_or_model_missing(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_sync_includes_system_when_provided() -> None:
|
||||
async def test_send_message_async_includes_system_when_provided() -> None:
|
||||
"""system param is forwarded into the request body when not None."""
|
||||
config = OpenCodeConfig(
|
||||
url="http://x:1", user="u", password="", request_timeout_seconds=120
|
||||
@@ -136,7 +138,7 @@ async def test_send_message_sync_includes_system_when_provided() -> None:
|
||||
mock.responses.append((200, {"info": {}, "parts": []}))
|
||||
client = OpenCodeClient(config, http_client=mock)
|
||||
|
||||
await client.send_message_sync("sid", [{"type": "text", "text": "hi"}], system="be brief")
|
||||
await client.send_message_async("sid", [{"type": "text", "text": "hi"}], system="be brief")
|
||||
|
||||
assert len(mock.calls) == 1
|
||||
body = json.loads(mock.calls[0]["request"].body.decode("utf-8"))
|
||||
@@ -144,6 +146,83 @@ async def test_send_message_sync_includes_system_when_provided() -> None:
|
||||
assert body["parts"] == [{"type": "text", "text": "hi"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_global_events_yields_parsed_events(
|
||||
base_config: OpenCodeConfig,
|
||||
) -> None:
|
||||
"""stream_global_events yields one dict per `data: {...}` SSE block."""
|
||||
|
||||
class _StreamingMock(MockHTTPClient):
|
||||
async def fetch(self, request, **kwargs):
|
||||
self.calls.append({"request": request, "kwargs": kwargs})
|
||||
# Simulate OpenCode's /global/event: three events back-to-back.
|
||||
chunks = [
|
||||
b"data: {\"type\": \"session.next.text.delta\", \"properties\": "
|
||||
b"{\"sessionID\": \"s1\", \"delta\": \"hello \"}}\n\n",
|
||||
b"data: {\"type\": \"session.next.text.delta\", \"properties\": "
|
||||
b"{\"sessionID\": \"s1\", \"delta\": \"world\"}}\n\n",
|
||||
b"data: {\"type\": \"session.idle\", \"properties\": "
|
||||
b"{\"sessionID\": \"s1\"}}\n\n",
|
||||
]
|
||||
# streaming_callback now lives on the HTTPRequest (per tornado
|
||||
# API — fetch() rejects kwargs when an HTTPRequest is passed).
|
||||
cb = getattr(request, "streaming_callback", None)
|
||||
if cb is not None:
|
||||
for c in chunks:
|
||||
cb(c)
|
||||
# Empty body, code 200 to terminate normally.
|
||||
return tornado.httpclient.HTTPResponse(
|
||||
request=request,
|
||||
code=200,
|
||||
headers=tornado.httputil.HTTPHeaders(),
|
||||
buffer=BytesIO(b""),
|
||||
)
|
||||
|
||||
mock = _StreamingMock()
|
||||
client = OpenCodeClient(base_config, http_client=mock)
|
||||
|
||||
events = []
|
||||
async for ev in client.stream_global_events():
|
||||
events.append(ev)
|
||||
|
||||
assert len(events) == 3
|
||||
assert events[0]["type"] == "session.next.text.delta"
|
||||
assert events[0]["properties"]["delta"] == "hello "
|
||||
assert events[1]["properties"]["delta"] == "world"
|
||||
assert events[2]["type"] == "session.idle"
|
||||
# Stream used the SSE accept header and no timeout.
|
||||
call = mock.calls[0]
|
||||
assert call["kwargs"] == {"raise_error": False}
|
||||
assert call["request"].method == "GET"
|
||||
assert call["request"].url == "http://127.0.0.1:4096/global/event"
|
||||
assert call["request"].request_timeout == 0
|
||||
assert call["request"].headers.get("Accept") == "text/event-stream"
|
||||
assert call["request"].streaming_callback is not None
|
||||
|
||||
|
||||
def test_parse_sse_block_handles_concatenated_data_lines() -> None:
|
||||
"""Multi-line `data:` blocks are joined with \n (per SSE spec)."""
|
||||
parsed = OpenCodeClient._parse_sse_block(
|
||||
"data: {\"a\": 1,\ndata: \"b\": 2}"
|
||||
)
|
||||
assert parsed == {"a": 1, "b": 2}
|
||||
|
||||
|
||||
def test_parse_sse_block_ignores_non_data_fields() -> None:
|
||||
parsed = OpenCodeClient._parse_sse_block("event: ping\ndata: {\"k\": \"v\"}\n\n")
|
||||
assert parsed == {"k": "v"}
|
||||
|
||||
|
||||
def test_parse_sse_block_returns_none_on_empty() -> None:
|
||||
assert OpenCodeClient._parse_sse_block("") is None
|
||||
assert OpenCodeClient._parse_sse_block("event: ping\n\n") is None
|
||||
assert OpenCodeClient._parse_sse_block(": heartbeat\n\n") is None
|
||||
|
||||
|
||||
def test_parse_sse_block_returns_none_on_bad_json() -> None:
|
||||
assert OpenCodeClient._parse_sse_block("data: not-json\n\n") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_session_returns_bool_by_status(base_config: OpenCodeConfig) -> None:
|
||||
client, mock = _make_client(base_config, [(200, True), (404, False)])
|
||||
@@ -154,6 +233,39 @@ async def test_delete_session_returns_bool_by_status(base_config: OpenCodeConfig
|
||||
assert mock.calls[0]["request"].url == "http://127.0.0.1:4096/session/s1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_permission_posts_response(base_config: OpenCodeConfig) -> None:
|
||||
"""reply_permission forwards the response string to /session/:id/permissions/:permId."""
|
||||
client, mock = _make_client(base_config, [(200, True)])
|
||||
ok = await client.reply_permission("s1", "perm-1", "once")
|
||||
assert ok is True
|
||||
call = mock.calls[0]
|
||||
assert call["request"].url == "http://127.0.0.1:4096/session/s1/permissions/perm-1"
|
||||
assert call["request"].method == "POST"
|
||||
body = json.loads(call["request"].body.decode("utf-8"))
|
||||
assert body == {"response": "once"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_permission_returns_false_on_404(
|
||||
base_config: OpenCodeConfig,
|
||||
) -> None:
|
||||
client, mock = _make_client(base_config, [(404, False)])
|
||||
assert await client.reply_permission("s1", "perm-1", "reject") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_question_posts_answer(base_config: OpenCodeConfig) -> None:
|
||||
"""reply_question forwards the answer to /session/:id/question/:qid/reply."""
|
||||
client, mock = _make_client(base_config, [(200, True)])
|
||||
ok = await client.reply_question("s1", "q-7", "use 3.12")
|
||||
assert ok is True
|
||||
call = mock.calls[0]
|
||||
assert call["request"].url == "http://127.0.0.1:4096/session/s1/question/q-7/reply"
|
||||
body = json.loads(call["request"].body.decode("utf-8"))
|
||||
assert body == {"answer": "use 3.12"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_2xx_response_raises_with_body(base_config: OpenCodeConfig) -> None:
|
||||
client, mock = _make_client(base_config, [(500, {"error": "boom"})])
|
||||
|
||||
@@ -12,10 +12,6 @@ class FakeOpenCodeClient:
|
||||
self.session_id = "fake-session-123"
|
||||
self.health_response = {"healthy": True, "version": "0.1.0"}
|
||||
self.providers_response = {"providers": [{"id": "anthropic", "models": [{"id": "claude"}]}]}
|
||||
self.message_response = {
|
||||
"info": {"id": "msg-1"},
|
||||
"parts": [{"type": "text", "text": "def foo():\n return 42\n"}],
|
||||
}
|
||||
# Canned session-messages list response (list_session_messages).
|
||||
# Default: one user + one assistant message, mixed text parts.
|
||||
self.messages_response = [
|
||||
@@ -45,9 +41,13 @@ class FakeOpenCodeClient:
|
||||
self.calls.append(("create_session", title))
|
||||
return {"id": self.session_id, "title": title}
|
||||
|
||||
async def send_message_sync(self, session_id, parts, provider_id=None, model_id=None, system=None):
|
||||
self.calls.append(("send_message_sync", session_id, parts, system))
|
||||
return self.message_response
|
||||
async def send_message_async(
|
||||
self, session_id, parts, provider_id=None, model_id=None, system=None
|
||||
):
|
||||
self.calls.append(
|
||||
("send_message_async", session_id, parts, system, provider_id, model_id)
|
||||
)
|
||||
return None
|
||||
|
||||
async def delete_session(self, session_id):
|
||||
self.calls.append(("delete_session", session_id))
|
||||
@@ -57,6 +57,19 @@ class FakeOpenCodeClient:
|
||||
self.calls.append(("list_session_messages", session_id))
|
||||
return self.messages_response
|
||||
|
||||
async def reply_permission(self, session_id, permission_id, response):
|
||||
self.calls.append(("reply_permission", session_id, permission_id, response))
|
||||
return True
|
||||
|
||||
async def reply_question(self, session_id, question_id, answer):
|
||||
self.calls.append(("reply_question", session_id, question_id, answer))
|
||||
return True
|
||||
|
||||
async def stream_global_events(self):
|
||||
"""Async generator — tests can monkey-patch this to control the stream."""
|
||||
if False: # pragma: no cover - never executed, just makes this an asyncgen
|
||||
yield {}
|
||||
|
||||
|
||||
class FakeSessionManager:
|
||||
"""Drop-in replacement for SessionManager with recording."""
|
||||
@@ -131,6 +144,12 @@ async def test_providers_handler(monkeypatch, jp_fetch):
|
||||
|
||||
|
||||
async def test_edit_handler(monkeypatch, jp_fetch):
|
||||
"""POST /opencode-bridge/edit fires prompt_async and returns sessionId only.
|
||||
|
||||
The async path returns immediately with `{ok, sessionId, notebookPath}` —
|
||||
the actual LLM response is consumed via the /events SSE endpoint, not
|
||||
embedded in this response.
|
||||
"""
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
@@ -159,31 +178,68 @@ async def test_edit_handler(monkeypatch, jp_fetch):
|
||||
)
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload["ok"] is True
|
||||
# Server now returns the AI reply as raw markdown (```fences``` kept
|
||||
# so the frontend marked.parse renders code blocks).
|
||||
assert payload["markdown"] == "def foo():\n return 42"
|
||||
assert payload["sessionId"] == "fake-session-123"
|
||||
assert payload["notebookPath"] == "test.ipynb"
|
||||
assert payload == {
|
||||
"ok": True,
|
||||
"sessionId": "fake-session-123",
|
||||
"notebookPath": "test.ipynb",
|
||||
}
|
||||
# Async path: no `markdown` field. The LLM reply is consumed via SSE.
|
||||
assert "markdown" not in payload
|
||||
|
||||
# Session manager was used, not direct create/delete on client
|
||||
# Session manager was used (not direct create/delete on client).
|
||||
call_names = [c[0] for c in fake.calls]
|
||||
assert "create_session" not in call_names
|
||||
assert "delete_session" not in call_names
|
||||
assert "send_message_sync" in call_names
|
||||
assert "send_message_async" in call_names
|
||||
assert "send_message_sync" not in call_names # legacy sync path is gone
|
||||
|
||||
# send_message_sync received the session ID from the manager
|
||||
send_call = [c for c in fake.calls if c[0] == "send_message_sync"][0]
|
||||
# send_message_async received the session ID from the manager.
|
||||
send_call = [c for c in fake.calls if c[0] == "send_message_async"][0]
|
||||
assert send_call[1] == "fake-session-123"
|
||||
# system prompt was passed (v3+ allows markdown/fences in the reply)
|
||||
# system prompt was passed.
|
||||
assert "你是一个代码助手" in send_call[3]
|
||||
|
||||
# SessionManager.get_or_create was called with the notebook path
|
||||
# SessionManager.get_or_create was called with the notebook path.
|
||||
sm_call_names = [c[0] for c in fake_sm.calls]
|
||||
assert sm_call_names == ["get_or_create"]
|
||||
assert fake_sm.calls[0][1] == "test.ipynb"
|
||||
|
||||
|
||||
async def test_edit_handler_includes_model_when_provider_and_model_given(
|
||||
monkeypatch, jp_fetch
|
||||
):
|
||||
"""providerId/modelId are forwarded into the async send body when both set."""
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
monkeypatch.setattr(
|
||||
"opencode_bridge.routes.get_session_manager",
|
||||
lambda h: FakeSessionManager(),
|
||||
)
|
||||
|
||||
body = json.dumps({
|
||||
"prompt": "x",
|
||||
"context": {
|
||||
"notebookPath": "n.ipynb",
|
||||
"cellId": "c",
|
||||
"language": "python",
|
||||
"cellIndex": 0,
|
||||
"totalCells": 1,
|
||||
"source": "",
|
||||
"previousCode": None,
|
||||
"error": None,
|
||||
},
|
||||
"providerId": "anthropic",
|
||||
"modelId": "claude-sonnet-4-20250514",
|
||||
})
|
||||
response = await jp_fetch("opencode-bridge", "edit", method="POST", body=body)
|
||||
assert response.code == 200
|
||||
|
||||
send_call = [c for c in fake.calls if c[0] == "send_message_async"][0]
|
||||
# (send_message_async, sid, parts, system, provider_id, model_id)
|
||||
assert send_call[4] == "anthropic"
|
||||
assert send_call[5] == "claude-sonnet-4-20250514"
|
||||
|
||||
|
||||
async def test_session_list_handler(monkeypatch, jp_fetch):
|
||||
fake_sm = FakeSessionManager()
|
||||
fake_sm._sessions = {
|
||||
@@ -287,3 +343,119 @@ async def test_session_release_handler(monkeypatch, jp_fetch):
|
||||
assert payload["notebookPath"] == "foo.ipynb"
|
||||
assert payload["deleted"] is True
|
||||
assert ("release", "foo.ipynb") in fake_sm.calls
|
||||
|
||||
|
||||
async def test_permission_reply_forwards_response(monkeypatch, jp_fetch):
|
||||
"""POST /opencode-bridge/permissions/:permId?session=:sid forwards to client."""
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
response = await jp_fetch(
|
||||
"opencode-bridge", "permissions", "perm-42",
|
||||
method="POST",
|
||||
params={"session": "ses-1"},
|
||||
body=json.dumps({"response": "once"}),
|
||||
)
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload == {
|
||||
"ok": True,
|
||||
"permissionId": "perm-42",
|
||||
"response": "once",
|
||||
}
|
||||
assert ("reply_permission", "ses-1", "perm-42", "once") in fake.calls
|
||||
|
||||
|
||||
async def test_permission_reply_rejects_invalid_response(monkeypatch, jp_fetch):
|
||||
"""response must be one of once/always/reject — 400 otherwise."""
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
|
||||
await jp_fetch(
|
||||
"opencode-bridge", "permissions", "perm-1",
|
||||
method="POST",
|
||||
params={"session": "ses-1"},
|
||||
body=json.dumps({"response": "yes"}),
|
||||
)
|
||||
assert exc_info.value.code == 400
|
||||
assert "yes" not in fake.calls and fake.calls == []
|
||||
|
||||
|
||||
async def test_permission_reply_requires_session(monkeypatch, jp_fetch):
|
||||
"""Missing ?session=... is a 400."""
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
|
||||
await jp_fetch(
|
||||
"opencode-bridge", "permissions", "perm-1",
|
||||
method="POST",
|
||||
body=json.dumps({"response": "once"}),
|
||||
)
|
||||
assert exc_info.value.code == 400
|
||||
|
||||
|
||||
async def test_question_reply_forwards_answer(monkeypatch, jp_fetch):
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
response = await jp_fetch(
|
||||
"opencode-bridge", "questions", "q-7", "reply",
|
||||
method="POST",
|
||||
params={"session": "ses-2"},
|
||||
body=json.dumps({"answer": "use Python 3.12"}),
|
||||
)
|
||||
assert response.code == 200
|
||||
payload = json.loads(response.body)
|
||||
assert payload == {"ok": True, "questionId": "q-7"}
|
||||
assert ("reply_question", "ses-2", "q-7", "use Python 3.12") in fake.calls
|
||||
|
||||
|
||||
async def test_question_reply_rejects_empty_answer(monkeypatch, jp_fetch):
|
||||
fake = FakeOpenCodeClient()
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
|
||||
await jp_fetch(
|
||||
"opencode-bridge", "questions", "q-1", "reply",
|
||||
method="POST",
|
||||
params={"session": "ses-1"},
|
||||
body=json.dumps({"answer": " "}),
|
||||
)
|
||||
assert exc_info.value.code == 400
|
||||
assert fake.calls == []
|
||||
|
||||
|
||||
async def test_global_event_handler_forwards_all_events_unfiltered(
|
||||
monkeypatch, jp_fetch
|
||||
):
|
||||
"""Server-side SSE proxy must NOT filter by session — it forwards
|
||||
everything so the client can decide. (Previously a too-eager server
|
||||
filter silently dropped events the client would have accepted.)
|
||||
|
||||
We mock stream_global_events to push three events with different
|
||||
sessionID shapes (one matching, one not, one missing). The proxy
|
||||
should pass all three through as data: lines.
|
||||
"""
|
||||
fake = FakeOpenCodeClient()
|
||||
|
||||
async def fake_stream():
|
||||
yield {"type": "session.next.text.delta", "properties": {"sessionID": "ses-1", "delta": "A"}}
|
||||
yield {"type": "session.next.text.delta", "properties": {"sessionID": "ses-OTHER", "delta": "B"}}
|
||||
yield {"type": "server.connected", "properties": {}}
|
||||
|
||||
fake.stream_global_events = fake_stream
|
||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||
|
||||
# Use ?session=ses-1 to confirm the param is accepted but doesn't filter.
|
||||
response = await jp_fetch(
|
||||
"opencode-bridge", "events",
|
||||
params={"session": "ses-1"},
|
||||
)
|
||||
assert response.code == 200
|
||||
body = response.body.decode("utf-8")
|
||||
# All three events should appear in the body, regardless of sessionID.
|
||||
assert '"delta": "A"' in body
|
||||
assert '"delta": "B"' in body
|
||||
assert '"server.connected"' in body
|
||||
# The body should use the SSE format: each event as a `data:` line.
|
||||
assert body.count("data: ") == 3
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof Notification>;
|
||||
|
||||
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 <pre><code>. 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 <pre><code> 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 <pre>
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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=<sid> 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);
|
||||
});
|
||||
});
|
||||
|
||||
+249
-6
@@ -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<OpenCodeResponse> {
|
||||
): Promise<OpenCodeEditResponse> {
|
||||
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=<sid> 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<string, string> = {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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 <select>.
|
||||
*
|
||||
* Storage is best-effort: localStorage may be disabled (private mode),
|
||||
* the quota may be exhausted, or the value may be corrupt. All
|
||||
* failure modes return null / no-op rather than throw.
|
||||
*/
|
||||
const STORAGE_PREFIX = 'opencode_bridge:model-selection:';
|
||||
|
||||
export interface ModelSelection {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
export function loadModelSelection(notebookPath: string): ModelSelection | null {
|
||||
if (!notebookPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const raw = localStorage.getItem(STORAGE_PREFIX + notebookPath);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
typeof (parsed as ModelSelection).providerId === 'string' &&
|
||||
typeof (parsed as ModelSelection).modelId === 'string'
|
||||
) {
|
||||
return {
|
||||
providerId: (parsed as ModelSelection).providerId,
|
||||
modelId: (parsed as ModelSelection).modelId
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveModelSelection(
|
||||
notebookPath: string,
|
||||
sel: ModelSelection
|
||||
): void {
|
||||
if (!notebookPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(STORAGE_PREFIX + notebookPath, JSON.stringify(sel));
|
||||
} catch {
|
||||
// Ignore quota exceeded / disabled storage.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearModelSelection(notebookPath: string): void {
|
||||
if (!notebookPath) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
localStorage.removeItem(STORAGE_PREFIX + notebookPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
/**
|
||||
* Per-cell AI action button rendered inside JupyterLab's native Cell toolbar
|
||||
* (top-right of the active cell). v2: a single icon button. Clicking it
|
||||
* toggles an OpenCodeInlinePrompt panel attached to the cell's DOM.
|
||||
* (top-right of the active cell). Clicking it toggles an
|
||||
* OpenCodeInlinePrompt panel attached to the cell's DOM.
|
||||
*
|
||||
* Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...).
|
||||
*
|
||||
* v3-final: the frontend does NO semantic processing. It gathers the cell's
|
||||
* source / outputs / error, attaches the user's freeform instruction, and
|
||||
* POSTs to the server. The chosen provider/model comes from the inline
|
||||
* picker (no settings-based default).
|
||||
* v4 message flow (matches demo.html):
|
||||
* 1. User clicks 🪄 -> panel appears, shows session history (setMessages)
|
||||
* 2. User submits prompt -> callOpenCodeEdit returns immediately with
|
||||
* sessionId
|
||||
* 3. We subscribe to GET /opencode-bridge/events?session=<sid> and
|
||||
* forward each event to the prompt's applyEvent() dispatcher
|
||||
* 4. On session.idle we close the stream and re-enable the input
|
||||
*
|
||||
* The frontend does NO semantic processing of the LLM reply. The chosen
|
||||
* provider/model comes from the inline picker (no settings-based default).
|
||||
*/
|
||||
import type { CodeCell } from '@jupyterlab/cells';
|
||||
import { Notification } from '@jupyterlab/apputils';
|
||||
@@ -16,13 +22,18 @@ import type { NotebookPanel } from '@jupyterlab/notebook';
|
||||
import { ServerConnection } from '@jupyterlab/services';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
|
||||
import { callOpenCodeEdit, callOpenCodeSessionMessages } from '../api/opencode_client';
|
||||
import {
|
||||
callOpenCodeEdit,
|
||||
callOpenCodeReplyPermission,
|
||||
callOpenCodeReplyQuestion,
|
||||
callOpenCodeSessionMessages,
|
||||
subscribeOpenCodeEvents,
|
||||
type OpenCodeEventSubscription
|
||||
} from '../api/opencode_client';
|
||||
import { extractCellContext } from '../context/cell_context';
|
||||
import type {
|
||||
CellContext,
|
||||
OpenCodeProvidersResponse,
|
||||
OpenCodeRequest,
|
||||
OpenCodeResponse
|
||||
OpenCodeProvidersResponse
|
||||
} from '../types';
|
||||
|
||||
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
|
||||
@@ -50,6 +61,7 @@ export class OpenCodeCellActions extends Widget {
|
||||
private _context: CellContext | null = null;
|
||||
private _status: Status = 'idle';
|
||||
private _prompt: OpenCodeInlinePrompt | null = null;
|
||||
private _sseSub: OpenCodeEventSubscription | null = null;
|
||||
|
||||
constructor(cell: CodeCell) {
|
||||
super();
|
||||
@@ -65,6 +77,7 @@ export class OpenCodeCellActions extends Widget {
|
||||
if (this.isDisposed) {
|
||||
return;
|
||||
}
|
||||
this._closeSse();
|
||||
this._cell.model.contentChanged.disconnect(this._onModelChange, this);
|
||||
this._cell.model.stateChanged.disconnect(this._onModelChange, this);
|
||||
this._hidePrompt();
|
||||
@@ -114,19 +127,67 @@ export class OpenCodeCellActions extends Widget {
|
||||
const prompt = new OpenCodeInlinePrompt(this._cell, {
|
||||
disabled: !this._context || this._status === 'loading',
|
||||
providers: _providers,
|
||||
notebookPath: this._context?.notebookPath,
|
||||
onSubmit: (text: string, providerId?: string, modelId?: string) => {
|
||||
void this._onSubmit(text, providerId, modelId);
|
||||
},
|
||||
onCancel: () => {
|
||||
this._hidePrompt();
|
||||
},
|
||||
onPermissionReply: async (permId, response) => {
|
||||
if (!_serverSettings) {
|
||||
return false;
|
||||
}
|
||||
const sessionId = (this._prompt as any)._sessionId as string | null;
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const r = await callOpenCodeReplyPermission(
|
||||
sessionId,
|
||||
permId,
|
||||
response,
|
||||
_serverSettings
|
||||
);
|
||||
return r.ok;
|
||||
} catch (e) {
|
||||
console.warn('opencode_bridge: permission reply failed', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onQuestionReply: async (qId, answer) => {
|
||||
if (!_serverSettings) {
|
||||
return false;
|
||||
}
|
||||
const sessionId = (this._prompt as any)._sessionId as string | null;
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const r = await callOpenCodeReplyQuestion(
|
||||
sessionId,
|
||||
qId,
|
||||
answer,
|
||||
_serverSettings
|
||||
);
|
||||
return r.ok;
|
||||
} catch (e) {
|
||||
console.warn('opencode_bridge: question reply failed', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onStreamEnd: () => {
|
||||
// Centralized: the prompt detected "idle" (any shape) and tells
|
||||
// us to close the SSE stream + restore the input. We no longer
|
||||
// parse event types here.
|
||||
this._closeSse();
|
||||
}
|
||||
});
|
||||
Widget.attach(prompt, this._cell.node);
|
||||
this._prompt = prompt;
|
||||
|
||||
// Fetch the current session's message history and render it into
|
||||
// the prompt's scrollable history area. Empty list (no session yet)
|
||||
// is a normal no-op render.
|
||||
// Fetch the current session's static history and render it before
|
||||
// streaming begins. Empty list is a normal no-op.
|
||||
const notebookPath = this._context?.notebookPath;
|
||||
if (notebookPath) {
|
||||
void this._refreshHistory(notebookPath);
|
||||
@@ -144,8 +205,6 @@ export class OpenCodeCellActions extends Widget {
|
||||
);
|
||||
this._prompt.setMessages(resp.messages);
|
||||
} catch (e) {
|
||||
// Non-fatal: the history is a convenience. The user can still
|
||||
// send new prompts; just the scrollback won't update.
|
||||
console.warn('opencode_bridge: failed to fetch session messages', e);
|
||||
}
|
||||
}
|
||||
@@ -170,7 +229,7 @@ export class OpenCodeCellActions extends Widget {
|
||||
return;
|
||||
}
|
||||
|
||||
const request: OpenCodeRequest = {
|
||||
const request = {
|
||||
prompt: text,
|
||||
context: this._context,
|
||||
providerId,
|
||||
@@ -180,41 +239,55 @@ export class OpenCodeCellActions extends Widget {
|
||||
this._status = 'loading';
|
||||
if (this._prompt) {
|
||||
this._prompt.setDisabled(true);
|
||||
this._prompt.setSessionId(null);
|
||||
}
|
||||
|
||||
let resp;
|
||||
try {
|
||||
const resp = await callOpenCodeEdit(request, _serverSettings);
|
||||
this._handleResponse(resp);
|
||||
resp = await callOpenCodeEdit(request, _serverSettings);
|
||||
} catch (e) {
|
||||
this._status = 'idle';
|
||||
if (this._prompt) {
|
||||
this._prompt.setDisabled(false);
|
||||
}
|
||||
this._prompt?.setDisabled(false);
|
||||
Notification.error(`OpenCode 失败: ${(e as Error).message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
this._status = 'idle';
|
||||
this._prompt?.setDisabled(false);
|
||||
Notification.error(`OpenCode 错误: ${resp.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Async edit accepted. Bind the prompt to the returned session, clear
|
||||
// the input, then subscribe to the SSE event stream for this session.
|
||||
this._prompt?.setSessionId(resp.sessionId);
|
||||
this._prompt?.clearInput();
|
||||
this._sseSub = subscribeOpenCodeEvents(
|
||||
resp.sessionId,
|
||||
_serverSettings,
|
||||
{
|
||||
onEvent: ev => {
|
||||
this._prompt?.applyEvent(ev);
|
||||
// Stream-end is detected by the prompt and signalled via
|
||||
// onStreamEnd (see _showPrompt); no idle parsing here.
|
||||
},
|
||||
onError: err => {
|
||||
console.warn('opencode_bridge: SSE error', err);
|
||||
// Don't flip status here: the server may just have hiccuped.
|
||||
// session.idle (if it comes through) will re-enable the input.
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _handleResponse(resp: OpenCodeResponse): void {
|
||||
this._status = 'idle';
|
||||
if (resp.ok) {
|
||||
// Refetch the (now-updated) session history and render the new
|
||||
// assistant message as the last item in the scrollable history.
|
||||
// The cell source is NOT replaced.
|
||||
const notebookPath = this._context?.notebookPath;
|
||||
if (notebookPath) {
|
||||
void this._refreshHistory(notebookPath);
|
||||
}
|
||||
// Clear the input so the user can type a follow-up message.
|
||||
this._prompt?.clearInput();
|
||||
Notification.info(
|
||||
`OpenCode 完成 (${resp.markdown.length} chars). Session: ${resp.sessionId.slice(0, 8)}…`
|
||||
);
|
||||
} else {
|
||||
if (this._prompt) {
|
||||
this._prompt.setDisabled(false);
|
||||
}
|
||||
Notification.error(`OpenCode 错误: ${resp.error}`);
|
||||
private _closeSse(): void {
|
||||
if (this._sseSub) {
|
||||
this._sseSub.close();
|
||||
this._sseSub = null;
|
||||
}
|
||||
this._status = 'idle';
|
||||
this._prompt?.setDisabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,70 @@
|
||||
/**
|
||||
* Inline prompt widget attached to a cell's DOM when the AI button is clicked.
|
||||
* Renders two <select> 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 <select> boxes (provider + model), a textarea, send/cancel
|
||||
* buttons, and a scrollable history area. The history area serves two modes:
|
||||
*
|
||||
* 1. **Static** — `setMessages(messages)` renders the session's stored
|
||||
* history (user/assistant markdown) when the panel is first opened.
|
||||
* 2. **Streaming** — after the user submits a prompt, the owning cell
|
||||
* action subscribes to `/opencode-bridge/events` and forwards every
|
||||
* `OpenCodeEvent` to `applyEvent(event)`, which dispatches to the
|
||||
* appropriate DOM helper (text deltas, reasoning blocks, tool blocks,
|
||||
* permission/question prompts, system lines). On `session.idle` the
|
||||
* stream pointers reset.
|
||||
*
|
||||
* The model select's default for the chosen provider is `default[providerID]`
|
||||
* from the /config/providers response.
|
||||
*
|
||||
* v4: cell source is NOT replaced. The AI reply is rendered into this
|
||||
* history area. The user can still Copy / Insert / Replace individual
|
||||
* code blocks from a finished assistant message.
|
||||
*/
|
||||
import { CodeCell } from '@jupyterlab/cells';
|
||||
import { Notification } from '@jupyterlab/apputils';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import type { OpenCodeMessage, OpenCodeProvidersResponse } from '../types';
|
||||
import type {
|
||||
OpenCodeEvent,
|
||||
OpenCodeMessage,
|
||||
OpenCodeProvidersResponse
|
||||
} from '../types';
|
||||
import {
|
||||
loadModelSelection,
|
||||
saveModelSelection
|
||||
} from './model_selection';
|
||||
|
||||
export interface IOpenCodeInlinePromptOptions {
|
||||
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
|
||||
onCancel: () => void;
|
||||
disabled: boolean;
|
||||
providers: OpenCodeProvidersResponse | null;
|
||||
/**
|
||||
* Notebook path used to key the persisted (provider, model) selection
|
||||
* in localStorage. If omitted (or the stored selection is no longer
|
||||
* valid in the current `providers` payload), the prompt falls back
|
||||
* to the default selection (first provider / `default[pid]` model).
|
||||
*/
|
||||
notebookPath?: string;
|
||||
/**
|
||||
* Respond to a `permission.asked` event. Returns a promise that resolves
|
||||
* to true on success, false on failure (the prompt updates the UI in
|
||||
* both cases).
|
||||
*/
|
||||
onPermissionReply?: (
|
||||
permissionId: string,
|
||||
response: 'once' | 'always' | 'reject'
|
||||
) => Promise<boolean>;
|
||||
/** Reply to a `question.asked` event with the user's freeform answer. */
|
||||
onQuestionReply?: (questionId: string, answer: string) => Promise<boolean>;
|
||||
/**
|
||||
* Fired when the prompt detects the agent has finished its turn
|
||||
* (any of: `session.idle`, `session.status` with `status.type === 'idle'`,
|
||||
* or the same shapes nested under `payload`). Centralizing this in
|
||||
* the prompt keeps the cell action from re-parsing event types.
|
||||
*/
|
||||
onStreamEnd?: () => void;
|
||||
}
|
||||
|
||||
interface FlatProvider {
|
||||
@@ -65,8 +109,14 @@ function pickDefaultModelId(
|
||||
return keys[0];
|
||||
}
|
||||
|
||||
interface BlockEntry {
|
||||
details: HTMLDetailsElement;
|
||||
content: HTMLDivElement;
|
||||
}
|
||||
|
||||
export class OpenCodeInlinePrompt extends Widget {
|
||||
private _cell: CodeCell;
|
||||
private _options: IOpenCodeInlinePromptOptions;
|
||||
private _textarea: HTMLTextAreaElement;
|
||||
private _sendBtn: HTMLButtonElement;
|
||||
private _cancelBtn: HTMLButtonElement;
|
||||
@@ -76,18 +126,40 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
private _defaultMap: { [pid: string]: string };
|
||||
private _historyEl: HTMLDivElement;
|
||||
|
||||
// Streaming state. _sessionId is set by the cell action after a successful
|
||||
// /edit response; applyEvent() drops events that don't belong to it.
|
||||
private _sessionId: string | null = null;
|
||||
// The current assistant message DOM node (re-rendered as markdown on
|
||||
// every text delta so the user sees a properly formatted response in
|
||||
// real-time, not raw ```fence``` source).
|
||||
private _currentAssistantEl: HTMLDivElement | null = null;
|
||||
// Accumulator for the current assistant turn's raw markdown source.
|
||||
// Each text delta appends here; the whole string is then re-rendered
|
||||
// through marked.parse to refresh the DOM. Using the raw source as
|
||||
// truth (not innerHTML) avoids lossy round-trips through partial HTML.
|
||||
private _currentAssistantText: string = '';
|
||||
// Active collapsible blocks (reasoning / tool / etc.), keyed by type.
|
||||
private _activeBlocks: { [key: string]: BlockEntry } = {};
|
||||
|
||||
constructor(
|
||||
cell: CodeCell,
|
||||
options: IOpenCodeInlinePromptOptions
|
||||
) {
|
||||
super();
|
||||
this._cell = cell;
|
||||
this._options = options;
|
||||
this.addClass('opencode-inline-prompt');
|
||||
|
||||
const flat = flattenProviders(options.providers);
|
||||
this._providerModels = flat.providers;
|
||||
this._defaultMap = flat.defaultMap;
|
||||
|
||||
// Resolve the initial selection: prefer a previously-saved
|
||||
// (providerId, modelId) IF it's still valid in the current
|
||||
// providers payload; otherwise fall back to the default
|
||||
// (first provider / `default[pid]` model).
|
||||
const stored = this._resolveStoredSelection(options.notebookPath);
|
||||
|
||||
if (this._providerModels.length > 0) {
|
||||
this._providerSelect = document.createElement('select');
|
||||
this._providerSelect.className = 'opencode-provider-select';
|
||||
@@ -97,16 +169,25 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
o.textContent = p.name ? `${p.id} (${p.name})` : p.id;
|
||||
this._providerSelect.appendChild(o);
|
||||
}
|
||||
this._providerSelect.value = this._providerModels[0].id;
|
||||
this._providerSelect.value = stored
|
||||
? stored.providerId
|
||||
: this._providerModels[0].id;
|
||||
this._providerSelect.addEventListener('change', () => {
|
||||
this._rebuildModelSelect(this._providerSelect!.value);
|
||||
this._persistSelection();
|
||||
});
|
||||
}
|
||||
|
||||
if (this._providerModels.length > 0) {
|
||||
this._modelSelect = document.createElement('select');
|
||||
this._modelSelect.className = 'opencode-model-select';
|
||||
this._rebuildModelSelect(this._providerSelect?.value);
|
||||
this._rebuildModelSelect(
|
||||
this._providerSelect?.value,
|
||||
stored ? stored.modelId : undefined
|
||||
);
|
||||
this._modelSelect.addEventListener('change', () => {
|
||||
this._persistSelection();
|
||||
});
|
||||
}
|
||||
|
||||
this._textarea = document.createElement('textarea');
|
||||
@@ -125,8 +206,6 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
const modelId = this._modelSelect?.value || undefined;
|
||||
options.onSubmit(text, providerId, modelId);
|
||||
});
|
||||
// Chat-input convention: Enter submits, Shift+Enter / Ctrl+Enter
|
||||
// insert a newline (textarea default).
|
||||
this._textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
@@ -167,8 +246,8 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
this.node.appendChild(row);
|
||||
}
|
||||
|
||||
// Scrollable history of the current session's messages. Empty until
|
||||
// setMessages() is called by the owning cell action.
|
||||
// Scrollable history (static + streaming). Empty until setMessages() /
|
||||
// applyEvent() populate it.
|
||||
this._historyEl = document.createElement('div');
|
||||
this._historyEl.className = 'opencode-inline-history';
|
||||
this.node.appendChild(this._historyEl);
|
||||
@@ -177,7 +256,10 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
this.node.appendChild(actions);
|
||||
}
|
||||
|
||||
private _rebuildModelSelect(providerId: string | undefined): void {
|
||||
private _rebuildModelSelect(
|
||||
providerId: string | undefined,
|
||||
preferredModelId?: string
|
||||
): void {
|
||||
if (!this._modelSelect) {
|
||||
return;
|
||||
}
|
||||
@@ -205,8 +287,65 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
this._modelSelect.appendChild(o);
|
||||
}
|
||||
this._modelSelect.disabled = false;
|
||||
this._modelSelect.value =
|
||||
pickDefaultModelId(providerId, provider.models, this._defaultMap) ?? keys[0];
|
||||
// Honor a preferred model (e.g. from a stored selection), but only
|
||||
// if it actually exists in this provider's model list. Otherwise
|
||||
// fall back to the default.
|
||||
if (
|
||||
preferredModelId &&
|
||||
Object.prototype.hasOwnProperty.call(provider.models, preferredModelId)
|
||||
) {
|
||||
this._modelSelect.value = preferredModelId;
|
||||
} else {
|
||||
this._modelSelect.value =
|
||||
pickDefaultModelId(providerId, provider.models, this._defaultMap) ??
|
||||
keys[0];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the stored (providerId, modelId) for this notebook and
|
||||
* validate it against the current providers payload. Returns the
|
||||
* stored selection only when BOTH the provider and the model still
|
||||
* exist; otherwise returns null and the caller uses the default.
|
||||
*/
|
||||
private _resolveStoredSelection(
|
||||
notebookPath: string | undefined
|
||||
): { providerId: string; modelId: string } | null {
|
||||
if (!notebookPath) {
|
||||
return null;
|
||||
}
|
||||
const sel = loadModelSelection(notebookPath);
|
||||
if (!sel) {
|
||||
return null;
|
||||
}
|
||||
const provider = this._providerModels.find(p => p.id === sel.providerId);
|
||||
if (!provider) {
|
||||
// The provider was removed since the user last picked.
|
||||
return null;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(provider.models, sel.modelId)) {
|
||||
// The model was removed (or renamed) under a still-valid provider.
|
||||
return null;
|
||||
}
|
||||
return sel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the current (providerSelect.value, modelSelect.value) for
|
||||
* this notebook. No-op if the prompt has no notebookPath, no
|
||||
* selects, or the values are empty.
|
||||
*/
|
||||
private _persistSelection(): void {
|
||||
const notebookPath = this._options.notebookPath;
|
||||
if (!notebookPath || !this._providerSelect || !this._modelSelect) {
|
||||
return;
|
||||
}
|
||||
const providerId = this._providerSelect.value;
|
||||
const modelId = this._modelSelect.value;
|
||||
if (!providerId || !modelId) {
|
||||
return;
|
||||
}
|
||||
saveModelSelection(notebookPath, { providerId, modelId });
|
||||
}
|
||||
|
||||
setDisabled(disabled: boolean): void {
|
||||
@@ -219,20 +358,27 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the user input textarea (called by the cell action after a
|
||||
* successful response, so the user can type a follow-up message
|
||||
* without manually deleting the previous prompt).
|
||||
*/
|
||||
/** Clear the user input textarea. */
|
||||
clearInput(): void {
|
||||
this._textarea.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current session's messages into the scrollable history
|
||||
* area. User messages are plain text; assistant messages are rendered
|
||||
* as markdown via marked. Auto-scrolls to the bottom so the most
|
||||
* recent message is visible.
|
||||
* Bind the prompt to a specific OpenCode session id. Events arriving via
|
||||
* `applyEvent` for a different session are dropped. Pass null to clear.
|
||||
*/
|
||||
setSessionId(sessionId: string | null): void {
|
||||
this._sessionId = sessionId;
|
||||
if (sessionId) {
|
||||
// Fresh turn: drop any in-progress stream DOM from a previous session.
|
||||
this._resetStreamPointers();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current session's static messages (loaded once from
|
||||
* /session-messages when the panel opens). Subsequent assistant turns
|
||||
* during the same session are appended live via applyEvent().
|
||||
*/
|
||||
setMessages(messages: OpenCodeMessage[]): void {
|
||||
this._historyEl.textContent = '';
|
||||
@@ -242,74 +388,501 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
if (msg.role === 'user') {
|
||||
wrap.textContent = msg.content;
|
||||
} else {
|
||||
// Assistant: render the markdown, then for every fenced code
|
||||
// block (<pre>) attach a small toolbar with Copy / Insert /
|
||||
// Replace buttons that act on THAT block's code (not the whole
|
||||
// message). Messages without code fences get no toolbar.
|
||||
const rendered = document.createElement('div');
|
||||
rendered.className = 'opencode-msg-content';
|
||||
rendered.innerHTML = marked.parse(msg.content) as string;
|
||||
|
||||
const codeBlocks = rendered.querySelectorAll('pre');
|
||||
codeBlocks.forEach(pre => {
|
||||
const code = pre.querySelector('code');
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
// Snapshot the code text before we mutate the DOM so the
|
||||
// buttons act on the exact code inside this block (no
|
||||
// surrounding ```fences```, no toolbar label text).
|
||||
const codeText = code.textContent ?? '';
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'opencode-code-block';
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'opencode-code-toolbar';
|
||||
const mkBtn = (
|
||||
label: string,
|
||||
title: string,
|
||||
onClick: () => void
|
||||
) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'opencode-code-btn';
|
||||
b.textContent = label;
|
||||
b.title = title;
|
||||
b.addEventListener('click', onClick);
|
||||
return b;
|
||||
};
|
||||
toolbar.appendChild(
|
||||
mkBtn('📋 复制', '复制代码', () => {
|
||||
void this._copyToClipboard(codeText);
|
||||
})
|
||||
);
|
||||
toolbar.appendChild(
|
||||
mkBtn('↪ 插入', '在光标位置插入', () => {
|
||||
this._insertAtCursor(codeText);
|
||||
})
|
||||
);
|
||||
toolbar.appendChild(
|
||||
mkBtn('⟳ 替换', '替换单元格内容', () => {
|
||||
this._replaceCell(codeText);
|
||||
})
|
||||
);
|
||||
wrap.appendChild(toolbar);
|
||||
// Move the original <pre> into the wrapper.
|
||||
pre.parentNode!.insertBefore(wrap, pre);
|
||||
wrap.appendChild(pre);
|
||||
});
|
||||
|
||||
wrap.appendChild(rendered);
|
||||
this._renderAssistantMarkdown(wrap, msg.content);
|
||||
}
|
||||
this._historyEl.appendChild(wrap);
|
||||
}
|
||||
this._historyEl.scrollTop = this._historyEl.scrollHeight;
|
||||
this._scrollToBottom();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the given text to the clipboard. Prefers navigator.clipboard
|
||||
* (available in secure contexts incl. JupyterLab). Falls back to a
|
||||
* hidden textarea + execCommand for older / non-secure contexts.
|
||||
* Apply one OpenCode SSE event to the streaming UI. Mirrors
|
||||
* demo.html's handleGlobalEvent / processSessionEvent dispatch.
|
||||
*/
|
||||
applyEvent(event: OpenCodeEvent): void {
|
||||
const type = event.type || (event.payload && event.payload.type) || '';
|
||||
const props =
|
||||
event.properties ||
|
||||
(event.payload && event.payload.properties) ||
|
||||
{};
|
||||
const sessionID =
|
||||
props.sessionID ||
|
||||
props.sessionId ||
|
||||
(event.syncEvent && event.syncEvent.aggregateID) ||
|
||||
(event.payload &&
|
||||
event.payload.syncEvent &&
|
||||
event.payload.syncEvent.aggregateID);
|
||||
|
||||
// Drop events for other sessions (defensive; matches demo.html
|
||||
// handleGlobalEvent). Only drop when the event has an explicit
|
||||
// sessionID — events without one (e.g. system events) are still
|
||||
// processed and may be dropped by _isSystemEvent below.
|
||||
if (sessionID && this._sessionId && sessionID !== this._sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// System / workspace / pty / etc. events are not displayed.
|
||||
if (this._isSystemEvent(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For text/reasoning/tool/idle/permission/question we expect to match.
|
||||
// If none of the branches below fire, log a warning so the missing
|
||||
// event type can be diagnosed in DevTools.
|
||||
const handled = this._dispatchContentEvent(event, type, props);
|
||||
if (!handled) {
|
||||
console.warn(
|
||||
'opencode_bridge: unrecognized SSE event type (dropped):',
|
||||
type,
|
||||
event
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _dispatchContentEvent(
|
||||
event: OpenCodeEvent,
|
||||
type: string,
|
||||
props: { [k: string]: any }
|
||||
): boolean {
|
||||
|
||||
if (type === 'permission.asked') {
|
||||
const permId = String(props.id || props.permissionID || '');
|
||||
this._createPermissionBlock(
|
||||
permId,
|
||||
String(
|
||||
props.description || props.title || '系统请求执行权限(命令行/文件修改)'
|
||||
)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (type === 'question.asked') {
|
||||
const qId = String(props.id || props.questionID || '');
|
||||
this._createQuestionBlock(
|
||||
qId,
|
||||
String(props.question || props.title || 'Agent 正在等待确认…')
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
type === 'message.part.delta' &&
|
||||
props.field === 'text' &&
|
||||
typeof props.delta === 'string'
|
||||
) {
|
||||
this._appendToAssistantText(props.delta);
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
(type === 'message.part.delta' && props.field === 'reasoning') ||
|
||||
type === 'session.next.reasoning.delta'
|
||||
) {
|
||||
this._appendToBlock(
|
||||
'reasoning',
|
||||
'🧠 思考与推理过程',
|
||||
String(props.delta || props.text || '')
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (type === 'session.next.text.delta' && typeof props.delta === 'string') {
|
||||
this._appendToAssistantText(props.delta);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (type === 'session.next.reasoning.started') {
|
||||
this._getOrCreateBlock('reasoning', '🧠 思考与推理过程');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
type === 'session.next.tool.input.started' ||
|
||||
type === 'session.next.tool.called'
|
||||
) {
|
||||
const toolName = String(props.name || props.tool || '未知工具');
|
||||
this._getOrCreateBlock('tool', `🛠️ 调用工具: ${toolName}`);
|
||||
return true;
|
||||
}
|
||||
if (type === 'session.next.tool.input.delta') {
|
||||
this._appendToBlock('tool', '🛠️ 工具输入与参数', String(props.delta || ''));
|
||||
return true;
|
||||
}
|
||||
if (type === 'session.next.tool.progress') {
|
||||
this._appendToBlock(
|
||||
'tool',
|
||||
'🛠️ 工具执行中…',
|
||||
`\n[Progress]: ${String(props.message || '')}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (type === 'session.next.tool.success') {
|
||||
this._appendToBlock(
|
||||
'tool',
|
||||
'🛠️ 工具执行完毕',
|
||||
`\n[执行成功结果]: ${JSON.stringify(props.result || {}, null, 2)}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (type === 'session.next.tool.failed') {
|
||||
this._appendToBlock(
|
||||
'tool',
|
||||
'🛠️ 工具执行失败',
|
||||
`\n[执行失败异常]: ${String(props.error || 'Unknown')}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._isIdleEvent(event, type, props)) {
|
||||
this._resetStreamPointers();
|
||||
this._options.onStreamEnd?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect "agent is now idle" across the multiple shapes OpenCode has
|
||||
* emitted across versions: top-level `session.idle`, top-level
|
||||
* `session.status` with `status.type === 'idle'`, and the same shapes
|
||||
* nested under `payload`.
|
||||
*/
|
||||
private _isIdleEvent(
|
||||
event: OpenCodeEvent,
|
||||
topType: string,
|
||||
topProps: { [k: string]: any }
|
||||
): boolean {
|
||||
const check = (t: string, p: { [k: string]: any }): boolean => {
|
||||
if (t === 'session.idle') {
|
||||
return true;
|
||||
}
|
||||
if (t === 'session.status' && p && p.status && p.status.type === 'idle') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (check(topType, topProps)) {
|
||||
return true;
|
||||
}
|
||||
const payload = event.payload;
|
||||
if (payload) {
|
||||
return check(
|
||||
payload.type || topType,
|
||||
payload.properties || topProps
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------- Private streaming helpers ----------
|
||||
|
||||
private _isSystemEvent(type: string): boolean {
|
||||
return (
|
||||
type.startsWith('server.') ||
|
||||
type.startsWith('workspace.') ||
|
||||
type.startsWith('worktree.') ||
|
||||
type.startsWith('lsp.') ||
|
||||
type.startsWith('mcp.') ||
|
||||
type.startsWith('pty.') ||
|
||||
type.startsWith('installation.') ||
|
||||
type === 'global.disposed' ||
|
||||
// Session-level control events are not part of the assistant reply
|
||||
// and would be confusing as separate lines in the history.
|
||||
type === 'session.next.agent.switched' ||
|
||||
type === 'session.next.model.switched' ||
|
||||
type === 'file.edited'
|
||||
);
|
||||
}
|
||||
|
||||
private _appendToAssistantText(text: string): void {
|
||||
if (!this._currentAssistantEl) {
|
||||
this._currentAssistantEl = this._createMessageElement('assistant', '');
|
||||
}
|
||||
// Accumulate the raw markdown source, then re-render the whole
|
||||
// message through marked.parse. This way the user sees properly
|
||||
// formatted output (code blocks as <pre><code>, headings, lists)
|
||||
// in real-time as text deltas arrive — NOT the raw ```fence```
|
||||
// source that an append-only .textContent would show.
|
||||
this._currentAssistantText += text;
|
||||
this._renderAssistantMarkdown(
|
||||
this._currentAssistantEl,
|
||||
this._currentAssistantText
|
||||
);
|
||||
this._scrollToBottom();
|
||||
}
|
||||
|
||||
private _getOrCreateBlock(key: string, title: string): BlockEntry {
|
||||
let entry = this._activeBlocks[key];
|
||||
if (entry) {
|
||||
return entry;
|
||||
}
|
||||
const details = document.createElement('details');
|
||||
details.className = `opencode-msg-block opencode-msg-block-${key}`;
|
||||
details.open = true;
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = title;
|
||||
const content = document.createElement('div');
|
||||
content.className = 'opencode-msg-block-content';
|
||||
details.appendChild(summary);
|
||||
details.appendChild(content);
|
||||
this._historyEl.appendChild(details);
|
||||
entry = { details, content };
|
||||
this._activeBlocks[key] = entry;
|
||||
this._scrollToBottom();
|
||||
return entry;
|
||||
}
|
||||
|
||||
private _appendToBlock(
|
||||
key: string,
|
||||
defaultTitle: string,
|
||||
text: string
|
||||
): void {
|
||||
const block = this._getOrCreateBlock(key, defaultTitle);
|
||||
block.content.textContent = (block.content.textContent || '') + text;
|
||||
this._scrollToBottom();
|
||||
}
|
||||
|
||||
private _createPermissionBlock(permId: string, description: string): void {
|
||||
if (!permId) {
|
||||
return;
|
||||
}
|
||||
const domId = `opencode-perm-${permId}`;
|
||||
if (this._historyEl.querySelector('#' + domId)) {
|
||||
return;
|
||||
}
|
||||
const details = document.createElement('details');
|
||||
details.id = domId;
|
||||
details.className =
|
||||
'opencode-msg-block opencode-msg-block-interaction';
|
||||
details.open = true;
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = '🔐 权限请求等待处理';
|
||||
const content = document.createElement('div');
|
||||
content.className = 'opencode-msg-block-content opencode-perm-body';
|
||||
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'opencode-perm-desc';
|
||||
desc.textContent = `请求内容:${description}`;
|
||||
content.appendChild(desc);
|
||||
|
||||
const btnGroup = document.createElement('div');
|
||||
btnGroup.className = 'opencode-perm-buttons';
|
||||
const mkBtn = (
|
||||
label: string,
|
||||
cls: string,
|
||||
resp: 'once' | 'always' | 'reject'
|
||||
): HTMLButtonElement => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = cls;
|
||||
b.textContent = label;
|
||||
b.addEventListener('click', () => {
|
||||
this._handlePermissionResponse(permId, resp, btnGroup, details);
|
||||
});
|
||||
return b;
|
||||
};
|
||||
btnGroup.appendChild(
|
||||
mkBtn('允许一次 (Once)', 'opencode-perm-btn-allow', 'once')
|
||||
);
|
||||
btnGroup.appendChild(
|
||||
mkBtn('总是允许 (Always)', 'opencode-perm-btn-allow', 'always')
|
||||
);
|
||||
btnGroup.appendChild(
|
||||
mkBtn('拒绝 (Reject)', 'opencode-perm-btn-reject', 'reject')
|
||||
);
|
||||
content.appendChild(btnGroup);
|
||||
|
||||
details.appendChild(summary);
|
||||
details.appendChild(content);
|
||||
this._historyEl.appendChild(details);
|
||||
this._scrollToBottom();
|
||||
}
|
||||
|
||||
private async _handlePermissionResponse(
|
||||
permId: string,
|
||||
response: 'once' | 'always' | 'reject',
|
||||
btnGroup: HTMLElement,
|
||||
details: HTMLElement
|
||||
): Promise<void> {
|
||||
if (!this._options.onPermissionReply) {
|
||||
btnGroup.textContent = '(未配置 onPermissionReply 回调)';
|
||||
return;
|
||||
}
|
||||
btnGroup.textContent = '正在提交…';
|
||||
try {
|
||||
const ok = await this._options.onPermissionReply(permId, response);
|
||||
const labels: Record<string, string> = {
|
||||
once: '✓ 已授权 (仅一次)',
|
||||
always: '✓ 已授权 (总是允许)',
|
||||
reject: '✕ 已拒绝操作'
|
||||
};
|
||||
const color = response === 'reject' ? '#ef4444' : '#10b981';
|
||||
btnGroup.innerHTML = `<span style="color: ${color}; font-weight: bold;">${
|
||||
labels[response]
|
||||
}${ok ? '' : ' (后端失败)'}</span>`;
|
||||
} catch (e) {
|
||||
btnGroup.innerHTML = `<span style="color: #ef4444; font-size: 11px;">提交失败: ${(e as Error).message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
private _createQuestionBlock(questionId: string, questionText: string): void {
|
||||
if (!questionId) {
|
||||
return;
|
||||
}
|
||||
const domId = `opencode-q-${questionId}`;
|
||||
if (this._historyEl.querySelector('#' + domId)) {
|
||||
return;
|
||||
}
|
||||
const details = document.createElement('details');
|
||||
details.id = domId;
|
||||
details.className =
|
||||
'opencode-msg-block opencode-msg-block-interaction';
|
||||
details.open = true;
|
||||
const summary = document.createElement('summary');
|
||||
summary.textContent = '❓ Agent 提问等待回复';
|
||||
const content = document.createElement('div');
|
||||
content.className = 'opencode-msg-block-content opencode-q-body';
|
||||
|
||||
const desc = document.createElement('div');
|
||||
desc.className = 'opencode-q-desc';
|
||||
desc.textContent = `问题:${questionText}`;
|
||||
content.appendChild(desc);
|
||||
|
||||
const inputRow = document.createElement('div');
|
||||
inputRow.className = 'opencode-q-input-row';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'opencode-q-input';
|
||||
input.placeholder = '请输入你的回答…';
|
||||
const submit = document.createElement('button');
|
||||
submit.type = 'button';
|
||||
submit.className = 'opencode-q-submit';
|
||||
submit.textContent = '提交回答';
|
||||
submit.addEventListener('click', () => {
|
||||
void this._handleQuestionResponse(
|
||||
questionId,
|
||||
input,
|
||||
inputRow,
|
||||
details
|
||||
);
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit.click();
|
||||
}
|
||||
});
|
||||
inputRow.appendChild(input);
|
||||
inputRow.appendChild(submit);
|
||||
content.appendChild(inputRow);
|
||||
|
||||
details.appendChild(summary);
|
||||
details.appendChild(content);
|
||||
this._historyEl.appendChild(details);
|
||||
this._scrollToBottom();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
private async _handleQuestionResponse(
|
||||
questionId: string,
|
||||
input: HTMLInputElement,
|
||||
inputRow: HTMLElement,
|
||||
_details: HTMLElement
|
||||
): Promise<void> {
|
||||
const answer = input.value.trim();
|
||||
if (!answer) {
|
||||
return;
|
||||
}
|
||||
if (!this._options.onQuestionReply) {
|
||||
inputRow.innerHTML = '<span style="color: #64748b;">(未配置 onQuestionReply 回调)</span>';
|
||||
return;
|
||||
}
|
||||
inputRow.innerHTML = '<span style="color: #64748b;">正在提交…</span>';
|
||||
try {
|
||||
const ok = await this._options.onQuestionReply(questionId, answer);
|
||||
const color = ok ? '#10b981' : '#ef4444';
|
||||
const text = ok ? `✓ 已回答:${answer}` : `提交失败 (后端 ok=false)`;
|
||||
inputRow.innerHTML = `<span style="color: ${color}; font-weight: bold;">${text}</span>`;
|
||||
} catch (e) {
|
||||
inputRow.innerHTML = `<span style="color: #ef4444;">提交失败: ${(e as Error).message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
private _resetStreamPointers(): void {
|
||||
this._currentAssistantEl = null;
|
||||
this._currentAssistantText = '';
|
||||
this._activeBlocks = {};
|
||||
}
|
||||
|
||||
// ---------- Static-message rendering (used by setMessages + streaming) ----------
|
||||
|
||||
private _renderAssistantMarkdown(wrap: HTMLElement, content: string): void {
|
||||
// Idempotent: clear existing content first so this can be called
|
||||
// repeatedly with the same `wrap` element (used by streaming —
|
||||
// every text delta re-renders the whole message).
|
||||
wrap.textContent = '';
|
||||
const rendered = document.createElement('div');
|
||||
rendered.className = 'opencode-msg-content';
|
||||
rendered.innerHTML = marked.parse(content) as string;
|
||||
const codeBlocks = rendered.querySelectorAll('pre');
|
||||
codeBlocks.forEach(pre => {
|
||||
const code = pre.querySelector('code');
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
const codeText = code.textContent ?? '';
|
||||
const blockWrap = document.createElement('div');
|
||||
blockWrap.className = 'opencode-code-block';
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'opencode-code-toolbar';
|
||||
const mkBtn = (
|
||||
label: string,
|
||||
title: string,
|
||||
onClick: () => void
|
||||
): HTMLButtonElement => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'opencode-code-btn';
|
||||
b.textContent = label;
|
||||
b.title = title;
|
||||
b.addEventListener('click', onClick);
|
||||
return b;
|
||||
};
|
||||
toolbar.appendChild(
|
||||
mkBtn('📋 复制', '复制代码', () => {
|
||||
void this._copyToClipboard(codeText);
|
||||
})
|
||||
);
|
||||
toolbar.appendChild(
|
||||
mkBtn('↪ 插入', '在光标位置插入', () => {
|
||||
this._insertAtCursor(codeText);
|
||||
})
|
||||
);
|
||||
toolbar.appendChild(
|
||||
mkBtn('⟳ 替换', '替换单元格内容', () => {
|
||||
this._replaceCell(codeText);
|
||||
})
|
||||
);
|
||||
blockWrap.appendChild(toolbar);
|
||||
pre.parentNode!.insertBefore(blockWrap, pre);
|
||||
blockWrap.appendChild(pre);
|
||||
});
|
||||
wrap.appendChild(rendered);
|
||||
}
|
||||
|
||||
private _createMessageElement(role: string, text: string): HTMLDivElement {
|
||||
const div = document.createElement('div');
|
||||
div.className = `opencode-msg opencode-msg-${role}`;
|
||||
div.textContent = text;
|
||||
this._historyEl.appendChild(div);
|
||||
this._scrollToBottom();
|
||||
return div;
|
||||
}
|
||||
|
||||
private _scrollToBottom(): void {
|
||||
this._historyEl.scrollTop = this._historyEl.scrollHeight;
|
||||
}
|
||||
|
||||
// ---------- Cell-edit actions (Copy / Insert / Replace) ----------
|
||||
|
||||
private async _copyToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
if (
|
||||
@@ -335,11 +908,6 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert text at the current editor cursor position. If the cell has
|
||||
* no editor (e.g. a non-Code cell) or the cursor can't be read, falls
|
||||
* back to appending at the end of the cell.
|
||||
*/
|
||||
private _insertAtCursor(text: string): void {
|
||||
const cell = this._cell;
|
||||
if (!cell || !cell.model || !cell.model.sharedModel) {
|
||||
@@ -367,7 +935,6 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
offset += col;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to append on any error reading the cursor.
|
||||
offset = source.length;
|
||||
}
|
||||
const newSource = source.slice(0, offset) + text + source.slice(offset);
|
||||
@@ -375,7 +942,6 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
Notification.info('已插入到光标位置');
|
||||
}
|
||||
|
||||
/** Replace the entire cell source with the given text. */
|
||||
private _replaceCell(text: string): void {
|
||||
const cell = this._cell;
|
||||
if (!cell || !cell.model || !cell.model.sharedModel) {
|
||||
|
||||
+32
-11
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Shared types for the opencode-bridge frontend.
|
||||
*
|
||||
* These mirror the Python server's `CellContext` / `OpenCodeRequest` / `OpenCodeResponse`
|
||||
* shapes in `opencode_bridge/routes.py`. Keep them in sync.
|
||||
* Mirrors the Python server's response shapes in `opencode_bridge/routes.py`
|
||||
* and the OpenCode Serve SSE event shape documented in
|
||||
* `/Users/taochen/temp/demo.html` (`handleGlobalEvent` / `processSessionEvent`).
|
||||
*
|
||||
* v3-final: the JupyterLab plugin settings have been removed entirely.
|
||||
* All connection config lives in startup environment variables (see
|
||||
* `opencode_bridge/config.py`); the inline model picker is fully dynamic.
|
||||
* v4: POST /edit is async (returns immediately with sessionId). The LLM
|
||||
* reply is consumed via the GET /events SSE stream. Each SSE event is
|
||||
* a JSON object with a `type` string; consumers route on `type`.
|
||||
*/
|
||||
|
||||
export interface ErrorOutput {
|
||||
@@ -33,21 +34,41 @@ export interface OpenCodeRequest {
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeSuccess {
|
||||
/**
|
||||
* Response from POST /opencode-bridge/edit (async).
|
||||
* The actual assistant reply arrives via the /events SSE stream and is
|
||||
* NOT embedded in this response.
|
||||
*/
|
||||
export interface OpenCodeEditSuccess {
|
||||
ok: true;
|
||||
/** The AI's reply as markdown (code in ```fences```, optional explanation).
|
||||
* Render with marked. The cell source is NOT replaced. */
|
||||
markdown: string;
|
||||
sessionId: string;
|
||||
notebookPath: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeFailure {
|
||||
export interface OpenCodeEditFailure {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type OpenCodeResponse = OpenCodeSuccess | OpenCodeFailure;
|
||||
export type OpenCodeEditResponse = OpenCodeEditSuccess | OpenCodeEditFailure;
|
||||
|
||||
/**
|
||||
* One SSE event from GET /opencode-bridge/events (proxied from OpenCode
|
||||
* Serve's /global/event). The shape is loosely typed because OpenCode's
|
||||
* payload envelope is not strictly documented; we follow demo.html's
|
||||
* defensive property lookup pattern (try top-level then nested under
|
||||
* `payload`).
|
||||
*/
|
||||
export interface OpenCodeEvent {
|
||||
type: string;
|
||||
payload?: {
|
||||
type?: string;
|
||||
properties?: { [k: string]: any };
|
||||
syncEvent?: { aggregateID?: string };
|
||||
};
|
||||
properties?: { [k: string]: any };
|
||||
syncEvent?: { aggregateID?: string };
|
||||
}
|
||||
|
||||
/** Response from GET /opencode-bridge/session-messages?notebook=... .
|
||||
* Projected from OpenCode's {info, parts}[] by the server into a
|
||||
|
||||
+129
@@ -210,3 +210,132 @@
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Streaming event blocks (v4+ — see opencode_inline_prompt.applyEvent)
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
/* System / status line rendered for server.*, workspace.*, lsp.*, etc. */
|
||||
.opencode-inline-prompt .opencode-msg-system {
|
||||
font-size: var(--jp-ui-font-size0, 11px);
|
||||
color: var(--jp-ui-font-color2, #888);
|
||||
padding: 2px 4px;
|
||||
font-family: var(--jp-code-font-family, monospace);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Base collapsible block used for reasoning / tool / permission / question. */
|
||||
.opencode-inline-prompt .opencode-msg-block {
|
||||
margin: 4px 0;
|
||||
border: 1px solid var(--jp-border-color2, #ddd);
|
||||
border-radius: 3px;
|
||||
background: var(--jp-layout-color1, #fafafa);
|
||||
font-size: var(--jp-ui-font-size0, 11px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-msg-block > summary {
|
||||
padding: 2px 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: var(--jp-layout-color2, #f1f5f9);
|
||||
color: var(--jp-ui-font-color2, #64748b);
|
||||
user-select: none;
|
||||
font-size: var(--jp-ui-font-size0, 11px);
|
||||
}
|
||||
|
||||
.opencode-inline-prompt .opencode-msg-block-content {
|
||||
padding: 4px 8px;
|
||||
font-family: var(--jp-code-font-family, monospace);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--jp-ui-font-color1, #334155);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Reasoning: green tint (matches demo.html). */
|
||||
.opencode-inline-prompt .opencode-msg-block-reasoning > summary {
|
||||
background: #f0fdf4;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
/* Tool call: yellow tint. */
|
||||
.opencode-inline-prompt .opencode-msg-block-tool > summary {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
/* Permission / question: pink tint. */
|
||||
.opencode-inline-prompt .opencode-msg-block-interaction > summary {
|
||||
background: #fce7f3;
|
||||
color: #be185d;
|
||||
}
|
||||
|
||||
/* Permission block: description + 3 buttons. */
|
||||
.opencode-inline-prompt .opencode-perm-desc {
|
||||
margin-bottom: 6px;
|
||||
font-size: var(--jp-ui-font-size0, 11px);
|
||||
color: var(--jp-ui-font-color1, #334155);
|
||||
}
|
||||
.opencode-inline-prompt .opencode-perm-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.opencode-inline-prompt .opencode-perm-btn-allow,
|
||||
.opencode-inline-prompt .opencode-perm-btn-reject {
|
||||
border: none;
|
||||
padding: 4px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: var(--jp-ui-font-size0, 11px);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
.opencode-inline-prompt .opencode-perm-btn-allow {
|
||||
background: #10b981;
|
||||
}
|
||||
.opencode-inline-prompt .opencode-perm-btn-allow:hover {
|
||||
background: #059669;
|
||||
}
|
||||
.opencode-inline-prompt .opencode-perm-btn-reject {
|
||||
background: #ef4444;
|
||||
}
|
||||
.opencode-inline-prompt .opencode-perm-btn-reject:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
/* Question block: text + input + submit. */
|
||||
.opencode-inline-prompt .opencode-q-desc {
|
||||
margin-bottom: 6px;
|
||||
font-size: var(--jp-ui-font-size0, 11px);
|
||||
color: var(--jp-ui-font-color1, #334155);
|
||||
}
|
||||
.opencode-inline-prompt .opencode-q-input-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.opencode-inline-prompt .opencode-q-input {
|
||||
flex: 1;
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--jp-border-color2, #ccc);
|
||||
border-radius: 3px;
|
||||
font-size: var(--jp-ui-font-size0, 11px);
|
||||
font-family: inherit;
|
||||
background: var(--jp-layout-color1, #fff);
|
||||
}
|
||||
.opencode-inline-prompt .opencode-q-submit {
|
||||
border: 1px solid var(--jp-border-color2, #ccc);
|
||||
background: var(--jp-layout-color2, #f0f0f0);
|
||||
padding: 3px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: var(--jp-ui-font-size0, 11px);
|
||||
}
|
||||
.opencode-inline-prompt .opencode-q-submit:hover {
|
||||
background: var(--jp-layout-color3, #e0e0e0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user