feat: multi-session management (browse all + bind N sessions per notebook)

User can now see EVERY session on the OpenCode Serve side (not just
the per-notebook mapping) and bind multiple sessions to a single
notebook. One of the bound sessions is the "active" one (the one
the next /edit uses); the others are historical. The user can
switch, create, or delete via the new session selector UI in the
inline prompt.

Backend
-------
- SessionManager rewritten for 1-notebook-N-sessions: keeps both an
   map and a  map. New methods: create_new,
  bind_existing, set_active, unbind, unbind_active, delete_session,
  list_sessions_for_notebook. Existing get_or_create / peek /
  invalidate / list_sessions / release semantics preserved
  (release now deletes ALL bound sessions, not just the active).
- New route GET /opencode-bridge/sessions/all — list ALL OpenCode
  sessions via /session.
- New route GET /opencode-bridge/sessions/notebook?notebook=... —
  bound sessions for one notebook, enriched with title/createdAt
  from the global list.
- POST .../sessions/notebook — create a new session, bind, set
  active.
- PUT  .../sessions/notebook — bind an existing sessionId, set
  active (no OpenCode round-trip).
- DELETE .../sessions/notebook — delete the session on OpenCode
  AND remove its binding.
- New route GET/PUT/DELETE /opencode-bridge/sessions/active —
  read / switch / unbind the active session for one notebook.
- OpenCodeClient.list_all_sessions() — proxy OpenCode /session.

Frontend
-------
- SessionSelector dropdown in the inline prompt: lists ALL OpenCode
  sessions with the active one selected, plus a "+ 新建会话"
  sentinel entry. "+ 新建" button + "🔄" refresh button.
  Switching calls onSwitchSession; selecting "+ 新建会话" or
  clicking the new button calls onCreateSession. After both,
  the prompt's history is reloaded for the new active session.
- New cell-action callbacks: onListSessions, onCreateSession,
  onSwitchSession, onReloadHistory. Each closes the SSE first
  so events for the OLD session don't bleed in during the switch.
- 8 new API client functions in api/opencode_client.ts.
- New types in types.ts: OpenCodeSessionMeta,
  OpenCodeNotebookSession, OpenCodeAllSessionsResponse,
  OpenCodeNotebookSessionsResponse, OpenCodeSessionOpResponse.

Tests
-----
- 73 backend pytest (was 64): +6 new SessionManager multi-session
  unit tests + 7 new route tests (all-sessions, notebook GET/POST/
  PUT/DELETE, active GET/PUT/DELETE).
- 76 frontend jest (was 72): +4 new prompt tests (session selector
  rendered when callbacks wired, initialize() populates the list,
  + 新建 creates+binds, dropdown change switches and reloads).
- package.json version bumped 0.1.0 -> 0.1.1 to match the v0.1.1 tag
  that was already published.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-28 16:25:42 +08:00
co-authored by Claude
parent fcd015ac0e
commit d2bd4f1e48
13 changed files with 1772 additions and 99 deletions
+339 -33
View File
@@ -57,6 +57,29 @@ class FakeOpenCodeClient:
self.calls.append(("list_session_messages", session_id))
return self.messages_response
async def list_all_sessions(self):
self.calls.append(("list_all_sessions",))
return [
{
"id": "fake-session-123",
"title": "jupyter:foo.ipynb",
"createdAt": 1700000000000,
"updatedAt": 1700000001000,
},
{
"id": "other-session",
"title": "manual session",
"createdAt": 1700000010000,
"updatedAt": 1700000011000,
},
]
async def create_session(self, title):
self.calls.append(("create_session", title))
# Return a fresh id (different from the default "fake-session-123").
new_id = "newly-created-" + title
return {"id": new_id, "title": title}
async def reply_permission(self, session_id, permission_id, response):
self.calls.append(("reply_permission", session_id, permission_id, response))
return True
@@ -72,35 +95,84 @@ class FakeOpenCodeClient:
class FakeSessionManager:
"""Drop-in replacement for SessionManager with recording."""
"""Thin wrapper around the real SessionManager with a recording
``FakeOpenCodeClient``. Lets route tests exercise the real
multi-session logic without touching OpenCode.
"""
def __init__(self, session_id: str = "fake-session-123") -> None:
self._session_id = session_id
self.calls: list = []
def __init__(self, session_id: str = "fake-session-123", client=None) -> None:
from opencode_bridge.session_manager import SessionManager
# `_client` attribute used by some legacy tests. Allow injection
# so a test can share its FakeOpenCodeClient with the SessionManager
# (the real SessionManager calls into its own client_factory, NOT
# the route's make_client).
self._client = client if client is not None else FakeOpenCodeClient()
self._sm = SessionManager(lambda: self._client)
self.calls: list = self._client.calls
# Legacy attribute used by list_sessions() etc.
self._sessions: dict = {}
async def get_or_create(self, notebook_path: str) -> str:
self.calls.append(("get_or_create", notebook_path))
return self._session_id
# ---- delegation to the real SessionManager ----
def _record(self, name, *args):
self.calls.append((name, *args))
return None
def get_or_create(self, notebook_path: str):
self._record("get_or_create", notebook_path)
return self._sm.get_or_create(notebook_path)
def peek(self, notebook_path: str):
# Mirror SessionManager.peek: return the session id without
# creating one. None means "no session yet" (used by the no-session
# test to short-circuit the history route).
return self._session_id or None
return self._sm.peek(notebook_path)
async def release(self, notebook_path: str) -> bool:
self.calls.append(("release", notebook_path))
return True
def get_active(self, notebook_path: str):
return self._sm.get_active(notebook_path)
def list_sessions(self) -> list:
return [
{"notebookPath": path, "sessionId": sid}
for path, sid in sorted(self._sessions.items())
]
return self._sm.list_sessions()
def list_sessions_for_notebook(self, notebook_path: str):
return self._sm.list_sessions_for_notebook(notebook_path)
def bind_existing(self, notebook_path: str, session_id: str) -> None:
self._record("bind_existing", notebook_path, session_id)
self._sm.bind_existing(notebook_path, session_id)
def set_active(self, notebook_path: str, session_id: str) -> bool:
self._record("set_active", notebook_path, session_id)
return self._sm.set_active(notebook_path, session_id)
def unbind(self, notebook_path: str, session_id: str) -> bool:
self._record("unbind", notebook_path, session_id)
return self._sm.unbind(notebook_path, session_id)
def unbind_active(self, notebook_path: str) -> bool:
self._record("unbind_active", notebook_path)
return self._sm.unbind_active(notebook_path)
def invalidate(self, notebook_path: str) -> bool:
self.calls.append(("invalidate", notebook_path))
return True
self._record("invalidate", notebook_path)
return self._sm.invalidate(notebook_path)
async def release(self, notebook_path: str) -> bool:
self._record("release", notebook_path)
return await self._sm.release(notebook_path)
async def delete_session(self, notebook_path: str, session_id: str) -> bool:
self._record("delete_session", notebook_path, session_id)
return await self._sm.delete_session(notebook_path, session_id)
async def create_new(self, notebook_path: str, title: str | None = None) -> str:
self._record("create_new", notebook_path, title)
return await self._sm.create_new(notebook_path, title)
# ---- internal-state proxies (used by tests that poke internals) ----
@property
def _active(self) -> dict:
return self._sm._active
@property
def _bound(self) -> dict:
return self._sm._bound
async def test_hello(jp_fetch):
@@ -178,11 +250,11 @@ async def test_edit_handler(monkeypatch, jp_fetch):
)
assert response.code == 200
payload = json.loads(response.body)
assert payload == {
"ok": True,
"sessionId": "fake-session-123",
"notebookPath": "test.ipynb",
}
# The exact sessionId comes from the FakeClient; just check the
# shape — keys are present and ok is true.
assert payload["ok"] is True
assert isinstance(payload["sessionId"], str) and payload["sessionId"]
assert payload["notebookPath"] == "test.ipynb"
# Async path: no `markdown` field. The LLM reply is consumed via SSE.
assert "markdown" not in payload
@@ -193,16 +265,21 @@ async def test_edit_handler(monkeypatch, jp_fetch):
assert "send_message_async" in call_names
assert "send_message_sync" not in call_names # legacy sync path is gone
# send_message_async received the session ID from the manager.
# send_message_async received a session id from the manager (the
# exact value comes from the FakeClient; just verify it matches
# the response's sessionId).
send_call = [c for c in fake.calls if c[0] == "send_message_async"][0]
assert send_call[1] == "fake-session-123"
assert send_call[1] == payload["sessionId"]
# system prompt was passed.
assert "你是一个代码助手" in send_call[3]
# SessionManager.get_or_create was called with the notebook path.
# (The real SessionManager internally calls create_session on the
# FakeClient too, so the shared calls list has both entries.)
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"
assert "get_or_create" in sm_call_names
get_or_create_call = [c for c in fake_sm.calls if c[0] == "get_or_create"][0]
assert get_or_create_call[1] == "test.ipynb"
async def test_edit_handler_includes_model_when_provider_and_model_given(
@@ -242,10 +319,12 @@ async def test_edit_handler_includes_model_when_provider_and_model_given(
async def test_session_list_handler(monkeypatch, jp_fetch):
fake_sm = FakeSessionManager()
fake_sm._sessions = {
"foo.ipynb": "sid-1",
"bar.ipynb": "sid-2",
} # direct injection
# Seed the underlying real SessionManager's state so list_sessions
# returns the expected debug mapping.
fake_sm._active["foo.ipynb"] = "sid-1"
fake_sm._active["bar.ipynb"] = "sid-2"
fake_sm._bound["foo.ipynb"] = ["sid-1"]
fake_sm._bound["bar.ipynb"] = ["sid-2"]
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
)
@@ -290,6 +369,10 @@ async def test_session_messages_handler_projects_opencode_messages(
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
fake_sm = FakeSessionManager(session_id="fake-session-123")
# Seed the bound state so peek() returns the session id (the route
# only fetches messages if there's an active session).
fake_sm._active["test.ipynb"] = "fake-session-123"
fake_sm._bound["test.ipynb"] = ["fake-session-123"]
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
)
@@ -332,6 +415,11 @@ async def test_session_release_handler(monkeypatch, jp_fetch):
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
)
# Set up state: get_or_create actually binds a session to the
# notebook in the real SessionManager (via the FakeClient). release
# then has something to delete.
await fake_sm.get_or_create("foo.ipynb")
response = await jp_fetch(
"opencode-bridge", "session",
method="DELETE",
@@ -459,3 +547,221 @@ async def test_global_event_handler_forwards_all_events_unfiltered(
assert '"server.connected"' in body
# The body should use the SSE format: each event as a `data:` line.
assert body.count("data: ") == 3
# ---------------------------------------------------------------------------
# Multi-session management endpoints
# ---------------------------------------------------------------------------
async def test_all_sessions_handler_returns_global_list(monkeypatch, jp_fetch):
"""GET /sessions/all proxies OpenCode Serve's GET /session."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
response = await jp_fetch("opencode-bridge", "sessions", "all")
assert response.code == 200
payload = json.loads(response.body)
assert payload["ok"] is True
assert len(payload["sessions"]) == 2
# The fake returns both the default session and a hand-rolled "other-session".
ids = {s["id"] for s in payload["sessions"]}
assert "fake-session-123" in ids
assert "other-session" in ids
assert ("list_all_sessions",) in fake.calls
async def test_notebook_sessions_get_returns_bound_with_metadata(
monkeypatch, jp_fetch
):
"""GET /sessions/notebook?notebook=... returns the bound sessions
enriched with title/createdAt from the global list."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
sm = FakeSessionManager()
sm._active["foo.ipynb"] = "fake-session-123"
sm._bound["foo.ipynb"] = ["fake-session-123"]
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
params={"notebook": "foo.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["notebookPath"] == "foo.ipynb"
assert payload["activeSessionId"] == "fake-session-123"
assert len(payload["sessions"]) == 1
s = payload["sessions"][0]
assert s["sessionId"] == "fake-session-123"
assert s["title"] == "jupyter:foo.ipynb"
assert s["isActive"] is True
async def test_notebook_sessions_post_creates_and_binds(monkeypatch, jp_fetch):
"""POST /sessions/notebook creates a new session on OpenCode and
binds it as active."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
# Share the client with the SessionManager so the test can assert
# against `fake.calls` (the real SessionManager calls its own
# client_factory, NOT the route's make_client).
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager",
lambda h: FakeSessionManager(client=fake),
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
method="POST",
params={"notebook": "foo.ipynb", "title": "fresh"},
body=json.dumps({}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["ok"] is True
assert payload["sessionId"] == "newly-created-fresh"
# The new session is the active one.
assert payload["activeSessionId"] == "newly-created-fresh"
# OpenCode was called.
assert ("create_session", "fresh") in fake.calls
async def test_notebook_sessions_put_binds_existing(monkeypatch, jp_fetch):
"""PUT /sessions/notebook binds an existing sessionId without
calling OpenCode. Sets it as active."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
sm = FakeSessionManager()
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
method="PUT",
params={"notebook": "foo.ipynb", "sessionId": "some-existing-sid"},
body=json.dumps({}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["sessionId"] == "some-existing-sid"
assert payload["activeSessionId"] == "some-existing-sid"
# No OpenCode round-trip for the bind.
assert ("create_session", ...) not in [
c for c in fake.calls if c[0] == "create_session"
]
async def test_active_session_set_requires_bound(monkeypatch, jp_fetch):
"""PUT /sessions/active?sessionId=X fails with 400 if X is not bound."""
monkeypatch.setattr(
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
)
sm = FakeSessionManager()
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
await jp_fetch(
"opencode-bridge", "sessions", "active",
method="PUT",
params={"notebook": "foo.ipynb", "sessionId": "never-bound"},
body=json.dumps({}),
)
assert exc_info.value.code == 400
async def test_active_session_set_switches(monkeypatch, jp_fetch):
"""PUT /sessions/active successfully switches when the sessionId is bound."""
monkeypatch.setattr(
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
)
sm = FakeSessionManager()
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "active",
method="PUT",
params={"notebook": "foo.ipynb", "sessionId": "sid-A"},
body=json.dumps({}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["activeSessionId"] == "sid-A"
async def test_active_session_delete_unbinds_only(monkeypatch, jp_fetch):
"""DELETE /sessions/active unbinds the active session; other
bound sessions are kept; OpenCode is NOT called."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
sm = FakeSessionManager()
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "active",
method="DELETE",
params={"notebook": "foo.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["activeSessionId"] is None
# sid-A is still bound; sid-B was the active and is gone.
assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-A"]
# No OpenCode call.
assert not any(c[0] == "delete_session" for c in fake.calls)
async def test_active_session_get_returns_id_or_null(monkeypatch, jp_fetch):
monkeypatch.setattr(
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
)
sm = FakeSessionManager()
sm.bind_existing("foo.ipynb", "sid-A")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "active",
params={"notebook": "foo.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["activeSessionId"] == "sid-A"
async def test_notebook_sessions_delete_removes_session(monkeypatch, jp_fetch):
"""DELETE /sessions/notebook?notebook=...&sessionId=... deletes
the session on OpenCode AND removes its binding."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
# Share the client with the SessionManager so the test can assert
# against `fake.calls` (the real SessionManager calls its own
# client_factory, NOT the route's make_client).
sm = FakeSessionManager(client=fake)
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
method="DELETE",
params={"notebook": "foo.ipynb", "sessionId": "sid-A"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["deleted"] is True
# OpenCode delete was called for sid-A.
assert ("delete_session", "sid-A") in fake.calls
# sid-A is gone; sid-B remains active.
assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-B"]
assert sm.get_active("foo.ipynb") == "sid-B"