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>
768 lines
28 KiB
Python
768 lines
28 KiB
Python
import json
|
|
|
|
import pytest
|
|
import tornado.httpclient
|
|
|
|
|
|
class FakeOpenCodeClient:
|
|
"""Drop-in replacement for OpenCodeClient with recording + canned responses."""
|
|
|
|
def __init__(self):
|
|
self.calls = []
|
|
self.session_id = "fake-session-123"
|
|
self.health_response = {"healthy": True, "version": "0.1.0"}
|
|
self.providers_response = {"providers": [{"id": "anthropic", "models": [{"id": "claude"}]}]}
|
|
# Canned session-messages list response (list_session_messages).
|
|
# Default: one user + one assistant message, mixed text parts.
|
|
self.messages_response = [
|
|
{
|
|
"info": {"role": "user", "id": "m1"},
|
|
"parts": [{"type": "text", "text": "fix the bug"}],
|
|
},
|
|
{
|
|
"info": {"role": "assistant", "id": "m2"},
|
|
"parts": [{"type": "text", "text": "```python\nx = 1\n```"}],
|
|
},
|
|
]
|
|
|
|
@property
|
|
def endpoint(self):
|
|
return "http://fake-opencode"
|
|
|
|
async def health(self):
|
|
self.calls.append(("health",))
|
|
return self.health_response
|
|
|
|
async def list_providers(self):
|
|
self.calls.append(("list_providers",))
|
|
return self.providers_response
|
|
|
|
async def create_session(self, title):
|
|
self.calls.append(("create_session", title))
|
|
return {"id": self.session_id, "title": title}
|
|
|
|
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))
|
|
return True
|
|
|
|
async def list_session_messages(self, session_id):
|
|
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
|
|
|
|
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:
|
|
"""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", 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 = {}
|
|
|
|
# ---- 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):
|
|
return self._sm.peek(notebook_path)
|
|
|
|
def get_active(self, notebook_path: str):
|
|
return self._sm.get_active(notebook_path)
|
|
|
|
def list_sessions(self) -> list:
|
|
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._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):
|
|
# When
|
|
response = await jp_fetch("opencode-bridge", "hello")
|
|
|
|
# Then
|
|
assert response.code == 200
|
|
payload = json.loads(response.body)
|
|
assert payload == {
|
|
"data": (
|
|
"Hello, world!"
|
|
" This is the '/opencode-bridge/hello' endpoint."
|
|
" Try visiting me in your browser!"
|
|
),
|
|
}
|
|
|
|
|
|
async def test_health_handler(monkeypatch, jp_fetch):
|
|
fake = FakeOpenCodeClient()
|
|
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
|
|
|
response = await jp_fetch("opencode-bridge", "health")
|
|
assert response.code == 200
|
|
payload = json.loads(response.body)
|
|
assert payload["ok"] is True
|
|
assert payload["version"] == "0.1.0"
|
|
assert ("health",) in fake.calls
|
|
|
|
|
|
async def test_providers_handler(monkeypatch, jp_fetch):
|
|
fake = FakeOpenCodeClient()
|
|
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
|
|
|
response = await jp_fetch("opencode-bridge", "providers")
|
|
assert response.code == 200
|
|
payload = json.loads(response.body)
|
|
assert "providers" in payload
|
|
assert payload["providers"][0]["id"] == "anthropic"
|
|
assert ("list_providers",) in fake.calls
|
|
|
|
|
|
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)
|
|
|
|
fake_sm = FakeSessionManager(session_id="fake-session-123")
|
|
monkeypatch.setattr(
|
|
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
|
)
|
|
|
|
body = json.dumps({
|
|
"prompt": "Add type hints",
|
|
"context": {
|
|
"notebookPath": "test.ipynb",
|
|
"cellId": "cell-1",
|
|
"language": "python",
|
|
"cellIndex": 0,
|
|
"totalCells": 1,
|
|
"source": "def foo(): return 42\n",
|
|
"previousCode": None,
|
|
"error": None,
|
|
},
|
|
})
|
|
response = await jp_fetch(
|
|
"opencode-bridge", "edit",
|
|
method="POST",
|
|
body=body,
|
|
)
|
|
assert response.code == 200
|
|
payload = json.loads(response.body)
|
|
# 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
|
|
|
|
# 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_async" in call_names
|
|
assert "send_message_sync" not in call_names # legacy sync path is gone
|
|
|
|
# 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] == 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 "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(
|
|
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()
|
|
# 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
|
|
)
|
|
|
|
response = await jp_fetch("opencode-bridge", "sessions")
|
|
assert response.code == 200
|
|
payload = json.loads(response.body)
|
|
assert "sessions" in payload
|
|
paths = {s["notebookPath"] for s in payload["sessions"]}
|
|
assert paths == {"foo.ipynb", "bar.ipynb"}
|
|
|
|
|
|
async def test_session_messages_handler_returns_empty_when_no_session(
|
|
monkeypatch, jp_fetch
|
|
) -> None:
|
|
# No session registered for this notebook -> handler returns
|
|
# {"messages": []} WITHOUT calling OpenCodeClient (peek short-circuits).
|
|
fake = FakeOpenCodeClient()
|
|
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
|
|
|
fake_sm = FakeSessionManager(session_id="")
|
|
monkeypatch.setattr(
|
|
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
|
)
|
|
|
|
response = await jp_fetch(
|
|
"opencode-bridge", "session-messages",
|
|
method="GET",
|
|
params={"notebook": "fresh.ipynb"},
|
|
)
|
|
assert response.code == 200
|
|
payload = json.loads(response.body)
|
|
assert payload == {"messages": []}
|
|
# No OpenCode call was made.
|
|
assert all(c[0] != "list_session_messages" for c in fake.calls)
|
|
|
|
|
|
async def test_session_messages_handler_projects_opencode_messages(
|
|
monkeypatch, jp_fetch
|
|
) -> None:
|
|
fake = FakeOpenCodeClient()
|
|
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
|
|
)
|
|
|
|
response = await jp_fetch(
|
|
"opencode-bridge", "session-messages",
|
|
method="GET",
|
|
params={"notebook": "test.ipynb"},
|
|
)
|
|
assert response.code == 200
|
|
payload = json.loads(response.body)
|
|
assert payload == {
|
|
"messages": [
|
|
{"role": "user", "content": "fix the bug"},
|
|
{"role": "assistant", "content": "```python\nx = 1\n```"},
|
|
]
|
|
}
|
|
# The OpenCode client was called with the session id from the manager.
|
|
assert ("list_session_messages", "fake-session-123") in fake.calls
|
|
|
|
|
|
async def test_session_messages_handler_requires_notebook(
|
|
monkeypatch, jp_fetch
|
|
) -> None:
|
|
fake = FakeOpenCodeClient()
|
|
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
|
# jp_fetch raises HTTPClientError on 4xx; assert the handler 400s
|
|
# (the body would contain "missing 'notebook' query parameter").
|
|
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
|
|
await jp_fetch("opencode-bridge", "session-messages", method="GET")
|
|
assert exc_info.value.code == 400
|
|
|
|
|
|
async def test_session_release_handler(monkeypatch, jp_fetch):
|
|
fake = FakeOpenCodeClient()
|
|
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
|
|
|
fake_sm = FakeSessionManager()
|
|
monkeypatch.setattr(
|
|
"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",
|
|
params={"notebook": "foo.ipynb"},
|
|
)
|
|
assert response.code == 200
|
|
payload = json.loads(response.body)
|
|
assert payload["ok"] is True
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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"
|