9 Commits
Author SHA1 Message Date
tao.chenandClaude fcd015ac0e fix: stop running marked.parse on every streaming text delta
CI / CI (push) Successful in 17m54s
The streaming path used to call _renderAssistantMarkdown on every
incoming text delta, which re-ran marked.parse on the WHOLE
accumulated markdown source and re-post-processed (added the
Copy/Insert/Replace toolbar to) every <pre> block.

This is a partial-render problem: a markdown source mid-stream (e.g.
an opening ```py fence with no closing fence yet, or a partial
heading) classifies DIFFERENTLY than the complete message. Each
new delta could re-classify the same text into a different DOM
shape, causing visible flicker - <pre>s appearing and disappearing,
the toolbar popping in and out, paragraphs re-grouping - while the
model is still typing. The bug surfaced because the user could see
the glitchy view during a stream but everything looked correct
after closing+reopening the panel (which goes through the static
setMessages -> marked.parse path on the full text).

Fix: keep the streaming path dumb. _appendToAssistantText now just
appends the raw delta to the assistant element's textContent. The
full markdown render happens exactly once at the end of the turn,
inside _resetStreamPointers (which the prompt invokes when
session.idle arrives, before calling onStreamEnd). Result:

- The user still sees real-time text growth (textContent += delta).
- No more intermediate-state flicker.
- The rendered view at turn end is byte-for-byte equivalent to what
  setMessages would produce from the stored history (they share
  _renderAssistantMarkdown).

The corresponding regression test is updated: it now asserts the
DURING-stream state is raw text (no <pre>, no toolbar) and the
POST-idle state is the rendered DOM with the toolbar attached.

Tests: 72 jest (unchanged count, the one streaming test was
rewritten), 52 pytest, build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 17:52:53 +08:00
tao.chen d867b2f6d6 Merge remote-tracking branch 'origin/main' 2026-07-27 17:40:49 +08:00
tao.chen b4060d3b2f Merge pull request 'Feat/async sse streaming' (#1) from feat/async-sse-streaming into main
Reviewed-on: #1
2026-07-27 17:38:53 +08:00
dd5667cea4 feat: persist user (provider, model) selection per notebook in localStorage
When the user picks a provider+model in the inline prompt, remember
that choice across panel close/reopen AND across JupyterLab restarts,
keyed by notebookPath. If the stored provider (or the model under it)
is no longer present in the current providers payload, fall back to
the default (first provider / default[pid] model) - never silently
apply a stale value that would result in an empty or disabled select.

- New module src/components/model_selection.ts with
  loadModelSelection / saveModelSelection / clearModelSelection.
  Best-effort: localStorage may be disabled (private mode), quota
  may be exhausted, value may be corrupt - all failure modes return
  null / no-op rather than throw.
- IOpenCodeInlinePromptOptions gains notebookPath?: string.
- Constructor resolves the initial selection: prefer stored+valid,
  else first provider / default[pid]. providerSelect.change and
  modelSelect.change listeners call _persistSelection() to keep
  storage in sync.
- _rebuildModelSelect now accepts a preferredModelId; if it exists
  in the current provider's models it's used, else the default.
- OpenCodeCellActions._showPrompt passes notebookPath =
  this._context?.notebookPath so the prompt can key the storage.

Tests
-----
- 10 unit tests in model_selection.spec.ts (load/save/clear +
  malformed JSON + per-notebook independence).
- 4 prompt behavior tests in opencode_cell_actions.spec.ts
  (valid stored applied, provider removed -> default, model removed
  -> default, change -> saved).
- 72 frontend jest (was 58), 52 backend pytest (unchanged),
  build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 17:38:53 +08:00
b7089519fd feat: async + SSE message flow with interactive permission/question UI
Replace the synchronous /edit (wait for full markdown response) with an
async + SSE flow per demo.html so the user sees text stream in real-time
and can interact with permission/question events the agent raises.

Backend
-------
- EditHandler now calls OpenCode /session/:id/prompt_async and returns
  immediately with {ok, sessionId, notebookPath}; the LLM reply is no
  longer embedded in this response.
- New GlobalEventHandler proxies OpenCode /global/event as
  text/event-stream. Server forwards ALL events; the client filters.
  A too-eager server-side ?session= filter was silently dropping events
  the client would have accepted, so it was removed.
- New PermissionReplyHandler + QuestionReplyHandler forward user
  replies (once/always/reject and freeform answer) back to OpenCode
  Serve at /session/:sid/permissions/:permId and
  /session/:sid/question/:qId/reply.
- OpenCodeClient gains send_message_async(), stream_global_events()
  (async generator over the SSE feed via tornado streaming_callback),
  reply_permission() and reply_question(). Legacy send_message_sync
  removed.

Frontend
--------
- subscribeOpenCodeEvents() opens a fetch+reader SSE client (XSRF
  token injected from serverSettings); AbortController-backed close()
  is idempotent.
- OpenCodeInlinePrompt.applyEvent() routes events into the streaming
  UI: text delta -> assistant message (re-rendered as markdown on
  every delta so the user sees formatted <pre><code> blocks in
  real-time, not raw fence source); reasoning/tool/permission/question
  get collapsible details blocks. session.idle resets stream pointers
  and fires onStreamEnd.
- Permission/question blocks render real interactive UI: three
  buttons (once/always/reject) for permission, a text input + submit
  for question. Click handlers post through the new API routes and
  show success/failure status in place.
- Centralized idle detection (4 shapes: top-level + payload-nested
  x {session.idle, session.status/idle}) inside the prompt; cell
  action reacts via onStreamEnd callback rather than re-parsing
  event types.
- System / workspace / pty / lsp / mcp / installation events AND
  session-level control events (agent.switched, model.switched,
  file.edited) are not rendered in the frontend.

Tests
-----
- 52 backend pytest (was 43)
- 58 frontend jest (was 46)

design.md section 3 updated for the new async /edit response shape
and the new GET /events SSE endpoint.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 17:38:53 +08:00
tao.chenandtao.chen a43174e5bc update: optimize config.py 2026-07-27 17:38:53 +08:00
tao.chen 0bd04a825d update: optimize config.py 2026-07-27 14:33:04 +08:00
tao.chen 380e4a3150 更新 .github/workflows/build.yml 2026-07-24 11:02:27 +08:00
tao.chen b0abeaf329 更新 .github/scripts/create-release.sh 2026-07-24 10:59:47 +08:00
18 changed files with 3105 additions and 261 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ if [ -z "${RELEASE_ID}" ]; then
fi
echo "Release created with ID ${RELEASE_ID}"
for asset in dist/snapshot-*.whl dist/snapshot-*.tar.gz; do
for asset in dist/*.whl dist/*.tar.gz; do
if [ -f "${asset}" ]; then
echo "Uploading ${asset}..."
curl -fsS -X POST "${API_URL}/repos/${REPOSITORY}/releases/${RELEASE_ID}/assets" \
+2
View File
@@ -7,6 +7,8 @@ on:
tags:
- 'v*'
pull_request:
workflow_dispatch:
jobs:
ci:
+25 -5
View File
@@ -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 实现:单次 JSONSSE 流式属于 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 过滤。
---
+27 -1
View File
@@ -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
View File
@@ -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
)
),
)
+164 -8
View File
@@ -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
View File
@@ -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)
+121 -9
View File
@@ -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"})])
+191 -19
View File
@@ -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
+99
View File
@@ -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();
});
});
+723 -33
View File
@@ -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,416 @@ 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 shows raw text in real-time, then renders to HTML on session.idle', () => {
// Regression: a previous version ran marked.parse on every text
// delta. That produced glitchy partial-render states (e.g. an
// opening ```py fence with no closing fence yet is a different
// DOM shape than the complete code block, so the toolbar
// post-processing flickered in and out between deltas). The fix
// is to keep the streaming path dumb (append raw text only) and
// run the markdown render exactly once at the end of the turn
// (session.idle). The user sees real-time text grow during the
// stream, and a clean rendered view the moment the turn ends —
// matching what setMessages would produce from the stored history.
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
prompt.setSessionId('s1');
const fullSource =
'Here is the fix:\n\n```python\nimport pandas as pd\nimport numpy as np\n```\n';
// Stream every delta of the source.
for (const d of fullSource.split(/(?=H|```|\n)/)) {
if (!d) continue;
prompt.applyEvent({
type: 'session.next.text.delta',
properties: { sessionID: 's1', delta: d }
});
}
// During streaming: raw text, NO markdown render yet.
const asstDuring = prompt.node.querySelector(
'.opencode-msg-assistant'
) as HTMLElement;
expect(asstDuring.textContent).toBe(fullSource);
// The ```fence``` characters are present as literal text — the
// user is looking at the source, not the rendered view.
expect(asstDuring.textContent).toContain('```python');
// No <pre> element yet (no markdown render was attempted).
expect(asstDuring.querySelector('pre')).toBeNull();
// No toolbar yet either.
expect(asstDuring.querySelector('.opencode-code-toolbar')).toBeNull();
// Still a single assistant element (no forks per delta).
expect(
prompt.node.querySelectorAll(
'.opencode-msg-assistant'
).length
).toBe(1);
// Turn end -> session.idle triggers the final markdown render
// (via _resetStreamPointers, which the prompt invokes before
// calling onStreamEnd).
prompt.applyEvent({
type: 'session.idle',
properties: { sessionID: 's1' }
});
// After idle: the assistant message is now a fully rendered
// markdown DOM, identical to what setMessages would produce.
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.textContent).toBe('import pandas as pd\nimport numpy as np');
// The Copy / Insert / Replace toolbar is attached to the <pre>
// block on the final render, 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);
});
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);
});
});
+170 -4
View File
@@ -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
View File
@@ -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 };
}
+82
View File
@@ -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
}
}
+114 -41
View File
@@ -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);
}
}
+676 -94
View File
@@ -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,517 @@ 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', '');
}
// During streaming, append the raw delta to the assistant element's
// textContent. We do NOT run marked.parse on every delta: the
// partial source (e.g. an opening ```py fence with no closing
// fence yet) is structurally different from the complete message
// and the toolbar post-processing creates visible flicker —
// <pre>s appear and disappear as the renderer re-classifies the
// incomplete markdown between deltas. The user gets stable
// real-time text here, and the full markdown render happens once
// at the end of the turn (see _resetStreamPointers) so the result
// is identical to what setMessages would produce from the stored
// history.
this._currentAssistantText += text;
this._currentAssistantEl.textContent = 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 {
// Finalize the in-progress assistant message by running the
// markdown render ONCE on the accumulated raw text. This is the
// moment where the streaming path converges to the same DOM
// structure that setMessages would produce from the stored
// history (i.e. identical to "reopen" behavior). Without this
// final pass the user would see raw ```fence``` source
// permanently until they close+reopen the panel.
if (this._currentAssistantEl && this._currentAssistantText) {
this._renderAssistantMarkdown(
this._currentAssistantEl,
this._currentAssistantText
);
}
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 +924,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 +951,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 +958,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
View File
@@ -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
View File
@@ -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);
}