Files
tao.chen 310d50759a fix: SSE filter no longer drops content events for a different session
Bug: after switching to a new session via the SessionSelector, the
next /edit would not get a reply. Root cause: applyEvent dropped
ANY event whose sessionID didn't match the prompt's _sessionId,
including text deltas / reasoning / tool / idle. OpenCode's sessionID
extraction from event payloads is not perfectly consistent across
event types, so the prompt could end up with a stale _sessionId
that doesn't match the events coming in, and EVERY event got
filtered out -> no reply.

Fix: the cross-session filter is now strict ONLY for
permission.asked / question.asked (so the user can't accidentally
reply to another session's prompt). Content events always pass
through. The server's /events handler is the single source of
truth for session filtering (it has the URL ?session= param); the
client's filter would only ever mask the user's interaction with
their own active session.

Also: drop the cell-context auto-injection. The OpenCodeRequest
now carries only {notebookPath}. The user explicitly attaches
whatever they want via the new '📋 插入单元格内容' button
(inserts the cell source as a markdown code block into the input).
This makes the LLM context match user intent and stops the
OpenCode prompt from being polluted with stale previousCode /
traceback snapshots.

Backend
-------
- _build_request_body: now takes only the prompt, returns
  parts=[{text: prompt}]. No more <previous_cell>/<traceback>/<cell>
  tag wrapping.

Frontend
-------
- types.ts: CellContext collapsed to {notebookPath}. ErrorOutput /
  cellId / source / previousCode / error / cellIndex / totalCells
  / language all removed.
- opencode_cell_actions.ts: extractCellContextFromCell() replaced
  with extractNotebookPathFromCell(). _context field renamed to
  _notebookPath. NotebookPanel / extractCellContext / context/
  cell_context imports all removed.
- src/context/cell_context.ts + src/__tests__/cell_context.spec.ts:
  deleted.

New feature: '📋 插入单元格内容' button
----------------------------------------
The button sits at the start of the actions row (visually
left-aligned via margin-right:auto) and appends the current cell's
source as a markdown code fence to the textarea. Caret is moved
to the end so the user can keep typing. The fence info string is
the cell's model type (falls back to a plain fence if the type
isn't a clean language identifier). No-op for empty cells.

Tests
-----
- 72 jest (was 78: -8 cell-context tests + 1 SSE filter test +
  3 insert-cell tests + rewrites). 73 pytest (unchanged). Build
  green.
2026-07-28 18:02:21 +08:00

703 lines
27 KiB
Python

import json
import logging
from jupyter_server.base.handlers import APIHandler
from jupyter_server.utils import url_path_join
import tornado
from .config import resolve_config
from .opencode_client import OpenCodeClient, OpenCodeError
from .session_manager import SessionManager
log = logging.getLogger("opencode_bridge.routes")
# Unified system prompt — the LLM (OpenCode) is asked to return its reply
# as MARKDOWN (code wrapped in ```language fences; a brief explanation is
# fine). The frontend renders this with `marked` directly — no fence
# stripping on the server, so the response keeps the structure that makes
# markdown rendering meaningful (code blocks, headings, etc.).
UNIFIED_SYSTEM_PROMPT = (
"你是一个代码助手。基于提供的代码上下文(以及可选的 traceback),"
"按照用户的指令修改代码。"
"可附简短说明。"
)
def make_client(handler: APIHandler) -> OpenCodeClient:
"""Factory for OpenCodeClient. Tests monkey-patch this."""
cfg = resolve_config(handler.settings.get("opencode_bridge", {}))
return OpenCodeClient(cfg)
def get_session_manager(handler: APIHandler) -> SessionManager:
"""Return the SessionManager singleton for this web app, creating on first use.
Stored in handler.settings["opencode_bridge_session_manager"] so it survives
across requests but is per-server-instance. Tests monkey-patch this.
"""
sm = handler.settings.get("opencode_bridge_session_manager")
if sm is None:
def client_factory() -> OpenCodeClient:
cfg = resolve_config(handler.settings.get("opencode_bridge", {}))
return OpenCodeClient(cfg)
sm = SessionManager(client_factory)
handler.settings["opencode_bridge_session_manager"] = sm
return sm
def _build_request_body(prompt: str) -> dict:
"""Build request body for OpenCode POST /session/:id/prompt_async.
v0.2.x: only the user's prompt is sent to the LLM. We no longer
auto-wrap the cell source / previous-cell / traceback in tags —
the user inserts whatever they want via the "📋 插入单元格内容"
button (or pastes manually), so the LLM context exactly matches
user intent. Returns dict with 'parts' and 'system' keys.
"""
return {
"parts": [{"type": "text", "text": prompt}],
"system": UNIFIED_SYSTEM_PROMPT,
}
def _strip_code_fence(s: str) -> str:
"""Strip ```language ... ``` fences from LLM output.
Kept for any future "apply-to-cell" path that needs clean source.
Not used by the current markdown-rendering display flow.
"""
s = s.strip()
if s.startswith("```"):
lines = s.split("\n")
if lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].startswith("```"):
lines = lines[:-1]
return "\n".join(lines).strip()
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
# Jupyter server
@tornado.web.authenticated
def get(self):
self.finish(json.dumps({
"data": (
"Hello, world!"
" This is the '/opencode-bridge/hello' endpoint."
" Try visiting me in your browser!"
),
}))
class HealthHandler(APIHandler):
@tornado.web.authenticated
async def get(self):
try:
client = make_client(self)
data = await client.health()
self.finish(json.dumps({
"ok": data.get("healthy", False),
"version": data.get("version"),
"endpoint": client.endpoint,
}))
except Exception as e:
log.exception("health check failed")
self.set_status(503)
self.finish(json.dumps({
"ok": False,
"error": str(e),
"endpoint": make_client(self).endpoint,
}))
class ProvidersHandler(APIHandler):
@tornado.web.authenticated
async def get(self):
try:
data = await make_client(self).list_providers()
self.finish(json.dumps(data))
except Exception as e:
log.exception("providers list failed")
self.set_status(502)
self.finish(json.dumps({"error": str(e)}))
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:
body = json.loads(self.request.body)
prompt = body.get("prompt", "")
context = body["context"]
provider_id = body.get("providerId") or None
model_id = body.get("modelId") or None
notebook_path = context.get("notebookPath", "")
except (KeyError, json.JSONDecodeError) as e:
self.set_status(400)
self.finish(json.dumps({"error": "bad request: %s" % e}))
return
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing context.notebookPath"}))
return
client = make_client(self)
sm = get_session_manager(self)
try:
sid = await sm.get_or_create(notebook_path)
request_body = _build_request_body(prompt)
await client.send_message_async(
sid,
request_body["parts"],
provider_id=provider_id,
model_id=model_id,
system=request_body["system"],
)
self.finish(json.dumps({
"ok": True,
"sessionId": sid,
"notebookPath": notebook_path,
}))
except OpenCodeError as e:
# If session is invalid on OpenCode side, invalidate cache.
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 (async) failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
except Exception as e:
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)}))
class SessionListHandler(APIHandler):
"""List all active notebook -> session mappings. Debug endpoint."""
@tornado.web.authenticated
def get(self):
sm = get_session_manager(self)
self.finish(json.dumps({"sessions": sm.list_sessions()}))
class SessionReleaseHandler(APIHandler):
"""Release the OpenCode session for a specific notebook.
Query param: notebook=<notebook path, URL-encoded>
"""
@tornado.web.authenticated
async def delete(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
sm = get_session_manager(self)
deleted = await sm.release(notebook_path)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"deleted": deleted,
}))
class SessionMessagesHandler(APIHandler):
"""List the current session's messages for a notebook (scrollable history).
Query param: notebook=<notebook path, URL-encoded>
Returns: { messages: [{ role: "user"|"assistant", content: string }] }
If no session exists for the notebook yet, returns { messages: [] }
(does NOT create a session just to report emptiness).
"""
@tornado.web.authenticated
async def get(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
sm = get_session_manager(self)
sid = sm.peek(notebook_path)
if sid is None:
self.finish(json.dumps({"messages": []}))
return
try:
client = make_client(self)
raw = await client.list_session_messages(sid)
except OpenCodeError as e:
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("list session messages failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
except Exception as e:
log.exception("list session messages failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
# Project OpenCode's {info, parts}[] into a frontend-friendly
# {role, content}[] by joining the text parts.
messages = []
for m in raw or []:
info = m.get("info") or {}
role = info.get("role") or "assistant"
parts = m.get("parts") or []
content = "\n".join(
p.get("text", "") for p in parts if p.get("type") == "text"
).strip()
messages.append({"role": role, "content": content})
self.finish(json.dumps({"messages": messages}))
# ---------------------------------------------------------------------------
# Multi-session management endpoints
# ---------------------------------------------------------------------------
#
# Model: each notebook can have multiple OpenCode sessions bound to it.
# One of the bound sessions is the "active" one (used by the next /edit).
# The user can:
# - list all OpenCode sessions (global, for the "browse" UI)
# - list sessions bound to a specific notebook
# - create a brand-new session and bind it (becomes active)
# - bind an existing session id (e.g. one they saw in the global list)
# - switch which bound session is active
# - unbind the active one (next /edit will lazy-create a new one)
# - delete a specific bound session (also removes it from OpenCode Serve)
# ---------------------------------------------------------------------------
class AllSessionsListHandler(APIHandler):
"""GET /opencode-bridge/sessions/all — list EVERY session on OpenCode
Serve, regardless of which notebook (if any) it is bound to. Powers
the "browse all conversations" UI in the cell-action panel.
"""
@tornado.web.authenticated
async def get(self):
try:
client = make_client(self)
sessions = await client.list_all_sessions()
except OpenCodeError as e:
log.exception("list all sessions failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
except Exception as e:
log.exception("list all sessions failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
self.finish(json.dumps({"ok": True, "sessions": sessions or []}))
class NotebookSessionsHandler(APIHandler):
"""Manage the list of sessions BOUND to one notebook.
GET /opencode-bridge/sessions/notebook?notebook=...
-> { ok, activeSessionId, sessions: [{sessionId, title, ...}, ...] }
The metadata (title, createdAt, ...) is enriched from
OpenCode's `GET /session` so the frontend can render
the picker without a second round-trip per session.
POST /opencode-bridge/sessions/notebook?notebook=...&title=...
-> { ok, sessionId, activeSessionId } — creates a new
session on OpenCode, binds it, sets as active.
PUT /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
-> { ok, sessionId, activeSessionId } — binds an
existing sessionId (e.g. one the user picked from
the global list) to this notebook and sets as active.
DELETE /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
-> { ok, deleted } — deletes the session on OpenCode
Serve AND removes its binding from this notebook.
If it was the active one, clears active.
"""
@tornado.web.authenticated
async def get(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
sm = get_session_manager(self)
bound_ids = sm.list_sessions_for_notebook(notebook_path)
active = sm.get_active(notebook_path)
# Enrich with metadata from OpenCode Serve so the UI can show
# the title / createdAt without a second round-trip.
try:
all_sessions = await make_client(self).list_all_sessions()
by_id = {s.get("id"): s for s in (all_sessions or [])}
except Exception as e:
log.warning(
"could not enrich bound sessions with metadata: %s", e
)
by_id = {}
sessions = []
for sid in bound_ids:
meta = by_id.get(sid, {})
sessions.append({
"sessionId": sid,
"title": meta.get("title"),
"createdAt": meta.get("createdAt"),
"updatedAt": meta.get("updatedAt"),
"isActive": sid == active,
})
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": active,
"sessions": sessions,
}))
@tornado.web.authenticated
async def post(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
title = self.get_query_argument("title", None) or None
sm = get_session_manager(self)
try:
sid = await sm.create_new(notebook_path, title=title)
except OpenCodeError as e:
log.exception("create session failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
except Exception as e:
log.exception("create session failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"sessionId": sid,
"activeSessionId": sid,
}))
@tornado.web.authenticated
def put(self):
notebook_path = self.get_query_argument("notebook", "")
session_id = self.get_query_argument("sessionId", "")
if not notebook_path or not session_id:
self.set_status(400)
self.finish(json.dumps({
"error": "missing 'notebook' and/or 'sessionId' query parameter"
}))
return
sm = get_session_manager(self)
sm.bind_existing(notebook_path, session_id)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"sessionId": session_id,
"activeSessionId": sm.get_active(notebook_path),
}))
@tornado.web.authenticated
async def delete(self):
notebook_path = self.get_query_argument("notebook", "")
session_id = self.get_query_argument("sessionId", "")
if not notebook_path or not session_id:
self.set_status(400)
self.finish(json.dumps({
"error": "missing 'notebook' and/or 'sessionId' query parameter"
}))
return
sm = get_session_manager(self)
deleted = await sm.delete_session(notebook_path, session_id)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"sessionId": session_id,
"deleted": deleted,
"activeSessionId": sm.get_active(notebook_path),
}))
class ActiveSessionHandler(APIHandler):
"""Manage the ACTIVE session for one notebook.
GET /opencode-bridge/sessions/active?notebook=...
-> { ok, activeSessionId, notebookPath }
PUT /opencode-bridge/sessions/active?notebook=...&sessionId=...
-> { ok, activeSessionId } — switch the active bound
session. sessionId MUST already be in the bound list.
DELETE /opencode-bridge/sessions/active?notebook=...
-> { ok } — unbind the active session (next /edit will
lazy-create a new one). Does NOT delete the session
on OpenCode Serve (use the notebook DELETE for that).
"""
@tornado.web.authenticated
def get(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
sm = get_session_manager(self)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": sm.get_active(notebook_path),
}))
@tornado.web.authenticated
def put(self):
notebook_path = self.get_query_argument("notebook", "")
session_id = self.get_query_argument("sessionId", "")
if not notebook_path or not session_id:
self.set_status(400)
self.finish(json.dumps({
"error": "missing 'notebook' and/or 'sessionId' query parameter"
}))
return
sm = get_session_manager(self)
if not sm.set_active(notebook_path, session_id):
self.set_status(400)
self.finish(json.dumps({
"ok": False,
"error": "sessionId is not bound to this notebook; "
"POST /sessions/notebook or PUT /sessions/notebook first"
}))
return
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": sm.get_active(notebook_path),
}))
@tornado.web.authenticated
def delete(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
sm = get_session_manager(self)
sm.unbind_active(notebook_path)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": sm.get_active(notebook_path),
}))
def setup_route_handlers(web_app):
host_pattern = ".*$"
base_url = web_app.settings["base_url"]
handlers = [
(url_path_join(base_url, "opencode-bridge", "hello"), HelloRouteHandler),
(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),
# Multi-session management (notebook can bind N sessions; one is active).
(url_path_join(base_url, "opencode-bridge", "sessions", "all"), AllSessionsListHandler),
(url_path_join(base_url, "opencode-bridge", "sessions", "notebook"), NotebookSessionsHandler),
(url_path_join(base_url, "opencode-bridge", "sessions", "active"), ActiveSessionHandler),
# /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)