Compare commits
13
Commits
380e4a3150
..
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6507368e2 | ||
|
|
f9d52f7b8e | ||
|
|
310d50759a | ||
|
|
4ff5d7021c | ||
|
|
893f049eb7 | ||
|
|
d2bd4f1e48 | ||
|
|
fcd015ac0e | ||
|
|
d867b2f6d6 | ||
|
|
b4060d3b2f | ||
|
|
dd5667cea4 | ||
|
|
b7089519fd | ||
|
|
a43174e5bc | ||
|
|
0bd04a825d |
+110
@@ -0,0 +1,110 @@
|
|||||||
|
# opencode_bridge 交接文档
|
||||||
|
|
||||||
|
## 项目概览
|
||||||
|
JupyterLab extension,把本地 OpenCode Serve 进程的能力桥接到 notebook 单元。
|
||||||
|
**v0.1.1 (tag 在远端,当前 main 已经是 310d507)**。
|
||||||
|
|
||||||
|
## 仓库
|
||||||
|
`/Users/taochen/llm/notebook-extension/opencode-bridge`
|
||||||
|
- 主分支:`main`(已切回)
|
||||||
|
- 远端:`http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git`
|
||||||
|
- Tag:`v0.1.1` (310d507)
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
```
|
||||||
|
Frontend (JupyterLab TS) Server ext (Python tornado) OpenCode Serve
|
||||||
|
───────────────────────────────────── ───────────────────────────────── ─────────────
|
||||||
|
opencode_cell_actions.ts ─POST /opencode-bridge/edit──> EditHandler
|
||||||
|
per-cell AI 按钮 + inline panel _build_request_body(prompt) ──> /session/:id/prompt_async
|
||||||
|
(只发 prompt,不再自动注入 cell) ──> /session/:id/abort
|
||||||
|
opencode_inline_prompt.ts /session (list all)
|
||||||
|
1. SessionSelector 下拉(全 OpenCode sessions)
|
||||||
|
2. Provider + Model 选择(本地存储)
|
||||||
|
3. History 区域(流式 markdown 渲染)
|
||||||
|
4. "📋 插入单元格内容" 按钮(显式 opt-in)
|
||||||
|
5. 输入框 + send/cancel
|
||||||
|
|
||||||
|
SSE 订阅 GET /events?session= GlobalEventHandler ──proxy──> /global/event
|
||||||
|
→ 5 类事件路由:text/reasoning/tool/permission.asked/question.asked + session.idle
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键代码位置
|
||||||
|
| 文件 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| `src/components/opencode_cell_actions.ts` | cell 工具栏按钮 + 流编排(open/edit/SSE/permission/question/session 管理) |
|
||||||
|
| `src/components/opencode_inline_prompt.ts` | 内嵌 prompt 面板(最复杂,~1300 行) |
|
||||||
|
| `src/api/opencode_client.ts` | 8 个 session 管理 API + 4 个原有 API(edit/messages/permissions/questions/events) |
|
||||||
|
| `src/components/model_selection.ts` | (provider, model) localStorage 持久化 |
|
||||||
|
| `opencode_bridge/opencode_client.py` | HTTP 客户端,带 `send_message_async` + `stream_global_events`(SSE) + `reply_permission/question` + `list_all_sessions` |
|
||||||
|
| `opencode_bridge/routes.py` | 11 个路由 |
|
||||||
|
| `opencode_bridge/session_manager.py` | **1 notebook N sessions** 模型(`_active` + `_bound`) |
|
||||||
|
| `opencode_bridge/config.py` | OpencodeConfig Configurable(env var → `OpencodeConfig` Traitlets) |
|
||||||
|
|
||||||
|
## 主要路由
|
||||||
|
| 端点 | 方法 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/opencode-bridge/edit` | POST | 异步发 prompt_async(只发 prompt + context.notebookPath) |
|
||||||
|
| `/opencode-bridge/events?session=X` | GET | SSE 代理 OpenCode `/global/event` |
|
||||||
|
| `/opencode-bridge/session-messages?notebook=X` | GET | 当前 session 的消息历史 |
|
||||||
|
| `/opencode-bridge/sessions/all` | GET | OpenCode 上**所有** session(全局) |
|
||||||
|
| `/opencode-bridge/sessions/notebook?notebook=X` | GET/POST/PUT/DELETE | 绑定 + 列出 + 创建 + 删除 |
|
||||||
|
| `/opencode-bridge/sessions/active?notebook=X` | GET/PUT/DELETE | active session 操作 |
|
||||||
|
| `/opencode-bridge/permissions/:permId?session=X` | POST | 权限响应(once/always/reject) |
|
||||||
|
| `/opencode-bridge/questions/:qId/reply?session=X` | POST | 提问响应 |
|
||||||
|
|
||||||
|
## 关键设计决策(避免来回改)
|
||||||
|
|
||||||
|
### 1. SSE 事件路由(`applyEvent`)
|
||||||
|
- 客户端**不**对内容事件(text/reasoning/tool/idle)做 sessionID 过滤 — 服务端 `?session=` 参数是唯一权威
|
||||||
|
- 只对 `permission.asked` / `question.asked` 严格过滤,防止用户误答别 session 的提示
|
||||||
|
- 这条规则是 **bug fix 310d507 的核心**,改之前会"切 session 后发消息无回复"
|
||||||
|
|
||||||
|
### 2. Cell 上下文不再自动注入
|
||||||
|
- `OpenCodeRequest.context` 收缩为 `{notebookPath}` — 只告诉服务端哪个 notebook
|
||||||
|
- `_build_request_body` 只发 `parts=[{text: prompt}]`
|
||||||
|
- 用户通过"📋 插入单元格内容"按钮**显式 opt-in** 把 cell 源码作为 markdown code block 插入
|
||||||
|
- 理由:LLM context 严格匹配用户意图,避免被前一个 cell 的 traceback 污染
|
||||||
|
- 这是 310d507 的第二部分
|
||||||
|
|
||||||
|
### 3. 1 notebook N sessions
|
||||||
|
- `SessionManager._active` (dict[notebook] → active sessionId) + `_bound` (dict[notebook] → [sessionId, ...])
|
||||||
|
- 新方法:`create_new / bind_existing / set_active / unbind / unbind_active / delete_session / list_sessions_for_notebook / get_active`
|
||||||
|
- `release(notebook)` **删所有** bound sessions(不只是 active)
|
||||||
|
- `delete_session(notebook, sid)`:先调 OpenCode delete,成功后再 unbind(失败时 retry 仍可能)
|
||||||
|
|
||||||
|
### 4. 流式 markdown 渲染(避免 partial-render flicker)
|
||||||
|
- 流式期间只 `textContent += delta`,**不**调 `marked.parse`(否则 fence 没闭合时的部分渲染会闪 toolbar)
|
||||||
|
- `session.idle` 时 `_resetStreamPointers` 触发**一次**完整 `marked.parse` 渲染
|
||||||
|
- 终态与 `setMessages` 路径(setMessages 重新打开时拉的)完全一致
|
||||||
|
|
||||||
|
### 5. 交互 UI 权限
|
||||||
|
- `permission.asked` / `question.asked` 事件渲染**真实交互组件**(按钮 / 输入框)
|
||||||
|
- 点击通过后端路由代理回 OpenCode Serve
|
||||||
|
- 后端路由:`PUT /session/:sid/permissions/:permId` + `PUT /session/:sid/question/:qId/reply`
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
- 后端:73 pytest(主要看 `test_routes.py` + `test_session_manager.py`)
|
||||||
|
- 前端:72 jest(`opencode_cell_actions.spec.ts` 是核心,900+ 行覆盖了 SessionSelector / 流式渲染 / permission/question 交互)
|
||||||
|
- 命令: `source .venv/bin/activate && jlpm test && python -m pytest opencode_bridge/tests/`
|
||||||
|
|
||||||
|
## 已知小问题 / 待优化
|
||||||
|
1. **多 session 切换**时,前端用 PUT `/sessions/notebook` 一个调用搞定(bind + set active 都做),不再用 `/sessions/active` — 后者保留供"已 bound sessions 之间切换"用
|
||||||
|
2. `cell_context.ts` 已删,如果新增"只想要 cellId"的功能,需要重建
|
||||||
|
3. `package.json` version 是 `0.1.1`,跟远端 tag 一致(之前是用户手工 bump 上去的)
|
||||||
|
4. `UNIFIED_SYSTEM_PROMPT` 还在后端 routes.py 里,描述 LLM 的角色("你是一个代码助手...")。如果改 prompt,会同时影响所有 prompt
|
||||||
|
|
||||||
|
## 修改后的交互模式
|
||||||
|
1. 用户在 cell 工具栏点 🪄 按钮
|
||||||
|
2. 内嵌 prompt 出现:SessionSelector(列出 OpenCode 所有 session) + Provider/Model + history + "📋 插入单元格内容" + 输入框 + send/cancel
|
||||||
|
3. 用户输入 prompt,**可选**按"📋 插入单元格内容"把 cell 源码作为 code block 追加
|
||||||
|
4. send → 后端 `prompt_async` 立即返回 `{ok, sessionId}` + 订阅 SSE
|
||||||
|
5. 流式事件实时渲染,`session.idle` 触发 markdown 渲染(终态正确)
|
||||||
|
6. permission/question 事件渲染交互 UI,点击代理回 OpenCode
|
||||||
|
7. 切换 session → 关闭旧 SSE + bind 新 session(后端 PUT `/sessions/notebook`)+ reload history
|
||||||
|
|
||||||
|
## 还没做过的事(如果新会话要继续)
|
||||||
|
- UI tests (Playwright) — 写了 spec 但没真跑
|
||||||
|
- 持久化 session 选择(目前只持久化 provider/model)
|
||||||
|
- cell 类型推断优化(目前用 `model.type`,真实 cell 应该有 metadata.language)
|
||||||
|
- 后端 session TTL / cleanup(目前不删,除非用户显式 release)
|
||||||
|
- 真实 OpenCode Serve 接入测试(目前用 mock)
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
|
|
||||||
## 🔌 3. 接口契约设计 (API Contract)
|
## 🔌 3. 接口契约设计 (API Contract)
|
||||||
|
|
||||||
### 接口:`POST /opencode-bridge/edit`(v2 — 无 `mode`)
|
### 接口:`POST /opencode-bridge/edit`(v4 — 异步 + SSE)
|
||||||
|
|
||||||
**Request Payload**(前端 `OpenCodeCellActions` 在用户提交 inline 输入框时发出):
|
**Request Payload**(前端 `OpenCodeCellActions` 在用户提交 inline 输入框时发出):
|
||||||
|
|
||||||
@@ -84,20 +84,40 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> **v2 变化**:body 不再含 `mode` / `action` 字段。前端只把 cell 的 input/output/error 收集到 `context`,加用户的自然语言 `prompt` 一起发出;后端用统一的 `UNIFIED_SYSTEM_PROMPT` + LLM 自己判断优化/排错/编辑。`providerId` / `modelId` 来自 JupyterLab settings 透传。
|
> body 不含 `mode` / `action`。`providerId` / `modelId` 来自 inline 输入框动态选择,无 settings 持久化。
|
||||||
|
|
||||||
**Response**(当前 v2 实现:单次 JSON;SSE 流式属于 Slice 4 后续工作):
|
**Response**(v4 异步):后端调用 OpenCode `/session/:id/prompt_async` 后**立即返回**——不等 LLM 完成。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"finalSource": "import plotly.express as px\nfig = px.line(x=[1, 2, 3], y=[1, 2, 3])\nfig.show()",
|
|
||||||
"sessionId": "ses-abc123",
|
"sessionId": "ses-abc123",
|
||||||
"notebookPath": "analysis/demo.ipynb"
|
"notebookPath": "analysis/demo.ipynb"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 成功时前端用 `finalSource` 就地替换 cell 源码(`cell.model.sharedModel.setSource`)。Session 由服务端 `SessionManager` 按 `notebookPath` 复用(1 notebook 1 sid),404 / `session not found` 时服务端自动 invalidate 并在下次请求重建。
|
> 响应中**无** `markdown` 字段。AI 的回复通过独立的 SSE 端点(`/opencode-bridge/events`)实时推送给前端,前端在 inline prompt 的 history 区里增量渲染文本流 / 推理 / 工具调用 / 权限提问等。Session 仍由 `SessionManager` 按 `notebookPath` 复用(1 notebook 1 sid),404 / `session not found` 时服务端自动 invalidate 并在下次请求重建。cell 源码**不**被替换;用户可对 AI 消息中的每个代码块使用 Copy / Insert / Replace 按钮。
|
||||||
|
|
||||||
|
### 接口:`GET /opencode-bridge/events?session=<sid>`(v4 新增 — SSE 代理)
|
||||||
|
|
||||||
|
服务端长连接到 OpenCode Serve 的 `GET /global/event`,按 `session=<sid>` 过滤后以 `text/event-stream` 透传给前端。OpenCode 事件 schema 较松散,事件示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "session.next.text.delta", "properties": {"sessionID": "ses-abc123", "delta": "hel"}}
|
||||||
|
{"type": "session.next.text.delta", "properties": {"sessionID": "ses-abc123", "delta": "lo"}}
|
||||||
|
{"type": "session.next.reasoning.delta", "properties": {"sessionID": "ses-abc123", "delta": "thinking..."}}
|
||||||
|
{"type": "session.next.tool.called", "properties": {"sessionID": "ses-abc123", "name": "bash"}}
|
||||||
|
{"type": "session.next.tool.input.delta", "properties": {"sessionID": "ses-abc123", "delta": "{\"cmd\": \"ls\"}"}}
|
||||||
|
{"type": "session.next.tool.success", "properties": {"sessionID": "ses-abc123", "result": {"exitCode": 0}}}
|
||||||
|
{"type": "session.next.agent.switched", "properties": {"sessionID": "ses-abc123", "agentID": "build"}}
|
||||||
|
{"type": "session.next.model.switched", "properties": {"sessionID": "ses-abc123", "modelID": "claude-..."}}
|
||||||
|
{"type": "file.edited", "properties": {"sessionID": "ses-abc123", "path": "foo.py"}}
|
||||||
|
{"type": "permission.asked", "properties": {"sessionID": "ses-abc123", "id": "perm-1", "description": "rm -rf"}}
|
||||||
|
{"type": "question.asked", "properties": {"sessionID": "ses-abc123", "id": "q-1", "question": "Which env?"}}
|
||||||
|
{"type": "session.idle", "properties": {"sessionID": "ses-abc123"}}
|
||||||
|
```
|
||||||
|
|
||||||
|
> v4 前端 `OpenCodeInlinePrompt.applyEvent()` 按 `type` 路由到不同渲染器:text/reasoning/tool 块用 `<details>` 可折叠,permission/question 提示独立块(响应需要 OpenCode Serve 提供对应 API;本扩展 v4 仅渲染,不实现 allow/answer 回调路由)。`session.idle` 触发流指针重置、关闭 SSE 订阅、重新启用输入框。System 事件(`server.*` / `workspace.*` / `lsp.*` / `mcp.*` / `pty.*` / `installation.*`)以日志行形式展示,不参与 session 过滤。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,24 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
# Fallback when using the package in dev mode without installing
|
# Fallback when using the package in dev mode without installing
|
||||||
# in editable mode with pip. It is highly recommended to install
|
# 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
|
import warnings
|
||||||
warnings.warn("Importing 'opencode_bridge' outside a proper installation.")
|
warnings.warn("Importing 'opencode_bridge' outside a proper installation.")
|
||||||
__version__ = "dev"
|
__version__ = "dev"
|
||||||
|
from .config import OpencodeConfig
|
||||||
from .routes import setup_route_handlers
|
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():
|
def _jupyter_labextension_paths():
|
||||||
return [{
|
return [{
|
||||||
@@ -26,11 +38,25 @@ def _jupyter_server_extension_points():
|
|||||||
def _load_jupyter_server_extension(server_app):
|
def _load_jupyter_server_extension(server_app):
|
||||||
"""Registers the API handler to receive HTTP requests from the frontend extension.
|
"""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
|
Parameters
|
||||||
----------
|
----------
|
||||||
server_app: jupyterlab.labapp.LabApp
|
server_app: jupyterlab.labapp.LabApp
|
||||||
JupyterLab application instance
|
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)
|
setup_route_handlers(server_app.web_app)
|
||||||
name = "opencode_bridge"
|
name = "opencode_bridge"
|
||||||
server_app.log.info(f"Registered {name} server extension")
|
server_app.log.info(f"Registered {name} server extension")
|
||||||
|
|||||||
+113
-11
@@ -1,10 +1,22 @@
|
|||||||
"""Configuration resolution for the opencode-bridge extension.
|
"""Configuration resolution for the opencode-bridge extension.
|
||||||
|
|
||||||
All connection fields (URL / user / password / request timeout) are resolved
|
Connection fields (URL / user / password / request timeout) are resolved
|
||||||
exclusively from environment variables + built-in defaults at server startup.
|
in the following priority order, highest first:
|
||||||
They are NOT stored in the JupyterLab plugin settings.
|
|
||||||
|
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_URL — OpenCode Serve base URL (default http://127.0.0.1:4096)
|
||||||
OPENCODE_BRIDGE_USER — HTTP Basic Auth username (default 'opencode')
|
OPENCODE_BRIDGE_USER — HTTP Basic Auth username (default 'opencode')
|
||||||
OPENCODE_BRIDGE_PASSWORD — HTTP Basic Auth password (default '' = no auth)
|
OPENCODE_BRIDGE_PASSWORD — HTTP Basic Auth password (default '' = no auth)
|
||||||
@@ -15,6 +27,9 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
from typing import NamedTuple, Optional, Tuple
|
from typing import NamedTuple, Optional, Tuple
|
||||||
|
|
||||||
|
from traitlets import Int, Unicode, default
|
||||||
|
from traitlets.config import Configurable
|
||||||
|
|
||||||
|
|
||||||
ENV_URL = "OPENCODE_BRIDGE_URL"
|
ENV_URL = "OPENCODE_BRIDGE_URL"
|
||||||
ENV_USER = "OPENCODE_BRIDGE_USER"
|
ENV_USER = "OPENCODE_BRIDGE_USER"
|
||||||
@@ -40,19 +55,106 @@ class OpenCodeConfig(NamedTuple):
|
|||||||
return (self.user, self.password)
|
return (self.user, self.password)
|
||||||
|
|
||||||
|
|
||||||
def resolve_config(jupyter_settings: dict) -> OpenCodeConfig:
|
class OpencodeConfig(Configurable):
|
||||||
"""Resolve OpenCode connection config from environment variables + defaults.
|
"""Traitlets-configurable connection settings for the opencode-bridge server extension.
|
||||||
|
|
||||||
The `jupyter_settings` argument is accepted for API compatibility but no
|
Exposed to ``jupyter lab`` / ``jupyter server`` as ``--OpencodeConfig.<trait>``
|
||||||
fields are read from it — all connection config is now startup-only
|
and to ``jupyter_server_config.py`` as ``c.OpencodeConfig.<trait>``. Defaults
|
||||||
(environment variables).
|
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(
|
return OpenCodeConfig(
|
||||||
url=os.environ.get(ENV_URL) or DEFAULT_URL,
|
url=os.environ.get(ENV_URL) or DEFAULT_URL,
|
||||||
user=os.environ.get(ENV_USER) or DEFAULT_USER,
|
user=os.environ.get(ENV_USER) or DEFAULT_USER,
|
||||||
password=os.environ.get(ENV_PASSWORD) or "",
|
password=os.environ.get(ENV_PASSWORD) or "",
|
||||||
request_timeout_seconds=int(
|
request_timeout_seconds=int(
|
||||||
os.environ.get(ENV_TIMEOUT) or DEFAULT_REQUEST_TIMEOUT
|
os.environ.get(ENV_TIMEOUT) or DEFAULT_REQUEST_TIMEOUT
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
"""Async HTTP client for the local OpenCode server."""
|
"""Async HTTP client for the local OpenCode server.
|
||||||
|
|
||||||
|
Two interaction modes:
|
||||||
|
|
||||||
|
- **Request/response** (one-shot) - ``health``, ``list_providers``,
|
||||||
|
``create_session``, ``send_message_async``, ``abort``, ``delete_session``,
|
||||||
|
``list_session_messages``. ``send_message_async`` fires the prompt and
|
||||||
|
returns immediately; the actual LLM response is consumed via the SSE
|
||||||
|
stream (next mode).
|
||||||
|
- **Streaming** (long-lived) - :meth:`stream_global_events` is an async
|
||||||
|
generator over OpenCode Serve's ``GET /global/event`` SSE feed. It
|
||||||
|
yields each event as a parsed JSON dict until the upstream closes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import atexit
|
import atexit
|
||||||
import json
|
import json
|
||||||
from typing import Any, Optional
|
import logging
|
||||||
|
from typing import Any, AsyncIterator, Optional
|
||||||
|
|
||||||
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
|
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
|
||||||
from tornado.httputil import HTTPHeaders
|
from tornado.httputil import HTTPHeaders
|
||||||
@@ -10,6 +24,9 @@ from tornado.httputil import HTTPHeaders
|
|||||||
from .config import OpenCodeConfig
|
from .config import OpenCodeConfig
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger("opencode_bridge.opencode_client")
|
||||||
|
|
||||||
|
|
||||||
class OpenCodeError(Exception):
|
class OpenCodeError(Exception):
|
||||||
"""Raised when an OpenCode API request fails."""
|
"""Raised when an OpenCode API request fails."""
|
||||||
|
|
||||||
@@ -64,22 +81,28 @@ class OpenCodeClient:
|
|||||||
async def create_session(self, title: str) -> dict[str, Any]:
|
async def create_session(self, title: str) -> dict[str, Any]:
|
||||||
return await self._request("POST", "/session", {"title": title})
|
return await self._request("POST", "/session", {"title": title})
|
||||||
|
|
||||||
async def send_message_sync(
|
async def send_message_async(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
parts: list[dict[str, Any]],
|
parts: list[dict[str, Any]],
|
||||||
provider_id: Optional[str] = None,
|
provider_id: Optional[str] = None,
|
||||||
model_id: Optional[str] = None,
|
model_id: Optional[str] = None,
|
||||||
system: 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}
|
body: dict[str, Any] = {"parts": parts}
|
||||||
if provider_id is not None and model_id is not None:
|
if provider_id is not None and model_id is not None:
|
||||||
body["model"] = {"providerID": provider_id, "modelID": model_id}
|
body["model"] = {"providerID": provider_id, "modelID": model_id}
|
||||||
if system is not None:
|
if system is not None:
|
||||||
body["system"] = system
|
body["system"] = system
|
||||||
return await self._request(
|
await self._request(
|
||||||
"POST",
|
"POST",
|
||||||
"/session/%s/message" % session_id,
|
"/session/%s/prompt_async" % session_id,
|
||||||
body,
|
body,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -87,6 +110,41 @@ class OpenCodeClient:
|
|||||||
result = await self._request("POST", "/session/%s/abort" % session_id)
|
result = await self._request("POST", "/session/%s/abort" % session_id)
|
||||||
return result is not None
|
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:
|
async def delete_session(self, session_id: str) -> bool:
|
||||||
result = await self._request("DELETE", "/session/%s" % session_id)
|
result = await self._request("DELETE", "/session/%s" % session_id)
|
||||||
return result is not None
|
return result is not None
|
||||||
@@ -95,16 +153,124 @@ class OpenCodeClient:
|
|||||||
"""List all messages in the given OpenCode session.
|
"""List all messages in the given OpenCode session.
|
||||||
|
|
||||||
Returns the raw OpenCode response: a list of
|
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
|
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)
|
return await self._request("GET", "/session/%s/message" % session_id)
|
||||||
|
|
||||||
|
async def list_all_sessions(self) -> list[dict[str, Any]]:
|
||||||
|
"""List ALL sessions on the OpenCode Serve side (not just the ones
|
||||||
|
bound to a notebook in our SessionManager).
|
||||||
|
|
||||||
|
Returns the raw OpenCode response: a list of session objects with
|
||||||
|
metadata (``id``, ``title``, ``createdAt``, ``updatedAt``,
|
||||||
|
``messageCount`` etc., whatever the upstream returns).
|
||||||
|
"""
|
||||||
|
return await self._request("GET", "/session")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def endpoint(self) -> str:
|
def endpoint(self) -> str:
|
||||||
return self._config.url
|
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(
|
async def _request(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
|
|||||||
+438
-54
@@ -48,44 +48,19 @@ def get_session_manager(handler: APIHandler) -> SessionManager:
|
|||||||
return sm
|
return sm
|
||||||
|
|
||||||
|
|
||||||
def _build_request_body(prompt: str, context: dict) -> dict:
|
def _build_request_body(prompt: str) -> dict:
|
||||||
"""Build full request body for OpenCode POST /session/:id/message.
|
"""Build request body for OpenCode POST /session/:id/prompt_async.
|
||||||
|
|
||||||
Returns dict with 'parts' (list) and 'system' (str) keys. No mode concept:
|
v0.2.x: only the user's prompt is sent to the LLM. We no longer
|
||||||
the LLM interprets the user's natural-language instruction, with the code
|
auto-wrap the cell source / previous-cell / traceback in tags —
|
||||||
context and the optional traceback in front of it.
|
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.
|
||||||
"""
|
"""
|
||||||
parts: list[dict] = []
|
return {
|
||||||
|
"parts": [{"type": "text", "text": prompt}],
|
||||||
if context.get("previousCode"):
|
"system": UNIFIED_SYSTEM_PROMPT,
|
||||||
parts.append({
|
}
|
||||||
"type": "text",
|
|
||||||
"text": "<previous_cell>\n%s\n</previous_cell>\n" % context["previousCode"],
|
|
||||||
})
|
|
||||||
|
|
||||||
error = context.get("error")
|
|
||||||
if error:
|
|
||||||
parts.append({
|
|
||||||
"type": "text",
|
|
||||||
"text": (
|
|
||||||
"<traceback>\n%s: %s\n" % (error["ename"], error["evalue"])
|
|
||||||
+ "\n".join(error.get("traceback", []))
|
|
||||||
+ "\n</traceback>\n"
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
parts.append({
|
|
||||||
"type": "text",
|
|
||||||
"text": "<cell language='%s'>\n%s\n</cell>\n" % (
|
|
||||||
context.get("language", "python"),
|
|
||||||
context["source"],
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
if prompt:
|
|
||||||
parts.append({"type": "text", "text": "<instruction>\n%s\n</instruction>" % prompt})
|
|
||||||
|
|
||||||
return {"parts": parts, "system": UNIFIED_SYSTEM_PROMPT}
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_code_fence(s: str) -> str:
|
def _strip_code_fence(s: str) -> str:
|
||||||
@@ -105,6 +80,26 @@ def _strip_code_fence(s: str) -> str:
|
|||||||
return s
|
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):
|
class HelloRouteHandler(APIHandler):
|
||||||
# The following decorator should be present on all verb methods (head, get, post,
|
# 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
|
# patch, put, delete, options) to ensure only authorized user can request the
|
||||||
@@ -154,6 +149,13 @@ class ProvidersHandler(APIHandler):
|
|||||||
|
|
||||||
|
|
||||||
class EditHandler(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
|
@tornado.web.authenticated
|
||||||
async def post(self):
|
async def post(self):
|
||||||
try:
|
try:
|
||||||
@@ -177,45 +179,175 @@ class EditHandler(APIHandler):
|
|||||||
sm = get_session_manager(self)
|
sm = get_session_manager(self)
|
||||||
try:
|
try:
|
||||||
sid = await sm.get_or_create(notebook_path)
|
sid = await sm.get_or_create(notebook_path)
|
||||||
request_body = _build_request_body(prompt, context)
|
request_body = _build_request_body(prompt)
|
||||||
result = await client.send_message_sync(
|
await client.send_message_async(
|
||||||
sid,
|
sid,
|
||||||
request_body["parts"],
|
request_body["parts"],
|
||||||
provider_id=provider_id,
|
provider_id=provider_id,
|
||||||
model_id=model_id,
|
model_id=model_id,
|
||||||
system=request_body["system"],
|
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({
|
self.finish(json.dumps({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"markdown": markdown,
|
|
||||||
"sessionId": sid,
|
"sessionId": sid,
|
||||||
"notebookPath": notebook_path,
|
"notebookPath": notebook_path,
|
||||||
}))
|
}))
|
||||||
except OpenCodeError as e:
|
except OpenCodeError as e:
|
||||||
# If session is invalid on OpenCode side, invalidate cache.
|
# 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():
|
if "404" in str(e) or "not found" in str(e).lower():
|
||||||
sm.invalidate(notebook_path)
|
sm.invalidate(notebook_path)
|
||||||
log.warning("invalidated dead session for %s", 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.set_status(502)
|
||||||
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
||||||
except Exception as 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.set_status(502)
|
||||||
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
self.finish(json.dumps({"ok": False, "error": str(e)}))
|
||||||
# NO finally delete — session is reused per notebook.
|
|
||||||
|
|
||||||
|
|
||||||
class SessionListHandler(APIHandler):
|
class SessionListHandler(APIHandler):
|
||||||
@@ -301,6 +433,242 @@ class SessionMessagesHandler(APIHandler):
|
|||||||
self.finish(json.dumps({"messages": messages}))
|
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):
|
def setup_route_handlers(web_app):
|
||||||
host_pattern = ".*$"
|
host_pattern = ".*$"
|
||||||
base_url = web_app.settings["base_url"]
|
base_url = web_app.settings["base_url"]
|
||||||
@@ -310,9 +678,25 @@ def setup_route_handlers(web_app):
|
|||||||
(url_path_join(base_url, "opencode-bridge", "health"), HealthHandler),
|
(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", "providers"), ProvidersHandler),
|
||||||
(url_path_join(base_url, "opencode-bridge", "edit"), EditHandler),
|
(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", "sessions"), SessionListHandler),
|
||||||
(url_path_join(base_url, "opencode-bridge", "session"), SessionReleaseHandler),
|
(url_path_join(base_url, "opencode-bridge", "session"), SessionReleaseHandler),
|
||||||
(url_path_join(base_url, "opencode-bridge", "session-messages"), SessionMessagesHandler),
|
(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)
|
web_app.add_handlers(host_pattern, handlers)
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
"""Per-notebook session manager for OpenCode.
|
"""Per-notebook session manager for OpenCode.
|
||||||
|
|
||||||
Maps notebookPath -> OpenCode sessionID. Lazy create on first use.
|
Maps each notebookPath to one OR MORE OpenCode session IDs. One of
|
||||||
Async-safe via per-path asyncio.Lock. No automatic cleanup.
|
the bound sessions is the "active" one (the one used for the next
|
||||||
|
``/edit``). The others are historical — they were created in this
|
||||||
|
notebook at some point but the user switched away (e.g. started a
|
||||||
|
new conversation topic).
|
||||||
|
|
||||||
|
Lifecycle:
|
||||||
|
- ``get_or_create`` returns the active session, lazily creating one on
|
||||||
|
first use. This is the path used by ``EditHandler`` on every prompt.
|
||||||
|
- ``create_new`` always creates a brand-new session on OpenCode Serve,
|
||||||
|
binds it, sets it as active. Used by the "New session" UI action.
|
||||||
|
- ``bind_existing`` / ``set_active`` / ``unbind`` / ``delete_session``
|
||||||
|
are the management operations for the per-notebook session list.
|
||||||
|
|
||||||
|
Async-safety: a per-notebook ``asyncio.Lock`` serializes
|
||||||
|
``get_or_create`` so concurrent prompts on the same notebook don't
|
||||||
|
double-create. The lock survives across calls (held in ``_locks``) so
|
||||||
|
subsequent calls can reuse it.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Callable
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from .opencode_client import OpenCodeClient
|
from .opencode_client import OpenCodeClient
|
||||||
|
|
||||||
@@ -17,81 +33,216 @@ ClientFactory = Callable[[], OpenCodeClient]
|
|||||||
|
|
||||||
|
|
||||||
class SessionManager:
|
class SessionManager:
|
||||||
"""Tracks one OpenCode session per notebook path.
|
|
||||||
|
|
||||||
Threading/async model:
|
|
||||||
- Multiple coroutines may call get_or_create for the same notebook.
|
|
||||||
- First call creates; subsequent calls return the same sessionID.
|
|
||||||
- Per-notebook asyncio.Lock prevents double-create under concurrency.
|
|
||||||
- Locks remain in the map; they may be needed again for the same path.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, client_factory: ClientFactory) -> None:
|
def __init__(self, client_factory: ClientFactory) -> None:
|
||||||
self._client_factory = client_factory
|
self._client_factory = client_factory
|
||||||
self._sessions: dict[str, str] = {} # notebookPath -> sessionID
|
# notebookPath -> active sessionId
|
||||||
self._locks: dict[str, asyncio.Lock] = {} # notebookPath -> lock
|
self._active: dict[str, str] = {}
|
||||||
self._titles: dict[str, str] = {} # notebookPath -> title (for debug)
|
# notebookPath -> ordered list of sessionIds bound to this
|
||||||
|
# notebook (the first is the oldest; the active one may be
|
||||||
|
# anywhere in the list). Used by the UI to render the session
|
||||||
|
# picker.
|
||||||
|
self._bound: dict[str, list[str]] = {}
|
||||||
|
# Per-notebook lock for serializing lazy-create.
|
||||||
|
self._locks: dict[str, asyncio.Lock] = {}
|
||||||
|
|
||||||
|
# ---------- active-session lookup / create ----------
|
||||||
|
|
||||||
async def get_or_create(self, notebook_path: str) -> str:
|
async def get_or_create(self, notebook_path: str) -> str:
|
||||||
"""Return session ID for the notebook, creating one if needed.
|
"""Return the active session id for this notebook, creating one
|
||||||
|
if no active session is bound yet. This is what ``EditHandler``
|
||||||
Idempotent for the same path. Different paths get different sessions.
|
calls on every prompt.
|
||||||
"""
|
"""
|
||||||
existing = self._sessions.get(notebook_path)
|
active = self._active.get(notebook_path)
|
||||||
if existing is not None:
|
if active is not None:
|
||||||
return existing
|
return active
|
||||||
lock = self._locks.setdefault(notebook_path, asyncio.Lock())
|
lock = self._locks.setdefault(notebook_path, asyncio.Lock())
|
||||||
async with lock:
|
async with lock:
|
||||||
existing = self._sessions.get(notebook_path)
|
active = self._active.get(notebook_path)
|
||||||
if existing is not None:
|
if active is not None:
|
||||||
return existing
|
return active
|
||||||
|
return await self._create_and_bind(notebook_path, title=None)
|
||||||
|
|
||||||
|
async def _create_and_bind(
|
||||||
|
self, notebook_path: str, title: Optional[str]
|
||||||
|
) -> str:
|
||||||
client = self._client_factory()
|
client = self._client_factory()
|
||||||
session = await client.create_session(
|
session = await client.create_session(
|
||||||
title="jupyter:%s" % notebook_path
|
title=title or "jupyter:%s" % notebook_path
|
||||||
)
|
)
|
||||||
sid = session["id"]
|
sid = session["id"]
|
||||||
self._sessions[notebook_path] = sid
|
self._bind(notebook_path, sid)
|
||||||
self._titles[notebook_path] = notebook_path
|
|
||||||
log.info("created opencode session %s for %s", sid, notebook_path)
|
log.info("created opencode session %s for %s", sid, notebook_path)
|
||||||
return sid
|
return sid
|
||||||
|
|
||||||
async def release(self, notebook_path: str) -> bool:
|
def get_active(self, notebook_path: str) -> Optional[str]:
|
||||||
"""Delete session and remove from map. Returns True if a session existed."""
|
"""Return the active sessionId for the notebook, or None if no
|
||||||
sid = self._sessions.pop(notebook_path, None)
|
active session has been bound/created yet. Does NOT create one.
|
||||||
self._titles.pop(notebook_path, None)
|
"""
|
||||||
self._locks.pop(notebook_path, None)
|
return self._active.get(notebook_path)
|
||||||
if sid is None:
|
|
||||||
|
def peek(self, notebook_path: str) -> Optional[str]:
|
||||||
|
"""Alias for :meth:`get_active` (legacy name)."""
|
||||||
|
return self.get_active(notebook_path)
|
||||||
|
|
||||||
|
def has_session(self, notebook_path: str) -> bool:
|
||||||
|
"""True iff at least one session has ever been bound to this
|
||||||
|
notebook (active or not)."""
|
||||||
|
return notebook_path in self._bound
|
||||||
|
|
||||||
|
# ---------- mutation: bind / unbind / set_active / create_new ----------
|
||||||
|
|
||||||
|
def _bind(self, notebook_path: str, session_id: str) -> None:
|
||||||
|
"""Add session_id to the bound list (if not already) and set it
|
||||||
|
as active. Idempotent on re-bind.
|
||||||
|
"""
|
||||||
|
self._active[notebook_path] = session_id
|
||||||
|
bound = self._bound.setdefault(notebook_path, [])
|
||||||
|
if session_id not in bound:
|
||||||
|
bound.append(session_id)
|
||||||
|
|
||||||
|
async def create_new(
|
||||||
|
self, notebook_path: str, title: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
|
"""Create a brand new session on OpenCode Serve, bind it to
|
||||||
|
this notebook, set as active, return its id.
|
||||||
|
"""
|
||||||
|
return await self._create_and_bind(notebook_path, title)
|
||||||
|
|
||||||
|
def bind_existing(self, notebook_path: str, session_id: str) -> None:
|
||||||
|
"""Bind an existing sessionId to this notebook and set as active.
|
||||||
|
Does NOT call OpenCode — the caller must verify the session
|
||||||
|
exists (e.g. via ``GET /session``).
|
||||||
|
"""
|
||||||
|
self._bind(notebook_path, session_id)
|
||||||
|
|
||||||
|
def set_active(self, notebook_path: str, session_id: str) -> bool:
|
||||||
|
"""Switch the active session. Returns False if ``session_id`` is
|
||||||
|
not currently bound to this notebook (callers should bind first
|
||||||
|
if uncertain).
|
||||||
|
"""
|
||||||
|
bound = self._bound.get(notebook_path, [])
|
||||||
|
if session_id not in bound:
|
||||||
|
return False
|
||||||
|
self._active[notebook_path] = session_id
|
||||||
|
return True
|
||||||
|
|
||||||
|
def unbind(self, notebook_path: str, session_id: str) -> bool:
|
||||||
|
"""Remove ``session_id`` from this notebook's bindings. If it was
|
||||||
|
the active one, also clear active (next prompt will lazy-create
|
||||||
|
a new session). Returns True if it was bound.
|
||||||
|
"""
|
||||||
|
bound = self._bound.get(notebook_path)
|
||||||
|
if not bound or session_id not in bound:
|
||||||
|
return False
|
||||||
|
bound.remove(session_id)
|
||||||
|
if self._active.get(notebook_path) == session_id:
|
||||||
|
self._active.pop(notebook_path, None)
|
||||||
|
if not bound:
|
||||||
|
self._bound.pop(notebook_path, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def unbind_active(self, notebook_path: str) -> bool:
|
||||||
|
"""Remove the active session binding only (leaves the rest of
|
||||||
|
the notebook's bound sessions intact). Next prompt will
|
||||||
|
lazy-create a new active session. Returns True if there was an
|
||||||
|
active binding to remove.
|
||||||
|
"""
|
||||||
|
active = self._active.pop(notebook_path, None)
|
||||||
|
if active is None:
|
||||||
|
return False
|
||||||
|
bound = self._bound.get(notebook_path)
|
||||||
|
if bound is not None:
|
||||||
|
bound.remove(active)
|
||||||
|
if not bound:
|
||||||
|
self._bound.pop(notebook_path, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def delete_session(
|
||||||
|
self, notebook_path: str, session_id: str
|
||||||
|
) -> bool:
|
||||||
|
"""Delete the session on OpenCode Serve AND remove its binding.
|
||||||
|
|
||||||
|
Order matters: we ask OpenCode to delete FIRST, and only remove
|
||||||
|
the local binding if OpenCode confirmed the delete. That way a
|
||||||
|
failed delete leaves the binding intact (the user can retry);
|
||||||
|
a deleted session never leaves a phantom binding behind.
|
||||||
|
|
||||||
|
Returns True if the session was actually deleted (i.e. it was
|
||||||
|
bound AND OpenCode acknowledged the delete). If the session is
|
||||||
|
not bound to this notebook, do NOT call OpenCode (we don't
|
||||||
|
want to silently delete sessions owned by other tools).
|
||||||
|
"""
|
||||||
|
if not self._is_bound(notebook_path, session_id):
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
client = self._client_factory()
|
client = self._client_factory()
|
||||||
return await client.delete_session(sid)
|
deleted = await client.delete_session(session_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning(
|
log.warning("failed to delete opencode session %s", session_id)
|
||||||
"failed to delete opencode session %s for %s", sid, notebook_path
|
|
||||||
)
|
|
||||||
return False
|
return False
|
||||||
|
# Whether the delete succeeded or just returned False (404), the
|
||||||
|
# session is gone on the OpenCode side — drop the binding to
|
||||||
|
# keep the UI consistent. We return whether OpenCode said
|
||||||
|
# "yes" so the caller can surface a warning on a 404.
|
||||||
|
self.unbind(notebook_path, session_id)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def _is_bound(self, notebook_path: str, session_id: str) -> bool:
|
||||||
|
bound = self._bound.get(notebook_path)
|
||||||
|
return bool(bound) and session_id in bound
|
||||||
|
|
||||||
|
# ---------- legacy / compat ----------
|
||||||
|
|
||||||
|
async def release(self, notebook_path: str) -> bool:
|
||||||
|
"""Tear down ALL state for this notebook: delete every bound
|
||||||
|
session on OpenCode Serve AND clear every local binding.
|
||||||
|
Returns True iff at least one session was actually deleted
|
||||||
|
on the OpenCode side. (For deleting a SPECIFIC session use
|
||||||
|
:meth:`delete_session`.)
|
||||||
|
"""
|
||||||
|
bound = list(self._bound.get(notebook_path, []))
|
||||||
|
deleted_any = False
|
||||||
|
for sid in bound:
|
||||||
|
if await self.delete_session(notebook_path, sid):
|
||||||
|
deleted_any = True
|
||||||
|
# `delete_session` clears the binding. Belt-and-suspenders: drop
|
||||||
|
# any straggler state.
|
||||||
|
self._bound.pop(notebook_path, None)
|
||||||
|
self._active.pop(notebook_path, None)
|
||||||
|
self._locks.pop(notebook_path, None)
|
||||||
|
return deleted_any
|
||||||
|
|
||||||
def invalidate(self, notebook_path: str) -> bool:
|
def invalidate(self, notebook_path: str) -> bool:
|
||||||
"""Drop the cached sessionID without calling OpenCode. Returns True if removed.
|
"""Drop the cached active sessionId without calling OpenCode.
|
||||||
|
Returns True if a binding was removed. Use this when an
|
||||||
Use this when an upstream error indicates the session is dead (e.g., 404).
|
upstream error indicates the session is dead (e.g. 404).
|
||||||
"""
|
"""
|
||||||
sid = self._sessions.pop(notebook_path, None)
|
was_active = self._active.pop(notebook_path, None)
|
||||||
self._titles.pop(notebook_path, None)
|
if was_active is None:
|
||||||
return sid is not None
|
return False
|
||||||
|
bound = self._bound.get(notebook_path)
|
||||||
def has_session(self, notebook_path: str) -> bool:
|
if bound is not None:
|
||||||
return notebook_path in self._sessions
|
try:
|
||||||
|
bound.remove(was_active)
|
||||||
def peek(self, notebook_path: str) -> Optional[str]:
|
except ValueError:
|
||||||
"""Return the cached sessionID for the notebook, or None if no
|
pass
|
||||||
session has been created yet. Does NOT create one (unlike
|
if not bound:
|
||||||
get_or_create) — used by the history endpoint to avoid spawning
|
self._bound.pop(notebook_path, None)
|
||||||
a session just to report that there is none."""
|
return True
|
||||||
return self._sessions.get(notebook_path)
|
|
||||||
|
|
||||||
def list_sessions(self) -> list[dict]:
|
def list_sessions(self) -> list[dict]:
|
||||||
|
"""Debug: list all (notebookPath, activeSessionId) pairs across
|
||||||
|
every notebook. One row per notebook — does NOT enumerate the
|
||||||
|
full bound-list per notebook. Use
|
||||||
|
:meth:`list_sessions_for_notebook` for that.
|
||||||
|
"""
|
||||||
return [
|
return [
|
||||||
{"notebookPath": path, "sessionId": sid}
|
{"notebookPath": path, "sessionId": sid}
|
||||||
for path, sid in sorted(self._sessions.items())
|
for path, sid in sorted(self._active.items())
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def list_sessions_for_notebook(self, notebook_path: str) -> list[str]:
|
||||||
|
"""All sessionIds bound to this notebook (in bind order: oldest
|
||||||
|
first, newest last). Empty list if none.
|
||||||
|
"""
|
||||||
|
return list(self._bound.get(notebook_path, []))
|
||||||
|
|||||||
@@ -98,28 +98,30 @@ async def test_auth_absent_when_no_password(base_config: OpenCodeConfig) -> None
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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,
|
base_config: OpenCodeConfig,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""send_message_async posts to /prompt_async with provider/model when both set."""
|
||||||
client, mock = _make_client(base_config, [(200, {"done": True})])
|
client, mock = _make_client(base_config, [(200, {"done": True})])
|
||||||
parts = [{"type": "text", "text": "hello"}]
|
parts = [{"type": "text", "text": "hello"}]
|
||||||
result = await client.send_message_sync("s1", parts, provider_id="p1", model_id="m1")
|
result = await client.send_message_async("s1", parts, provider_id="p1", model_id="m1")
|
||||||
assert result == {"done": True}
|
assert result is None
|
||||||
call = mock.calls[0]
|
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"))
|
body = json.loads(call["request"].body.decode("utf-8"))
|
||||||
assert body["parts"] == parts
|
assert body["parts"] == parts
|
||||||
assert body["model"] == {"providerID": "p1", "modelID": "m1"}
|
assert body["model"] == {"providerID": "p1", "modelID": "m1"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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,
|
base_config: OpenCodeConfig,
|
||||||
) -> None:
|
) -> None:
|
||||||
client, mock = _make_client(base_config, [(200, {"done": True})])
|
client, mock = _make_client(base_config, [(200, {"done": True})])
|
||||||
parts = [{"type": "text", "text": "hello"}]
|
parts = [{"type": "text", "text": "hello"}]
|
||||||
result = await client.send_message_sync("s1", parts)
|
result = await client.send_message_async("s1", parts)
|
||||||
assert result == {"done": True}
|
assert result is None
|
||||||
call = mock.calls[0]
|
call = mock.calls[0]
|
||||||
body = json.loads(call["request"].body.decode("utf-8"))
|
body = json.loads(call["request"].body.decode("utf-8"))
|
||||||
assert body["parts"] == parts
|
assert body["parts"] == parts
|
||||||
@@ -127,7 +129,7 @@ async def test_send_message_sync_omits_model_when_provider_or_model_missing(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""system param is forwarded into the request body when not None."""
|
||||||
config = OpenCodeConfig(
|
config = OpenCodeConfig(
|
||||||
url="http://x:1", user="u", password="", request_timeout_seconds=120
|
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": []}))
|
mock.responses.append((200, {"info": {}, "parts": []}))
|
||||||
client = OpenCodeClient(config, http_client=mock)
|
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
|
assert len(mock.calls) == 1
|
||||||
body = json.loads(mock.calls[0]["request"].body.decode("utf-8"))
|
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"}]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_delete_session_returns_bool_by_status(base_config: OpenCodeConfig) -> None:
|
async def test_delete_session_returns_bool_by_status(base_config: OpenCodeConfig) -> None:
|
||||||
client, mock = _make_client(base_config, [(200, True), (404, False)])
|
client, mock = _make_client(base_config, [(200, True), (404, False)])
|
||||||
@@ -154,6 +233,55 @@ 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"
|
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_list_all_sessions_gets_session(base_config: OpenCodeConfig) -> None:
|
||||||
|
"""list_all_sessions proxies OpenCode Serve's GET /session and returns
|
||||||
|
the raw list (no projection)."""
|
||||||
|
payload = [
|
||||||
|
{"id": "s1", "title": "first", "createdAt": 1, "updatedAt": 2},
|
||||||
|
{"id": "s2", "title": "second", "createdAt": 3, "updatedAt": 4},
|
||||||
|
]
|
||||||
|
client, mock = _make_client(base_config, [(200, payload)])
|
||||||
|
sessions = await client.list_all_sessions()
|
||||||
|
assert sessions == payload
|
||||||
|
call = mock.calls[0]
|
||||||
|
assert call["request"].url == "http://127.0.0.1:4096/session"
|
||||||
|
assert call["request"].method == "GET"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_non_2xx_response_raises_with_body(base_config: OpenCodeConfig) -> None:
|
async def test_non_2xx_response_raises_with_body(base_config: OpenCodeConfig) -> None:
|
||||||
client, mock = _make_client(base_config, [(500, {"error": "boom"})])
|
client, mock = _make_client(base_config, [(500, {"error": "boom"})])
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ class FakeOpenCodeClient:
|
|||||||
self.session_id = "fake-session-123"
|
self.session_id = "fake-session-123"
|
||||||
self.health_response = {"healthy": True, "version": "0.1.0"}
|
self.health_response = {"healthy": True, "version": "0.1.0"}
|
||||||
self.providers_response = {"providers": [{"id": "anthropic", "models": [{"id": "claude"}]}]}
|
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).
|
# Canned session-messages list response (list_session_messages).
|
||||||
# Default: one user + one assistant message, mixed text parts.
|
# Default: one user + one assistant message, mixed text parts.
|
||||||
self.messages_response = [
|
self.messages_response = [
|
||||||
@@ -45,9 +41,13 @@ class FakeOpenCodeClient:
|
|||||||
self.calls.append(("create_session", title))
|
self.calls.append(("create_session", title))
|
||||||
return {"id": self.session_id, "title": 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):
|
async def send_message_async(
|
||||||
self.calls.append(("send_message_sync", session_id, parts, system))
|
self, session_id, parts, provider_id=None, model_id=None, system=None
|
||||||
return self.message_response
|
):
|
||||||
|
self.calls.append(
|
||||||
|
("send_message_async", session_id, parts, system, provider_id, model_id)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
async def delete_session(self, session_id):
|
async def delete_session(self, session_id):
|
||||||
self.calls.append(("delete_session", session_id))
|
self.calls.append(("delete_session", session_id))
|
||||||
@@ -57,38 +57,123 @@ class FakeOpenCodeClient:
|
|||||||
self.calls.append(("list_session_messages", session_id))
|
self.calls.append(("list_session_messages", session_id))
|
||||||
return self.messages_response
|
return self.messages_response
|
||||||
|
|
||||||
|
async def list_all_sessions(self):
|
||||||
class FakeSessionManager:
|
self.calls.append(("list_all_sessions",))
|
||||||
"""Drop-in replacement for SessionManager with recording."""
|
|
||||||
|
|
||||||
def __init__(self, session_id: str = "fake-session-123") -> None:
|
|
||||||
self._session_id = session_id
|
|
||||||
self.calls: list = []
|
|
||||||
|
|
||||||
async def get_or_create(self, notebook_path: str) -> str:
|
|
||||||
self.calls.append(("get_or_create", notebook_path))
|
|
||||||
return self._session_id
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
async def release(self, notebook_path: str) -> bool:
|
|
||||||
self.calls.append(("release", notebook_path))
|
|
||||||
return True
|
|
||||||
|
|
||||||
def list_sessions(self) -> list:
|
|
||||||
return [
|
return [
|
||||||
{"notebookPath": path, "sessionId": sid}
|
{
|
||||||
for path, sid in sorted(self._sessions.items())
|
"id": "fake-session-123",
|
||||||
|
"title": "jupyter:foo.ipynb",
|
||||||
|
"createdAt": 1700000000000,
|
||||||
|
"updatedAt": 1700000001000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "other-session",
|
||||||
|
"title": "manual session",
|
||||||
|
"createdAt": 1700000010000,
|
||||||
|
"updatedAt": 1700000011000,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
def invalidate(self, notebook_path: str) -> bool:
|
async def create_session(self, title):
|
||||||
self.calls.append(("invalidate", notebook_path))
|
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
|
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):
|
async def test_hello(jp_fetch):
|
||||||
# When
|
# When
|
||||||
@@ -131,6 +216,12 @@ async def test_providers_handler(monkeypatch, jp_fetch):
|
|||||||
|
|
||||||
|
|
||||||
async def test_edit_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()
|
fake = FakeOpenCodeClient()
|
||||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||||
|
|
||||||
@@ -159,37 +250,81 @@ async def test_edit_handler(monkeypatch, jp_fetch):
|
|||||||
)
|
)
|
||||||
assert response.code == 200
|
assert response.code == 200
|
||||||
payload = json.loads(response.body)
|
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 payload["ok"] is True
|
||||||
# Server now returns the AI reply as raw markdown (```fences``` kept
|
assert isinstance(payload["sessionId"], str) and payload["sessionId"]
|
||||||
# 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["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]
|
call_names = [c[0] for c in fake.calls]
|
||||||
assert "create_session" not in call_names
|
assert "create_session" not in call_names
|
||||||
assert "delete_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_message_async received a session id from the manager (the
|
||||||
send_call = [c for c in fake.calls if c[0] == "send_message_sync"][0]
|
# exact value comes from the FakeClient; just verify it matches
|
||||||
assert send_call[1] == "fake-session-123"
|
# the response's sessionId).
|
||||||
# system prompt was passed (v3+ allows markdown/fences in the reply)
|
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]
|
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.
|
||||||
|
# (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]
|
sm_call_names = [c[0] for c in fake_sm.calls]
|
||||||
assert sm_call_names == ["get_or_create"]
|
assert "get_or_create" in sm_call_names
|
||||||
assert fake_sm.calls[0][1] == "test.ipynb"
|
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):
|
async def test_session_list_handler(monkeypatch, jp_fetch):
|
||||||
fake_sm = FakeSessionManager()
|
fake_sm = FakeSessionManager()
|
||||||
fake_sm._sessions = {
|
# Seed the underlying real SessionManager's state so list_sessions
|
||||||
"foo.ipynb": "sid-1",
|
# returns the expected debug mapping.
|
||||||
"bar.ipynb": "sid-2",
|
fake_sm._active["foo.ipynb"] = "sid-1"
|
||||||
} # direct injection
|
fake_sm._active["bar.ipynb"] = "sid-2"
|
||||||
|
fake_sm._bound["foo.ipynb"] = ["sid-1"]
|
||||||
|
fake_sm._bound["bar.ipynb"] = ["sid-2"]
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
||||||
)
|
)
|
||||||
@@ -234,6 +369,10 @@ async def test_session_messages_handler_projects_opencode_messages(
|
|||||||
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||||
|
|
||||||
fake_sm = FakeSessionManager(session_id="fake-session-123")
|
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(
|
monkeypatch.setattr(
|
||||||
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
||||||
)
|
)
|
||||||
@@ -276,6 +415,11 @@ async def test_session_release_handler(monkeypatch, jp_fetch):
|
|||||||
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
"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(
|
response = await jp_fetch(
|
||||||
"opencode-bridge", "session",
|
"opencode-bridge", "session",
|
||||||
method="DELETE",
|
method="DELETE",
|
||||||
@@ -287,3 +431,337 @@ async def test_session_release_handler(monkeypatch, jp_fetch):
|
|||||||
assert payload["notebookPath"] == "foo.ipynb"
|
assert payload["notebookPath"] == "foo.ipynb"
|
||||||
assert payload["deleted"] is True
|
assert payload["deleted"] is True
|
||||||
assert ("release", "foo.ipynb") in fake_sm.calls
|
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"
|
||||||
|
|||||||
@@ -7,13 +7,31 @@ from opencode_bridge.session_manager import SessionManager
|
|||||||
|
|
||||||
|
|
||||||
class FakeClient:
|
class FakeClient:
|
||||||
"""Mimics OpenCodeClient just enough for SessionManager."""
|
"""Mimics OpenCodeClient just enough for SessionManager.
|
||||||
def __init__(self, session_id: str = "sid-from-fake") -> None:
|
|
||||||
|
By default returns the same id for every create_session call
|
||||||
|
(matching the original legacy behavior; several pre-existing tests
|
||||||
|
rely on that). Tests that need a fresh id per call should set
|
||||||
|
``unique_ids_per_call=True`` in the constructor.
|
||||||
|
"""
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session_id: str = "sid-from-fake",
|
||||||
|
unique_ids_per_call: bool = False,
|
||||||
|
) -> None:
|
||||||
self._session_id = session_id
|
self._session_id = session_id
|
||||||
|
self._unique_ids = unique_ids_per_call
|
||||||
|
self._next_id = 0
|
||||||
self.calls: list = []
|
self.calls: list = []
|
||||||
|
|
||||||
async def create_session(self, title: str) -> dict:
|
async def create_session(self, title: str) -> dict:
|
||||||
self.calls.append(("create_session", title))
|
self.calls.append(("create_session", title))
|
||||||
|
if self._unique_ids:
|
||||||
|
self._next_id += 1
|
||||||
|
return {
|
||||||
|
"id": "%s-%d" % (self._session_id, self._next_id),
|
||||||
|
"title": title,
|
||||||
|
}
|
||||||
return {"id": self._session_id, "title": title}
|
return {"id": self._session_id, "title": title}
|
||||||
|
|
||||||
async def delete_session(self, session_id: str) -> bool:
|
async def delete_session(self, session_id: str) -> bool:
|
||||||
@@ -112,7 +130,7 @@ async def test_list_sessions_returns_all_mappings():
|
|||||||
sm = SessionManager(lambda: client)
|
sm = SessionManager(lambda: client)
|
||||||
await sm.get_or_create("a.ipynb")
|
await sm.get_or_create("a.ipynb")
|
||||||
# Manually inject a second to test listing
|
# Manually inject a second to test listing
|
||||||
sm._sessions["b.ipynb"] = "sid-b"
|
sm._active["b.ipynb"] = "sid-b"
|
||||||
listing = sm.list_sessions()
|
listing = sm.list_sessions()
|
||||||
paths = {s["notebookPath"] for s in listing}
|
paths = {s["notebookPath"] for s in listing}
|
||||||
assert paths == {"a.ipynb", "b.ipynb"}
|
assert paths == {"a.ipynb", "b.ipynb"}
|
||||||
@@ -133,3 +151,147 @@ async def test_concurrent_get_or_create_does_not_double_create():
|
|||||||
assert sids[0] == sids[1] == sids[2]
|
assert sids[0] == sids[1] == sids[2]
|
||||||
create_calls = [c for c in client.calls if c[0] == "create_session"]
|
create_calls = [c for c in client.calls if c[0] == "create_session"]
|
||||||
assert len(create_calls) == 1, "concurrent get_or_create must not double-create"
|
assert len(create_calls) == 1, "concurrent get_or_create must not double-create"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Multi-session: 1 notebook can bind N sessions, one is active
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_new_binds_and_makes_active():
|
||||||
|
client = FakeClient(unique_ids_per_call=True)
|
||||||
|
sm = SessionManager(lambda: client)
|
||||||
|
sid = await sm.create_new("foo.ipynb", title="custom")
|
||||||
|
assert sid == "sid-from-fake-1"
|
||||||
|
# New session is the active one and the only bound one.
|
||||||
|
assert sm.get_active("foo.ipynb") == sid
|
||||||
|
assert sm.list_sessions_for_notebook("foo.ipynb") == [sid]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_new_when_active_exists_keeps_old_bound():
|
||||||
|
client = FakeClient(unique_ids_per_call=True)
|
||||||
|
sm = SessionManager(lambda: client)
|
||||||
|
first = await sm.get_or_create("foo.ipynb")
|
||||||
|
second = await sm.create_new("foo.ipynb")
|
||||||
|
# Two distinct session ids.
|
||||||
|
assert first != second
|
||||||
|
assert first == "sid-from-fake-1"
|
||||||
|
assert second == "sid-from-fake-2"
|
||||||
|
# Both are bound; the new one is active.
|
||||||
|
bound = sm.list_sessions_for_notebook("foo.ipynb")
|
||||||
|
assert set(bound) == {first, second}
|
||||||
|
assert sm.get_active("foo.ipynb") == second
|
||||||
|
# get_or_create still returns the active one (does NOT auto-pick the latest).
|
||||||
|
assert await sm.get_or_create("foo.ipynb") == second
|
||||||
|
|
||||||
|
|
||||||
|
def test_bind_existing_does_not_call_opencode():
|
||||||
|
client = FakeClient()
|
||||||
|
sm = SessionManager(lambda: client)
|
||||||
|
sm.bind_existing("foo.ipynb", "sid-from-list")
|
||||||
|
assert client.calls == [] # no OpenCode round-trip
|
||||||
|
assert sm.get_active("foo.ipynb") == "sid-from-list"
|
||||||
|
assert "sid-from-list" in sm.list_sessions_for_notebook("foo.ipynb")
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_active_requires_session_to_be_bound():
|
||||||
|
sm = SessionManager(lambda: FakeClient())
|
||||||
|
sm.bind_existing("foo.ipynb", "sid-A")
|
||||||
|
assert sm.set_active("foo.ipynb", "sid-A") is True
|
||||||
|
# Switching to a session that is NOT bound must fail.
|
||||||
|
assert sm.set_active("foo.ipynb", "sid-UNKNOWN") is False
|
||||||
|
# Active didn't change.
|
||||||
|
assert sm.get_active("foo.ipynb") == "sid-A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbind_removes_from_bound_and_clears_active_if_was_active():
|
||||||
|
sm = SessionManager(lambda: FakeClient())
|
||||||
|
sm.bind_existing("foo.ipynb", "sid-A")
|
||||||
|
sm.bind_existing("foo.ipynb", "sid-B")
|
||||||
|
# B is the active one (last bind wins). Unbind B.
|
||||||
|
assert sm.get_active("foo.ipynb") == "sid-B"
|
||||||
|
assert sm.unbind("foo.ipynb", "sid-B") is True
|
||||||
|
# Active falls back to None (no other session auto-promotes).
|
||||||
|
assert sm.get_active("foo.ipynb") is None
|
||||||
|
assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-A"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbind_non_active_leaves_active_intact():
|
||||||
|
sm = SessionManager(lambda: FakeClient())
|
||||||
|
sm.bind_existing("foo.ipynb", "sid-A")
|
||||||
|
sm.bind_existing("foo.ipynb", "sid-B")
|
||||||
|
assert sm.get_active("foo.ipynb") == "sid-B"
|
||||||
|
# Unbind A (not the active one).
|
||||||
|
assert sm.unbind("foo.ipynb", "sid-A") is True
|
||||||
|
# Active is still B.
|
||||||
|
assert sm.get_active("foo.ipynb") == "sid-B"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbind_active_keeps_other_sessions():
|
||||||
|
sm = SessionManager(lambda: FakeClient())
|
||||||
|
sm.bind_existing("foo.ipynb", "sid-A")
|
||||||
|
sm.bind_existing("foo.ipynb", "sid-B")
|
||||||
|
sm.unbind_active("foo.ipynb")
|
||||||
|
assert sm.get_active("foo.ipynb") is None
|
||||||
|
# B is gone from the bound list; A remains.
|
||||||
|
assert "sid-A" in sm.list_sessions_for_notebook("foo.ipynb")
|
||||||
|
assert "sid-B" not in sm.list_sessions_for_notebook("foo.ipynb")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_session_unbinds_and_calls_opencode():
|
||||||
|
client = FakeClient()
|
||||||
|
sm = SessionManager(lambda: client)
|
||||||
|
sm.bind_existing("foo.ipynb", "sid-A")
|
||||||
|
sm.bind_existing("foo.ipynb", "sid-B")
|
||||||
|
deleted = await sm.delete_session("foo.ipynb", "sid-A")
|
||||||
|
assert deleted is True
|
||||||
|
# OpenCode was called.
|
||||||
|
assert any(
|
||||||
|
c[0] == "delete_session" and c[1] == "sid-A" for c in client.calls
|
||||||
|
)
|
||||||
|
# sid-A is gone from bindings; sid-B remains as active.
|
||||||
|
assert "sid-A" not in sm.list_sessions_for_notebook("foo.ipynb")
|
||||||
|
assert sm.get_active("foo.ipynb") == "sid-B"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_session_not_bound_returns_false():
|
||||||
|
client = FakeClient()
|
||||||
|
sm = SessionManager(lambda: client)
|
||||||
|
assert await sm.delete_session("foo.ipynb", "never-bound") is False
|
||||||
|
# No OpenCode call.
|
||||||
|
assert not any(c[0] == "delete_session" for c in client.calls)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_release_drops_all_bindings_and_deletes_active():
|
||||||
|
client = FakeClient()
|
||||||
|
sm = SessionManager(lambda: client)
|
||||||
|
sm.bind_existing("foo.ipynb", "sid-A")
|
||||||
|
sm.bind_existing("foo.ipynb", "sid-B")
|
||||||
|
ok = await sm.release("foo.ipynb")
|
||||||
|
assert ok is True
|
||||||
|
# OpenCode delete called for the active one (sid-B).
|
||||||
|
assert any(
|
||||||
|
c[0] == "delete_session" and c[1] == "sid-B" for c in client.calls
|
||||||
|
)
|
||||||
|
# Both bindings cleared.
|
||||||
|
assert sm.list_sessions_for_notebook("foo.ipynb") == []
|
||||||
|
assert sm.get_active("foo.ipynb") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_or_create_after_set_active_returns_the_active_one():
|
||||||
|
client = FakeClient()
|
||||||
|
sm = SessionManager(lambda: client)
|
||||||
|
first = await sm.get_or_create("foo.ipynb")
|
||||||
|
sm.bind_existing("foo.ipynb", "another-sid")
|
||||||
|
sm.set_active("foo.ipynb", "another-sid")
|
||||||
|
# Next /edit-style call returns the manually-set active session.
|
||||||
|
assert await sm.get_or_create("foo.ipynb") == "another-sid"
|
||||||
|
# No additional create_session call.
|
||||||
|
creates = [c for c in client.calls if c[0] == "create_session"]
|
||||||
|
assert len(creates) == 1 # only the initial get_or_create
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opencode_bridge",
|
"name": "opencode_bridge",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"description": "A JupyterLab extension.",
|
"description": "A JupyterLab extension.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"jupyter",
|
"jupyter",
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import type { CodeCell, ICodeCellModel } from '@jupyterlab/cells';
|
|
||||||
import type { NotebookPanel } from '@jupyterlab/notebook';
|
|
||||||
|
|
||||||
import { collectErrorOutputs, extractCellContext } from '../context/cell_context';
|
|
||||||
|
|
||||||
function makeMockOutputs(items: unknown[]): ICodeCellModel['outputs'] {
|
|
||||||
return {
|
|
||||||
get length() {
|
|
||||||
return items.length;
|
|
||||||
},
|
|
||||||
get(i: number) {
|
|
||||||
return items[i];
|
|
||||||
},
|
|
||||||
// Other IObservableList methods are unused in collectErrorOutputs.
|
|
||||||
} as unknown as ICodeCellModel['outputs'];
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeMockModel(overrides: {
|
|
||||||
id?: string;
|
|
||||||
type?: 'code' | 'markdown' | 'raw';
|
|
||||||
source?: string;
|
|
||||||
outputs?: unknown[];
|
|
||||||
}): ICodeCellModel {
|
|
||||||
return {
|
|
||||||
id: overrides.id ?? 'cell-1',
|
|
||||||
type: overrides.type ?? 'code',
|
|
||||||
sharedModel: {
|
|
||||||
getSource: () => overrides.source ?? '',
|
|
||||||
},
|
|
||||||
outputs: makeMockOutputs(overrides.outputs ?? []),
|
|
||||||
} as unknown as ICodeCellModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeMockCell(source: string, errorOutputs: unknown[] = []): CodeCell {
|
|
||||||
return { model: makeMockModel({ source, outputs: errorOutputs }) } as CodeCell;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeMockNotebook(cells: CodeCell[]): { widgets: CodeCell[] } {
|
|
||||||
return { widgets: cells };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeMockPanel(
|
|
||||||
notebook: { widgets: CodeCell[] },
|
|
||||||
path: string
|
|
||||||
): NotebookPanel {
|
|
||||||
return {
|
|
||||||
context: { path } as NotebookPanel['context'],
|
|
||||||
content: notebook as unknown as NotebookPanel['content'],
|
|
||||||
} as unknown as NotebookPanel;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('collectErrorOutputs', () => {
|
|
||||||
it('returns null when there are no outputs', () => {
|
|
||||||
const model = makeMockModel({ outputs: [] });
|
|
||||||
expect(collectErrorOutputs(model)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when outputs are non-error', () => {
|
|
||||||
const model = makeMockModel({
|
|
||||||
outputs: [{ type: 'stream', text: 'hello' }],
|
|
||||||
});
|
|
||||||
expect(collectErrorOutputs(model)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts the first error output', () => {
|
|
||||||
const errOut = {
|
|
||||||
type: 'error',
|
|
||||||
ename: 'ValueError',
|
|
||||||
evalue: 'bad value',
|
|
||||||
traceback: ['line 1', 'line 2'],
|
|
||||||
};
|
|
||||||
const model = makeMockModel({ outputs: [errOut] });
|
|
||||||
const result = collectErrorOutputs(model);
|
|
||||||
expect(result).toEqual({
|
|
||||||
ename: 'ValueError',
|
|
||||||
evalue: 'bad value',
|
|
||||||
traceback: ['line 1', 'line 2'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles missing traceback gracefully', () => {
|
|
||||||
const model = makeMockModel({
|
|
||||||
outputs: [{ type: 'error', ename: 'E', evalue: 'v' }],
|
|
||||||
});
|
|
||||||
const result = collectErrorOutputs(model);
|
|
||||||
expect(result?.traceback).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('extractCellContext', () => {
|
|
||||||
it('returns null when cell is not in notebook', () => {
|
|
||||||
const cell = makeMockCell('x = 1');
|
|
||||||
const other = makeMockCell('y = 2');
|
|
||||||
const notebook = makeMockNotebook([other]);
|
|
||||||
const panel = makeMockPanel(notebook, 'foo.ipynb');
|
|
||||||
expect(extractCellContext(cell, panel)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts source, language, and indices', () => {
|
|
||||||
const cell = makeMockCell('x = 1');
|
|
||||||
const notebook = makeMockNotebook([cell]);
|
|
||||||
const panel = makeMockPanel(notebook, 'foo.ipynb');
|
|
||||||
const ctx = extractCellContext(cell, panel);
|
|
||||||
expect(ctx).toMatchObject({
|
|
||||||
notebookPath: 'foo.ipynb',
|
|
||||||
cellId: 'cell-1',
|
|
||||||
language: 'python',
|
|
||||||
cellIndex: 0,
|
|
||||||
totalCells: 1,
|
|
||||||
source: 'x = 1',
|
|
||||||
previousCode: null,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('captures the previous cell source as previousCode', () => {
|
|
||||||
const prev = makeMockCell('import os');
|
|
||||||
const cell = makeMockCell('os.getcwd()');
|
|
||||||
const notebook = makeMockNotebook([prev, cell]);
|
|
||||||
const panel = makeMockPanel(notebook, 'foo.ipynb');
|
|
||||||
const ctx = extractCellContext(cell, panel);
|
|
||||||
expect(ctx?.previousCode).toBe('import os');
|
|
||||||
expect(ctx?.cellIndex).toBe(1);
|
|
||||||
expect(ctx?.totalCells).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes the error when the cell has an error output', () => {
|
|
||||||
const cell = makeMockCell('1/0', [
|
|
||||||
{
|
|
||||||
type: 'error',
|
|
||||||
ename: 'ZeroDivisionError',
|
|
||||||
evalue: 'division by zero',
|
|
||||||
traceback: ['Traceback...'],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const notebook = makeMockNotebook([cell]);
|
|
||||||
const panel = makeMockPanel(notebook, 'foo.ipynb');
|
|
||||||
const ctx = extractCellContext(cell, panel);
|
|
||||||
expect(ctx?.error).toEqual({
|
|
||||||
ename: 'ZeroDivisionError',
|
|
||||||
evalue: 'division by zero',
|
|
||||||
traceback: ['Traceback...'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when given null cell or panel', () => {
|
|
||||||
expect(
|
|
||||||
extractCellContext(
|
|
||||||
null as unknown as CodeCell,
|
|
||||||
makeMockPanel(makeMockNotebook([]), 'x.ipynb')
|
|
||||||
)
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,12 @@
|
|||||||
import { ServerConnection } from '@jupyterlab/services';
|
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';
|
import type { OpenCodeRequest } from '../types';
|
||||||
|
|
||||||
jest.mock('@jupyterlab/services', () => {
|
jest.mock('@jupyterlab/services', () => {
|
||||||
@@ -66,15 +72,8 @@ function mockResponse(overrides: {
|
|||||||
const sampleRequest: OpenCodeRequest = {
|
const sampleRequest: OpenCodeRequest = {
|
||||||
prompt: 'add type hints',
|
prompt: 'add type hints',
|
||||||
context: {
|
context: {
|
||||||
notebookPath: 'foo.ipynb',
|
notebookPath: 'foo.ipynb'
|
||||||
cellId: 'cell-1',
|
}
|
||||||
language: 'python',
|
|
||||||
cellIndex: 0,
|
|
||||||
totalCells: 1,
|
|
||||||
source: 'def foo(x): return x',
|
|
||||||
previousCode: null,
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('callOpenCodeEdit', () => {
|
describe('callOpenCodeEdit', () => {
|
||||||
@@ -82,10 +81,9 @@ describe('callOpenCodeEdit', () => {
|
|||||||
mockedMakeRequest.mockReset();
|
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 = {
|
const respBody = {
|
||||||
ok: true,
|
ok: true,
|
||||||
markdown: '```python\ndef foo(x: int) -> int: return x\n```',
|
|
||||||
sessionId: 'sid',
|
sessionId: 'sid',
|
||||||
notebookPath: 'foo.ipynb',
|
notebookPath: 'foo.ipynb',
|
||||||
};
|
};
|
||||||
@@ -107,7 +105,10 @@ describe('callOpenCodeEdit', () => {
|
|||||||
);
|
);
|
||||||
expect(resp.ok).toBe(true);
|
expect(resp.ok).toBe(true);
|
||||||
if (resp.ok) {
|
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 +220,161 @@ describe('callOpenCodeProviders', () => {
|
|||||||
).rejects.toThrow(/network error/);
|
).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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+476
-6
@@ -1,19 +1,35 @@
|
|||||||
/**
|
/**
|
||||||
* HTTP client for the opencode-bridge server extension.
|
* 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 { URLExt } from '@jupyterlab/coreutils';
|
||||||
import { ServerConnection } from '@jupyterlab/services';
|
import { ServerConnection } from '@jupyterlab/services';
|
||||||
|
|
||||||
import type { OpenCodeProvidersResponse, OpenCodeRequest, OpenCodeResponse, OpenCodeMessagesResponse } from '../types';
|
import type {
|
||||||
|
OpenCodeAllSessionsResponse,
|
||||||
|
OpenCodeEditResponse,
|
||||||
|
OpenCodeEvent,
|
||||||
|
OpenCodeMessagesResponse,
|
||||||
|
OpenCodeNotebookSessionsResponse,
|
||||||
|
OpenCodeProvidersResponse,
|
||||||
|
OpenCodeRequest,
|
||||||
|
OpenCodeSessionOpResponse
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call POST /opencode-bridge/edit. Returns parsed response (success or failure).
|
* Call POST /opencode-bridge/edit. The endpoint is async — it fires
|
||||||
* Throws on network error or non-JSON response.
|
* 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(
|
export async function callOpenCodeEdit(
|
||||||
request: OpenCodeRequest,
|
request: OpenCodeRequest,
|
||||||
serverSettings: ServerConnection.ISettings
|
serverSettings: ServerConnection.ISettings
|
||||||
): Promise<OpenCodeResponse> {
|
): Promise<OpenCodeEditResponse> {
|
||||||
const url = URLExt.join(
|
const url = URLExt.join(
|
||||||
serverSettings.baseUrl,
|
serverSettings.baseUrl,
|
||||||
'opencode-bridge',
|
'opencode-bridge',
|
||||||
@@ -23,7 +39,7 @@ export async function callOpenCodeEdit(
|
|||||||
const init: RequestInit = {
|
const init: RequestInit = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(request),
|
body: JSON.stringify(request),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' }
|
||||||
};
|
};
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
@@ -51,7 +67,7 @@ export async function callOpenCodeEdit(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsed as OpenCodeResponse;
|
return parsed as OpenCodeEditResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,3 +131,457 @@ export async function callOpenCodeProviders(
|
|||||||
|
|
||||||
return (await response.json()) as OpenCodeProvidersResponse;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Multi-session management (1 notebook can bind N sessions; one is active)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function _jsonRequest<T>(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
serverSettings: ServerConnection.ISettings,
|
||||||
|
label: string
|
||||||
|
): Promise<T> {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await ServerConnection.makeRequest(url, init, serverSettings);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`network error calling opencode-bridge/${label}: ${(error as Error).message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`opencode-bridge/${label} failed: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
throw new Error(`empty response from opencode-bridge/${label}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as T;
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`non-JSON response from opencode-bridge/${label} (status ${res.status})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /opencode-bridge/sessions/all — every session on OpenCode Serve. */
|
||||||
|
export async function callOpenCodeListAllSessions(
|
||||||
|
serverSettings: ServerConnection.ISettings
|
||||||
|
): Promise<OpenCodeAllSessionsResponse> {
|
||||||
|
const url = URLExt.join(
|
||||||
|
serverSettings.baseUrl,
|
||||||
|
'opencode-bridge',
|
||||||
|
'sessions',
|
||||||
|
'all'
|
||||||
|
);
|
||||||
|
return _jsonRequest(
|
||||||
|
url,
|
||||||
|
{ method: 'GET' },
|
||||||
|
serverSettings,
|
||||||
|
'sessions/all'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /opencode-bridge/sessions/notebook?notebook=... — sessions bound
|
||||||
|
* to this notebook (enriched with metadata, marked which is active). */
|
||||||
|
export async function callOpenCodeListNotebookSessions(
|
||||||
|
notebookPath: string,
|
||||||
|
serverSettings: ServerConnection.ISettings
|
||||||
|
): Promise<OpenCodeNotebookSessionsResponse> {
|
||||||
|
const url =
|
||||||
|
URLExt.join(
|
||||||
|
serverSettings.baseUrl,
|
||||||
|
'opencode-bridge',
|
||||||
|
'sessions',
|
||||||
|
'notebook'
|
||||||
|
) +
|
||||||
|
'?notebook=' +
|
||||||
|
encodeURIComponent(notebookPath);
|
||||||
|
return _jsonRequest(url, { method: 'GET' }, serverSettings, 'sessions/notebook');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /opencode-bridge/sessions/notebook?notebook=...&title=... —
|
||||||
|
* create a brand-new session on OpenCode, bind it, set as active. */
|
||||||
|
export async function callOpenCodeCreateNotebookSession(
|
||||||
|
notebookPath: string,
|
||||||
|
title: string | undefined,
|
||||||
|
serverSettings: ServerConnection.ISettings
|
||||||
|
): Promise<OpenCodeSessionOpResponse> {
|
||||||
|
const url =
|
||||||
|
URLExt.join(
|
||||||
|
serverSettings.baseUrl,
|
||||||
|
'opencode-bridge',
|
||||||
|
'sessions',
|
||||||
|
'notebook'
|
||||||
|
) +
|
||||||
|
'?notebook=' +
|
||||||
|
encodeURIComponent(notebookPath) +
|
||||||
|
(title ? '&title=' + encodeURIComponent(title) : '');
|
||||||
|
return _jsonRequest(
|
||||||
|
url,
|
||||||
|
{ method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
|
||||||
|
serverSettings,
|
||||||
|
'sessions/notebook (POST)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /opencode-bridge/sessions/notebook?notebook=...&sessionId=... —
|
||||||
|
* bind an existing sessionId to this notebook (no OpenCode round-trip)
|
||||||
|
* and set it as active. */
|
||||||
|
export async function callOpenCodeBindExistingSession(
|
||||||
|
notebookPath: string,
|
||||||
|
sessionId: string,
|
||||||
|
serverSettings: ServerConnection.ISettings
|
||||||
|
): Promise<OpenCodeSessionOpResponse> {
|
||||||
|
const url =
|
||||||
|
URLExt.join(
|
||||||
|
serverSettings.baseUrl,
|
||||||
|
'opencode-bridge',
|
||||||
|
'sessions',
|
||||||
|
'notebook'
|
||||||
|
) +
|
||||||
|
'?notebook=' +
|
||||||
|
encodeURIComponent(notebookPath) +
|
||||||
|
'&sessionId=' +
|
||||||
|
encodeURIComponent(sessionId);
|
||||||
|
return _jsonRequest(
|
||||||
|
url,
|
||||||
|
{ method: 'PUT', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
|
||||||
|
serverSettings,
|
||||||
|
'sessions/notebook (PUT)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /opencode-bridge/sessions/notebook?notebook=...&sessionId=... —
|
||||||
|
* delete a specific bound session on OpenCode AND remove its binding. */
|
||||||
|
export async function callOpenCodeDeleteNotebookSession(
|
||||||
|
notebookPath: string,
|
||||||
|
sessionId: string,
|
||||||
|
serverSettings: ServerConnection.ISettings
|
||||||
|
): Promise<OpenCodeSessionOpResponse> {
|
||||||
|
const url =
|
||||||
|
URLExt.join(
|
||||||
|
serverSettings.baseUrl,
|
||||||
|
'opencode-bridge',
|
||||||
|
'sessions',
|
||||||
|
'notebook'
|
||||||
|
) +
|
||||||
|
'?notebook=' +
|
||||||
|
encodeURIComponent(notebookPath) +
|
||||||
|
'&sessionId=' +
|
||||||
|
encodeURIComponent(sessionId);
|
||||||
|
return _jsonRequest(
|
||||||
|
url,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
serverSettings,
|
||||||
|
'sessions/notebook (DELETE)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /opencode-bridge/sessions/active?notebook=... — get the active
|
||||||
|
* sessionId (or null). */
|
||||||
|
export async function callOpenCodeGetActiveSession(
|
||||||
|
notebookPath: string,
|
||||||
|
serverSettings: ServerConnection.ISettings
|
||||||
|
): Promise<OpenCodeSessionOpResponse> {
|
||||||
|
const url =
|
||||||
|
URLExt.join(
|
||||||
|
serverSettings.baseUrl,
|
||||||
|
'opencode-bridge',
|
||||||
|
'sessions',
|
||||||
|
'active'
|
||||||
|
) +
|
||||||
|
'?notebook=' +
|
||||||
|
encodeURIComponent(notebookPath);
|
||||||
|
return _jsonRequest(url, { method: 'GET' }, serverSettings, 'sessions/active');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /opencode-bridge/sessions/active?notebook=...&sessionId=... —
|
||||||
|
* switch which bound session is active. sessionId MUST already be
|
||||||
|
* in the bound list.
|
||||||
|
*
|
||||||
|
* TODO(frontend): unused in v0.1.x — the cell-action's session
|
||||||
|
* switcher uses PUT /sessions/notebook (which does both bind and set
|
||||||
|
* active in one call). This is kept for a future "switch between
|
||||||
|
* already-bound sessions" flow that avoids the bind round-trip.
|
||||||
|
* Do NOT delete the corresponding server route without also
|
||||||
|
* removing this client function (or vice versa). */
|
||||||
|
export async function callOpenCodeSetActiveSession(
|
||||||
|
notebookPath: string,
|
||||||
|
sessionId: string,
|
||||||
|
serverSettings: ServerConnection.ISettings
|
||||||
|
): Promise<OpenCodeSessionOpResponse> {
|
||||||
|
const url =
|
||||||
|
URLExt.join(
|
||||||
|
serverSettings.baseUrl,
|
||||||
|
'opencode-bridge',
|
||||||
|
'sessions',
|
||||||
|
'active'
|
||||||
|
) +
|
||||||
|
'?notebook=' +
|
||||||
|
encodeURIComponent(notebookPath) +
|
||||||
|
'&sessionId=' +
|
||||||
|
encodeURIComponent(sessionId);
|
||||||
|
return _jsonRequest(
|
||||||
|
url,
|
||||||
|
{ method: 'PUT', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
|
||||||
|
serverSettings,
|
||||||
|
'sessions/active (PUT)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DELETE /opencode-bridge/sessions/active?notebook=... — unbind the
|
||||||
|
* active session (next /edit lazy-creates a new one). Does NOT delete
|
||||||
|
* the session on OpenCode Serve. */
|
||||||
|
export async function callOpenCodeUnbindActiveSession(
|
||||||
|
notebookPath: string,
|
||||||
|
serverSettings: ServerConnection.ISettings
|
||||||
|
): Promise<OpenCodeSessionOpResponse> {
|
||||||
|
const url =
|
||||||
|
URLExt.join(
|
||||||
|
serverSettings.baseUrl,
|
||||||
|
'opencode-bridge',
|
||||||
|
'sessions',
|
||||||
|
'active'
|
||||||
|
) +
|
||||||
|
'?notebook=' +
|
||||||
|
encodeURIComponent(notebookPath);
|
||||||
|
return _jsonRequest(
|
||||||
|
url,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
serverSettings,
|
||||||
|
'sessions/active (DELETE)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenCodeEventHandlers {
|
||||||
|
onEvent: (event: OpenCodeEvent) => void;
|
||||||
|
onError?: (err: Error) => void;
|
||||||
|
onOpen?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenCodeEventSubscription {
|
||||||
|
/** Cancel the stream. Safe to call multiple times. */
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to GET /opencode-bridge/events?session=<sid> as an SSE stream.
|
||||||
|
*
|
||||||
|
* Mirrors demo.html `initSSE`: opens a fetch(), reads the response body
|
||||||
|
* with a TextDecoder, splits on `\n\n` event boundaries, and parses each
|
||||||
|
* `data: {...}` line as JSON. Use `close()` to cancel.
|
||||||
|
*
|
||||||
|
* ServerConnection.makeRequest is unsuitable for SSE (it buffers the
|
||||||
|
* entire response), so we use plain fetch with the XSRF token injected
|
||||||
|
* from `serverSettings.token` when present.
|
||||||
|
*/
|
||||||
|
export function subscribeOpenCodeEvents(
|
||||||
|
sessionId: string,
|
||||||
|
serverSettings: ServerConnection.ISettings,
|
||||||
|
handlers: OpenCodeEventHandlers
|
||||||
|
): OpenCodeEventSubscription {
|
||||||
|
const baseUrl = serverSettings.baseUrl.replace(/\/$/, '');
|
||||||
|
const url =
|
||||||
|
URLExt.join(baseUrl, 'opencode-bridge', 'events') +
|
||||||
|
'?session=' +
|
||||||
|
encodeURIComponent(sessionId);
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'text/event-stream'
|
||||||
|
};
|
||||||
|
if (serverSettings.token) {
|
||||||
|
headers['X-XSRFToken'] = serverSettings.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
const close = (): void => {
|
||||||
|
if (closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closed = true;
|
||||||
|
try {
|
||||||
|
controller.abort();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, { method: 'GET', headers, signal: controller.signal });
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlers.onError?.(err as Error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
handlers.onError?.(
|
||||||
|
new Error(
|
||||||
|
`opencode-bridge/events failed: ${response.status} ${response.statusText}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.body) {
|
||||||
|
handlers.onError?.(new Error('opencode-bridge/events: no response body'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlers.onOpen?.();
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (!closed) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let sepIdx: number;
|
||||||
|
while ((sepIdx = buffer.indexOf('\n\n')) !== -1) {
|
||||||
|
const block = buffer.slice(0, sepIdx);
|
||||||
|
buffer = buffer.slice(sepIdx + 2);
|
||||||
|
for (const line of block.split('\n')) {
|
||||||
|
if (!line.startsWith('data:')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const jsonStr = line.slice(5).trim();
|
||||||
|
if (!jsonStr) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(jsonStr) as OpenCodeEvent;
|
||||||
|
handlers.onEvent(event);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
'opencode_bridge: SSE JSON parse error:',
|
||||||
|
e,
|
||||||
|
jsonStr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drain trailing block (no final \n\n).
|
||||||
|
if (buffer.trim()) {
|
||||||
|
for (const line of buffer.split('\n')) {
|
||||||
|
if (!line.startsWith('data:')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const jsonStr = line.slice(5).trim();
|
||||||
|
if (!jsonStr) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
handlers.onEvent(JSON.parse(jsonStr) as OpenCodeEvent);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handlers.onError?.(err as Error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return { close };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Persistence of the user's (provider, model) selection in the inline
|
||||||
|
* prompt, keyed by notebook path. Stored in localStorage so the
|
||||||
|
* preference survives panel close/reopen and JupyterLab restart.
|
||||||
|
*
|
||||||
|
* The selection is ALWAYS validated against the current providers
|
||||||
|
* payload before being applied: if the stored provider (or the model
|
||||||
|
* under it) is no longer present, the caller falls back to its
|
||||||
|
* default-selection logic. We never silently apply a stale value that
|
||||||
|
* would result in an empty / disabled <select>.
|
||||||
|
*
|
||||||
|
* Storage is best-effort: localStorage may be disabled (private mode),
|
||||||
|
* the quota may be exhausted, or the value may be corrupt. All
|
||||||
|
* failure modes return null / no-op rather than throw.
|
||||||
|
*/
|
||||||
|
const STORAGE_PREFIX = 'opencode_bridge:model-selection:';
|
||||||
|
|
||||||
|
export interface ModelSelection {
|
||||||
|
providerId: string;
|
||||||
|
modelId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadModelSelection(notebookPath: string): ModelSelection | null {
|
||||||
|
if (!notebookPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const raw = localStorage.getItem(STORAGE_PREFIX + notebookPath);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
typeof parsed === 'object' &&
|
||||||
|
typeof (parsed as ModelSelection).providerId === 'string' &&
|
||||||
|
typeof (parsed as ModelSelection).modelId === 'string'
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
providerId: (parsed as ModelSelection).providerId,
|
||||||
|
modelId: (parsed as ModelSelection).modelId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveModelSelection(
|
||||||
|
notebookPath: string,
|
||||||
|
sel: ModelSelection
|
||||||
|
): void {
|
||||||
|
if (!notebookPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.setItem(STORAGE_PREFIX + notebookPath, JSON.stringify(sel));
|
||||||
|
} catch {
|
||||||
|
// Ignore quota exceeded / disabled storage.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearModelSelection(notebookPath: string): void {
|
||||||
|
if (!notebookPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (typeof localStorage === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
localStorage.removeItem(STORAGE_PREFIX + notebookPath);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +1,38 @@
|
|||||||
/**
|
/**
|
||||||
* Per-cell AI action button rendered inside JupyterLab's native Cell toolbar
|
* 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
|
* (top-right of the active cell). Clicking it toggles an
|
||||||
* toggles an OpenCodeInlinePrompt panel attached to the cell's DOM.
|
* OpenCodeInlinePrompt panel attached to the cell's DOM.
|
||||||
*
|
*
|
||||||
* Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...).
|
* Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...).
|
||||||
*
|
*
|
||||||
* v3-final: the frontend does NO semantic processing. It gathers the cell's
|
* v4 message flow (matches demo.html):
|
||||||
* source / outputs / error, attaches the user's freeform instruction, and
|
* 1. User clicks 🪄 -> panel appears, shows session history (setMessages)
|
||||||
* POSTs to the server. The chosen provider/model comes from the inline
|
* 2. User submits prompt -> callOpenCodeEdit returns immediately with
|
||||||
* picker (no settings-based default).
|
* 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 type { CodeCell } from '@jupyterlab/cells';
|
||||||
import { Notification } from '@jupyterlab/apputils';
|
import { Notification } from '@jupyterlab/apputils';
|
||||||
import type { NotebookPanel } from '@jupyterlab/notebook';
|
|
||||||
import { ServerConnection } from '@jupyterlab/services';
|
import { ServerConnection } from '@jupyterlab/services';
|
||||||
import { Widget } from '@lumino/widgets';
|
import { Widget } from '@lumino/widgets';
|
||||||
|
|
||||||
import { callOpenCodeEdit, callOpenCodeSessionMessages } from '../api/opencode_client';
|
import {
|
||||||
import { extractCellContext } from '../context/cell_context';
|
callOpenCodeBindExistingSession,
|
||||||
import type {
|
callOpenCodeCreateNotebookSession,
|
||||||
CellContext,
|
callOpenCodeEdit,
|
||||||
OpenCodeProvidersResponse,
|
callOpenCodeListAllSessions,
|
||||||
OpenCodeRequest,
|
callOpenCodeReplyPermission,
|
||||||
OpenCodeResponse
|
callOpenCodeReplyQuestion,
|
||||||
} from '../types';
|
callOpenCodeSessionMessages,
|
||||||
|
subscribeOpenCodeEvents,
|
||||||
|
type OpenCodeEventSubscription
|
||||||
|
} from '../api/opencode_client';
|
||||||
|
import type { OpenCodeProvidersResponse } from '../types';
|
||||||
|
|
||||||
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
|
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
|
||||||
|
|
||||||
@@ -47,9 +56,14 @@ type Status = 'idle' | 'loading';
|
|||||||
|
|
||||||
export class OpenCodeCellActions extends Widget {
|
export class OpenCodeCellActions extends Widget {
|
||||||
private _cell: CodeCell;
|
private _cell: CodeCell;
|
||||||
private _context: CellContext | null = null;
|
// The cell's notebookPath is the only context we need for the
|
||||||
|
// /edit request. The cell source, error traceback, and previous
|
||||||
|
// cell are no longer auto-injected — the user attaches whatever
|
||||||
|
// they want via the "📋 插入单元格内容" button.
|
||||||
|
private _notebookPath: string | null = null;
|
||||||
private _status: Status = 'idle';
|
private _status: Status = 'idle';
|
||||||
private _prompt: OpenCodeInlinePrompt | null = null;
|
private _prompt: OpenCodeInlinePrompt | null = null;
|
||||||
|
private _sseSub: OpenCodeEventSubscription | null = null;
|
||||||
|
|
||||||
constructor(cell: CodeCell) {
|
constructor(cell: CodeCell) {
|
||||||
super();
|
super();
|
||||||
@@ -65,6 +79,7 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
if (this.isDisposed) {
|
if (this.isDisposed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this._closeSse();
|
||||||
this._cell.model.contentChanged.disconnect(this._onModelChange, this);
|
this._cell.model.contentChanged.disconnect(this._onModelChange, this);
|
||||||
this._cell.model.stateChanged.disconnect(this._onModelChange, this);
|
this._cell.model.stateChanged.disconnect(this._onModelChange, this);
|
||||||
this._hidePrompt();
|
this._hidePrompt();
|
||||||
@@ -72,9 +87,9 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _onModelChange(): void {
|
private _onModelChange(): void {
|
||||||
this._context = extractCellContextFromCell(this._cell);
|
this._notebookPath = extractNotebookPathFromCell(this._cell);
|
||||||
if (this._prompt) {
|
if (this._prompt) {
|
||||||
this._prompt.setDisabled(!this._context || this._status === 'loading');
|
this._prompt.setDisabled(!this._notebookPath || this._status === 'loading');
|
||||||
} else {
|
} else {
|
||||||
this._render();
|
this._render();
|
||||||
}
|
}
|
||||||
@@ -84,7 +99,7 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
const node = this.node;
|
const node = this.node;
|
||||||
node.textContent = '';
|
node.textContent = '';
|
||||||
|
|
||||||
const baseDisabled = !this._context || this._status === 'loading';
|
const baseDisabled = !this._notebookPath || this._status === 'loading';
|
||||||
|
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.className = 'opencode-btn opencode-btn-ai';
|
btn.className = 'opencode-btn opencode-btn-ai';
|
||||||
@@ -112,25 +127,135 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const prompt = new OpenCodeInlinePrompt(this._cell, {
|
const prompt = new OpenCodeInlinePrompt(this._cell, {
|
||||||
disabled: !this._context || this._status === 'loading',
|
disabled: !this._notebookPath || this._status === 'loading',
|
||||||
providers: _providers,
|
providers: _providers,
|
||||||
|
notebookPath: this._notebookPath ?? undefined,
|
||||||
onSubmit: (text: string, providerId?: string, modelId?: string) => {
|
onSubmit: (text: string, providerId?: string, modelId?: string) => {
|
||||||
void this._onSubmit(text, providerId, modelId);
|
void this._onSubmit(text, providerId, modelId);
|
||||||
},
|
},
|
||||||
onCancel: () => {
|
onCancel: () => {
|
||||||
this._hidePrompt();
|
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();
|
||||||
|
},
|
||||||
|
onListSessions: async () => {
|
||||||
|
if (!_serverSettings) return [];
|
||||||
|
try {
|
||||||
|
const r = await callOpenCodeListAllSessions(_serverSettings);
|
||||||
|
return r.sessions || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('opencode_bridge: list all sessions failed', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCreateSession: async () => {
|
||||||
|
if (!_serverSettings || !this._notebookPath) {
|
||||||
|
throw new Error('notebook not ready');
|
||||||
|
}
|
||||||
|
// Tear down any in-flight SSE first — the old session is
|
||||||
|
// being replaced.
|
||||||
|
this._closeSse();
|
||||||
|
const r = await callOpenCodeCreateNotebookSession(
|
||||||
|
this._notebookPath,
|
||||||
|
undefined,
|
||||||
|
_serverSettings
|
||||||
|
);
|
||||||
|
if (!r.ok || !r.sessionId) {
|
||||||
|
throw new Error(r.error || 'create_session failed');
|
||||||
|
}
|
||||||
|
// The prompt's _handleCreateSession calls setSessionId
|
||||||
|
// internally; no need to set it here too.
|
||||||
|
return r.sessionId;
|
||||||
|
},
|
||||||
|
onSwitchSession: async (sessionId: string) => {
|
||||||
|
if (!_serverSettings || !this._notebookPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Closing the SSE first ensures the old session's events stop
|
||||||
|
// coming in while we transition.
|
||||||
|
this._closeSse();
|
||||||
|
// PUT /sessions/notebook binds AND sets active in one call.
|
||||||
|
// (The separate PUT /sessions/active is only for switching
|
||||||
|
// between sessions that are ALREADY bound; for a session the
|
||||||
|
// user just picked from the global list, we need to bind
|
||||||
|
// first — calling set_active before bind returns 400.)
|
||||||
|
const r = await callOpenCodeBindExistingSession(
|
||||||
|
this._notebookPath,
|
||||||
|
sessionId,
|
||||||
|
_serverSettings
|
||||||
|
);
|
||||||
|
if (!r.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// The prompt's _handleSwitchSession calls setSessionId
|
||||||
|
// internally; no need to set it here too.
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
onReloadHistory: async () => {
|
||||||
|
if (!this._notebookPath) return;
|
||||||
|
await this._refreshHistory(this._notebookPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Widget.attach(prompt, this._cell.node);
|
Widget.attach(prompt, this._cell.node);
|
||||||
this._prompt = prompt;
|
this._prompt = prompt;
|
||||||
|
|
||||||
// Fetch the current session's message history and render it into
|
// Fetch the current session's static history and render it before
|
||||||
// the prompt's scrollable history area. Empty list (no session yet)
|
// streaming begins. Empty list is a normal no-op.
|
||||||
// is a normal no-op render.
|
const notebookPath = this._notebookPath;
|
||||||
const notebookPath = this._context?.notebookPath;
|
|
||||||
if (notebookPath) {
|
if (notebookPath) {
|
||||||
void this._refreshHistory(notebookPath);
|
void this._refreshHistory(notebookPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate the session selector dropdown (loads OpenCode's global
|
||||||
|
// session list async; if the host didn't wire up the callbacks,
|
||||||
|
// the row is simply not rendered).
|
||||||
|
prompt.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _refreshHistory(notebookPath: string): Promise<void> {
|
private async _refreshHistory(notebookPath: string): Promise<void> {
|
||||||
@@ -144,8 +269,6 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
);
|
);
|
||||||
this._prompt.setMessages(resp.messages);
|
this._prompt.setMessages(resp.messages);
|
||||||
} catch (e) {
|
} 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);
|
console.warn('opencode_bridge: failed to fetch session messages', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +285,7 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
providerId?: string,
|
providerId?: string,
|
||||||
modelId?: string
|
modelId?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this._context) {
|
if (!this._notebookPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!_serverSettings) {
|
if (!_serverSettings) {
|
||||||
@@ -170,9 +293,9 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const request: OpenCodeRequest = {
|
const request = {
|
||||||
prompt: text,
|
prompt: text,
|
||||||
context: this._context,
|
context: { notebookPath: this._notebookPath },
|
||||||
providerId,
|
providerId,
|
||||||
modelId
|
modelId
|
||||||
};
|
};
|
||||||
@@ -180,65 +303,76 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
this._status = 'loading';
|
this._status = 'loading';
|
||||||
if (this._prompt) {
|
if (this._prompt) {
|
||||||
this._prompt.setDisabled(true);
|
this._prompt.setDisabled(true);
|
||||||
|
this._prompt.setSessionId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let resp;
|
||||||
try {
|
try {
|
||||||
const resp = await callOpenCodeEdit(request, _serverSettings);
|
resp = await callOpenCodeEdit(request, _serverSettings);
|
||||||
this._handleResponse(resp);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this._status = 'idle';
|
this._status = 'idle';
|
||||||
if (this._prompt) {
|
this._prompt?.setDisabled(false);
|
||||||
this._prompt.setDisabled(false);
|
|
||||||
}
|
|
||||||
Notification.error(`OpenCode 失败: ${(e as Error).message}`);
|
Notification.error(`OpenCode 失败: ${(e as Error).message}`);
|
||||||
}
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _handleResponse(resp: OpenCodeResponse): void {
|
if (!resp.ok) {
|
||||||
this._status = 'idle';
|
this._status = 'idle';
|
||||||
if (resp.ok) {
|
this._prompt?.setDisabled(false);
|
||||||
// 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}`);
|
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 _closeSse(): void {
|
||||||
|
if (this._sseSub) {
|
||||||
|
this._sseSub.close();
|
||||||
|
this._sseSub = null;
|
||||||
|
}
|
||||||
|
this._status = 'idle';
|
||||||
|
this._prompt?.setDisabled(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a CodeCell's parent NotebookPanel by walking the parent chain,
|
* Walk the parent chain to find the cell's parent NotebookPanel and
|
||||||
* then build the CellContext.
|
* return its content path. Returns null if the cell is detached
|
||||||
|
* (no notebook parent).
|
||||||
*/
|
*/
|
||||||
function extractCellContextFromCell(cell: CodeCell): CellContext | null {
|
function extractNotebookPathFromCell(cell: CodeCell): string | null {
|
||||||
let node: Widget | null = cell.parent;
|
let node: Widget | null = cell.parent;
|
||||||
let notebookPanel: NotebookPanel | null = null;
|
|
||||||
while (node) {
|
while (node) {
|
||||||
const candidate = node as any;
|
const candidate = node as any;
|
||||||
if (
|
if (
|
||||||
candidate.context &&
|
candidate.context &&
|
||||||
|
typeof candidate.context.path === 'string' &&
|
||||||
candidate.content &&
|
candidate.content &&
|
||||||
Array.isArray(candidate.content.widgets)
|
Array.isArray(candidate.content.widgets)
|
||||||
) {
|
) {
|
||||||
notebookPanel = candidate;
|
return candidate.context.path;
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
node = node.parent;
|
node = node.parent;
|
||||||
}
|
}
|
||||||
if (!notebookPanel) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return extractCellContext(cell, notebookPanel);
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,81 +0,0 @@
|
|||||||
/**
|
|
||||||
* Extract a CellContext snapshot from a CodeCell + its parent NotebookPanel.
|
|
||||||
*
|
|
||||||
* Pure functions, no DOM, no signals — testable with plain object mocks.
|
|
||||||
*/
|
|
||||||
import type { CodeCell, ICodeCellModel } from '@jupyterlab/cells';
|
|
||||||
import type { NotebookPanel } from '@jupyterlab/notebook';
|
|
||||||
|
|
||||||
import type { CellContext, ErrorOutput } from '../types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the first error output in a code cell's outputs.
|
|
||||||
* Returns null if the cell has no error output or is not a code cell.
|
|
||||||
*/
|
|
||||||
export function collectErrorOutputs(model: ICodeCellModel): ErrorOutput | null {
|
|
||||||
const outputs = model.outputs;
|
|
||||||
for (let i = 0; i < outputs.length; i++) {
|
|
||||||
const out = outputs.get(i);
|
|
||||||
if (out && out.type === 'error') {
|
|
||||||
const e = out as {
|
|
||||||
ename?: unknown;
|
|
||||||
evalue?: unknown;
|
|
||||||
traceback?: unknown;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
ename: String(e.ename ?? ''),
|
|
||||||
evalue: String(e.evalue ?? ''),
|
|
||||||
traceback: Array.isArray(e.traceback) ? (e.traceback as string[]) : [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a CellContext from a CodeCell and its parent NotebookPanel.
|
|
||||||
* Returns null if essential data is missing.
|
|
||||||
*
|
|
||||||
* @param cell The code cell to inspect.
|
|
||||||
* @param panel The notebook panel that contains the cell.
|
|
||||||
*/
|
|
||||||
export function extractCellContext(
|
|
||||||
cell: CodeCell,
|
|
||||||
panel: NotebookPanel
|
|
||||||
): CellContext | null {
|
|
||||||
if (!cell || !panel) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cells = panel.content.widgets;
|
|
||||||
const cellIndex = cells ? cells.indexOf(cell) : -1;
|
|
||||||
if (cellIndex < 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCells = cells ? cells.length : 0;
|
|
||||||
const previousCell = cellIndex > 0 ? cells[cellIndex - 1] : null;
|
|
||||||
const previousCode = previousCell
|
|
||||||
? previousCell.model.sharedModel.getSource()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Language: use 'python' as default for code cells.
|
|
||||||
// Future improvement: read from kernel info_reply.
|
|
||||||
const language = 'python';
|
|
||||||
|
|
||||||
let error: ErrorOutput | null = null;
|
|
||||||
if (cell.model.type === 'code') {
|
|
||||||
error = collectErrorOutputs(cell.model as ICodeCellModel);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
notebookPath: panel.context.path,
|
|
||||||
cellId: cell.model.id,
|
|
||||||
language,
|
|
||||||
cellIndex,
|
|
||||||
totalCells,
|
|
||||||
source: cell.model.sharedModel.getSource(),
|
|
||||||
previousCode,
|
|
||||||
error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+91
-24
@@ -1,29 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* Shared types for the opencode-bridge frontend.
|
* Shared types for the opencode-bridge frontend.
|
||||||
*
|
*
|
||||||
* These mirror the Python server's `CellContext` / `OpenCodeRequest` / `OpenCodeResponse`
|
* Mirrors the Python server's response shapes in `opencode_bridge/routes.py`
|
||||||
* shapes in `opencode_bridge/routes.py`. Keep them in sync.
|
* 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.
|
* v4: POST /edit is async (returns immediately with sessionId). The LLM
|
||||||
* All connection config lives in startup environment variables (see
|
* reply is consumed via the GET /events SSE stream. Each SSE event is
|
||||||
* `opencode_bridge/config.py`); the inline model picker is fully dynamic.
|
* a JSON object with a `type` string; consumers route on `type`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ErrorOutput {
|
/**
|
||||||
ename: string;
|
* Minimal context carried with each edit request. Only the
|
||||||
evalue: string;
|
* notebookPath is needed (so the server can pick the right OpenCode
|
||||||
traceback: string[];
|
* session). The cell source, previous-cell, and traceback are NO
|
||||||
}
|
* LONGER auto-injected into the LLM prompt — the user inserts them
|
||||||
|
* manually via the "📋 插入单元格内容" button (or pastes anything
|
||||||
|
* else they want) so the LLM only sees what they explicitly chose
|
||||||
|
* to send. v0.1.x used to pack a lot more here; that auto-wrap
|
||||||
|
* made the LLM context hard to reason about and made the user's
|
||||||
|
* intent ambiguous.
|
||||||
|
*/
|
||||||
export interface CellContext {
|
export interface CellContext {
|
||||||
notebookPath: string;
|
notebookPath: string;
|
||||||
cellId: string;
|
|
||||||
language: string;
|
|
||||||
cellIndex: number;
|
|
||||||
totalCells: number;
|
|
||||||
source: string;
|
|
||||||
previousCode: string | null;
|
|
||||||
error: ErrorOutput | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeRequest {
|
export interface OpenCodeRequest {
|
||||||
@@ -33,21 +32,41 @@ export interface OpenCodeRequest {
|
|||||||
modelId?: string;
|
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;
|
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;
|
sessionId: string;
|
||||||
notebookPath: string;
|
notebookPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeFailure {
|
export interface OpenCodeEditFailure {
|
||||||
ok: false;
|
ok: false;
|
||||||
error: string;
|
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=... .
|
/** Response from GET /opencode-bridge/session-messages?notebook=... .
|
||||||
* Projected from OpenCode's {info, parts}[] by the server into a
|
* Projected from OpenCode's {info, parts}[] by the server into a
|
||||||
@@ -61,6 +80,54 @@ export interface OpenCodeMessagesResponse {
|
|||||||
messages: OpenCodeMessage[];
|
messages: OpenCodeMessage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Multi-session management types (1 notebook can bind N sessions, one active)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** One session on the OpenCode Serve side (returned by GET /session). */
|
||||||
|
export interface OpenCodeSessionMeta {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response from GET /opencode-bridge/sessions/all. */
|
||||||
|
export interface OpenCodeAllSessionsResponse {
|
||||||
|
ok: boolean;
|
||||||
|
sessions: OpenCodeSessionMeta[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One session bound to a notebook (returned by GET /sessions/notebook). */
|
||||||
|
export interface OpenCodeNotebookSession {
|
||||||
|
sessionId: string;
|
||||||
|
title?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response from GET /opencode-bridge/sessions/notebook?notebook=... */
|
||||||
|
export interface OpenCodeNotebookSessionsResponse {
|
||||||
|
ok: boolean;
|
||||||
|
notebookPath: string;
|
||||||
|
activeSessionId: string | null;
|
||||||
|
sessions: OpenCodeNotebookSession[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response from POST/PUT /sessions/notebook and PUT /sessions/active. */
|
||||||
|
export interface OpenCodeSessionOpResponse {
|
||||||
|
ok: boolean;
|
||||||
|
notebookPath: string;
|
||||||
|
sessionId?: string;
|
||||||
|
activeSessionId?: string | null;
|
||||||
|
deleted?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Response from GET /opencode-bridge/providers (proxies OpenCode /config/providers).
|
/** Response from GET /opencode-bridge/providers (proxies OpenCode /config/providers).
|
||||||
* Each Provider.models is a Record keyed by modelID (NOT an array). */
|
* Each Provider.models is a Record keyed by modelID (NOT an array). */
|
||||||
export interface OpenCodeModel {
|
export interface OpenCodeModel {
|
||||||
|
|||||||
+192
@@ -191,6 +191,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opencode-inline-prompt .opencode-inline-actions button {
|
.opencode-inline-prompt .opencode-inline-actions button {
|
||||||
@@ -210,3 +211,194 @@
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The "📋 插入单元格内容" button: left-aligned, visually distinct from
|
||||||
|
send/cancel so the user can tell at a glance it's a "modifier"
|
||||||
|
rather than a "submit/cancel" action. */
|
||||||
|
.opencode-inline-prompt .opencode-btn-insert-cell {
|
||||||
|
margin-right: auto; /* push to the left; send/cancel stay on the right */
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
.opencode-inline-prompt .opencode-btn-insert-cell:hover:enabled {
|
||||||
|
background: #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------
|
||||||
|
Session selector (multi-session UI) — top of the inline prompt
|
||||||
|
------------------------------------------------------------------ */
|
||||||
|
.opencode-inline-prompt .opencode-prompt-sessions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 2px 0 4px;
|
||||||
|
}
|
||||||
|
.opencode-inline-prompt .opencode-prompt-sessions label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: var(--jp-ui-font-size1);
|
||||||
|
color: var(--jp-ui-font-color1, #333);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.opencode-inline-prompt .opencode-session-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-size: var(--jp-ui-font-size1);
|
||||||
|
font-family: inherit;
|
||||||
|
border: 1px solid var(--jp-border-color2, #ccc);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--jp-layout-color1, #fff);
|
||||||
|
}
|
||||||
|
.opencode-inline-prompt .opencode-session-btn-new,
|
||||||
|
.opencode-inline-prompt .opencode-session-btn-refresh {
|
||||||
|
border: 1px solid var(--jp-border-color2, #ccc);
|
||||||
|
background: var(--jp-layout-color2, #f0f0f0);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--jp-ui-font-size0, 11px);
|
||||||
|
color: var(--jp-ui-font-color1, #333);
|
||||||
|
}
|
||||||
|
.opencode-inline-prompt .opencode-session-btn-new:hover,
|
||||||
|
.opencode-inline-prompt .opencode-session-btn-refresh:hover {
|
||||||
|
background: var(--jp-layout-color3, #e0e0e0);
|
||||||
|
}
|
||||||
|
.opencode-inline-prompt .opencode-session-btn-new {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user