7 Commits
Author SHA1 Message Date
tao.chen e742bf4527 docs: handover — capture v0.1.2 state, feature/floating-ai-button unmerged, Codex pain 2026-07-29 14:26:20 +08:00
tao.chen d6507368e2 chore: bump version to 0.1.2
CI / CI (push) Failing after 18m48s
Floating panel UI work + hide+show accumulation fix.
2026-07-29 14:15:58 +08:00
tao.chenandClaude f9d52f7b8e docs: add HANDOVER.md for next session context
The current session's context window is nearly full. This document
captures the project state, the key design decisions that future
maintainers should NOT re-derive, and the known minor issues that
were intentionally left for a follow-up session.

Contents:
- Project overview + current state (tag v0.1.1, branch main @ 310d507)
- Architecture diagram
- Critical code locations
- All 11 route endpoints in one table
- 5 key design decisions (SSE filter, no cell auto-inject, multi-session
  model, no partial-render markdown, real interactive permission/question
  UI) - written so the next session doesn't accidentally undo them
- How to run the tests
- Known minor issues / TODO list

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

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

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

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

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

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

Tests
-----
- 72 jest (was 78: -8 cell-context tests + 1 SSE filter test +
  3 insert-cell tests + rewrites). 73 pytest (unchanged). Build
  green.
2026-07-28 18:02:21 +08:00
tao.chenandClaude 4ff5d7021c refactor: dedup setSessionId calls in session-switch path; add test coverage
Code review of 893f049 flagged 6 findings. This commit addresses them all:

#1 (real issue, production code): setSessionId was being called
TWICE per session switch - once explicitly from the cell-action
callback, and again from the prompt's _handleSwitchSession (which
also assigned _sessionId, called _resetStreamPointers, and set
_sessionSelect.value directly). The explicit setSessionId in the
cell-action callbacks (onCreateSession, onSwitchSession) is now
removed; the prompt's handlers do all of that bookkeeping through
a single setSessionId() call, which already implements the
dropdown + reset-stream-pointers logic.

#2 / #4 (test coverage): the existing switching test only checked
that the right HTTP call was made. New assertions: the prompt's
session-messages mock IS called again (history actually reloaded)
and the rendered history reflects the new session's content
(verifies the user sees the right messages, not stale DOM).

#3 (test coverage): new test for the error path. bind_existing
returns ok:false -> the prompt rolls the dropdown back to the
previous active id, leaves _sessionId unchanged, and shows a
Notification.error. This locks in the rollback behavior so a
future refactor can't silently break it.

#5 (test cleanup): mockedCallOpenCodeSessionMessages (the spy used
to verify history reload) is now declared at the top of the spec
file alongside the other mocked* variables, with a meaningful
name (mockedSessionMessages) instead of an inline jest.mocked()
that was hard to find.

#6 (source-doc): added a TODO(frontend) comment on
callOpenCodeSetActiveSession explaining why it is currently
unused (kept for a future 'switch between already-bound sessions'
flow that would avoid the bind round-trip) and warning a future
maintainer not to delete the corresponding server route without
removing the client function (or vice versa).

Tests: 77 jest (was 76), 73 pytest, build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 17:00:29 +08:00
tao.chenandClaude 893f049eb7 fix: call bind (not set_active) when switching to a fresh session
Bug: 400 from PUT /opencode-bridge/sessions/active when the user picked
a session from the global list that wasn't yet bound to this notebook.
The cell-action onSwitchSession callback was:
  1. PUT /sessions/active (server checks bound -> 400)
  2. PUT /sessions/notebook (bind, but never reached)
The order was wrong: set_active requires the session to be bound FIRST.

Fix: the PUT /sessions/notebook route does both bind AND set active
in a single call (server handler: bind_existing() then response
includes the new activeSessionId). So the callback now only needs to
call bind-existing. The /sessions/active endpoint is still useful for
future "switch between already-bound sessions" flows but the current
"pick from global list" path doesn't need it.

Tests: updated the switching test to assert bind-existing is called
and set-active is NOT. Also added not.toHaveBeenCalled() to lock in
the new behavior. 76 jest, 73 pytest, build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 16:39:27 +08:00
tao.chenandClaude d2bd4f1e48 feat: multi-session management (browse all + bind N sessions per notebook)
User can now see EVERY session on the OpenCode Serve side (not just
the per-notebook mapping) and bind multiple sessions to a single
notebook. One of the bound sessions is the "active" one (the one
the next /edit uses); the others are historical. The user can
switch, create, or delete via the new session selector UI in the
inline prompt.

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 16:25:42 +08:00
17 changed files with 2225 additions and 433 deletions
+164
View File
@@ -0,0 +1,164 @@
# opencode_bridge 交接文档
## 状态速览
**分支**:`main`(已切回,在 main 上操作)
**远端**:`http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git`
**当前 HEAD (main)**:`d650736 chore: bump version to 0.1.2`
**Tag**:`v0.1.2``d650736`(已推到远端,CI 会自动 build + 发 Gitea release + Bark 通知)
```
d650736 chore: bump version to 0.1.2 ← main HEAD, v0.1.2
f9d52f7 docs: add HANDOVER.md for next session context
310d507 fix: SSE filter no longer drops content events for a different session
```
⚠️ **重要分歧**:`main` 这次的 v0.1.2 发布**只包含了 version bump**,没有功能改动。完整功能(per-cell → 全局 floating panel 重构)在分支 `feature/floating-ai-button` 上,**未合并**。git status 是干净的,如果你想继续 feature,先 `git checkout feature/floating-ai-button`
```
feature/floating-ai-button HEAD:
12ec84a docs: refresh HANDOVER for floating panel work (d412130)
d412130 fix: hide+show cycles no longer accumulate prompt nodes in floating panel
c0eba7e feat: replace per-cell AI button with global floating panel
(已 push 到远端 + tracking 已设)
```
---
## 本次会话做了什么(按时间序)
1. **HANDOVER.md 初版**(上一任留下的)— 已读
2. **创建 `feature/floating-ai-button` 分支**
3. **重构**:把 per-cell 工具栏 AI 按钮(`OpenCodeCellActions`)整个换成全局右下角悬浮按钮 + 弹出 panel
- 新增 `src/components/opencode_floating_panel.ts`(Widget,挂 `document.body`)
- 删除 `src/components/opencode_cell_actions.ts`(逻辑搬上去)
-`src/components/opencode_inline_prompt.ts` — 加 `getActiveCell?: () => CodeCell | null` 回调,3 处 cell 引用全用回调
-`src/index.ts` — 删 `toolbarRegistry.addFactory`,接入 floating panel
-`style/base.css` — 删 `.opencode-cell-actions`,新增 `.opencode-floating-*`(fixed 定位、z-index 1000)
- 重命名 + 重写 `src/__tests__/opencode_floating_panel.spec.ts`
4. **Bug fix (d412130)**:hide+show 循环累积 prompt 节点 — 用户手测报告,在 `_hidePrompt` 加 defensive DOM cleanup
5. **Commit + push**`feature/floating-ai-button`(只到这,没合并)
6. **回 main**:**用户决定在 main 上单独发 v0.1.2**(只 bump 版本号,不含功能)
7. **v0.1.2 tag + push**
---
## 验证状态
| 检查 | 结果 |
|------|------|
| Jest | ✅ 68/68(3 suites)— **在 feature 分支验证过**,main 上 0.1.2 没跑过 |
| pytest | ✅ 73/73(同上,feature 分支) |
| `tsc --noEmit` | ✅ exit 0(feature 分支) |
| ESLint | ⚠️ 2 pre-existing 命名错(`FlatProvider`/`BlockEntry` 接口名,不符合 `/^I[A-Z]/u`),我没引入 |
| `jlpm build:prod` | ⚠️ 未跑(见下) |
---
## ⚠️ 下个 agent 必须知道的几件事
### 1. Codex 在本会话里基本不可用 ⚠️⚠️⚠️
CLAUDE.md 强制 `model: kimi/kimi-k2.7-code`(或后来的 `kimi/kimi-k2.6`),但火山方舟 Coding Plan proxy 反复拒绝:
- `kimi-k2.7-code` — HTTP 400 "model not valid"(模型名错)
- `kimi/kimi-k2.7-code`(CLAUDE.md 当前版本) — 同样被拒
- `kimi-k2.6` — HTTP 400 "reasoning is not supported"
- `kimi/kimi-k2.7-code` noop 偶尔通一次,然后实际任务又失败
**实际代码工作几乎全是 CC 手动做的**。Codex 临死前对 spec 文件做了一次有用的部分重写(改完了 65% 然后挂了)。
**建议下次**:
- 先 noop 测一次 Codex,通了再用;不通直接走 CC fallback(CLAUDE.md 规则 `codex-fail-3x → switch-to-cc`)
- 如果必须用 Codex,可能需要你这边再调一次 proxy 配置
### 2. d412130 bug 的防御性修复 + 测试局限
**症状**:点悬浮按钮 → 显示 panel。再点 → 隐藏。再点 → **看到 2 个 prompt 同时显示**(节点累积)。
**根因**:`OpenCodeFloatingPanel``Widget.attach(prompt, this._panelEl)` 把 Lumino Widget 挂到裸 `HTMLDivElement` 上(host 不是 Widget),所以 prompt 的 Lumino `_parent` 是 null。真实 Lumino `Widget.dispose()` 在 parent=null 时走 `Widget.detach(this)` 分支按理能清 DOM,但实际有遗漏场景。
**修法**(`src/components/opencode_floating_panel.ts``_hidePrompt`):
```ts
if (this._prompt) {
const node = this._prompt.node;
this._prompt.dispose();
if (node.parentNode === this._panelEl) { // defensive
this._panelEl.removeChild(node);
}
this._prompt = null;
}
```
**回归测试局限**:`src/__tests__/opencode_floating_panel.spec.ts` 的测试 mock `Widget.dispose()` 直接用 `node.parentNode.removeChild` 清 — **比真实 Lumino 更激进**,所以这个回归测试在 mock 下永远过。**真实 bug 只能在真实 JupyterLab 里复现和验证**。如果你看到类似累积 bug,**必须真跑 JupyterLab**,别只信 jest pass。
### 3. 几个 `_onShellChanged` / `as any` 的取舍
- `_onShellChanged` 写成 **arrow function class field**(`= (): void => { ... }`)而不是 method,这样 `this` 锁定到实例上(Lumino signal 调用 slot 时 `this` 不可靠)
- `this._app.shell as any` 出现过几次 — 因为 `JupyterFrontEnd.shell` 在不同 JupyterLab 版本里类型签名是 `JupyterShell | null`,严格模式要求 cast。可以后续更精确处理,但当前能用
### 4. `jlpm build:prod` 没跑成功
`.venv/bin/jlpm` 是 Python 入口脚本,直接 `jlpm build:prod` 找不到。我试过 `python -m jupyter_builder.jlpm build:prod` 但也失败(参数解析对不上)。**完整 labextension 打包未验证** — 只跑了 `jest` + `tsc --noEmit` 间接确认编译能过。如果发版前要打包验证,你可能要在终端手动跑。
### 5. `v0.1.2` 是个"假版本"
只 bump 了 `package.json` 的 version 字段(`pyproject.toml` 是 hatch dynamic,从 git tag 自动读),**没有功能变更**。发这个 tag 大概是为了占位 / 触发 CI。下次真发版时需要把 `feature/floating-ai-button` 合并到 main,然后重新打 tag。
---
## 仍然没做的事
- [ ]`feature/floating-ai-button` 合到 main(用户说"暂时不管",所以还没合)
- [ ] **真实 JupyterLab + OpenCode Serve 端到端测试** — 没在活的 JupyterLab 里点过按钮。bug d412130 是用户手测报的
- [ ] **Playwright UI tests** — 项目交接文档里说写过 spec 但没跑,本次也没动
- [ ] **pre-existing lint 错清理**:`FlatProvider``IFlatProvider`,`BlockEntry``IBlockEntry`(2 处改名,小 PR)
- [ ] **`jlpm build:prod`** — 没跑通(见上)
- [ ] **CI 验证**:`v0.1.2` tag push 触发 CI,看一下 `.github/workflows/build.yml` 跑通没
---
## 关键文件速查(给下任的 quick map)
```
src/components/opencode_floating_panel.ts ← 新文件,核心逻辑都在这(310+ 行)
src/components/opencode_inline_prompt.ts ← 解耦 Cell,加了 getActiveCell 选项
src/__tests__/opencode_floating_panel.spec.ts ← 重写后的测试(1556 行,68 tests)
src/index.ts ← 极简,只剩 activate() + 挂 floating panel
style/base.css ← 找 .opencode-floating-* 块
```
参考架构/HTTP 细节(未变)参考本仓库 HANDOVER.md 之前章节的 [架构](#架构) / [主要路由](#主要路由) / [关键设计决策](#关键设计决策-避免来回改)。
---
## 测试命令
```bash
# 后端
.venv/bin/pytest opencode_bridge/tests/ -q # 73 passed
# 前端
./node_modules/.bin/jest --no-coverage # 68 passed
./node_modules/.bin/tsc --noEmit # exit 0
./node_modules/.bin/eslint --ext .ts,.tsx src/ # 仅 pre-existing 错
# 在哪个分支跑? feature/floating-ai-button 上跑过了,main 上 v0.1.2 没跑
```
---
## 上下文管理提示
上下文快满了。新 agent 第一件事:
1. **读这份 HANDOVER.md 全文**(包括本节之前的所有内容)
2. `git log --oneline -5` 确认 commit hash 没变
3. 跑上面 4 个验证命令
4. 如果要继续 feature 工作 → `git checkout feature/floating-ai-button`
5. 如果只是在 main 上发版 → 留在这里,准备好就 `git merge feature/floating-ai-button`(或 PR 流程)
**不要**:
- 不要从 main 重新开发 — 浪费时间
- 不要相信 jest 单测覆盖 d412130 那类 bug — 必须在真 JupyterLab 里点
- 不要相信 Codex 能用 — 先 noop 测一下
+10
View File
@@ -159,6 +159,16 @@ class OpenCodeClient:
"""
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
def endpoint(self) -> str:
return self._config.url
+252 -37
View File
@@ -48,44 +48,19 @@ def get_session_manager(handler: APIHandler) -> SessionManager:
return sm
def _build_request_body(prompt: str, context: dict) -> dict:
"""Build full request body for OpenCode POST /session/:id/prompt_async.
def _build_request_body(prompt: str) -> dict:
"""Build request body for OpenCode POST /session/:id/prompt_async.
Returns dict with 'parts' (list) and 'system' (str) keys. No mode concept:
the LLM interprets the user's natural-language instruction, with the code
context and the optional traceback in front of it.
v0.2.x: only the user's prompt is sent to the LLM. We no longer
auto-wrap the cell source / previous-cell / traceback in tags —
the user inserts whatever they want via the "📋 插入单元格内容"
button (or pastes manually), so the LLM context exactly matches
user intent. Returns dict with 'parts' and 'system' keys.
"""
parts: list[dict] = []
if context.get("previousCode"):
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}
return {
"parts": [{"type": "text", "text": prompt}],
"system": UNIFIED_SYSTEM_PROMPT,
}
def _strip_code_fence(s: str) -> str:
@@ -204,7 +179,7 @@ class EditHandler(APIHandler):
sm = get_session_manager(self)
try:
sid = await sm.get_or_create(notebook_path)
request_body = _build_request_body(prompt, context)
request_body = _build_request_body(prompt)
await client.send_message_async(
sid,
request_body["parts"],
@@ -458,6 +433,242 @@ class SessionMessagesHandler(APIHandler):
self.finish(json.dumps({"messages": messages}))
# ---------------------------------------------------------------------------
# Multi-session management endpoints
# ---------------------------------------------------------------------------
#
# Model: each notebook can have multiple OpenCode sessions bound to it.
# One of the bound sessions is the "active" one (used by the next /edit).
# The user can:
# - list all OpenCode sessions (global, for the "browse" UI)
# - list sessions bound to a specific notebook
# - create a brand-new session and bind it (becomes active)
# - bind an existing session id (e.g. one they saw in the global list)
# - switch which bound session is active
# - unbind the active one (next /edit will lazy-create a new one)
# - delete a specific bound session (also removes it from OpenCode Serve)
# ---------------------------------------------------------------------------
class AllSessionsListHandler(APIHandler):
"""GET /opencode-bridge/sessions/all — list EVERY session on OpenCode
Serve, regardless of which notebook (if any) it is bound to. Powers
the "browse all conversations" UI in the cell-action panel.
"""
@tornado.web.authenticated
async def get(self):
try:
client = make_client(self)
sessions = await client.list_all_sessions()
except OpenCodeError as e:
log.exception("list all sessions failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
except Exception as e:
log.exception("list all sessions failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
self.finish(json.dumps({"ok": True, "sessions": sessions or []}))
class NotebookSessionsHandler(APIHandler):
"""Manage the list of sessions BOUND to one notebook.
GET /opencode-bridge/sessions/notebook?notebook=...
-> { ok, activeSessionId, sessions: [{sessionId, title, ...}, ...] }
The metadata (title, createdAt, ...) is enriched from
OpenCode's `GET /session` so the frontend can render
the picker without a second round-trip per session.
POST /opencode-bridge/sessions/notebook?notebook=...&title=...
-> { ok, sessionId, activeSessionId } — creates a new
session on OpenCode, binds it, sets as active.
PUT /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
-> { ok, sessionId, activeSessionId } — binds an
existing sessionId (e.g. one the user picked from
the global list) to this notebook and sets as active.
DELETE /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
-> { ok, deleted } — deletes the session on OpenCode
Serve AND removes its binding from this notebook.
If it was the active one, clears active.
"""
@tornado.web.authenticated
async def get(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
sm = get_session_manager(self)
bound_ids = sm.list_sessions_for_notebook(notebook_path)
active = sm.get_active(notebook_path)
# Enrich with metadata from OpenCode Serve so the UI can show
# the title / createdAt without a second round-trip.
try:
all_sessions = await make_client(self).list_all_sessions()
by_id = {s.get("id"): s for s in (all_sessions or [])}
except Exception as e:
log.warning(
"could not enrich bound sessions with metadata: %s", e
)
by_id = {}
sessions = []
for sid in bound_ids:
meta = by_id.get(sid, {})
sessions.append({
"sessionId": sid,
"title": meta.get("title"),
"createdAt": meta.get("createdAt"),
"updatedAt": meta.get("updatedAt"),
"isActive": sid == active,
})
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": active,
"sessions": sessions,
}))
@tornado.web.authenticated
async def post(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
title = self.get_query_argument("title", None) or None
sm = get_session_manager(self)
try:
sid = await sm.create_new(notebook_path, title=title)
except OpenCodeError as e:
log.exception("create session failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
except Exception as e:
log.exception("create session failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"sessionId": sid,
"activeSessionId": sid,
}))
@tornado.web.authenticated
def put(self):
notebook_path = self.get_query_argument("notebook", "")
session_id = self.get_query_argument("sessionId", "")
if not notebook_path or not session_id:
self.set_status(400)
self.finish(json.dumps({
"error": "missing 'notebook' and/or 'sessionId' query parameter"
}))
return
sm = get_session_manager(self)
sm.bind_existing(notebook_path, session_id)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"sessionId": session_id,
"activeSessionId": sm.get_active(notebook_path),
}))
@tornado.web.authenticated
async def delete(self):
notebook_path = self.get_query_argument("notebook", "")
session_id = self.get_query_argument("sessionId", "")
if not notebook_path or not session_id:
self.set_status(400)
self.finish(json.dumps({
"error": "missing 'notebook' and/or 'sessionId' query parameter"
}))
return
sm = get_session_manager(self)
deleted = await sm.delete_session(notebook_path, session_id)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"sessionId": session_id,
"deleted": deleted,
"activeSessionId": sm.get_active(notebook_path),
}))
class ActiveSessionHandler(APIHandler):
"""Manage the ACTIVE session for one notebook.
GET /opencode-bridge/sessions/active?notebook=...
-> { ok, activeSessionId, notebookPath }
PUT /opencode-bridge/sessions/active?notebook=...&sessionId=...
-> { ok, activeSessionId } — switch the active bound
session. sessionId MUST already be in the bound list.
DELETE /opencode-bridge/sessions/active?notebook=...
-> { ok } — unbind the active session (next /edit will
lazy-create a new one). Does NOT delete the session
on OpenCode Serve (use the notebook DELETE for that).
"""
@tornado.web.authenticated
def get(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
sm = get_session_manager(self)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": sm.get_active(notebook_path),
}))
@tornado.web.authenticated
def put(self):
notebook_path = self.get_query_argument("notebook", "")
session_id = self.get_query_argument("sessionId", "")
if not notebook_path or not session_id:
self.set_status(400)
self.finish(json.dumps({
"error": "missing 'notebook' and/or 'sessionId' query parameter"
}))
return
sm = get_session_manager(self)
if not sm.set_active(notebook_path, session_id):
self.set_status(400)
self.finish(json.dumps({
"ok": False,
"error": "sessionId is not bound to this notebook; "
"POST /sessions/notebook or PUT /sessions/notebook first"
}))
return
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": sm.get_active(notebook_path),
}))
@tornado.web.authenticated
def delete(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
sm = get_session_manager(self)
sm.unbind_active(notebook_path)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": sm.get_active(notebook_path),
}))
def setup_route_handlers(web_app):
host_pattern = ".*$"
base_url = web_app.settings["base_url"]
@@ -471,6 +682,10 @@ def setup_route_handlers(web_app):
(url_path_join(base_url, "opencode-bridge", "sessions"), SessionListHandler),
(url_path_join(base_url, "opencode-bridge", "session"), SessionReleaseHandler),
(url_path_join(base_url, "opencode-bridge", "session-messages"), SessionMessagesHandler),
# Multi-session management (notebook can bind N sessions; one is active).
(url_path_join(base_url, "opencode-bridge", "sessions", "all"), AllSessionsListHandler),
(url_path_join(base_url, "opencode-bridge", "sessions", "notebook"), NotebookSessionsHandler),
(url_path_join(base_url, "opencode-bridge", "sessions", "active"), ActiveSessionHandler),
# /permissions/<perm_id> and /questions/<q_id>/reply
# use a URLSpec with a regex so the path param is forwarded to
# the handler as a positional argument.
+205 -54
View File
@@ -1,13 +1,29 @@
"""Per-notebook session manager for OpenCode.
Maps notebookPath -> OpenCode sessionID. Lazy create on first use.
Async-safe via per-path asyncio.Lock. No automatic cleanup.
Maps each notebookPath to one OR MORE OpenCode session IDs. One of
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
import asyncio
import logging
from typing import Callable
from typing import Callable, Optional
from .opencode_client import OpenCodeClient
@@ -17,81 +33,216 @@ ClientFactory = Callable[[], OpenCodeClient]
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:
self._client_factory = client_factory
self._sessions: dict[str, str] = {} # notebookPath -> sessionID
self._locks: dict[str, asyncio.Lock] = {} # notebookPath -> lock
self._titles: dict[str, str] = {} # notebookPath -> title (for debug)
# notebookPath -> active sessionId
self._active: dict[str, str] = {}
# 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:
"""Return session ID for the notebook, creating one if needed.
Idempotent for the same path. Different paths get different sessions.
"""Return the active session id for this notebook, creating one
if no active session is bound yet. This is what ``EditHandler``
calls on every prompt.
"""
existing = self._sessions.get(notebook_path)
if existing is not None:
return existing
active = self._active.get(notebook_path)
if active is not None:
return active
lock = self._locks.setdefault(notebook_path, asyncio.Lock())
async with lock:
existing = self._sessions.get(notebook_path)
if existing is not None:
return existing
active = self._active.get(notebook_path)
if active is not None:
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()
session = await client.create_session(
title="jupyter:%s" % notebook_path
title=title or "jupyter:%s" % notebook_path
)
sid = session["id"]
self._sessions[notebook_path] = sid
self._titles[notebook_path] = notebook_path
self._bind(notebook_path, sid)
log.info("created opencode session %s for %s", sid, notebook_path)
return sid
async def release(self, notebook_path: str) -> bool:
"""Delete session and remove from map. Returns True if a session existed."""
sid = self._sessions.pop(notebook_path, None)
self._titles.pop(notebook_path, None)
self._locks.pop(notebook_path, None)
if sid is None:
def get_active(self, notebook_path: str) -> Optional[str]:
"""Return the active sessionId for the notebook, or None if no
active session has been bound/created yet. Does NOT create one.
"""
return self._active.get(notebook_path)
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
try:
client = self._client_factory()
return await client.delete_session(sid)
deleted = await client.delete_session(session_id)
except Exception:
log.warning(
"failed to delete opencode session %s for %s", sid, notebook_path
)
log.warning("failed to delete opencode session %s", session_id)
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:
"""Drop the cached sessionID without calling OpenCode. Returns True if removed.
Use this when an upstream error indicates the session is dead (e.g., 404).
"""Drop the cached active sessionId without calling OpenCode.
Returns True if a binding was removed. Use this when an
upstream error indicates the session is dead (e.g. 404).
"""
sid = self._sessions.pop(notebook_path, None)
self._titles.pop(notebook_path, None)
return sid is not None
def has_session(self, notebook_path: str) -> bool:
return notebook_path in self._sessions
def peek(self, notebook_path: str) -> Optional[str]:
"""Return the cached sessionID for the notebook, or None if no
session has been created yet. Does NOT create one (unlike
get_or_create) — used by the history endpoint to avoid spawning
a session just to report that there is none."""
return self._sessions.get(notebook_path)
was_active = self._active.pop(notebook_path, None)
if was_active is None:
return False
bound = self._bound.get(notebook_path)
if bound is not None:
try:
bound.remove(was_active)
except ValueError:
pass
if not bound:
self._bound.pop(notebook_path, None)
return True
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 [
{"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, []))
+16
View File
@@ -266,6 +266,22 @@ async def test_reply_question_posts_answer(base_config: OpenCodeConfig) -> None:
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
async def test_non_2xx_response_raises_with_body(base_config: OpenCodeConfig) -> None:
client, mock = _make_client(base_config, [(500, {"error": "boom"})])
+339 -33
View File
@@ -57,6 +57,29 @@ class FakeOpenCodeClient:
self.calls.append(("list_session_messages", session_id))
return self.messages_response
async def list_all_sessions(self):
self.calls.append(("list_all_sessions",))
return [
{
"id": "fake-session-123",
"title": "jupyter:foo.ipynb",
"createdAt": 1700000000000,
"updatedAt": 1700000001000,
},
{
"id": "other-session",
"title": "manual session",
"createdAt": 1700000010000,
"updatedAt": 1700000011000,
},
]
async def create_session(self, title):
self.calls.append(("create_session", title))
# Return a fresh id (different from the default "fake-session-123").
new_id = "newly-created-" + title
return {"id": new_id, "title": title}
async def reply_permission(self, session_id, permission_id, response):
self.calls.append(("reply_permission", session_id, permission_id, response))
return True
@@ -72,35 +95,84 @@ class FakeOpenCodeClient:
class FakeSessionManager:
"""Drop-in replacement for SessionManager with recording."""
"""Thin wrapper around the real SessionManager with a recording
``FakeOpenCodeClient``. Lets route tests exercise the real
multi-session logic without touching OpenCode.
"""
def __init__(self, session_id: str = "fake-session-123") -> None:
self._session_id = session_id
self.calls: list = []
def __init__(self, session_id: str = "fake-session-123", client=None) -> None:
from opencode_bridge.session_manager import SessionManager
# `_client` attribute used by some legacy tests. Allow injection
# so a test can share its FakeOpenCodeClient with the SessionManager
# (the real SessionManager calls into its own client_factory, NOT
# the route's make_client).
self._client = client if client is not None else FakeOpenCodeClient()
self._sm = SessionManager(lambda: self._client)
self.calls: list = self._client.calls
# Legacy attribute used by list_sessions() etc.
self._sessions: dict = {}
async def get_or_create(self, notebook_path: str) -> str:
self.calls.append(("get_or_create", notebook_path))
return self._session_id
# ---- delegation to the real SessionManager ----
def _record(self, name, *args):
self.calls.append((name, *args))
return None
def get_or_create(self, notebook_path: str):
self._record("get_or_create", notebook_path)
return self._sm.get_or_create(notebook_path)
def peek(self, notebook_path: str):
# Mirror SessionManager.peek: return the session id without
# creating one. None means "no session yet" (used by the no-session
# test to short-circuit the history route).
return self._session_id or None
return self._sm.peek(notebook_path)
async def release(self, notebook_path: str) -> bool:
self.calls.append(("release", notebook_path))
return True
def get_active(self, notebook_path: str):
return self._sm.get_active(notebook_path)
def list_sessions(self) -> list:
return [
{"notebookPath": path, "sessionId": sid}
for path, sid in sorted(self._sessions.items())
]
return self._sm.list_sessions()
def list_sessions_for_notebook(self, notebook_path: str):
return self._sm.list_sessions_for_notebook(notebook_path)
def bind_existing(self, notebook_path: str, session_id: str) -> None:
self._record("bind_existing", notebook_path, session_id)
self._sm.bind_existing(notebook_path, session_id)
def set_active(self, notebook_path: str, session_id: str) -> bool:
self._record("set_active", notebook_path, session_id)
return self._sm.set_active(notebook_path, session_id)
def unbind(self, notebook_path: str, session_id: str) -> bool:
self._record("unbind", notebook_path, session_id)
return self._sm.unbind(notebook_path, session_id)
def unbind_active(self, notebook_path: str) -> bool:
self._record("unbind_active", notebook_path)
return self._sm.unbind_active(notebook_path)
def invalidate(self, notebook_path: str) -> bool:
self.calls.append(("invalidate", notebook_path))
return True
self._record("invalidate", notebook_path)
return self._sm.invalidate(notebook_path)
async def release(self, notebook_path: str) -> bool:
self._record("release", notebook_path)
return await self._sm.release(notebook_path)
async def delete_session(self, notebook_path: str, session_id: str) -> bool:
self._record("delete_session", notebook_path, session_id)
return await self._sm.delete_session(notebook_path, session_id)
async def create_new(self, notebook_path: str, title: str | None = None) -> str:
self._record("create_new", notebook_path, title)
return await self._sm.create_new(notebook_path, title)
# ---- internal-state proxies (used by tests that poke internals) ----
@property
def _active(self) -> dict:
return self._sm._active
@property
def _bound(self) -> dict:
return self._sm._bound
async def test_hello(jp_fetch):
@@ -178,11 +250,11 @@ async def test_edit_handler(monkeypatch, jp_fetch):
)
assert response.code == 200
payload = json.loads(response.body)
assert payload == {
"ok": True,
"sessionId": "fake-session-123",
"notebookPath": "test.ipynb",
}
# The exact sessionId comes from the FakeClient; just check the
# shape — keys are present and ok is true.
assert payload["ok"] is True
assert isinstance(payload["sessionId"], str) and payload["sessionId"]
assert payload["notebookPath"] == "test.ipynb"
# Async path: no `markdown` field. The LLM reply is consumed via SSE.
assert "markdown" not in payload
@@ -193,16 +265,21 @@ async def test_edit_handler(monkeypatch, jp_fetch):
assert "send_message_async" in call_names
assert "send_message_sync" not in call_names # legacy sync path is gone
# send_message_async received the session ID from the manager.
# send_message_async received a session id from the manager (the
# exact value comes from the FakeClient; just verify it matches
# the response's sessionId).
send_call = [c for c in fake.calls if c[0] == "send_message_async"][0]
assert send_call[1] == "fake-session-123"
assert send_call[1] == payload["sessionId"]
# system prompt was passed.
assert "你是一个代码助手" in send_call[3]
# SessionManager.get_or_create was called with the notebook path.
# (The real SessionManager internally calls create_session on the
# FakeClient too, so the shared calls list has both entries.)
sm_call_names = [c[0] for c in fake_sm.calls]
assert sm_call_names == ["get_or_create"]
assert fake_sm.calls[0][1] == "test.ipynb"
assert "get_or_create" in sm_call_names
get_or_create_call = [c for c in fake_sm.calls if c[0] == "get_or_create"][0]
assert get_or_create_call[1] == "test.ipynb"
async def test_edit_handler_includes_model_when_provider_and_model_given(
@@ -242,10 +319,12 @@ async def test_edit_handler_includes_model_when_provider_and_model_given(
async def test_session_list_handler(monkeypatch, jp_fetch):
fake_sm = FakeSessionManager()
fake_sm._sessions = {
"foo.ipynb": "sid-1",
"bar.ipynb": "sid-2",
} # direct injection
# Seed the underlying real SessionManager's state so list_sessions
# returns the expected debug mapping.
fake_sm._active["foo.ipynb"] = "sid-1"
fake_sm._active["bar.ipynb"] = "sid-2"
fake_sm._bound["foo.ipynb"] = ["sid-1"]
fake_sm._bound["bar.ipynb"] = ["sid-2"]
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
)
@@ -290,6 +369,10 @@ async def test_session_messages_handler_projects_opencode_messages(
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
fake_sm = FakeSessionManager(session_id="fake-session-123")
# Seed the bound state so peek() returns the session id (the route
# only fetches messages if there's an active session).
fake_sm._active["test.ipynb"] = "fake-session-123"
fake_sm._bound["test.ipynb"] = ["fake-session-123"]
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
)
@@ -332,6 +415,11 @@ async def test_session_release_handler(monkeypatch, jp_fetch):
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
)
# Set up state: get_or_create actually binds a session to the
# notebook in the real SessionManager (via the FakeClient). release
# then has something to delete.
await fake_sm.get_or_create("foo.ipynb")
response = await jp_fetch(
"opencode-bridge", "session",
method="DELETE",
@@ -459,3 +547,221 @@ async def test_global_event_handler_forwards_all_events_unfiltered(
assert '"server.connected"' in body
# The body should use the SSE format: each event as a `data:` line.
assert body.count("data: ") == 3
# ---------------------------------------------------------------------------
# Multi-session management endpoints
# ---------------------------------------------------------------------------
async def test_all_sessions_handler_returns_global_list(monkeypatch, jp_fetch):
"""GET /sessions/all proxies OpenCode Serve's GET /session."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
response = await jp_fetch("opencode-bridge", "sessions", "all")
assert response.code == 200
payload = json.loads(response.body)
assert payload["ok"] is True
assert len(payload["sessions"]) == 2
# The fake returns both the default session and a hand-rolled "other-session".
ids = {s["id"] for s in payload["sessions"]}
assert "fake-session-123" in ids
assert "other-session" in ids
assert ("list_all_sessions",) in fake.calls
async def test_notebook_sessions_get_returns_bound_with_metadata(
monkeypatch, jp_fetch
):
"""GET /sessions/notebook?notebook=... returns the bound sessions
enriched with title/createdAt from the global list."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
sm = FakeSessionManager()
sm._active["foo.ipynb"] = "fake-session-123"
sm._bound["foo.ipynb"] = ["fake-session-123"]
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
params={"notebook": "foo.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["notebookPath"] == "foo.ipynb"
assert payload["activeSessionId"] == "fake-session-123"
assert len(payload["sessions"]) == 1
s = payload["sessions"][0]
assert s["sessionId"] == "fake-session-123"
assert s["title"] == "jupyter:foo.ipynb"
assert s["isActive"] is True
async def test_notebook_sessions_post_creates_and_binds(monkeypatch, jp_fetch):
"""POST /sessions/notebook creates a new session on OpenCode and
binds it as active."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
# Share the client with the SessionManager so the test can assert
# against `fake.calls` (the real SessionManager calls its own
# client_factory, NOT the route's make_client).
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager",
lambda h: FakeSessionManager(client=fake),
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
method="POST",
params={"notebook": "foo.ipynb", "title": "fresh"},
body=json.dumps({}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["ok"] is True
assert payload["sessionId"] == "newly-created-fresh"
# The new session is the active one.
assert payload["activeSessionId"] == "newly-created-fresh"
# OpenCode was called.
assert ("create_session", "fresh") in fake.calls
async def test_notebook_sessions_put_binds_existing(monkeypatch, jp_fetch):
"""PUT /sessions/notebook binds an existing sessionId without
calling OpenCode. Sets it as active."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
sm = FakeSessionManager()
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
method="PUT",
params={"notebook": "foo.ipynb", "sessionId": "some-existing-sid"},
body=json.dumps({}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["sessionId"] == "some-existing-sid"
assert payload["activeSessionId"] == "some-existing-sid"
# No OpenCode round-trip for the bind.
assert ("create_session", ...) not in [
c for c in fake.calls if c[0] == "create_session"
]
async def test_active_session_set_requires_bound(monkeypatch, jp_fetch):
"""PUT /sessions/active?sessionId=X fails with 400 if X is not bound."""
monkeypatch.setattr(
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
)
sm = FakeSessionManager()
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
await jp_fetch(
"opencode-bridge", "sessions", "active",
method="PUT",
params={"notebook": "foo.ipynb", "sessionId": "never-bound"},
body=json.dumps({}),
)
assert exc_info.value.code == 400
async def test_active_session_set_switches(monkeypatch, jp_fetch):
"""PUT /sessions/active successfully switches when the sessionId is bound."""
monkeypatch.setattr(
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
)
sm = FakeSessionManager()
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "active",
method="PUT",
params={"notebook": "foo.ipynb", "sessionId": "sid-A"},
body=json.dumps({}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["activeSessionId"] == "sid-A"
async def test_active_session_delete_unbinds_only(monkeypatch, jp_fetch):
"""DELETE /sessions/active unbinds the active session; other
bound sessions are kept; OpenCode is NOT called."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
sm = FakeSessionManager()
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "active",
method="DELETE",
params={"notebook": "foo.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["activeSessionId"] is None
# sid-A is still bound; sid-B was the active and is gone.
assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-A"]
# No OpenCode call.
assert not any(c[0] == "delete_session" for c in fake.calls)
async def test_active_session_get_returns_id_or_null(monkeypatch, jp_fetch):
monkeypatch.setattr(
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
)
sm = FakeSessionManager()
sm.bind_existing("foo.ipynb", "sid-A")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "active",
params={"notebook": "foo.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["activeSessionId"] == "sid-A"
async def test_notebook_sessions_delete_removes_session(monkeypatch, jp_fetch):
"""DELETE /sessions/notebook?notebook=...&sessionId=... deletes
the session on OpenCode AND removes its binding."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
# Share the client with the SessionManager so the test can assert
# against `fake.calls` (the real SessionManager calls its own
# client_factory, NOT the route's make_client).
sm = FakeSessionManager(client=fake)
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
method="DELETE",
params={"notebook": "foo.ipynb", "sessionId": "sid-A"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["deleted"] is True
# OpenCode delete was called for sid-A.
assert ("delete_session", "sid-A") in fake.calls
# sid-A is gone; sid-B remains active.
assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-B"]
assert sm.get_active("foo.ipynb") == "sid-B"
+165 -3
View File
@@ -7,13 +7,31 @@ from opencode_bridge.session_manager import SessionManager
class FakeClient:
"""Mimics OpenCodeClient just enough for SessionManager."""
def __init__(self, session_id: str = "sid-from-fake") -> None:
"""Mimics OpenCodeClient just enough for SessionManager.
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._unique_ids = unique_ids_per_call
self._next_id = 0
self.calls: list = []
async def create_session(self, title: str) -> dict:
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}
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)
await sm.get_or_create("a.ipynb")
# Manually inject a second to test listing
sm._sessions["b.ipynb"] = "sid-b"
sm._active["b.ipynb"] = "sid-b"
listing = sm.list_sessions()
paths = {s["notebookPath"] for s in listing}
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]
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"
# ---------------------------------------------------------------------------
# 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
View File
@@ -1,6 +1,6 @@
{
"name": "opencode_bridge",
"version": "0.1.0",
"version": "0.1.2",
"description": "A JupyterLab extension.",
"keywords": [
"jupyter",
-154
View File
@@ -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();
});
});
+304 -8
View File
@@ -118,6 +118,23 @@ jest.mock('../api/opencode_client', () => ({
callOpenCodeSessionMessages: jest.fn().mockResolvedValue({ messages: [] }),
callOpenCodeReplyPermission: jest.fn().mockResolvedValue({ ok: true }),
callOpenCodeReplyQuestion: jest.fn().mockResolvedValue({ ok: true }),
callOpenCodeListAllSessions: jest.fn().mockResolvedValue({ sessions: [] }),
callOpenCodeCreateNotebookSession: jest.fn().mockResolvedValue({
ok: true,
notebookPath: '',
sessionId: 'newly-created',
activeSessionId: 'newly-created',
}),
callOpenCodeSetActiveSession: jest.fn().mockResolvedValue({
ok: true,
notebookPath: '',
activeSessionId: '',
}),
callOpenCodeBindExistingSession: jest.fn().mockResolvedValue({
ok: true,
notebookPath: '',
sessionId: 'sess-B',
}),
subscribeOpenCodeEvents: jest.fn().mockReturnValue({ close: jest.fn() })
}));
@@ -130,6 +147,11 @@ import { OpenCodeInlinePrompt } from '../components/opencode_inline_prompt';
import { CodeCell } from '@jupyterlab/cells';
import {
callOpenCodeEdit,
callOpenCodeListAllSessions,
callOpenCodeCreateNotebookSession,
callOpenCodeSetActiveSession,
callOpenCodeBindExistingSession,
callOpenCodeSessionMessages,
subscribeOpenCodeEvents
} from '../api/opencode_client';
import { Notification } from '@jupyterlab/apputils';
@@ -141,6 +163,21 @@ const mockedSubscribeOpenCodeEvents =
subscribeOpenCodeEvents as jest.MockedFunction<
typeof subscribeOpenCodeEvents
>;
const mockedListAllSessions = callOpenCodeListAllSessions as jest.MockedFunction<
typeof callOpenCodeListAllSessions
>;
const mockedCreateSession = callOpenCodeCreateNotebookSession as jest.MockedFunction<
typeof callOpenCodeCreateNotebookSession
>;
const mockedSetActiveSession = callOpenCodeSetActiveSession as jest.MockedFunction<
typeof callOpenCodeSetActiveSession
>;
const mockedBindExistingSession = callOpenCodeBindExistingSession as jest.MockedFunction<
typeof callOpenCodeBindExistingSession
>;
const mockedSessionMessages = callOpenCodeSessionMessages as jest.MockedFunction<
typeof callOpenCodeSessionMessages
>;
const mockedNotification = Notification as jest.Mocked<typeof Notification>;
function makeFakeOutputs(items: any[]): any {
@@ -729,7 +766,7 @@ describe('OpenCodeCellActions', () => {
});
describe('OpenCodeInlinePrompt', () => {
it('renders a textarea, a send and a cancel button (no selector when providers null)', () => {
it('renders a textarea, a send, a cancel, and an insert-cell-source button', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
@@ -738,9 +775,76 @@ describe('OpenCodeInlinePrompt', () => {
providers: null
});
expect(prompt.node.querySelector('textarea')).not.toBeNull();
// 2 buttons: 发送 and 取消 (no more output-area close button; the
// scrollable history area is the display).
expect(prompt.node.querySelectorAll('button').length).toBe(2);
// 3 buttons: 发送, 取消, 📋 插入单元格内容
expect(prompt.node.querySelectorAll('button').length).toBe(3);
const insertBtn = prompt.node.querySelector(
'.opencode-btn-insert-cell'
) as HTMLButtonElement;
expect(insertBtn).not.toBeNull();
expect(insertBtn.textContent).toContain('插入单元格内容');
});
it('"📋 插入单元格内容" appends the cell source as a markdown code block to the textarea', () => {
const cell = makeFakeCell('print("hello")\nprint("world")');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
const insertBtn = prompt.node.querySelector(
'.opencode-btn-insert-cell'
) as HTMLButtonElement;
insertBtn.click();
const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
// Inserted as a fenced code block (the fake cell's type is 'code'
// so the fence info string is whatever the cell reports — we just
// assert a fence + the source content, not a specific language).
expect(textarea.value).toMatch(/```\w*\n/);
expect(textarea.value).toContain('print("hello")');
expect(textarea.value).toContain('print("world")');
expect(textarea.value.trim().endsWith('```')).toBe(true);
});
it('"📋 插入单元格内容" appends to existing textarea content (with a leading newline if needed)', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
textarea.value = 'Explain:'; // no trailing newline
const insertBtn = prompt.node.querySelector(
'.opencode-btn-insert-cell'
) as HTMLButtonElement;
insertBtn.click();
// The original prefix is preserved at the start.
expect(textarea.value.startsWith('Explain:')).toBe(true);
// The code block was inserted; the user prompt + the code block
// are now both present.
expect(textarea.value).toContain('x = 1');
expect(textarea.value).toMatch(/```\w*[\s\S]*x = 1/);
// The cursor was moved to the end.
expect(textarea.selectionStart).toBe(textarea.value.length);
});
it('"📋 插入单元格内容" is a no-op when the cell is empty', () => {
const cell = makeFakeCell('');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
const insertBtn = prompt.node.querySelector(
'.opencode-btn-insert-cell'
) as HTMLButtonElement;
const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
insertBtn.click();
// Textarea is still empty.
expect(textarea.value).toBe('');
});
it('renders a history area; setMessages renders user text + assistant markdown and scrolls to bottom', () => {
@@ -1005,6 +1109,168 @@ describe('OpenCodeInlinePrompt', () => {
warnSpy.mockRestore();
});
it('renders the session selector when onListSessions is provided', () => {
mockedListAllSessions.mockResolvedValue({
ok: true,
sessions: [
{ id: 'sess-1', title: 'old chat' },
{ id: 'sess-2', title: 'current chat' },
]
});
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
(actions.node.querySelector('button') as HTMLButtonElement).click();
const prompt = (actions as any)._prompt;
// The session selector row + select are present.
const row = prompt.node.querySelector('.opencode-prompt-sessions');
expect(row).not.toBeNull();
const select = prompt.node.querySelector('.opencode-session-select');
expect(select).not.toBeNull();
// + 新建 / 🔄 刷新 buttons are present.
const newBtn = prompt.node.querySelector('.opencode-session-btn-new');
const refreshBtn = prompt.node.querySelector('.opencode-session-btn-refresh');
expect(newBtn).not.toBeNull();
expect(refreshBtn).not.toBeNull();
});
it('initialize() fetches and populates the session list', async () => {
mockedListAllSessions.mockResolvedValue({
ok: true,
sessions: [
{ id: 'sess-A', title: 'foo' },
{ id: 'sess-B', title: 'bar' },
]
});
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
(actions.node.querySelector('button') as HTMLButtonElement).click();
// initialize() is called inside _showPrompt.
await new Promise(r => setTimeout(r, 5));
expect(mockedListAllSessions).toHaveBeenCalled();
const prompt = (actions as any)._prompt;
const select = prompt.node.querySelector(
'.opencode-session-select'
) as HTMLSelectElement;
// Both sessions appear + the "new" sentinel.
const labels = Array.from(select.options).map(o => o.textContent);
expect(labels.length).toBe(3); // sess-A, sess-B, + 新建会话…
});
it('clicking + 新建 creates a new session, binds, and reloads history', async () => {
mockedListAllSessions.mockResolvedValue({ ok: true, sessions: [] });
mockedCreateSession.mockResolvedValue({
ok: true,
notebookPath: 'foo.ipynb',
sessionId: 'fresh-sid',
activeSessionId: 'fresh-sid',
});
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
(actions.node.querySelector('button') as HTMLButtonElement).click();
await new Promise(r => setTimeout(r, 5));
const prompt = (actions as any)._prompt;
const newBtn = prompt.node.querySelector(
'.opencode-session-btn-new'
) as HTMLButtonElement;
newBtn.click();
await new Promise(r => setTimeout(r, 5));
expect(mockedCreateSession).toHaveBeenCalled();
// The prompt's _sessionId is now the new one.
expect((prompt as any)._sessionId).toBe('fresh-sid');
});
it('switching session: bind_existing is called, SSE is closed, history is reloaded', async () => {
mockedListAllSessions.mockResolvedValue({
ok: true,
sessions: [
{ id: 'sess-A', title: 'first' },
{ id: 'sess-B', title: 'second' },
]
});
mockedBindExistingSession.mockResolvedValue({
ok: true, notebookPath: 'foo.ipynb', sessionId: 'sess-B',
});
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
(actions.node.querySelector('button') as HTMLButtonElement).click();
await new Promise(r => setTimeout(r, 5));
const prompt = (actions as any)._prompt;
// Mark sess-A as currently active (so we can verify the switch).
prompt.setSessionId('sess-A');
// Reset session-messages mock so we can assert it's called again.
mockedSessionMessages.mockClear();
mockedSessionMessages.mockResolvedValue({
messages: [{ role: 'user', content: 'from sess-B' }]
});
const select = prompt.node.querySelector(
'.opencode-session-select'
) as HTMLSelectElement;
select.value = 'sess-B';
select.dispatchEvent(new Event('change'));
await new Promise(r => setTimeout(r, 5));
// bind-existing is the single call (it does both bind AND set
// active on the server). Calling set_active first would 400
// because the session is not yet bound.
expect(mockedBindExistingSession).toHaveBeenCalled();
expect(mockedSetActiveSession).not.toHaveBeenCalled();
// Prompt's internal _sessionId is now the new one.
expect((prompt as any)._sessionId).toBe('sess-B');
// The prompt's history was reloaded for the new session.
expect(mockedSessionMessages).toHaveBeenCalledWith(
'foo.ipynb', expect.anything()
);
// The new history is rendered (the user sees sess-B's messages,
// not sess-A's stale DOM).
const userMsg = prompt.node.querySelector(
'.opencode-msg-user'
) as HTMLElement;
expect(userMsg.textContent).toBe('from sess-B');
});
it('switching session rolls back the dropdown + shows a Notification.error when bind fails', async () => {
mockedListAllSessions.mockResolvedValue({
ok: true,
sessions: [
{ id: 'sess-A', title: 'first' },
{ id: 'sess-B', title: 'second' },
]
});
// bind returns ok:false -> the prompt's _handleSwitchSession
// must roll back the dropdown to the previous active id.
mockedBindExistingSession.mockResolvedValue({
ok: false, notebookPath: 'foo.ipynb', error: 'upstream down',
});
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
(actions.node.querySelector('button') as HTMLButtonElement).click();
await new Promise(r => setTimeout(r, 5));
const prompt = (actions as any)._prompt;
prompt.setSessionId('sess-A');
const select = prompt.node.querySelector(
'.opencode-session-select'
) as HTMLSelectElement;
select.value = 'sess-B';
select.dispatchEvent(new Event('change'));
await new Promise(r => setTimeout(r, 5));
// Dropdown rolled back to the previous active id (sess-A).
expect(select.value).toBe('sess-A');
// Prompt's _sessionId is unchanged.
expect((prompt as any)._sessionId).toBe('sess-A');
// User sees a notification.
expect(mockedNotification.error).toHaveBeenCalledWith('切换会话失败');
});
it('applyEvent: streaming shows raw text in real-time, then renders to HTML on session.idle', () => {
// Regression: a previous version ran marked.parse on every text
// delta. That produced glitchy partial-render states (e.g. an
@@ -1183,7 +1449,14 @@ describe('OpenCodeInlinePrompt', () => {
expect(asstBlocks[1].textContent).toBe('turn2');
});
it('applyEvent: drops events for a different session (defensive filter)', () => {
it('applyEvent: text deltas pass through regardless of sessionID match (no client-side filter for content events)', () => {
// Regression: the SSE filter used to drop content events whose
// sessionID didn't match the prompt's _sessionId. After a session
// switch, this caused "no reply" because events for the new
// session came in with a sessionID the prompt wasn't tracking.
// Fix: only permission/question events are strict (so the user
// can't reply to a different session's prompt); content events
// (text, reasoning, tool, idle) always pass through.
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
@@ -1194,12 +1467,35 @@ describe('OpenCodeInlinePrompt', () => {
prompt.setSessionId('s1');
prompt.applyEvent({
type: 'session.next.text.delta',
properties: { sessionID: 's2', delta: 'should be ignored' }
properties: { sessionID: 's2', delta: 'still rendered' }
});
const asst = prompt.node.querySelector(
'.opencode-inline-prompt .opencode-msg-assistant'
'.opencode-msg-assistant'
) as HTMLElement;
expect(asst).not.toBeNull();
expect(asst.textContent).toBe('still rendered');
});
it('applyEvent: permission.asked IS still dropped for a different session', () => {
// The cross-session filter is strict ONLY for permission.asked /
// question.asked so the user can't accidentally reply to another
// session's prompt.
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null
});
prompt.setSessionId('s1');
prompt.applyEvent({
type: 'permission.asked',
properties: { sessionID: 's2', id: 'perm-X', description: 'rm -rf' }
});
const block = prompt.node.querySelector(
'.opencode-msg-block-interaction'
);
expect(asst).toBeNull();
expect(block).toBeNull();
});
it('applyEvent: permission.asked creates a permission block with 3 buttons', () => {
+2 -9
View File
@@ -72,15 +72,8 @@ function mockResponse(overrides: {
const sampleRequest: OpenCodeRequest = {
prompt: 'add type hints',
context: {
notebookPath: 'foo.ipynb',
cellId: 'cell-1',
language: 'python',
cellIndex: 0,
totalCells: 1,
source: 'def foo(x): return x',
previousCode: null,
error: null,
},
notebookPath: 'foo.ipynb'
}
};
describe('callOpenCodeEdit', () => {
+228 -1
View File
@@ -11,11 +11,14 @@ import { URLExt } from '@jupyterlab/coreutils';
import { ServerConnection } from '@jupyterlab/services';
import type {
OpenCodeAllSessionsResponse,
OpenCodeEditResponse,
OpenCodeEvent,
OpenCodeMessagesResponse,
OpenCodeNotebookSessionsResponse,
OpenCodeProvidersResponse,
OpenCodeRequest
OpenCodeRequest,
OpenCodeSessionOpResponse
} from '../types';
/**
@@ -215,6 +218,230 @@ export async function callOpenCodeReplyQuestion(
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;
+85 -24
View File
@@ -18,23 +18,21 @@
*/
import type { CodeCell } from '@jupyterlab/cells';
import { Notification } from '@jupyterlab/apputils';
import type { NotebookPanel } from '@jupyterlab/notebook';
import { ServerConnection } from '@jupyterlab/services';
import { Widget } from '@lumino/widgets';
import {
callOpenCodeBindExistingSession,
callOpenCodeCreateNotebookSession,
callOpenCodeEdit,
callOpenCodeListAllSessions,
callOpenCodeReplyPermission,
callOpenCodeReplyQuestion,
callOpenCodeSessionMessages,
subscribeOpenCodeEvents,
type OpenCodeEventSubscription
} from '../api/opencode_client';
import { extractCellContext } from '../context/cell_context';
import type {
CellContext,
OpenCodeProvidersResponse
} from '../types';
import type { OpenCodeProvidersResponse } from '../types';
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
@@ -58,7 +56,11 @@ type Status = 'idle' | 'loading';
export class OpenCodeCellActions extends Widget {
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 _prompt: OpenCodeInlinePrompt | null = null;
private _sseSub: OpenCodeEventSubscription | null = null;
@@ -85,9 +87,9 @@ export class OpenCodeCellActions extends Widget {
}
private _onModelChange(): void {
this._context = extractCellContextFromCell(this._cell);
this._notebookPath = extractNotebookPathFromCell(this._cell);
if (this._prompt) {
this._prompt.setDisabled(!this._context || this._status === 'loading');
this._prompt.setDisabled(!this._notebookPath || this._status === 'loading');
} else {
this._render();
}
@@ -97,7 +99,7 @@ export class OpenCodeCellActions extends Widget {
const node = this.node;
node.textContent = '';
const baseDisabled = !this._context || this._status === 'loading';
const baseDisabled = !this._notebookPath || this._status === 'loading';
const btn = document.createElement('button');
btn.className = 'opencode-btn opencode-btn-ai';
@@ -125,9 +127,9 @@ export class OpenCodeCellActions extends Widget {
return;
}
const prompt = new OpenCodeInlinePrompt(this._cell, {
disabled: !this._context || this._status === 'loading',
disabled: !this._notebookPath || this._status === 'loading',
providers: _providers,
notebookPath: this._context?.notebookPath,
notebookPath: this._notebookPath ?? undefined,
onSubmit: (text: string, providerId?: string, modelId?: string) => {
void this._onSubmit(text, providerId, modelId);
},
@@ -181,6 +183,63 @@ export class OpenCodeCellActions extends Widget {
// 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);
@@ -188,10 +247,15 @@ export class OpenCodeCellActions extends Widget {
// Fetch the current session's static history and render it before
// streaming begins. Empty list is a normal no-op.
const notebookPath = this._context?.notebookPath;
const notebookPath = this._notebookPath;
if (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> {
@@ -221,7 +285,7 @@ export class OpenCodeCellActions extends Widget {
providerId?: string,
modelId?: string
): Promise<void> {
if (!this._context) {
if (!this._notebookPath) {
return;
}
if (!_serverSettings) {
@@ -231,7 +295,7 @@ export class OpenCodeCellActions extends Widget {
const request = {
prompt: text,
context: this._context,
context: { notebookPath: this._notebookPath },
providerId,
modelId
};
@@ -292,26 +356,23 @@ export class OpenCodeCellActions extends Widget {
}
/**
* Resolve a CodeCell's parent NotebookPanel by walking the parent chain,
* then build the CellContext.
* Walk the parent chain to find the cell's parent NotebookPanel and
* 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 notebookPanel: NotebookPanel | null = null;
while (node) {
const candidate = node as any;
if (
candidate.context &&
typeof candidate.context.path === 'string' &&
candidate.content &&
Array.isArray(candidate.content.widgets)
) {
notebookPanel = candidate;
break;
return candidate.context.path;
}
node = node.parent;
}
if (!notebookPanel) {
return null;
}
return extractCellContext(cell, notebookPanel);
}
+325 -8
View File
@@ -28,7 +28,8 @@ import { marked } from 'marked';
import type {
OpenCodeEvent,
OpenCodeMessage,
OpenCodeProvidersResponse
OpenCodeProvidersResponse,
OpenCodeSessionMeta
} from '../types';
import {
loadModelSelection,
@@ -65,8 +66,22 @@ export interface IOpenCodeInlinePromptOptions {
* the prompt keeps the cell action from re-parsing event types.
*/
onStreamEnd?: () => void;
// ----- session management (multi-session UI) -----
/** List ALL OpenCode sessions (for the session-picker dropdown).
* Returns the raw array; the prompt picks the active one out. */
onListSessions?: () => Promise<OpenCodeSessionMeta[]>;
/** Create a brand-new session, bind it to the current notebook, and
* return its id (which becomes the new active session). */
onCreateSession?: () => Promise<string>;
/** Switch the active session. Returns true on success. */
onSwitchSession?: (sessionId: string) => Promise<boolean>;
/** Reload the message history for the currently-active session.
* Called after a create / switch so the panel reflects the new
* conversation's scrollback. */
onReloadHistory?: () => Promise<void>;
}
interface FlatProvider {
id: string;
name?: string;
@@ -120,6 +135,7 @@ export class OpenCodeInlinePrompt extends Widget {
private _textarea: HTMLTextAreaElement;
private _sendBtn: HTMLButtonElement;
private _cancelBtn: HTMLButtonElement;
private _insertCellBtn: HTMLButtonElement;
private _providerSelect: HTMLSelectElement | null = null;
private _modelSelect: HTMLSelectElement | null = null;
private _providerModels: FlatProvider[];
@@ -129,6 +145,10 @@ export class OpenCodeInlinePrompt extends Widget {
// Streaming state. _sessionId is set by the cell action after a successful
// /edit response; applyEvent() drops events that don't belong to it.
private _sessionId: string | null = null;
// Multi-session UI: the dropdown and its row (null when the host
// did not wire up the session callbacks).
private _sessionRow: HTMLDivElement | null = null;
private _sessionSelect: HTMLSelectElement | null = null;
// The current assistant message DOM node (re-rendered as markdown on
// every text delta so the user sees a properly formatted response in
// real-time, not raw ```fence``` source).
@@ -219,11 +239,85 @@ export class OpenCodeInlinePrompt extends Widget {
options.onCancel();
});
// "📋 插入单元格内容" button: appends the current cell's source
// (wrapped in a markdown code fence) to the textarea. Lets the
// user explicitly opt-in to including the cell content in the
// LLM prompt — the cell is no longer auto-injected.
this._insertCellBtn = document.createElement('button');
this._insertCellBtn.type = 'button';
this._insertCellBtn.className = 'opencode-btn-insert-cell';
this._insertCellBtn.textContent = '📋 插入单元格内容';
this._insertCellBtn.title = '把当前 cell 的源码作为 markdown code block 追加到输入框末尾';
this._insertCellBtn.addEventListener('click', () => {
this._insertCellSource();
});
const actions = document.createElement('div');
actions.className = 'opencode-inline-actions';
actions.appendChild(this._insertCellBtn);
actions.appendChild(this._sendBtn);
actions.appendChild(this._cancelBtn);
// Session selector (top of the panel, above provider/model).
// Only rendered when the host has provided the multi-session
// callbacks. The active session id is the option currently
// selected; switching the dropdown calls onSwitchSession, picking
// "+ 新建会话" calls onCreateSession, and the refresh button
// re-fetches the global session list.
if (
this._options.onListSessions &&
this._options.onCreateSession &&
this._options.onSwitchSession
) {
this._sessionRow = document.createElement('div');
this._sessionRow.className = 'opencode-prompt-sessions';
const sLabel = document.createElement('label');
const sSpan = document.createElement('span');
sSpan.textContent = 'Session:';
sLabel.appendChild(sSpan);
this._sessionSelect = document.createElement('select');
this._sessionSelect.className = 'opencode-session-select';
// Single placeholder until the list loads.
this._sessionSelect.appendChild(
this._mkSessionOption('', '加载中…', false)
);
this._sessionSelect.disabled = true;
this._sessionSelect.addEventListener('change', () => {
const v = this._sessionSelect!.value;
if (v === '__new__') {
void this._handleCreateSession();
} else if (v && v !== this._sessionId) {
void this._handleSwitchSession(v);
}
});
sLabel.appendChild(this._sessionSelect);
this._sessionRow.appendChild(sLabel);
const newBtn = document.createElement('button');
newBtn.type = 'button';
newBtn.className = 'opencode-session-btn-new';
newBtn.textContent = '+ 新建';
newBtn.title = '在 OpenCode 上创建新会话并绑定到当前 notebook';
newBtn.addEventListener('click', () => {
void this._handleCreateSession();
});
this._sessionRow.appendChild(newBtn);
const refreshBtn = document.createElement('button');
refreshBtn.type = 'button';
refreshBtn.className = 'opencode-session-btn-refresh';
refreshBtn.textContent = '🔄';
refreshBtn.title = '刷新 OpenCode 全局会话列表';
refreshBtn.addEventListener('click', () => {
void this._refreshSessionList();
});
this._sessionRow.appendChild(refreshBtn);
this.node.appendChild(this._sessionRow);
}
if (this._providerSelect || this._modelSelect) {
const row = document.createElement('div');
row.className = 'opencode-prompt-providers';
@@ -330,6 +424,156 @@ export class OpenCodeInlinePrompt extends Widget {
return sel;
}
// ---------- session selector (multi-session UI) ----------
/**
* Repopulate the session dropdown with the given list. The currently
* active session (this._sessionId, set by setSessionId or by
* _handleSwitchSession) is marked selected.
*/
private _populateSessionSelect(sessions: OpenCodeSessionMeta[]): void {
if (!this._sessionSelect) {
return;
}
// Clear existing options (drop the loading placeholder).
while (this._sessionSelect.firstChild) {
this._sessionSelect.removeChild(this._sessionSelect.firstChild);
}
for (const s of sessions) {
const label = s.title
? `${s.title} (${s.id.slice(0, 8)}…)`
: `${s.id.slice(0, 8)}`;
this._sessionSelect.appendChild(
this._mkSessionOption(s.id, label, s.id === this._sessionId)
);
}
// Always-available "create new" sentinel.
this._sessionSelect.appendChild(
this._mkSessionOption('__new__', '+ 新建会话…', false)
);
// Reflect the current active id (or "" if none).
this._sessionSelect.value = this._sessionId || '';
if (!this._sessionId) {
// No active session; show a hint via the first option being empty.
this._sessionSelect.value = '';
}
this._sessionSelect.disabled = false;
}
private _mkSessionOption(
value: string,
label: string,
selected: boolean
): HTMLOptionElement {
const o = document.createElement('option');
o.value = value;
o.textContent = label;
if (selected) {
o.selected = true;
}
return o;
}
/**
* Fetch the global session list from the host and repopulate the
* dropdown. Called on prompt show, on "刷新" click, and after
* every create / switch. Errors are non-fatal the dropdown just
* keeps whatever it had (or stays empty).
*/
async _refreshSessionList(): Promise<void> {
if (!this._options.onListSessions) {
return;
}
try {
const sessions = await this._options.onListSessions();
this._populateSessionSelect(sessions);
} catch (e) {
console.warn('opencode_bridge: failed to list sessions', e);
}
}
/**
* "新建" button or dropdown's __new__ entry. Creates a session,
* binds it, sets as active, refreshes the dropdown, and reloads the
* message history for the new (empty) session.
*/
private async _handleCreateSession(): Promise<void> {
if (!this._options.onCreateSession) {
return;
}
if (this._sessionSelect) {
this._sessionSelect.disabled = true;
}
try {
const newId = await this._options.onCreateSession();
// setSessionId() handles _sessionId + dropdown + reset pointers
// in one call; avoids duplicate work in this handler.
this.setSessionId(newId);
this._historyEl.textContent = '';
// Re-fetch the global list so the new id is in the dropdown
// and marked active.
await this._refreshSessionList();
// Reload history for the new session (empty list, but this
// also re-syncs the prompt's view of "current state").
if (this._options.onReloadHistory) {
try {
await this._options.onReloadHistory();
} catch (e) {
console.warn('opencode_bridge: reload history failed', e);
}
}
} catch (e) {
Notification.error('创建会话失败: ' + (e as Error).message);
if (this._sessionSelect) {
this._sessionSelect.disabled = false;
}
}
}
/**
* User picked a different session from the dropdown. Asks the host
* to bind + set active; on success, refreshes history.
*/
private async _handleSwitchSession(sessionId: string): Promise<void> {
if (!this._options.onSwitchSession) {
return;
}
if (this._sessionSelect) {
this._sessionSelect.disabled = true;
}
try {
const ok = await this._options.onSwitchSession(sessionId);
if (ok) {
// setSessionId() handles _sessionId + dropdown + reset
// pointers in one call; avoids duplicate work.
this.setSessionId(sessionId);
this._historyEl.textContent = '';
if (this._options.onReloadHistory) {
try {
await this._options.onReloadHistory();
} catch (e) {
console.warn('opencode_bridge: reload history failed', e);
}
}
} else {
// Roll back the select to the previous value.
if (this._sessionSelect) {
this._sessionSelect.value = this._sessionId || '';
}
Notification.error('切换会话失败');
}
} catch (e) {
Notification.error('切换会话失败: ' + (e as Error).message);
if (this._sessionSelect) {
this._sessionSelect.value = this._sessionId || '';
}
} finally {
if (this._sessionSelect) {
this._sessionSelect.disabled = false;
}
}
}
/**
* Persist the current (providerSelect.value, modelSelect.value) for
* this notebook. No-op if the prompt has no notebookPath, no
@@ -356,6 +600,12 @@ export class OpenCodeInlinePrompt extends Widget {
if (this._modelSelect) {
this._modelSelect.disabled = disabled;
}
if (this._sessionSelect) {
// Don't disable the select while streaming — the user can still
// switch to a different session to inspect it. The cell action
// closes the SSE on switch.
this._sessionSelect.disabled = false;
}
}
/** Clear the user input textarea. */
@@ -363,18 +613,76 @@ export class OpenCodeInlinePrompt extends Widget {
this._textarea.value = '';
}
/**
* "📋 插入单元格内容" button: append the current cell's source as a
* markdown code block to the textarea. The LLM only sees what the
* user explicitly chose to include the cell source is NEVER
* auto-injected (v0.2.x change).
*/
private _insertCellSource(): void {
if (!this._cell || !this._cell.model || !this._cell.model.sharedModel) {
Notification.info('当前 cell 没有内容');
return;
}
const source = this._cell.model.sharedModel.getSource() as string;
if (!source || !source.trim()) {
Notification.info('当前 cell 为空');
return;
}
// Use the cell's language as the fence info string when it
// looks like a real language identifier; otherwise use a plain
// fence so markdown stays portable.
const langRaw =
(this._cell.model as any).sharedModel && (this._cell.model as any).type
? ((this._cell.model as any).type as string)
: 'python';
const lang = /^[a-zA-Z0-9_+\-#]+$/.test(langRaw) ? langRaw : '';
const block = '\n```' + lang + '\n' + source.replace(/\n$/, '') + '\n```\n';
const before = this._textarea.value;
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
const insertion = (needsLeadingNewline ? '\n' : '') + block;
this._textarea.value = before + insertion;
// Move cursor to end so the user can continue typing.
this._textarea.focus();
const end = this._textarea.value.length;
this._textarea.setSelectionRange(end, end);
this._scrollToBottom();
}
/**
* Bind the prompt to a specific OpenCode session id. Events arriving via
* `applyEvent` for a different session are dropped. Pass null to clear.
*/
setSessionId(sessionId: string | null): void {
this._sessionId = sessionId;
if (this._sessionSelect) {
// Reflect the new active id in the dropdown without rebuilding it.
if (sessionId) {
const has = Array.from(this._sessionSelect.options).some(
o => o.value === sessionId
);
if (has) {
this._sessionSelect.value = sessionId;
}
} else {
this._sessionSelect.value = '';
}
}
if (sessionId) {
// Fresh turn: drop any in-progress stream DOM from a previous session.
this._resetStreamPointers();
}
}
/**
* First-time wiring after the prompt is constructed. Loads the global
* session list into the dropdown. Call from the host (cell action)
* right after constructing the widget.
*/
initialize(): void {
void this._refreshSessionList();
}
/**
* Render the current session's static messages (loaded once from
* /session-messages when the panel opens). Subsequent assistant turns
@@ -413,16 +721,25 @@ export class OpenCodeInlinePrompt extends Widget {
event.payload.syncEvent &&
event.payload.syncEvent.aggregateID);
// Drop events for other sessions (defensive; matches demo.html
// handleGlobalEvent). Only drop when the event has an explicit
// sessionID — events without one (e.g. system events) are still
// processed and may be dropped by _isSystemEvent below.
if (sessionID && this._sessionId && sessionID !== this._sessionId) {
// System / workspace / pty / etc. events are not displayed.
if (this._isSystemEvent(type)) {
return;
}
// System / workspace / pty / etc. events are not displayed.
if (this._isSystemEvent(type)) {
// Cross-session filtering: only strict for permission/question
// events (the user must NOT be able to reply to a permission
// prompt that belongs to a different session). Content events
// (text delta, reasoning, tool, idle) pass through regardless of
// sessionID match — the user is looking at the active session's
// panel, and we don't want to drop their reply because of a
// sessionID extraction edge case. The server already forwards
// ALL events; the SSE handler routes by URL ?session= param.
if (
(type === 'permission.asked' || type === 'question.asked') &&
sessionID &&
this._sessionId &&
sessionID !== this._sessionId
) {
return;
}
-81
View File
@@ -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,
};
}
+59 -13
View File
@@ -10,21 +10,19 @@
* a JSON object with a `type` string; consumers route on `type`.
*/
export interface ErrorOutput {
ename: string;
evalue: string;
traceback: string[];
}
/**
* Minimal context carried with each edit request. Only the
* notebookPath is needed (so the server can pick the right OpenCode
* 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 {
notebookPath: string;
cellId: string;
language: string;
cellIndex: number;
totalCells: number;
source: string;
previousCode: string | null;
error: ErrorOutput | null;
}
export interface OpenCodeRequest {
@@ -82,6 +80,54 @@ export interface OpenCodeMessagesResponse {
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).
* Each Provider.models is a Record keyed by modelID (NOT an array). */
export interface OpenCodeModel {
+63
View File
@@ -191,6 +191,7 @@
display: flex;
justify-content: flex-end;
gap: 4px;
align-items: center;
}
.opencode-inline-prompt .opencode-inline-actions button {
@@ -211,6 +212,19 @@
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)
------------------------------------------------------------------ */
@@ -339,3 +353,52 @@
.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;
}