16 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
tao.chenandClaude fcd015ac0e fix: stop running marked.parse on every streaming text delta
CI / CI (push) Successful in 17m54s
The streaming path used to call _renderAssistantMarkdown on every
incoming text delta, which re-ran marked.parse on the WHOLE
accumulated markdown source and re-post-processed (added the
Copy/Insert/Replace toolbar to) every <pre> block.

This is a partial-render problem: a markdown source mid-stream (e.g.
an opening ```py fence with no closing fence yet, or a partial
heading) classifies DIFFERENTLY than the complete message. Each
new delta could re-classify the same text into a different DOM
shape, causing visible flicker - <pre>s appearing and disappearing,
the toolbar popping in and out, paragraphs re-grouping - while the
model is still typing. The bug surfaced because the user could see
the glitchy view during a stream but everything looked correct
after closing+reopening the panel (which goes through the static
setMessages -> marked.parse path on the full text).

Fix: keep the streaming path dumb. _appendToAssistantText now just
appends the raw delta to the assistant element's textContent. The
full markdown render happens exactly once at the end of the turn,
inside _resetStreamPointers (which the prompt invokes when
session.idle arrives, before calling onStreamEnd). Result:

- The user still sees real-time text growth (textContent += delta).
- No more intermediate-state flicker.
- The rendered view at turn end is byte-for-byte equivalent to what
  setMessages would produce from the stored history (they share
  _renderAssistantMarkdown).

The corresponding regression test is updated: it now asserts the
DURING-stream state is raw text (no <pre>, no toolbar) and the
POST-idle state is the rendered DOM with the toolbar attached.

Tests: 72 jest (unchanged count, the one streaming test was
rewritten), 52 pytest, build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 17:52:53 +08:00
tao.chen d867b2f6d6 Merge remote-tracking branch 'origin/main' 2026-07-27 17:40:49 +08:00
tao.chen b4060d3b2f Merge pull request 'Feat/async sse streaming' (#1) from feat/async-sse-streaming into main
Reviewed-on: #1
2026-07-27 17:38:53 +08:00
dd5667cea4 feat: persist user (provider, model) selection per notebook in localStorage
When the user picks a provider+model in the inline prompt, remember
that choice across panel close/reopen AND across JupyterLab restarts,
keyed by notebookPath. If the stored provider (or the model under it)
is no longer present in the current providers payload, fall back to
the default (first provider / default[pid] model) - never silently
apply a stale value that would result in an empty or disabled select.

- New module src/components/model_selection.ts with
  loadModelSelection / saveModelSelection / clearModelSelection.
  Best-effort: localStorage may be disabled (private mode), quota
  may be exhausted, value may be corrupt - all failure modes return
  null / no-op rather than throw.
- IOpenCodeInlinePromptOptions gains notebookPath?: string.
- Constructor resolves the initial selection: prefer stored+valid,
  else first provider / default[pid]. providerSelect.change and
  modelSelect.change listeners call _persistSelection() to keep
  storage in sync.
- _rebuildModelSelect now accepts a preferredModelId; if it exists
  in the current provider's models it's used, else the default.
- OpenCodeCellActions._showPrompt passes notebookPath =
  this._context?.notebookPath so the prompt can key the storage.

Tests
-----
- 10 unit tests in model_selection.spec.ts (load/save/clear +
  malformed JSON + per-notebook independence).
- 4 prompt behavior tests in opencode_cell_actions.spec.ts
  (valid stored applied, provider removed -> default, model removed
  -> default, change -> saved).
- 72 frontend jest (was 58), 52 backend pytest (unchanged),
  build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 17:38:53 +08:00
b7089519fd feat: async + SSE message flow with interactive permission/question UI
Replace the synchronous /edit (wait for full markdown response) with an
async + SSE flow per demo.html so the user sees text stream in real-time
and can interact with permission/question events the agent raises.

Backend
-------
- EditHandler now calls OpenCode /session/:id/prompt_async and returns
  immediately with {ok, sessionId, notebookPath}; the LLM reply is no
  longer embedded in this response.
- New GlobalEventHandler proxies OpenCode /global/event as
  text/event-stream. Server forwards ALL events; the client filters.
  A too-eager server-side ?session= filter was silently dropping events
  the client would have accepted, so it was removed.
- New PermissionReplyHandler + QuestionReplyHandler forward user
  replies (once/always/reject and freeform answer) back to OpenCode
  Serve at /session/:sid/permissions/:permId and
  /session/:sid/question/:qId/reply.
- OpenCodeClient gains send_message_async(), stream_global_events()
  (async generator over the SSE feed via tornado streaming_callback),
  reply_permission() and reply_question(). Legacy send_message_sync
  removed.

Frontend
--------
- subscribeOpenCodeEvents() opens a fetch+reader SSE client (XSRF
  token injected from serverSettings); AbortController-backed close()
  is idempotent.
- OpenCodeInlinePrompt.applyEvent() routes events into the streaming
  UI: text delta -> assistant message (re-rendered as markdown on
  every delta so the user sees formatted <pre><code> blocks in
  real-time, not raw fence source); reasoning/tool/permission/question
  get collapsible details blocks. session.idle resets stream pointers
  and fires onStreamEnd.
- Permission/question blocks render real interactive UI: three
  buttons (once/always/reject) for permission, a text input + submit
  for question. Click handlers post through the new API routes and
  show success/failure status in place.
- Centralized idle detection (4 shapes: top-level + payload-nested
  x {session.idle, session.status/idle}) inside the prompt; cell
  action reacts via onStreamEnd callback rather than re-parsing
  event types.
- System / workspace / pty / lsp / mcp / installation events AND
  session-level control events (agent.switched, model.switched,
  file.edited) are not rendered in the frontend.

Tests
-----
- 52 backend pytest (was 43)
- 58 frontend jest (was 46)

design.md section 3 updated for the new async /edit response shape
and the new GET /events SSE endpoint.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 17:38:53 +08:00
tao.chenandtao.chen a43174e5bc update: optimize config.py 2026-07-27 17:38:53 +08:00
tao.chen 0bd04a825d update: optimize config.py 2026-07-27 14:33:04 +08:00
tao.chen 380e4a3150 更新 .github/workflows/build.yml 2026-07-24 11:02:27 +08:00
tao.chen b0abeaf329 更新 .github/scripts/create-release.sh 2026-07-24 10:59:47 +08:00
24 changed files with 5313 additions and 677 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ if [ -z "${RELEASE_ID}" ]; then
fi
echo "Release created with ID ${RELEASE_ID}"
for asset in dist/snapshot-*.whl dist/snapshot-*.tar.gz; do
for asset in dist/*.whl dist/*.tar.gz; do
if [ -f "${asset}" ]; then
echo "Uploading ${asset}..."
curl -fsS -X POST "${API_URL}/repos/${REPOSITORY}/releases/${RELEASE_ID}/assets" \
+2
View File
@@ -7,6 +7,8 @@ on:
tags:
- 'v*'
pull_request:
workflow_dispatch:
jobs:
ci:
+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 测一下
+25 -5
View File
@@ -62,7 +62,7 @@
## 🔌 3. 接口契约设计 (API Contract)
### 接口:`POST /opencode-bridge/edit`v2无 `mode`
### 接口:`POST /opencode-bridge/edit`v4异步 + SSE
**Request Payload**(前端 `OpenCodeCellActions` 在用户提交 inline 输入框时发出):
@@ -84,20 +84,40 @@
}
```
> **v2 变化**body 不含 `mode` / `action` 字段。前端只把 cell 的 input/output/error 收集到 `context`,加用户的自然语言 `prompt` 一起发出;后端用统一的 `UNIFIED_SYSTEM_PROMPT` + LLM 自己判断优化/排错/编辑。`providerId` / `modelId` 来自 JupyterLab settings 透传
> body 不含 `mode` / `action`。`providerId` / `modelId` 来自 inline 输入框动态选择,无 settings 持久化
**Response**当前 v2 实现:单次 JSONSSE 流式属于 Slice 4 后续工作):
**Response**v4 异步):后端调用 OpenCode `/session/:id/prompt_async` 后**立即返回**——不等 LLM 完成。
```json
{
"ok": true,
"finalSource": "import plotly.express as px\nfig = px.line(x=[1, 2, 3], y=[1, 2, 3])\nfig.show()",
"sessionId": "ses-abc123",
"notebookPath": "analysis/demo.ipynb"
}
```
> 成功时前端用 `finalSource` 就地替换 cell 源码(`cell.model.sharedModel.setSource`。Session 由服务端 `SessionManager` 按 `notebookPath` 复用(1 notebook 1 sid),404 / `session not found` 时服务端自动 invalidate 并在下次请求重建。
> 响应中**无** `markdown` 字段。AI 的回复通过独立的 SSE 端点(`/opencode-bridge/events`)实时推送给前端,前端在 inline prompt 的 history 区里增量渲染文本流 / 推理 / 工具调用 / 权限提问等。Session 由 `SessionManager` 按 `notebookPath` 复用(1 notebook 1 sid),404 / `session not found` 时服务端自动 invalidate 并在下次请求重建。cell 源码**不**被替换;用户可对 AI 消息中的每个代码块使用 Copy / Insert / Replace 按钮。
### 接口:`GET /opencode-bridge/events?session=<sid>`v4 新增 — SSE 代理)
服务端长连接到 OpenCode Serve 的 `GET /global/event`,按 `session=<sid>` 过滤后以 `text/event-stream` 透传给前端。OpenCode 事件 schema 较松散,事件示例:
```json
{"type": "session.next.text.delta", "properties": {"sessionID": "ses-abc123", "delta": "hel"}}
{"type": "session.next.text.delta", "properties": {"sessionID": "ses-abc123", "delta": "lo"}}
{"type": "session.next.reasoning.delta", "properties": {"sessionID": "ses-abc123", "delta": "thinking..."}}
{"type": "session.next.tool.called", "properties": {"sessionID": "ses-abc123", "name": "bash"}}
{"type": "session.next.tool.input.delta", "properties": {"sessionID": "ses-abc123", "delta": "{\"cmd\": \"ls\"}"}}
{"type": "session.next.tool.success", "properties": {"sessionID": "ses-abc123", "result": {"exitCode": 0}}}
{"type": "session.next.agent.switched", "properties": {"sessionID": "ses-abc123", "agentID": "build"}}
{"type": "session.next.model.switched", "properties": {"sessionID": "ses-abc123", "modelID": "claude-..."}}
{"type": "file.edited", "properties": {"sessionID": "ses-abc123", "path": "foo.py"}}
{"type": "permission.asked", "properties": {"sessionID": "ses-abc123", "id": "perm-1", "description": "rm -rf"}}
{"type": "question.asked", "properties": {"sessionID": "ses-abc123", "id": "q-1", "question": "Which env?"}}
{"type": "session.idle", "properties": {"sessionID": "ses-abc123"}}
```
> v4 前端 `OpenCodeInlinePrompt.applyEvent()` 按 `type` 路由到不同渲染器:text/reasoning/tool 块用 `<details>` 可折叠,permission/question 提示独立块(响应需要 OpenCode Serve 提供对应 API;本扩展 v4 仅渲染,不实现 allow/answer 回调路由)。`session.idle` 触发流指针重置、关闭 SSE 订阅、重新启用输入框。System 事件(`server.*` / `workspace.*` / `lsp.*` / `mcp.*` / `pty.*` / `installation.*`)以日志行形式展示,不参与 session 过滤。
---
+27 -1
View File
@@ -3,12 +3,24 @@ try:
except ImportError:
# Fallback when using the package in dev mode without installing
# in editable mode with pip. It is highly recommended to install
# the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-installs/#editable-installs
# the package from a stable release or in editable mode: https://pip.pypa.io/en/stable/topics/local-project-install/
import warnings
warnings.warn("Importing 'opencode_bridge' outside a proper installation.")
__version__ = "dev"
from .config import OpencodeConfig
from .routes import setup_route_handlers
# Re-export OpencodeConfig at module level so the JupyterApp CLI parser
# (which auto-discovers Configurable subclasses from extension-point
# modules) recognises ``--OpencodeConfig.<trait>`` flags.
__all__ = [
"OpencodeConfig",
"setup_route_handlers",
"_jupyter_labextension_paths",
"_jupyter_server_extension_points",
"_load_jupyter_server_extension",
]
def _jupyter_labextension_paths():
return [{
@@ -26,11 +38,25 @@ def _jupyter_server_extension_points():
def _load_jupyter_server_extension(server_app):
"""Registers the API handler to receive HTTP requests from the frontend extension.
Also instantiates the :class:`OpencodeConfig` configurable (with the
server app as parent so CLI / config-file overrides take effect) and
exposes it to request handlers via
``web_app.settings["opencode_bridge"]``.
Parameters
----------
server_app: jupyterlab.labapp.LabApp
JupyterLab application instance
"""
# Belt-and-suspenders: ensure the CLI parser recognises --OpencodeConfig.*
# even on JupyterApp versions that don't auto-discover Configurable
# subclasses from extension-point modules.
if OpencodeConfig not in server_app.classes:
server_app.classes.append(OpencodeConfig)
cfg = OpencodeConfig(parent=server_app)
server_app.web_app.settings["opencode_bridge"] = cfg
setup_route_handlers(server_app.web_app)
name = "opencode_bridge"
server_app.log.info(f"Registered {name} server extension")
+113 -11
View File
@@ -1,10 +1,22 @@
"""Configuration resolution for the opencode-bridge extension.
All connection fields (URL / user / password / request timeout) are resolved
exclusively from environment variables + built-in defaults at server startup.
They are NOT stored in the JupyterLab plugin settings.
Connection fields (URL / user / password / request timeout) are resolved
in the following priority order, highest first:
1. ``--OpencodeConfig.<trait>`` CLI flag (e.g. ``--OpencodeConfig.url=...``)
2. ``c.OpencodeConfig.<trait>`` line in a Jupyter config file
3. ``OPENCODE_BRIDGE_*`` environment variable
4. Built-in default
The ``OpencodeConfig`` class is a ``traitlets.config.Configurable``; the
Jupyter server extension instantiates it with ``parent=server_app`` and
stashes it on ``web_app.settings["opencode_bridge"]`` so handlers can pick
it up via ``resolve_config``.
Env vars (kept for backward compatibility — also the default-value source
for the traitlets so a fresh ``OpencodeConfig()`` with no overrides still
honours them):
Env vars:
OPENCODE_BRIDGE_URL — OpenCode Serve base URL (default http://127.0.0.1:4096)
OPENCODE_BRIDGE_USER — HTTP Basic Auth username (default 'opencode')
OPENCODE_BRIDGE_PASSWORD — HTTP Basic Auth password (default '' = no auth)
@@ -15,6 +27,9 @@ from __future__ import annotations
import os
from typing import NamedTuple, Optional, Tuple
from traitlets import Int, Unicode, default
from traitlets.config import Configurable
ENV_URL = "OPENCODE_BRIDGE_URL"
ENV_USER = "OPENCODE_BRIDGE_USER"
@@ -40,19 +55,106 @@ class OpenCodeConfig(NamedTuple):
return (self.user, self.password)
def resolve_config(jupyter_settings: dict) -> OpenCodeConfig:
"""Resolve OpenCode connection config from environment variables + defaults.
class OpencodeConfig(Configurable):
"""Traitlets-configurable connection settings for the opencode-bridge server extension.
The `jupyter_settings` argument is accepted for API compatibility but no
fields are read from it — all connection config is now startup-only
(environment variables).
Exposed to ``jupyter lab`` / ``jupyter server`` as ``--OpencodeConfig.<trait>``
and to ``jupyter_server_config.py`` as ``c.OpencodeConfig.<trait>``. Defaults
fall back to the ``OPENCODE_BRIDGE_*`` env vars and then to built-in defaults,
so the legacy env-var-only flow keeps working unchanged.
"""
_ = jupyter_settings # intentionally unused; see module docstring
url = Unicode(
DEFAULT_URL,
config=True,
help=(
"Base URL of the locally-running OpenCode Serve process. "
"Overrides the OPENCODE_BRIDGE_URL env var."
),
)
user = Unicode(
DEFAULT_USER,
config=True,
help=(
"HTTP Basic Auth username for OpenCode Serve. "
"Overrides the OPENCODE_BRIDGE_USER env var."
),
)
password = Unicode(
"",
config=True,
help=(
"HTTP Basic Auth password for OpenCode Serve (empty = no auth). "
"Overrides the OPENCODE_BRIDGE_PASSWORD env var."
),
)
request_timeout_seconds = Int(
DEFAULT_REQUEST_TIMEOUT,
config=True,
help=(
"Per-request timeout in seconds when calling OpenCode Serve. "
"Overrides the OPENCODE_BRIDGE_TIMEOUT env var."
),
)
@default("url")
def _default_url(self) -> str:
return os.environ.get(ENV_URL) or DEFAULT_URL
@default("user")
def _default_user(self) -> str:
return os.environ.get(ENV_USER) or DEFAULT_USER
@default("password")
def _default_password(self) -> str:
return os.environ.get(ENV_PASSWORD) or ""
@default("request_timeout_seconds")
def _default_request_timeout_seconds(self) -> int:
return int(os.environ.get(ENV_TIMEOUT) or DEFAULT_REQUEST_TIMEOUT)
def _to_opencode_config(bridge) -> OpenCodeConfig:
"""Project an :class:`OpencodeConfig` (or duck-typed equivalent) to the
internal ``OpenCodeConfig`` NamedTuple consumed by ``OpenCodeClient``.
"""
return OpenCodeConfig(
url=bridge.url,
user=bridge.user,
password=bridge.password,
request_timeout_seconds=bridge.request_timeout_seconds,
)
def resolve_config(jupyter_settings) -> OpenCodeConfig:
"""Resolve OpenCode connection config into an ``OpenCodeConfig`` NamedTuple.
Accepts either a settings dict (legacy) or an :class:`OpencodeConfig`
instance directly, because request handlers call
``resolve_config(handler.settings.get("opencode_bridge", {}))`` and pass
the inner value (which is now an ``OpencodeConfig`` instance after the
server extension's load hook populates it).
Source order (highest priority first):
1. The argument itself is an :class:`OpencodeConfig` instance.
2. ``jupyter_settings["opencode_bridge"]`` is an :class:`OpencodeConfig`
instance (legacy call style).
3. Otherwise fall back to env vars + built-in defaults.
"""
if isinstance(jupyter_settings, OpencodeConfig):
return _to_opencode_config(jupyter_settings)
bridge = (jupyter_settings or {}).get("opencode_bridge")
if isinstance(bridge, OpencodeConfig):
return _to_opencode_config(bridge)
return OpenCodeConfig(
url=os.environ.get(ENV_URL) or DEFAULT_URL,
user=os.environ.get(ENV_USER) or DEFAULT_USER,
password=os.environ.get(ENV_PASSWORD) or "",
request_timeout_seconds=int(
os.environ.get(ENV_TIMEOUT) or DEFAULT_REQUEST_TIMEOUT
)
),
)
+174 -8
View File
@@ -1,8 +1,22 @@
"""Async HTTP client for the local OpenCode server."""
"""Async HTTP client for the local OpenCode server.
Two interaction modes:
- **Request/response** (one-shot) - ``health``, ``list_providers``,
``create_session``, ``send_message_async``, ``abort``, ``delete_session``,
``list_session_messages``. ``send_message_async`` fires the prompt and
returns immediately; the actual LLM response is consumed via the SSE
stream (next mode).
- **Streaming** (long-lived) - :meth:`stream_global_events` is an async
generator over OpenCode Serve's ``GET /global/event`` SSE feed. It
yields each event as a parsed JSON dict until the upstream closes.
"""
import asyncio
import atexit
import json
from typing import Any, Optional
import logging
from typing import Any, AsyncIterator, Optional
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
from tornado.httputil import HTTPHeaders
@@ -10,6 +24,9 @@ from tornado.httputil import HTTPHeaders
from .config import OpenCodeConfig
log = logging.getLogger("opencode_bridge.opencode_client")
class OpenCodeError(Exception):
"""Raised when an OpenCode API request fails."""
@@ -64,22 +81,28 @@ class OpenCodeClient:
async def create_session(self, title: str) -> dict[str, Any]:
return await self._request("POST", "/session", {"title": title})
async def send_message_sync(
async def send_message_async(
self,
session_id: str,
parts: list[dict[str, Any]],
provider_id: Optional[str] = None,
model_id: Optional[str] = None,
system: Optional[str] = None,
) -> dict[str, Any]:
) -> None:
"""Fire-and-forget POST /session/:id/prompt_async.
Returns once OpenCode has accepted the prompt and queued it for
processing - NOT when the LLM is done. Subscribe to
:meth:`stream_global_events` to follow the agent's progress.
"""
body: dict[str, Any] = {"parts": parts}
if provider_id is not None and model_id is not None:
body["model"] = {"providerID": provider_id, "modelID": model_id}
if system is not None:
body["system"] = system
return await self._request(
await self._request(
"POST",
"/session/%s/message" % session_id,
"/session/%s/prompt_async" % session_id,
body,
)
@@ -87,6 +110,41 @@ class OpenCodeClient:
result = await self._request("POST", "/session/%s/abort" % session_id)
return result is not None
async def reply_permission(
self,
session_id: str,
permission_id: str,
response: str,
) -> bool:
"""Respond to a `permission.asked` event.
``response`` must be one of ``"once"`` / ``"always"`` / ``"reject"``,
matching the OpenCode Serve API. Returns True on success.
"""
result = await self._request(
"POST",
"/session/%s/permissions/%s" % (session_id, permission_id),
{"response": response},
)
return result is not None
async def reply_question(
self,
session_id: str,
question_id: str,
answer: str,
) -> bool:
"""Reply to a `question.asked` event with the user's freeform answer.
Returns True on success.
"""
result = await self._request(
"POST",
"/session/%s/question/%s/reply" % (session_id, question_id),
{"answer": answer},
)
return result is not None
async def delete_session(self, session_id: str) -> bool:
result = await self._request("DELETE", "/session/%s" % session_id)
return result is not None
@@ -95,16 +153,124 @@ class OpenCodeClient:
"""List all messages in the given OpenCode session.
Returns the raw OpenCode response: a list of
`{ info: { role: "user"|"assistant", ... }, parts: [...] }`.
``{ info: { role: "user"|"assistant", ... }, parts: [...] }``.
The server extension is responsible for projecting this into a
frontend-friendly `{role, content}[]` shape.
frontend-friendly ``{role, content}[]`` shape.
"""
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
async def stream_global_events(self) -> AsyncIterator[dict[str, Any]]:
"""Stream OpenCode Serve's ``GET /global/event`` SSE feed.
Yields one parsed JSON dict per ``data: {...}`` event block. The
stream runs until:
- the upstream disconnects (end of stream),
- the consumer stops iterating (caller cancels this generator),
- or a network error occurs (logged and the generator returns).
The consumer is responsible for filtering events by session ID
(the upstream pushes events for ALL sessions, plus system events).
"""
queue: asyncio.Queue = asyncio.Queue()
end_sentinel = object()
def on_chunk(chunk: bytes) -> None:
if chunk:
queue.put_nowait(chunk)
headers = HTTPHeaders({
"Accept": "text/event-stream",
"Cache-Control": "no-cache",
})
request_kwargs: dict[str, Any] = {
"method": "GET",
"headers": headers,
"request_timeout": 0, # 0 = no timeout for long-lived streams
# streaming_callback must live on the HTTPRequest itself; tornado
# rejects any fetch() kwargs when an HTTPRequest is passed.
"streaming_callback": on_chunk,
}
if self._config.auth is not None:
request_kwargs["auth_username"] = self._config.auth[0]
request_kwargs["auth_password"] = self._config.auth[1]
request = HTTPRequest(self._config.url + "/global/event", **request_kwargs)
async def _run_fetch() -> None:
try:
await self._http_client.fetch(request, raise_error=False)
except Exception as e:
log.warning("OpenCode /global/event fetch error: %s", e)
finally:
queue.put_nowait(end_sentinel)
runner_task = asyncio.create_task(_run_fetch())
buffer = ""
try:
while True:
chunk = await queue.get()
if chunk is end_sentinel:
break
if not isinstance(chunk, bytes):
continue
buffer += chunk.decode("utf-8", errors="replace")
while "\n\n" in buffer:
block, buffer = buffer.split("\n\n", 1)
parsed = self._parse_sse_block(block)
if parsed is not None:
yield parsed
# Drain any trailing data after the last \n\n
if buffer.strip():
parsed = self._parse_sse_block(buffer)
if parsed is not None:
yield parsed
finally:
if not runner_task.done():
runner_task.cancel()
try:
await runner_task
except (asyncio.CancelledError, Exception):
pass
@staticmethod
def _parse_sse_block(block: str) -> Optional[dict[str, Any]]:
"""Parse a single SSE event block into a JSON dict (or None).
SSE format reminder: a block is one or more ``field: value`` lines
separated by newlines; multiple ``data:`` lines are concatenated
with ``\\n``. We only care about ``data:``.
"""
data_parts: list[str] = []
for line in block.split("\n"):
if line.startswith("data:"):
data_parts.append(line[5:].lstrip())
if not data_parts:
return None
data = "\n".join(data_parts).strip()
if not data:
return None
try:
result = json.loads(data)
except json.JSONDecodeError:
return None
return result if isinstance(result, dict) else None
async def _request(
self,
method: str,
+438 -54
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/message.
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:
@@ -105,6 +80,26 @@ def _strip_code_fence(s: str) -> str:
return s
def _extract_session_id(event: dict) -> str | None:
"""Best-effort session ID extraction from an OpenCode event.
OpenCode's event shape is not strictly documented; this mirrors the
heuristics in demo.html ``handleGlobalEvent``:
properties.sessionID / properties.sessionId /
payload.properties.sessionID / syncEvent.aggregateID
"""
props = event.get("properties") or {}
sid = props.get("sessionID") or props.get("sessionId")
if sid:
return sid
payload_props = (event.get("payload") or {}).get("properties") or {}
sid = payload_props.get("sessionID") or payload_props.get("sessionId")
if sid:
return sid
sync = event.get("syncEvent") or (event.get("payload") or {}).get("syncEvent") or {}
return sync.get("aggregateID")
class HelloRouteHandler(APIHandler):
# The following decorator should be present on all verb methods (head, get, post,
# patch, put, delete, options) to ensure only authorized user can request the
@@ -154,6 +149,13 @@ class ProvidersHandler(APIHandler):
class EditHandler(APIHandler):
"""Async edit: fire prompt_async, return sessionId immediately.
The LLM's response is consumed via the separate ``/opencode-bridge/events``
SSE endpoint. Frontend subscribes to SSE after this call returns and
processes events incrementally.
"""
@tornado.web.authenticated
async def post(self):
try:
@@ -177,45 +179,175 @@ class EditHandler(APIHandler):
sm = get_session_manager(self)
try:
sid = await sm.get_or_create(notebook_path)
request_body = _build_request_body(prompt, context)
result = await client.send_message_sync(
request_body = _build_request_body(prompt)
await client.send_message_async(
sid,
request_body["parts"],
provider_id=provider_id,
model_id=model_id,
system=request_body["system"],
)
text_parts = [
p.get("text", "")
for p in result.get("parts", [])
if p.get("type") == "text"
]
# The AI's reply is markdown (code in ```fences```, optional
# explanation). Pass it through unchanged so the frontend
# `marked.parse` can render the code blocks and structure.
markdown = "\n".join(text_parts).strip()
self.finish(json.dumps({
"ok": True,
"markdown": markdown,
"sessionId": sid,
"notebookPath": notebook_path,
}))
except OpenCodeError as e:
# If session is invalid on OpenCode side, invalidate cache.
# 404 / 410 / "session not found" -> next get_or_create will recreate.
if "404" in str(e) or "not found" in str(e).lower():
sm.invalidate(notebook_path)
log.warning("invalidated dead session for %s", notebook_path)
log.exception("edit failed")
log.exception("edit (async) failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
except Exception as e:
log.exception("edit failed")
log.exception("edit (async) failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
class GlobalEventHandler(APIHandler):
"""SSE proxy for OpenCode Serve's ``GET /global/event``.
Streams every event from the upstream SSE feed to the client as
``text/event-stream`` so EventSource / fetch+reader on the frontend
can consume them directly. The frontend filters by session ID —
we deliberately do NOT filter on the server because OpenCode's
event-shape has varied across versions and a too-eager server
filter can silently drop events the client would have accepted.
The connection is held open until the client disconnects, the upstream
closes, or an unrecoverable error occurs.
"""
@tornado.web.authenticated
async def get(self):
# The ?session= query param is accepted for API compatibility
# but the server does NOT use it to filter — see docstring.
_ = self.get_query_argument("session", None)
self.set_header("Content-Type", "text/event-stream")
self.set_header("Cache-Control", "no-cache")
# Disable proxy buffering (e.g. nginx) so chunks reach the client
# immediately.
self.set_header("X-Accel-Buffering", "no")
# Prevent tornado from closing the connection on its own keep-alive
# timer — we hold it open as long as the upstream is alive.
self.set_header("Connection", "keep-alive")
client = make_client(self)
try:
async for event in client.stream_global_events():
payload = json.dumps(event, ensure_ascii=False)
self.write("data: %s\n\n" % payload)
# flush() pushes the chunk to the client; without it tornado
# would buffer until the response is "finished".
await self.flush()
except tornado.iostream.StreamClosedError:
# Client went away — normal disconnect, not an error.
log.info("SSE client disconnected")
except Exception as e:
log.exception("SSE proxy error")
try:
self.write("data: %s\n\n" % json.dumps({
"type": "error",
"message": str(e),
}))
await self.flush()
except Exception:
# Connection already gone; nothing to send.
pass
class PermissionReplyHandler(APIHandler):
"""Respond to a `permission.asked` event for a given session.
POST ``/opencode-bridge/permissions/:permId?session=:sid`` with body
``{"response": "once"|"always"|"reject"}``. Forwards to OpenCode
Serve's ``POST /session/:sid/permissions/:permId``.
"""
VALID_RESPONSES = ("once", "always", "reject")
@tornado.web.authenticated
async def post(self, perm_id: str):
session_id = self.get_query_argument("session", "")
if not session_id:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'session' query parameter"}))
return
try:
body = json.loads(self.request.body) if self.request.body else {}
except json.JSONDecodeError as e:
self.set_status(400)
self.finish(json.dumps({"error": "bad request: %s" % e}))
return
response = body.get("response")
if response not in self.VALID_RESPONSES:
self.set_status(400)
self.finish(json.dumps({
"error": "response must be one of %s" % list(self.VALID_RESPONSES)
}))
return
client = make_client(self)
try:
ok = await client.reply_permission(session_id, perm_id, response)
self.finish(json.dumps({
"ok": ok,
"permissionId": perm_id,
"response": response,
}))
except OpenCodeError as e:
log.exception("permission reply failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
except Exception as e:
log.exception("permission reply failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
class QuestionReplyHandler(APIHandler):
"""Reply to a `question.asked` event with a freeform answer.
POST ``/opencode-bridge/questions/:qId/reply?session=:sid`` with body
``{"answer": "..."}``. Forwards to OpenCode Serve's
``POST /session/:sid/question/:qId/reply``.
"""
@tornado.web.authenticated
async def post(self, question_id: str):
session_id = self.get_query_argument("session", "")
if not session_id:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'session' query parameter"}))
return
try:
body = json.loads(self.request.body) if self.request.body else {}
except json.JSONDecodeError as e:
self.set_status(400)
self.finish(json.dumps({"error": "bad request: %s" % e}))
return
answer = body.get("answer")
if not isinstance(answer, str) or not answer.strip():
self.set_status(400)
self.finish(json.dumps({"error": "missing 'answer'"}))
return
client = make_client(self)
try:
ok = await client.reply_question(session_id, question_id, answer)
self.finish(json.dumps({
"ok": ok,
"questionId": question_id,
}))
except OpenCodeError as e:
log.exception("question reply failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
except Exception as e:
log.exception("question reply failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
# NO finally delete — session is reused per notebook.
class SessionListHandler(APIHandler):
@@ -301,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"]
@@ -310,9 +678,25 @@ def setup_route_handlers(web_app):
(url_path_join(base_url, "opencode-bridge", "health"), HealthHandler),
(url_path_join(base_url, "opencode-bridge", "providers"), ProvidersHandler),
(url_path_join(base_url, "opencode-bridge", "edit"), EditHandler),
(url_path_join(base_url, "opencode-bridge", "events"), GlobalEventHandler),
(url_path_join(base_url, "opencode-bridge", "sessions"), SessionListHandler),
(url_path_join(base_url, "opencode-bridge", "session"), SessionReleaseHandler),
(url_path_join(base_url, "opencode-bridge", "session-messages"), SessionMessagesHandler),
# Multi-session management (notebook can bind N sessions; one is active).
(url_path_join(base_url, "opencode-bridge", "sessions", "all"), AllSessionsListHandler),
(url_path_join(base_url, "opencode-bridge", "sessions", "notebook"), NotebookSessionsHandler),
(url_path_join(base_url, "opencode-bridge", "sessions", "active"), ActiveSessionHandler),
# /permissions/<perm_id> and /questions/<q_id>/reply
# use a URLSpec with a regex so the path param is forwarded to
# the handler as a positional argument.
tornado.web.URLSpec(
url_path_join(base_url, "opencode-bridge", "permissions", r"([A-Za-z0-9_\-]+)"),
PermissionReplyHandler,
),
tornado.web.URLSpec(
url_path_join(base_url, "opencode-bridge", "questions", r"([A-Za-z0-9_\-]+)", "reply"),
QuestionReplyHandler,
),
]
web_app.add_handlers(host_pattern, handlers)
+211 -60
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
client = self._client_factory()
session = await client.create_session(
title="jupyter:%s" % notebook_path
)
sid = session["id"]
self._sessions[notebook_path] = sid
self._titles[notebook_path] = notebook_path
log.info("created opencode session %s for %s", sid, notebook_path)
return sid
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 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:
async def _create_and_bind(
self, notebook_path: str, title: Optional[str]
) -> str:
client = self._client_factory()
session = await client.create_session(
title=title or "jupyter:%s" % notebook_path
)
sid = session["id"]
self._bind(notebook_path, sid)
log.info("created opencode session %s for %s", sid, notebook_path)
return sid
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, []))
+137 -9
View File
@@ -98,28 +98,30 @@ async def test_auth_absent_when_no_password(base_config: OpenCodeConfig) -> None
@pytest.mark.asyncio
async def test_send_message_sync_includes_model_when_provider_and_model_given(
async def test_send_message_async_includes_model_when_provider_and_model_given(
base_config: OpenCodeConfig,
) -> None:
"""send_message_async posts to /prompt_async with provider/model when both set."""
client, mock = _make_client(base_config, [(200, {"done": True})])
parts = [{"type": "text", "text": "hello"}]
result = await client.send_message_sync("s1", parts, provider_id="p1", model_id="m1")
assert result == {"done": True}
result = await client.send_message_async("s1", parts, provider_id="p1", model_id="m1")
assert result is None
call = mock.calls[0]
assert call["request"].url == "http://127.0.0.1:4096/session/s1/message"
assert call["request"].url == "http://127.0.0.1:4096/session/s1/prompt_async"
assert call["request"].method == "POST"
body = json.loads(call["request"].body.decode("utf-8"))
assert body["parts"] == parts
assert body["model"] == {"providerID": "p1", "modelID": "m1"}
@pytest.mark.asyncio
async def test_send_message_sync_omits_model_when_provider_or_model_missing(
async def test_send_message_async_omits_model_when_provider_or_model_missing(
base_config: OpenCodeConfig,
) -> None:
client, mock = _make_client(base_config, [(200, {"done": True})])
parts = [{"type": "text", "text": "hello"}]
result = await client.send_message_sync("s1", parts)
assert result == {"done": True}
result = await client.send_message_async("s1", parts)
assert result is None
call = mock.calls[0]
body = json.loads(call["request"].body.decode("utf-8"))
assert body["parts"] == parts
@@ -127,7 +129,7 @@ async def test_send_message_sync_omits_model_when_provider_or_model_missing(
@pytest.mark.asyncio
async def test_send_message_sync_includes_system_when_provided() -> None:
async def test_send_message_async_includes_system_when_provided() -> None:
"""system param is forwarded into the request body when not None."""
config = OpenCodeConfig(
url="http://x:1", user="u", password="", request_timeout_seconds=120
@@ -136,7 +138,7 @@ async def test_send_message_sync_includes_system_when_provided() -> None:
mock.responses.append((200, {"info": {}, "parts": []}))
client = OpenCodeClient(config, http_client=mock)
await client.send_message_sync("sid", [{"type": "text", "text": "hi"}], system="be brief")
await client.send_message_async("sid", [{"type": "text", "text": "hi"}], system="be brief")
assert len(mock.calls) == 1
body = json.loads(mock.calls[0]["request"].body.decode("utf-8"))
@@ -144,6 +146,83 @@ async def test_send_message_sync_includes_system_when_provided() -> None:
assert body["parts"] == [{"type": "text", "text": "hi"}]
@pytest.mark.asyncio
async def test_stream_global_events_yields_parsed_events(
base_config: OpenCodeConfig,
) -> None:
"""stream_global_events yields one dict per `data: {...}` SSE block."""
class _StreamingMock(MockHTTPClient):
async def fetch(self, request, **kwargs):
self.calls.append({"request": request, "kwargs": kwargs})
# Simulate OpenCode's /global/event: three events back-to-back.
chunks = [
b"data: {\"type\": \"session.next.text.delta\", \"properties\": "
b"{\"sessionID\": \"s1\", \"delta\": \"hello \"}}\n\n",
b"data: {\"type\": \"session.next.text.delta\", \"properties\": "
b"{\"sessionID\": \"s1\", \"delta\": \"world\"}}\n\n",
b"data: {\"type\": \"session.idle\", \"properties\": "
b"{\"sessionID\": \"s1\"}}\n\n",
]
# streaming_callback now lives on the HTTPRequest (per tornado
# API — fetch() rejects kwargs when an HTTPRequest is passed).
cb = getattr(request, "streaming_callback", None)
if cb is not None:
for c in chunks:
cb(c)
# Empty body, code 200 to terminate normally.
return tornado.httpclient.HTTPResponse(
request=request,
code=200,
headers=tornado.httputil.HTTPHeaders(),
buffer=BytesIO(b""),
)
mock = _StreamingMock()
client = OpenCodeClient(base_config, http_client=mock)
events = []
async for ev in client.stream_global_events():
events.append(ev)
assert len(events) == 3
assert events[0]["type"] == "session.next.text.delta"
assert events[0]["properties"]["delta"] == "hello "
assert events[1]["properties"]["delta"] == "world"
assert events[2]["type"] == "session.idle"
# Stream used the SSE accept header and no timeout.
call = mock.calls[0]
assert call["kwargs"] == {"raise_error": False}
assert call["request"].method == "GET"
assert call["request"].url == "http://127.0.0.1:4096/global/event"
assert call["request"].request_timeout == 0
assert call["request"].headers.get("Accept") == "text/event-stream"
assert call["request"].streaming_callback is not None
def test_parse_sse_block_handles_concatenated_data_lines() -> None:
"""Multi-line `data:` blocks are joined with \n (per SSE spec)."""
parsed = OpenCodeClient._parse_sse_block(
"data: {\"a\": 1,\ndata: \"b\": 2}"
)
assert parsed == {"a": 1, "b": 2}
def test_parse_sse_block_ignores_non_data_fields() -> None:
parsed = OpenCodeClient._parse_sse_block("event: ping\ndata: {\"k\": \"v\"}\n\n")
assert parsed == {"k": "v"}
def test_parse_sse_block_returns_none_on_empty() -> None:
assert OpenCodeClient._parse_sse_block("") is None
assert OpenCodeClient._parse_sse_block("event: ping\n\n") is None
assert OpenCodeClient._parse_sse_block(": heartbeat\n\n") is None
def test_parse_sse_block_returns_none_on_bad_json() -> None:
assert OpenCodeClient._parse_sse_block("data: not-json\n\n") is None
@pytest.mark.asyncio
async def test_delete_session_returns_bool_by_status(base_config: OpenCodeConfig) -> None:
client, mock = _make_client(base_config, [(200, True), (404, False)])
@@ -154,6 +233,55 @@ async def test_delete_session_returns_bool_by_status(base_config: OpenCodeConfig
assert mock.calls[0]["request"].url == "http://127.0.0.1:4096/session/s1"
@pytest.mark.asyncio
async def test_reply_permission_posts_response(base_config: OpenCodeConfig) -> None:
"""reply_permission forwards the response string to /session/:id/permissions/:permId."""
client, mock = _make_client(base_config, [(200, True)])
ok = await client.reply_permission("s1", "perm-1", "once")
assert ok is True
call = mock.calls[0]
assert call["request"].url == "http://127.0.0.1:4096/session/s1/permissions/perm-1"
assert call["request"].method == "POST"
body = json.loads(call["request"].body.decode("utf-8"))
assert body == {"response": "once"}
@pytest.mark.asyncio
async def test_reply_permission_returns_false_on_404(
base_config: OpenCodeConfig,
) -> None:
client, mock = _make_client(base_config, [(404, False)])
assert await client.reply_permission("s1", "perm-1", "reject") is False
@pytest.mark.asyncio
async def test_reply_question_posts_answer(base_config: OpenCodeConfig) -> None:
"""reply_question forwards the answer to /session/:id/question/:qid/reply."""
client, mock = _make_client(base_config, [(200, True)])
ok = await client.reply_question("s1", "q-7", "use 3.12")
assert ok is True
call = mock.calls[0]
assert call["request"].url == "http://127.0.0.1:4096/session/s1/question/q-7/reply"
body = json.loads(call["request"].body.decode("utf-8"))
assert body == {"answer": "use 3.12"}
@pytest.mark.asyncio
async def test_list_all_sessions_gets_session(base_config: OpenCodeConfig) -> None:
"""list_all_sessions proxies OpenCode Serve's GET /session and returns
the raw list (no projection)."""
payload = [
{"id": "s1", "title": "first", "createdAt": 1, "updatedAt": 2},
{"id": "s2", "title": "second", "createdAt": 3, "updatedAt": 4},
]
client, mock = _make_client(base_config, [(200, payload)])
sessions = await client.list_all_sessions()
assert sessions == payload
call = mock.calls[0]
assert call["request"].url == "http://127.0.0.1:4096/session"
assert call["request"].method == "GET"
@pytest.mark.asyncio
async def test_non_2xx_response_raises_with_body(base_config: OpenCodeConfig) -> None:
client, mock = _make_client(base_config, [(500, {"error": "boom"})])
+529 -51
View File
@@ -12,10 +12,6 @@ class FakeOpenCodeClient:
self.session_id = "fake-session-123"
self.health_response = {"healthy": True, "version": "0.1.0"}
self.providers_response = {"providers": [{"id": "anthropic", "models": [{"id": "claude"}]}]}
self.message_response = {
"info": {"id": "msg-1"},
"parts": [{"type": "text", "text": "def foo():\n return 42\n"}],
}
# Canned session-messages list response (list_session_messages).
# Default: one user + one assistant message, mixed text parts.
self.messages_response = [
@@ -45,9 +41,13 @@ class FakeOpenCodeClient:
self.calls.append(("create_session", title))
return {"id": self.session_id, "title": title}
async def send_message_sync(self, session_id, parts, provider_id=None, model_id=None, system=None):
self.calls.append(("send_message_sync", session_id, parts, system))
return self.message_response
async def send_message_async(
self, session_id, parts, provider_id=None, model_id=None, system=None
):
self.calls.append(
("send_message_async", session_id, parts, system, provider_id, model_id)
)
return None
async def delete_session(self, session_id):
self.calls.append(("delete_session", session_id))
@@ -57,38 +57,123 @@ class FakeOpenCodeClient:
self.calls.append(("list_session_messages", session_id))
return self.messages_response
class FakeSessionManager:
"""Drop-in replacement for SessionManager with recording."""
def __init__(self, session_id: str = "fake-session-123") -> None:
self._session_id = session_id
self.calls: list = []
async def get_or_create(self, notebook_path: str) -> str:
self.calls.append(("get_or_create", notebook_path))
return self._session_id
def peek(self, notebook_path: str):
# Mirror SessionManager.peek: return the session id without
# creating one. None means "no session yet" (used by the no-session
# test to short-circuit the history route).
return self._session_id or None
async def release(self, notebook_path: str) -> bool:
self.calls.append(("release", notebook_path))
return True
def list_sessions(self) -> list:
async def list_all_sessions(self):
self.calls.append(("list_all_sessions",))
return [
{"notebookPath": path, "sessionId": sid}
for path, sid in sorted(self._sessions.items())
{
"id": "fake-session-123",
"title": "jupyter:foo.ipynb",
"createdAt": 1700000000000,
"updatedAt": 1700000001000,
},
{
"id": "other-session",
"title": "manual session",
"createdAt": 1700000010000,
"updatedAt": 1700000011000,
},
]
def invalidate(self, notebook_path: str) -> bool:
self.calls.append(("invalidate", notebook_path))
async def create_session(self, title):
self.calls.append(("create_session", title))
# Return a fresh id (different from the default "fake-session-123").
new_id = "newly-created-" + title
return {"id": new_id, "title": title}
async def reply_permission(self, session_id, permission_id, response):
self.calls.append(("reply_permission", session_id, permission_id, response))
return True
async def reply_question(self, session_id, question_id, answer):
self.calls.append(("reply_question", session_id, question_id, answer))
return True
async def stream_global_events(self):
"""Async generator — tests can monkey-patch this to control the stream."""
if False: # pragma: no cover - never executed, just makes this an asyncgen
yield {}
class FakeSessionManager:
"""Thin wrapper around the real SessionManager with a recording
``FakeOpenCodeClient``. Lets route tests exercise the real
multi-session logic without touching OpenCode.
"""
def __init__(self, session_id: str = "fake-session-123", client=None) -> None:
from opencode_bridge.session_manager import SessionManager
# `_client` attribute used by some legacy tests. Allow injection
# so a test can share its FakeOpenCodeClient with the SessionManager
# (the real SessionManager calls into its own client_factory, NOT
# the route's make_client).
self._client = client if client is not None else FakeOpenCodeClient()
self._sm = SessionManager(lambda: self._client)
self.calls: list = self._client.calls
# Legacy attribute used by list_sessions() etc.
self._sessions: dict = {}
# ---- delegation to the real SessionManager ----
def _record(self, name, *args):
self.calls.append((name, *args))
return None
def get_or_create(self, notebook_path: str):
self._record("get_or_create", notebook_path)
return self._sm.get_or_create(notebook_path)
def peek(self, notebook_path: str):
return self._sm.peek(notebook_path)
def get_active(self, notebook_path: str):
return self._sm.get_active(notebook_path)
def list_sessions(self) -> list:
return self._sm.list_sessions()
def list_sessions_for_notebook(self, notebook_path: str):
return self._sm.list_sessions_for_notebook(notebook_path)
def bind_existing(self, notebook_path: str, session_id: str) -> None:
self._record("bind_existing", notebook_path, session_id)
self._sm.bind_existing(notebook_path, session_id)
def set_active(self, notebook_path: str, session_id: str) -> bool:
self._record("set_active", notebook_path, session_id)
return self._sm.set_active(notebook_path, session_id)
def unbind(self, notebook_path: str, session_id: str) -> bool:
self._record("unbind", notebook_path, session_id)
return self._sm.unbind(notebook_path, session_id)
def unbind_active(self, notebook_path: str) -> bool:
self._record("unbind_active", notebook_path)
return self._sm.unbind_active(notebook_path)
def invalidate(self, notebook_path: str) -> bool:
self._record("invalidate", notebook_path)
return self._sm.invalidate(notebook_path)
async def release(self, notebook_path: str) -> bool:
self._record("release", notebook_path)
return await self._sm.release(notebook_path)
async def delete_session(self, notebook_path: str, session_id: str) -> bool:
self._record("delete_session", notebook_path, session_id)
return await self._sm.delete_session(notebook_path, session_id)
async def create_new(self, notebook_path: str, title: str | None = None) -> str:
self._record("create_new", notebook_path, title)
return await self._sm.create_new(notebook_path, title)
# ---- internal-state proxies (used by tests that poke internals) ----
@property
def _active(self) -> dict:
return self._sm._active
@property
def _bound(self) -> dict:
return self._sm._bound
async def test_hello(jp_fetch):
# When
@@ -131,6 +216,12 @@ async def test_providers_handler(monkeypatch, jp_fetch):
async def test_edit_handler(monkeypatch, jp_fetch):
"""POST /opencode-bridge/edit fires prompt_async and returns sessionId only.
The async path returns immediately with `{ok, sessionId, notebookPath}` —
the actual LLM response is consumed via the /events SSE endpoint, not
embedded in this response.
"""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
@@ -159,37 +250,81 @@ async def test_edit_handler(monkeypatch, jp_fetch):
)
assert response.code == 200
payload = json.loads(response.body)
# The exact sessionId comes from the FakeClient; just check the
# shape — keys are present and ok is true.
assert payload["ok"] is True
# Server now returns the AI reply as raw markdown (```fences``` kept
# so the frontend marked.parse renders code blocks).
assert payload["markdown"] == "def foo():\n return 42"
assert payload["sessionId"] == "fake-session-123"
assert isinstance(payload["sessionId"], str) and payload["sessionId"]
assert payload["notebookPath"] == "test.ipynb"
# Async path: no `markdown` field. The LLM reply is consumed via SSE.
assert "markdown" not in payload
# Session manager was used, not direct create/delete on client
# Session manager was used (not direct create/delete on client).
call_names = [c[0] for c in fake.calls]
assert "create_session" not in call_names
assert "delete_session" not in call_names
assert "send_message_sync" in call_names
assert "send_message_async" in call_names
assert "send_message_sync" not in call_names # legacy sync path is gone
# send_message_sync received the session ID from the manager
send_call = [c for c in fake.calls if c[0] == "send_message_sync"][0]
assert send_call[1] == "fake-session-123"
# system prompt was passed (v3+ allows markdown/fences in the reply)
# send_message_async received a session id from the manager (the
# exact value comes from the FakeClient; just verify it matches
# the response's sessionId).
send_call = [c for c in fake.calls if c[0] == "send_message_async"][0]
assert send_call[1] == payload["sessionId"]
# system prompt was passed.
assert "你是一个代码助手" in send_call[3]
# SessionManager.get_or_create was called with the notebook path
# 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(
monkeypatch, jp_fetch
):
"""providerId/modelId are forwarded into the async send body when both set."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager",
lambda h: FakeSessionManager(),
)
body = json.dumps({
"prompt": "x",
"context": {
"notebookPath": "n.ipynb",
"cellId": "c",
"language": "python",
"cellIndex": 0,
"totalCells": 1,
"source": "",
"previousCode": None,
"error": None,
},
"providerId": "anthropic",
"modelId": "claude-sonnet-4-20250514",
})
response = await jp_fetch("opencode-bridge", "edit", method="POST", body=body)
assert response.code == 200
send_call = [c for c in fake.calls if c[0] == "send_message_async"][0]
# (send_message_async, sid, parts, system, provider_id, model_id)
assert send_call[4] == "anthropic"
assert send_call[5] == "claude-sonnet-4-20250514"
async def test_session_list_handler(monkeypatch, jp_fetch):
fake_sm = FakeSessionManager()
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
)
@@ -234,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
)
@@ -276,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",
@@ -287,3 +431,337 @@ async def test_session_release_handler(monkeypatch, jp_fetch):
assert payload["notebookPath"] == "foo.ipynb"
assert payload["deleted"] is True
assert ("release", "foo.ipynb") in fake_sm.calls
async def test_permission_reply_forwards_response(monkeypatch, jp_fetch):
"""POST /opencode-bridge/permissions/:permId?session=:sid forwards to client."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
response = await jp_fetch(
"opencode-bridge", "permissions", "perm-42",
method="POST",
params={"session": "ses-1"},
body=json.dumps({"response": "once"}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload == {
"ok": True,
"permissionId": "perm-42",
"response": "once",
}
assert ("reply_permission", "ses-1", "perm-42", "once") in fake.calls
async def test_permission_reply_rejects_invalid_response(monkeypatch, jp_fetch):
"""response must be one of once/always/reject — 400 otherwise."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
await jp_fetch(
"opencode-bridge", "permissions", "perm-1",
method="POST",
params={"session": "ses-1"},
body=json.dumps({"response": "yes"}),
)
assert exc_info.value.code == 400
assert "yes" not in fake.calls and fake.calls == []
async def test_permission_reply_requires_session(monkeypatch, jp_fetch):
"""Missing ?session=... is a 400."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
await jp_fetch(
"opencode-bridge", "permissions", "perm-1",
method="POST",
body=json.dumps({"response": "once"}),
)
assert exc_info.value.code == 400
async def test_question_reply_forwards_answer(monkeypatch, jp_fetch):
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
response = await jp_fetch(
"opencode-bridge", "questions", "q-7", "reply",
method="POST",
params={"session": "ses-2"},
body=json.dumps({"answer": "use Python 3.12"}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload == {"ok": True, "questionId": "q-7"}
assert ("reply_question", "ses-2", "q-7", "use Python 3.12") in fake.calls
async def test_question_reply_rejects_empty_answer(monkeypatch, jp_fetch):
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
await jp_fetch(
"opencode-bridge", "questions", "q-1", "reply",
method="POST",
params={"session": "ses-1"},
body=json.dumps({"answer": " "}),
)
assert exc_info.value.code == 400
assert fake.calls == []
async def test_global_event_handler_forwards_all_events_unfiltered(
monkeypatch, jp_fetch
):
"""Server-side SSE proxy must NOT filter by session — it forwards
everything so the client can decide. (Previously a too-eager server
filter silently dropped events the client would have accepted.)
We mock stream_global_events to push three events with different
sessionID shapes (one matching, one not, one missing). The proxy
should pass all three through as data: lines.
"""
fake = FakeOpenCodeClient()
async def fake_stream():
yield {"type": "session.next.text.delta", "properties": {"sessionID": "ses-1", "delta": "A"}}
yield {"type": "session.next.text.delta", "properties": {"sessionID": "ses-OTHER", "delta": "B"}}
yield {"type": "server.connected", "properties": {}}
fake.stream_global_events = fake_stream
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
# Use ?session=ses-1 to confirm the param is accepted but doesn't filter.
response = await jp_fetch(
"opencode-bridge", "events",
params={"session": "ses-1"},
)
assert response.code == 200
body = response.body.decode("utf-8")
# All three events should appear in the body, regardless of sessionID.
assert '"delta": "A"' in body
assert '"delta": "B"' in body
assert '"server.connected"' in body
# The body should use the SSE format: each event as a `data:` line.
assert body.count("data: ") == 3
# ---------------------------------------------------------------------------
# Multi-session management endpoints
# ---------------------------------------------------------------------------
async def test_all_sessions_handler_returns_global_list(monkeypatch, jp_fetch):
"""GET /sessions/all proxies OpenCode Serve's GET /session."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
response = await jp_fetch("opencode-bridge", "sessions", "all")
assert response.code == 200
payload = json.loads(response.body)
assert payload["ok"] is True
assert len(payload["sessions"]) == 2
# The fake returns both the default session and a hand-rolled "other-session".
ids = {s["id"] for s in payload["sessions"]}
assert "fake-session-123" in ids
assert "other-session" in ids
assert ("list_all_sessions",) in fake.calls
async def test_notebook_sessions_get_returns_bound_with_metadata(
monkeypatch, jp_fetch
):
"""GET /sessions/notebook?notebook=... returns the bound sessions
enriched with title/createdAt from the global list."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
sm = FakeSessionManager()
sm._active["foo.ipynb"] = "fake-session-123"
sm._bound["foo.ipynb"] = ["fake-session-123"]
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
params={"notebook": "foo.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["notebookPath"] == "foo.ipynb"
assert payload["activeSessionId"] == "fake-session-123"
assert len(payload["sessions"]) == 1
s = payload["sessions"][0]
assert s["sessionId"] == "fake-session-123"
assert s["title"] == "jupyter:foo.ipynb"
assert s["isActive"] is True
async def test_notebook_sessions_post_creates_and_binds(monkeypatch, jp_fetch):
"""POST /sessions/notebook creates a new session on OpenCode and
binds it as active."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
# Share the client with the SessionManager so the test can assert
# against `fake.calls` (the real SessionManager calls its own
# client_factory, NOT the route's make_client).
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager",
lambda h: FakeSessionManager(client=fake),
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
method="POST",
params={"notebook": "foo.ipynb", "title": "fresh"},
body=json.dumps({}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["ok"] is True
assert payload["sessionId"] == "newly-created-fresh"
# The new session is the active one.
assert payload["activeSessionId"] == "newly-created-fresh"
# OpenCode was called.
assert ("create_session", "fresh") in fake.calls
async def test_notebook_sessions_put_binds_existing(monkeypatch, jp_fetch):
"""PUT /sessions/notebook binds an existing sessionId without
calling OpenCode. Sets it as active."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
sm = FakeSessionManager()
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
method="PUT",
params={"notebook": "foo.ipynb", "sessionId": "some-existing-sid"},
body=json.dumps({}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["sessionId"] == "some-existing-sid"
assert payload["activeSessionId"] == "some-existing-sid"
# No OpenCode round-trip for the bind.
assert ("create_session", ...) not in [
c for c in fake.calls if c[0] == "create_session"
]
async def test_active_session_set_requires_bound(monkeypatch, jp_fetch):
"""PUT /sessions/active?sessionId=X fails with 400 if X is not bound."""
monkeypatch.setattr(
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
)
sm = FakeSessionManager()
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
await jp_fetch(
"opencode-bridge", "sessions", "active",
method="PUT",
params={"notebook": "foo.ipynb", "sessionId": "never-bound"},
body=json.dumps({}),
)
assert exc_info.value.code == 400
async def test_active_session_set_switches(monkeypatch, jp_fetch):
"""PUT /sessions/active successfully switches when the sessionId is bound."""
monkeypatch.setattr(
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
)
sm = FakeSessionManager()
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "active",
method="PUT",
params={"notebook": "foo.ipynb", "sessionId": "sid-A"},
body=json.dumps({}),
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["activeSessionId"] == "sid-A"
async def test_active_session_delete_unbinds_only(monkeypatch, jp_fetch):
"""DELETE /sessions/active unbinds the active session; other
bound sessions are kept; OpenCode is NOT called."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
sm = FakeSessionManager()
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "active",
method="DELETE",
params={"notebook": "foo.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["activeSessionId"] is None
# sid-A is still bound; sid-B was the active and is gone.
assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-A"]
# No OpenCode call.
assert not any(c[0] == "delete_session" for c in fake.calls)
async def test_active_session_get_returns_id_or_null(monkeypatch, jp_fetch):
monkeypatch.setattr(
"opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient()
)
sm = FakeSessionManager()
sm.bind_existing("foo.ipynb", "sid-A")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "active",
params={"notebook": "foo.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["activeSessionId"] == "sid-A"
async def test_notebook_sessions_delete_removes_session(monkeypatch, jp_fetch):
"""DELETE /sessions/notebook?notebook=...&sessionId=... deletes
the session on OpenCode AND removes its binding."""
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
# Share the client with the SessionManager so the test can assert
# against `fake.calls` (the real SessionManager calls its own
# client_factory, NOT the route's make_client).
sm = FakeSessionManager(client=fake)
sm.bind_existing("foo.ipynb", "sid-A")
sm.bind_existing("foo.ipynb", "sid-B")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: sm
)
response = await jp_fetch(
"opencode-bridge", "sessions", "notebook",
method="DELETE",
params={"notebook": "foo.ipynb", "sessionId": "sid-A"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["deleted"] is True
# OpenCode delete was called for sid-A.
assert ("delete_session", "sid-A") in fake.calls
# sid-A is gone; sid-B remains active.
assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-B"]
assert sm.get_active("foo.ipynb") == "sid-B"
+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();
});
});
+99
View File
@@ -0,0 +1,99 @@
/**
* Tests for the localStorage-backed model selection persistence helper.
* jsdom provides a real (per-test-class) localStorage.
*/
import {
clearModelSelection,
loadModelSelection,
saveModelSelection
} from '../components/model_selection';
describe('loadModelSelection', () => {
beforeEach(() => {
localStorage.clear();
});
it('returns null when no entry exists', () => {
expect(loadModelSelection('foo.ipynb')).toBeNull();
});
it('returns null for an empty notebookPath (defensive)', () => {
expect(loadModelSelection('')).toBeNull();
});
it('returns the parsed entry when storage is valid', () => {
localStorage.setItem(
'opencode_bridge:model-selection:foo.ipynb',
JSON.stringify({ providerId: 'anthropic', modelId: 'claude-sonnet-4-20250514' })
);
expect(loadModelSelection('foo.ipynb')).toEqual({
providerId: 'anthropic',
modelId: 'claude-sonnet-4-20250514'
});
});
it('returns null for malformed JSON (does not throw)', () => {
localStorage.setItem(
'opencode_bridge:model-selection:foo.ipynb',
'{not json'
);
expect(loadModelSelection('foo.ipynb')).toBeNull();
});
it('returns null when the entry is missing required fields', () => {
localStorage.setItem(
'opencode_bridge:model-selection:foo.ipynb',
JSON.stringify({ providerId: 'anthropic' })
);
expect(loadModelSelection('foo.ipynb')).toBeNull();
});
it('keys entries independently per notebookPath', () => {
saveModelSelection('a.ipynb', { providerId: 'p1', modelId: 'm1' });
saveModelSelection('b.ipynb', { providerId: 'p2', modelId: 'm2' });
expect(loadModelSelection('a.ipynb')).toEqual({
providerId: 'p1',
modelId: 'm1'
});
expect(loadModelSelection('b.ipynb')).toEqual({
providerId: 'p2',
modelId: 'm2'
});
});
});
describe('saveModelSelection + clearModelSelection', () => {
beforeEach(() => {
localStorage.clear();
});
it('writes a JSON entry that can be re-read', () => {
saveModelSelection('foo.ipynb', { providerId: 'p1', modelId: 'm1' });
expect(loadModelSelection('foo.ipynb')).toEqual({
providerId: 'p1',
modelId: 'm1'
});
});
it('overwrites an existing entry', () => {
saveModelSelection('foo.ipynb', { providerId: 'p1', modelId: 'm1' });
saveModelSelection('foo.ipynb', { providerId: 'p2', modelId: 'm2' });
expect(loadModelSelection('foo.ipynb')).toEqual({
providerId: 'p2',
modelId: 'm2'
});
});
it('clearModelSelection removes the entry', () => {
saveModelSelection('foo.ipynb', { providerId: 'p1', modelId: 'm1' });
clearModelSelection('foo.ipynb');
expect(loadModelSelection('foo.ipynb')).toBeNull();
});
it('no-op for an empty notebookPath (defensive)', () => {
expect(() =>
saveModelSelection('', { providerId: 'p1', modelId: 'm1' })
).not.toThrow();
expect(() => clearModelSelection('')).not.toThrow();
});
});
File diff suppressed because it is too large Load Diff
+172 -13
View File
@@ -1,6 +1,12 @@
import { ServerConnection } from '@jupyterlab/services';
import { callOpenCodeEdit, callOpenCodeProviders } from '../api/opencode_client';
import {
callOpenCodeEdit,
callOpenCodeProviders,
callOpenCodeReplyPermission,
callOpenCodeReplyQuestion,
subscribeOpenCodeEvents
} from '../api/opencode_client';
import type { OpenCodeRequest } from '../types';
jest.mock('@jupyterlab/services', () => {
@@ -66,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', () => {
@@ -82,10 +81,9 @@ describe('callOpenCodeEdit', () => {
mockedMakeRequest.mockReset();
});
it('POSTs to /opencode-bridge/edit with JSON body', async () => {
it('POSTs to /opencode-bridge/edit and returns async {ok, sessionId, notebookPath}', async () => {
const respBody = {
ok: true,
markdown: '```python\ndef foo(x: int) -> int: return x\n```',
sessionId: 'sid',
notebookPath: 'foo.ipynb',
};
@@ -107,7 +105,10 @@ describe('callOpenCodeEdit', () => {
);
expect(resp.ok).toBe(true);
if (resp.ok) {
expect(resp.markdown).toContain('int');
// Async response has no `markdown` field — the LLM reply arrives
// via the SSE stream, not embedded in this response.
expect(resp.sessionId).toBe('sid');
expect((resp as any).markdown).toBeUndefined();
}
});
@@ -219,3 +220,161 @@ describe('callOpenCodeProviders', () => {
).rejects.toThrow(/network error/);
});
});
describe('callOpenCodeReplyPermission', () => {
beforeEach(() => {
mockedMakeRequest.mockReset();
});
it('POSTs to /opencode-bridge/permissions/:permId with {response}', async () => {
mockedMakeRequest.mockResolvedValue(
mockResponse({
ok: true,
status: 200,
statusText: 'OK',
text: JSON.stringify({ ok: true, permissionId: 'perm-1', response: 'once' })
})
);
const r = await callOpenCodeReplyPermission(
's1',
'perm-1',
'once',
mockServerSettings()
);
expect(r).toEqual({ ok: true, permissionId: 'perm-1', response: 'once' });
expect(mockedMakeRequest).toHaveBeenCalledWith(
'http://localhost:8888/opencode-bridge/permissions/perm-1?session=s1',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ response: 'once' })
}),
expect.anything()
);
});
it('throws on non-2xx', async () => {
mockedMakeRequest.mockResolvedValue(
mockResponse({
ok: false,
status: 400,
statusText: 'Bad Request',
text: 'bad response'
})
);
await expect(
callOpenCodeReplyPermission('s1', 'p', 'reject', mockServerSettings())
).rejects.toThrow(/400/);
});
});
describe('callOpenCodeReplyQuestion', () => {
beforeEach(() => {
mockedMakeRequest.mockReset();
});
it('POSTs to /opencode-bridge/questions/:qId/reply with {answer}', async () => {
mockedMakeRequest.mockResolvedValue(
mockResponse({
ok: true,
status: 200,
statusText: 'OK',
text: JSON.stringify({ ok: true, questionId: 'q-1' })
})
);
const r = await callOpenCodeReplyQuestion(
's1',
'q-1',
'use 3.12',
mockServerSettings()
);
expect(r).toEqual({ ok: true, questionId: 'q-1' });
expect(mockedMakeRequest).toHaveBeenCalledWith(
'http://localhost:8888/opencode-bridge/questions/q-1/reply?session=s1',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ answer: 'use 3.12' })
}),
expect.anything()
);
});
});
/**
* subscribeOpenCodeEvents uses the global `fetch` (not ServerConnection),
* so we mock fetch instead of ServerConnection.makeRequest.
*/
describe('subscribeOpenCodeEvents', () => {
let originalFetch: typeof fetch;
let mockReader: { read: jest.Mock };
let capturedUrl: string | undefined;
let capturedInit: RequestInit | undefined;
beforeEach(() => {
originalFetch = globalThis.fetch;
mockReader = {
read: jest
.fn()
// First call returns a chunk; second returns done.
.mockResolvedValueOnce({
value: new TextEncoder().encode(
'data: {"type":"session.next.text.delta","properties":{"sessionID":"sid","delta":"hello"}}\n\n' +
'data: {"type":"session.idle","properties":{"sessionID":"sid"}}\n\n'
),
done: false
})
.mockResolvedValueOnce({ value: undefined, done: true })
};
globalThis.fetch = jest.fn(async (url: any, init?: RequestInit) => {
capturedUrl = String(url);
capturedInit = init;
return {
ok: true,
status: 200,
statusText: 'OK',
body: {
getReader: () => mockReader
}
} as unknown as Response;
}) as unknown as typeof fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('GETs /opencode-bridge/events?session=<sid> and dispatches parsed events', async () => {
const onEvent = jest.fn();
const settings = mockServerSettings();
const sub = subscribeOpenCodeEvents('sid', settings, { onEvent });
// Let the async fetch + reader resolve.
await new Promise(r => setTimeout(r, 10));
sub.close();
expect(capturedUrl).toBe(
'http://localhost:8888/opencode-bridge/events?session=sid'
);
expect(capturedInit?.method).toBe('GET');
expect((capturedInit?.headers as any).Accept).toBe('text/event-stream');
expect((capturedInit?.headers as any)['X-XSRFToken']).toBe('test');
expect(onEvent).toHaveBeenCalledTimes(2);
expect(onEvent.mock.calls[0][0].type).toBe('session.next.text.delta');
expect(onEvent.mock.calls[0][0].properties.delta).toBe('hello');
expect(onEvent.mock.calls[1][0].type).toBe('session.idle');
});
it('close() is idempotent and aborts the in-flight fetch', async () => {
const onEvent = jest.fn();
const sub = subscribeOpenCodeEvents('sid', mockServerSettings(), { onEvent });
// Let the async fetch actually run and capture the signal.
await new Promise(r => setTimeout(r, 5));
sub.close();
sub.close(); // second call must not throw
const signal = capturedInit?.signal as AbortSignal | undefined;
expect(signal).toBeDefined();
expect(signal?.aborted).toBe(true);
});
});
+476 -6
View File
@@ -1,19 +1,35 @@
/**
* HTTP client for the opencode-bridge server extension.
*
* Two flows:
* - one-shot: POST /edit (async returns immediately with sessionId),
* GET /providers, GET /session-messages.
* - streaming: subscribeOpenCodeEvents() opens a fetch+reader connection
* to GET /events and yields parsed OpenCodeEvent values.
*/
import { URLExt } from '@jupyterlab/coreutils';
import { ServerConnection } from '@jupyterlab/services';
import type { OpenCodeProvidersResponse, OpenCodeRequest, OpenCodeResponse, OpenCodeMessagesResponse } from '../types';
import type {
OpenCodeAllSessionsResponse,
OpenCodeEditResponse,
OpenCodeEvent,
OpenCodeMessagesResponse,
OpenCodeNotebookSessionsResponse,
OpenCodeProvidersResponse,
OpenCodeRequest,
OpenCodeSessionOpResponse
} from '../types';
/**
* Call POST /opencode-bridge/edit. Returns parsed response (success or failure).
* Throws on network error or non-JSON response.
* Call POST /opencode-bridge/edit. The endpoint is async it fires
* prompt_async at OpenCode and returns once the prompt is queued. The
* actual LLM response is consumed via the SSE stream.
*/
export async function callOpenCodeEdit(
request: OpenCodeRequest,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeResponse> {
): Promise<OpenCodeEditResponse> {
const url = URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
@@ -23,7 +39,7 @@ export async function callOpenCodeEdit(
const init: RequestInit = {
method: 'POST',
body: JSON.stringify(request),
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }
};
let response: Response;
@@ -51,7 +67,7 @@ export async function callOpenCodeEdit(
);
}
return parsed as OpenCodeResponse;
return parsed as OpenCodeEditResponse;
}
/**
@@ -115,3 +131,457 @@ export async function callOpenCodeProviders(
return (await response.json()) as OpenCodeProvidersResponse;
}
/**
* Respond to a `permission.asked` event. `response` must be one of
* "once" | "always" | "reject". Returns the parsed JSON body.
*/
export async function callOpenCodeReplyPermission(
sessionId: string,
permissionId: string,
response: 'once' | 'always' | 'reject',
serverSettings: ServerConnection.ISettings
): Promise<{ ok: boolean; permissionId: string; response: string; error?: string }> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'permissions',
encodeURIComponent(permissionId)
) +
'?session=' +
encodeURIComponent(sessionId);
const init: RequestInit = {
method: 'POST',
body: JSON.stringify({ response }),
headers: { 'Content-Type': 'application/json' }
};
let res: Response;
try {
res = await ServerConnection.makeRequest(url, init, serverSettings);
} catch (error) {
throw new Error(
`network error calling opencode-bridge/permissions: ${(error as Error).message}`
);
}
const text = await res.text();
if (!res.ok) {
throw new Error(
`opencode-bridge/permissions failed: ${res.status} ${text}`
);
}
return JSON.parse(text);
}
/**
* Reply to a `question.asked` event with the user's freeform answer.
*/
export async function callOpenCodeReplyQuestion(
sessionId: string,
questionId: string,
answer: string,
serverSettings: ServerConnection.ISettings
): Promise<{ ok: boolean; questionId: string; error?: string }> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'questions',
encodeURIComponent(questionId),
'reply'
) +
'?session=' +
encodeURIComponent(sessionId);
const init: RequestInit = {
method: 'POST',
body: JSON.stringify({ answer }),
headers: { 'Content-Type': 'application/json' }
};
let res: Response;
try {
res = await ServerConnection.makeRequest(url, init, serverSettings);
} catch (error) {
throw new Error(
`network error calling opencode-bridge/questions: ${(error as Error).message}`
);
}
const text = await res.text();
if (!res.ok) {
throw new Error(
`opencode-bridge/questions failed: ${res.status} ${text}`
);
}
return JSON.parse(text);
}
// ---------------------------------------------------------------------------
// Multi-session management (1 notebook can bind N sessions; one is active)
// ---------------------------------------------------------------------------
async function _jsonRequest<T>(
url: string,
init: RequestInit,
serverSettings: ServerConnection.ISettings,
label: string
): Promise<T> {
let res: Response;
try {
res = await ServerConnection.makeRequest(url, init, serverSettings);
} catch (error) {
throw new Error(
`network error calling opencode-bridge/${label}: ${(error as Error).message}`
);
}
const text = await res.text();
if (!res.ok) {
throw new Error(`opencode-bridge/${label} failed: ${res.status} ${text}`);
}
if (!text) {
throw new Error(`empty response from opencode-bridge/${label}`);
}
try {
return JSON.parse(text) as T;
} catch {
throw new Error(
`non-JSON response from opencode-bridge/${label} (status ${res.status})`
);
}
}
/** GET /opencode-bridge/sessions/all — every session on OpenCode Serve. */
export async function callOpenCodeListAllSessions(
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeAllSessionsResponse> {
const url = URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'all'
);
return _jsonRequest(
url,
{ method: 'GET' },
serverSettings,
'sessions/all'
);
}
/** GET /opencode-bridge/sessions/notebook?notebook=... sessions bound
* to this notebook (enriched with metadata, marked which is active). */
export async function callOpenCodeListNotebookSessions(
notebookPath: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeNotebookSessionsResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'notebook'
) +
'?notebook=' +
encodeURIComponent(notebookPath);
return _jsonRequest(url, { method: 'GET' }, serverSettings, 'sessions/notebook');
}
/** POST /opencode-bridge/sessions/notebook?notebook=...&title=...
* create a brand-new session on OpenCode, bind it, set as active. */
export async function callOpenCodeCreateNotebookSession(
notebookPath: string,
title: string | undefined,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'notebook'
) +
'?notebook=' +
encodeURIComponent(notebookPath) +
(title ? '&title=' + encodeURIComponent(title) : '');
return _jsonRequest(
url,
{ method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
serverSettings,
'sessions/notebook (POST)'
);
}
/** PUT /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
* bind an existing sessionId to this notebook (no OpenCode round-trip)
* and set it as active. */
export async function callOpenCodeBindExistingSession(
notebookPath: string,
sessionId: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'notebook'
) +
'?notebook=' +
encodeURIComponent(notebookPath) +
'&sessionId=' +
encodeURIComponent(sessionId);
return _jsonRequest(
url,
{ method: 'PUT', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
serverSettings,
'sessions/notebook (PUT)'
);
}
/** DELETE /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
* delete a specific bound session on OpenCode AND remove its binding. */
export async function callOpenCodeDeleteNotebookSession(
notebookPath: string,
sessionId: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'notebook'
) +
'?notebook=' +
encodeURIComponent(notebookPath) +
'&sessionId=' +
encodeURIComponent(sessionId);
return _jsonRequest(
url,
{ method: 'DELETE' },
serverSettings,
'sessions/notebook (DELETE)'
);
}
/** GET /opencode-bridge/sessions/active?notebook=... get the active
* sessionId (or null). */
export async function callOpenCodeGetActiveSession(
notebookPath: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'active'
) +
'?notebook=' +
encodeURIComponent(notebookPath);
return _jsonRequest(url, { method: 'GET' }, serverSettings, 'sessions/active');
}
/** PUT /opencode-bridge/sessions/active?notebook=...&sessionId=...
* switch which bound session is active. sessionId MUST already be
* in the bound list.
*
* TODO(frontend): unused in v0.1.x the cell-action's session
* switcher uses PUT /sessions/notebook (which does both bind and set
* active in one call). This is kept for a future "switch between
* already-bound sessions" flow that avoids the bind round-trip.
* Do NOT delete the corresponding server route without also
* removing this client function (or vice versa). */
export async function callOpenCodeSetActiveSession(
notebookPath: string,
sessionId: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'active'
) +
'?notebook=' +
encodeURIComponent(notebookPath) +
'&sessionId=' +
encodeURIComponent(sessionId);
return _jsonRequest(
url,
{ method: 'PUT', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
serverSettings,
'sessions/active (PUT)'
);
}
/** DELETE /opencode-bridge/sessions/active?notebook=... unbind the
* active session (next /edit lazy-creates a new one). Does NOT delete
* the session on OpenCode Serve. */
export async function callOpenCodeUnbindActiveSession(
notebookPath: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeSessionOpResponse> {
const url =
URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'sessions',
'active'
) +
'?notebook=' +
encodeURIComponent(notebookPath);
return _jsonRequest(
url,
{ method: 'DELETE' },
serverSettings,
'sessions/active (DELETE)'
);
}
export interface OpenCodeEventHandlers {
onEvent: (event: OpenCodeEvent) => void;
onError?: (err: Error) => void;
onOpen?: () => void;
}
export interface OpenCodeEventSubscription {
/** Cancel the stream. Safe to call multiple times. */
close: () => void;
}
/**
* Subscribe to GET /opencode-bridge/events?session=<sid> as an SSE stream.
*
* Mirrors demo.html `initSSE`: opens a fetch(), reads the response body
* with a TextDecoder, splits on `\n\n` event boundaries, and parses each
* `data: {...}` line as JSON. Use `close()` to cancel.
*
* ServerConnection.makeRequest is unsuitable for SSE (it buffers the
* entire response), so we use plain fetch with the XSRF token injected
* from `serverSettings.token` when present.
*/
export function subscribeOpenCodeEvents(
sessionId: string,
serverSettings: ServerConnection.ISettings,
handlers: OpenCodeEventHandlers
): OpenCodeEventSubscription {
const baseUrl = serverSettings.baseUrl.replace(/\/$/, '');
const url =
URLExt.join(baseUrl, 'opencode-bridge', 'events') +
'?session=' +
encodeURIComponent(sessionId);
const headers: Record<string, string> = {
Accept: 'text/event-stream'
};
if (serverSettings.token) {
headers['X-XSRFToken'] = serverSettings.token;
}
const controller = new AbortController();
let closed = false;
const close = (): void => {
if (closed) {
return;
}
closed = true;
try {
controller.abort();
} catch {
// ignore
}
};
void (async () => {
let response: Response;
try {
response = await fetch(url, { method: 'GET', headers, signal: controller.signal });
} catch (err) {
if ((err as Error).name === 'AbortError') {
return;
}
handlers.onError?.(err as Error);
return;
}
if (!response.ok) {
handlers.onError?.(
new Error(
`opencode-bridge/events failed: ${response.status} ${response.statusText}`
)
);
return;
}
if (!response.body) {
handlers.onError?.(new Error('opencode-bridge/events: no response body'));
return;
}
handlers.onOpen?.();
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
try {
while (!closed) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
let sepIdx: number;
while ((sepIdx = buffer.indexOf('\n\n')) !== -1) {
const block = buffer.slice(0, sepIdx);
buffer = buffer.slice(sepIdx + 2);
for (const line of block.split('\n')) {
if (!line.startsWith('data:')) {
continue;
}
const jsonStr = line.slice(5).trim();
if (!jsonStr) {
continue;
}
try {
const event = JSON.parse(jsonStr) as OpenCodeEvent;
handlers.onEvent(event);
} catch (e) {
console.warn(
'opencode_bridge: SSE JSON parse error:',
e,
jsonStr
);
}
}
}
}
// Drain trailing block (no final \n\n).
if (buffer.trim()) {
for (const line of buffer.split('\n')) {
if (!line.startsWith('data:')) {
continue;
}
const jsonStr = line.slice(5).trim();
if (!jsonStr) {
continue;
}
try {
handlers.onEvent(JSON.parse(jsonStr) as OpenCodeEvent);
} catch {
// ignore
}
}
}
} catch (err) {
if ((err as Error).name === 'AbortError') {
return;
}
handlers.onError?.(err as Error);
}
})();
return { close };
}
+82
View File
@@ -0,0 +1,82 @@
/**
* Persistence of the user's (provider, model) selection in the inline
* prompt, keyed by notebook path. Stored in localStorage so the
* preference survives panel close/reopen and JupyterLab restart.
*
* The selection is ALWAYS validated against the current providers
* payload before being applied: if the stored provider (or the model
* under it) is no longer present, the caller falls back to its
* default-selection logic. We never silently apply a stale value that
* would result in an empty / disabled <select>.
*
* Storage is best-effort: localStorage may be disabled (private mode),
* the quota may be exhausted, or the value may be corrupt. All
* failure modes return null / no-op rather than throw.
*/
const STORAGE_PREFIX = 'opencode_bridge:model-selection:';
export interface ModelSelection {
providerId: string;
modelId: string;
}
export function loadModelSelection(notebookPath: string): ModelSelection | null {
if (!notebookPath) {
return null;
}
try {
if (typeof localStorage === 'undefined') {
return null;
}
const raw = localStorage.getItem(STORAGE_PREFIX + notebookPath);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (
parsed &&
typeof parsed === 'object' &&
typeof (parsed as ModelSelection).providerId === 'string' &&
typeof (parsed as ModelSelection).modelId === 'string'
) {
return {
providerId: (parsed as ModelSelection).providerId,
modelId: (parsed as ModelSelection).modelId
};
}
return null;
} catch {
return null;
}
}
export function saveModelSelection(
notebookPath: string,
sel: ModelSelection
): void {
if (!notebookPath) {
return;
}
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(STORAGE_PREFIX + notebookPath, JSON.stringify(sel));
} catch {
// Ignore quota exceeded / disabled storage.
}
}
export function clearModelSelection(notebookPath: string): void {
if (!notebookPath) {
return;
}
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.removeItem(STORAGE_PREFIX + notebookPath);
} catch {
// ignore
}
}
+198 -64
View File
@@ -1,29 +1,38 @@
/**
* Per-cell AI action button rendered inside JupyterLab's native Cell toolbar
* (top-right of the active cell). v2: a single icon button. Clicking it
* toggles an OpenCodeInlinePrompt panel attached to the cell's DOM.
* (top-right of the active cell). Clicking it toggles an
* OpenCodeInlinePrompt panel attached to the cell's DOM.
*
* Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...).
*
* v3-final: the frontend does NO semantic processing. It gathers the cell's
* source / outputs / error, attaches the user's freeform instruction, and
* POSTs to the server. The chosen provider/model comes from the inline
* picker (no settings-based default).
* v4 message flow (matches demo.html):
* 1. User clicks 🪄 -> panel appears, shows session history (setMessages)
* 2. User submits prompt -> callOpenCodeEdit returns immediately with
* sessionId
* 3. We subscribe to GET /opencode-bridge/events?session=<sid> and
* forward each event to the prompt's applyEvent() dispatcher
* 4. On session.idle we close the stream and re-enable the input
*
* The frontend does NO semantic processing of the LLM reply. The chosen
* provider/model comes from the inline picker (no settings-based default).
*/
import type { CodeCell } from '@jupyterlab/cells';
import { Notification } from '@jupyterlab/apputils';
import type { NotebookPanel } from '@jupyterlab/notebook';
import { ServerConnection } from '@jupyterlab/services';
import { Widget } from '@lumino/widgets';
import { callOpenCodeEdit, callOpenCodeSessionMessages } from '../api/opencode_client';
import { extractCellContext } from '../context/cell_context';
import type {
CellContext,
OpenCodeProvidersResponse,
OpenCodeRequest,
OpenCodeResponse
} from '../types';
import {
callOpenCodeBindExistingSession,
callOpenCodeCreateNotebookSession,
callOpenCodeEdit,
callOpenCodeListAllSessions,
callOpenCodeReplyPermission,
callOpenCodeReplyQuestion,
callOpenCodeSessionMessages,
subscribeOpenCodeEvents,
type OpenCodeEventSubscription
} from '../api/opencode_client';
import type { OpenCodeProvidersResponse } from '../types';
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
@@ -47,9 +56,14 @@ 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;
constructor(cell: CodeCell) {
super();
@@ -65,6 +79,7 @@ export class OpenCodeCellActions extends Widget {
if (this.isDisposed) {
return;
}
this._closeSse();
this._cell.model.contentChanged.disconnect(this._onModelChange, this);
this._cell.model.stateChanged.disconnect(this._onModelChange, this);
this._hidePrompt();
@@ -72,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();
}
@@ -84,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';
@@ -112,25 +127,135 @@ 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._notebookPath ?? undefined,
onSubmit: (text: string, providerId?: string, modelId?: string) => {
void this._onSubmit(text, providerId, modelId);
},
onCancel: () => {
this._hidePrompt();
},
onPermissionReply: async (permId, response) => {
if (!_serverSettings) {
return false;
}
const sessionId = (this._prompt as any)._sessionId as string | null;
if (!sessionId) {
return false;
}
try {
const r = await callOpenCodeReplyPermission(
sessionId,
permId,
response,
_serverSettings
);
return r.ok;
} catch (e) {
console.warn('opencode_bridge: permission reply failed', e);
return false;
}
},
onQuestionReply: async (qId, answer) => {
if (!_serverSettings) {
return false;
}
const sessionId = (this._prompt as any)._sessionId as string | null;
if (!sessionId) {
return false;
}
try {
const r = await callOpenCodeReplyQuestion(
sessionId,
qId,
answer,
_serverSettings
);
return r.ok;
} catch (e) {
console.warn('opencode_bridge: question reply failed', e);
return false;
}
},
onStreamEnd: () => {
// Centralized: the prompt detected "idle" (any shape) and tells
// us to close the SSE stream + restore the input. We no longer
// parse event types here.
this._closeSse();
},
onListSessions: async () => {
if (!_serverSettings) return [];
try {
const r = await callOpenCodeListAllSessions(_serverSettings);
return r.sessions || [];
} catch (e) {
console.warn('opencode_bridge: list all sessions failed', e);
return [];
}
},
onCreateSession: async () => {
if (!_serverSettings || !this._notebookPath) {
throw new Error('notebook not ready');
}
// Tear down any in-flight SSE first — the old session is
// being replaced.
this._closeSse();
const r = await callOpenCodeCreateNotebookSession(
this._notebookPath,
undefined,
_serverSettings
);
if (!r.ok || !r.sessionId) {
throw new Error(r.error || 'create_session failed');
}
// The prompt's _handleCreateSession calls setSessionId
// internally; no need to set it here too.
return r.sessionId;
},
onSwitchSession: async (sessionId: string) => {
if (!_serverSettings || !this._notebookPath) {
return false;
}
// Closing the SSE first ensures the old session's events stop
// coming in while we transition.
this._closeSse();
// PUT /sessions/notebook binds AND sets active in one call.
// (The separate PUT /sessions/active is only for switching
// between sessions that are ALREADY bound; for a session the
// user just picked from the global list, we need to bind
// first — calling set_active before bind returns 400.)
const r = await callOpenCodeBindExistingSession(
this._notebookPath,
sessionId,
_serverSettings
);
if (!r.ok) {
return false;
}
// The prompt's _handleSwitchSession calls setSessionId
// internally; no need to set it here too.
return true;
},
onReloadHistory: async () => {
if (!this._notebookPath) return;
await this._refreshHistory(this._notebookPath);
}
});
Widget.attach(prompt, this._cell.node);
this._prompt = prompt;
// Fetch the current session's message history and render it into
// the prompt's scrollable history area. Empty list (no session yet)
// is a normal no-op render.
const notebookPath = this._context?.notebookPath;
// Fetch the current session's static history and render it before
// streaming begins. Empty list is a normal no-op.
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> {
@@ -144,8 +269,6 @@ export class OpenCodeCellActions extends Widget {
);
this._prompt.setMessages(resp.messages);
} catch (e) {
// Non-fatal: the history is a convenience. The user can still
// send new prompts; just the scrollback won't update.
console.warn('opencode_bridge: failed to fetch session messages', e);
}
}
@@ -162,7 +285,7 @@ export class OpenCodeCellActions extends Widget {
providerId?: string,
modelId?: string
): Promise<void> {
if (!this._context) {
if (!this._notebookPath) {
return;
}
if (!_serverSettings) {
@@ -170,9 +293,9 @@ export class OpenCodeCellActions extends Widget {
return;
}
const request: OpenCodeRequest = {
const request = {
prompt: text,
context: this._context,
context: { notebookPath: this._notebookPath },
providerId,
modelId
};
@@ -180,65 +303,76 @@ export class OpenCodeCellActions extends Widget {
this._status = 'loading';
if (this._prompt) {
this._prompt.setDisabled(true);
this._prompt.setSessionId(null);
}
let resp;
try {
const resp = await callOpenCodeEdit(request, _serverSettings);
this._handleResponse(resp);
resp = await callOpenCodeEdit(request, _serverSettings);
} catch (e) {
this._status = 'idle';
if (this._prompt) {
this._prompt.setDisabled(false);
}
this._prompt?.setDisabled(false);
Notification.error(`OpenCode 失败: ${(e as Error).message}`);
return;
}
if (!resp.ok) {
this._status = 'idle';
this._prompt?.setDisabled(false);
Notification.error(`OpenCode 错误: ${resp.error}`);
return;
}
// Async edit accepted. Bind the prompt to the returned session, clear
// the input, then subscribe to the SSE event stream for this session.
this._prompt?.setSessionId(resp.sessionId);
this._prompt?.clearInput();
this._sseSub = subscribeOpenCodeEvents(
resp.sessionId,
_serverSettings,
{
onEvent: ev => {
this._prompt?.applyEvent(ev);
// Stream-end is detected by the prompt and signalled via
// onStreamEnd (see _showPrompt); no idle parsing here.
},
onError: err => {
console.warn('opencode_bridge: SSE error', err);
// Don't flip status here: the server may just have hiccuped.
// session.idle (if it comes through) will re-enable the input.
}
}
);
}
private _handleResponse(resp: OpenCodeResponse): void {
this._status = 'idle';
if (resp.ok) {
// Refetch the (now-updated) session history and render the new
// assistant message as the last item in the scrollable history.
// The cell source is NOT replaced.
const notebookPath = this._context?.notebookPath;
if (notebookPath) {
void this._refreshHistory(notebookPath);
}
// Clear the input so the user can type a follow-up message.
this._prompt?.clearInput();
Notification.info(
`OpenCode 完成 (${resp.markdown.length} chars). Session: ${resp.sessionId.slice(0, 8)}`
);
} else {
if (this._prompt) {
this._prompt.setDisabled(false);
}
Notification.error(`OpenCode 错误: ${resp.error}`);
private _closeSse(): void {
if (this._sseSub) {
this._sseSub.close();
this._sseSub = null;
}
this._status = 'idle';
this._prompt?.setDisabled(false);
}
}
/**
* Resolve a CodeCell's parent NotebookPanel by walking the parent chain,
* 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);
return null;
}
File diff suppressed because it is too large Load Diff
-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,
};
}
+91 -24
View File
@@ -1,29 +1,28 @@
/**
* Shared types for the opencode-bridge frontend.
*
* These mirror the Python server's `CellContext` / `OpenCodeRequest` / `OpenCodeResponse`
* shapes in `opencode_bridge/routes.py`. Keep them in sync.
* Mirrors the Python server's response shapes in `opencode_bridge/routes.py`
* and the OpenCode Serve SSE event shape documented in
* `/Users/taochen/temp/demo.html` (`handleGlobalEvent` / `processSessionEvent`).
*
* v3-final: the JupyterLab plugin settings have been removed entirely.
* All connection config lives in startup environment variables (see
* `opencode_bridge/config.py`); the inline model picker is fully dynamic.
* v4: POST /edit is async (returns immediately with sessionId). The LLM
* reply is consumed via the GET /events SSE stream. Each SSE event is
* 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 {
@@ -33,21 +32,41 @@ export interface OpenCodeRequest {
modelId?: string;
}
export interface OpenCodeSuccess {
/**
* Response from POST /opencode-bridge/edit (async).
* The actual assistant reply arrives via the /events SSE stream and is
* NOT embedded in this response.
*/
export interface OpenCodeEditSuccess {
ok: true;
/** The AI's reply as markdown (code in ```fences```, optional explanation).
* Render with marked. The cell source is NOT replaced. */
markdown: string;
sessionId: string;
notebookPath: string;
}
export interface OpenCodeFailure {
export interface OpenCodeEditFailure {
ok: false;
error: string;
}
export type OpenCodeResponse = OpenCodeSuccess | OpenCodeFailure;
export type OpenCodeEditResponse = OpenCodeEditSuccess | OpenCodeEditFailure;
/**
* One SSE event from GET /opencode-bridge/events (proxied from OpenCode
* Serve's /global/event). The shape is loosely typed because OpenCode's
* payload envelope is not strictly documented; we follow demo.html's
* defensive property lookup pattern (try top-level then nested under
* `payload`).
*/
export interface OpenCodeEvent {
type: string;
payload?: {
type?: string;
properties?: { [k: string]: any };
syncEvent?: { aggregateID?: string };
};
properties?: { [k: string]: any };
syncEvent?: { aggregateID?: string };
}
/** Response from GET /opencode-bridge/session-messages?notebook=... .
* Projected from OpenCode's {info, parts}[] by the server into a
@@ -61,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 {
+192
View File
@@ -191,6 +191,7 @@
display: flex;
justify-content: flex-end;
gap: 4px;
align-items: center;
}
.opencode-inline-prompt .opencode-inline-actions button {
@@ -210,3 +211,194 @@
opacity: 0.4;
cursor: default;
}
/* The "📋 插入单元格内容" button: left-aligned, visually distinct from
send/cancel so the user can tell at a glance it's a "modifier"
rather than a "submit/cancel" action. */
.opencode-inline-prompt .opencode-btn-insert-cell {
margin-right: auto; /* push to the left; send/cancel stay on the right */
background: #dbeafe;
color: #1e40af;
border-color: #93c5fd;
}
.opencode-inline-prompt .opencode-btn-insert-cell:hover:enabled {
background: #bfdbfe;
}
/* ------------------------------------------------------------------
Streaming event blocks (v4+ see opencode_inline_prompt.applyEvent)
------------------------------------------------------------------ */
/* System / status line rendered for server.*, workspace.*, lsp.*, etc. */
.opencode-inline-prompt .opencode-msg-system {
font-size: var(--jp-ui-font-size0, 11px);
color: var(--jp-ui-font-color2, #888);
padding: 2px 4px;
font-family: var(--jp-code-font-family, monospace);
white-space: pre-wrap;
word-break: break-all;
}
/* Base collapsible block used for reasoning / tool / permission / question. */
.opencode-inline-prompt .opencode-msg-block {
margin: 4px 0;
border: 1px solid var(--jp-border-color2, #ddd);
border-radius: 3px;
background: var(--jp-layout-color1, #fafafa);
font-size: var(--jp-ui-font-size0, 11px);
overflow: hidden;
}
.opencode-inline-prompt .opencode-msg-block > summary {
padding: 2px 6px;
font-weight: 600;
cursor: pointer;
background: var(--jp-layout-color2, #f1f5f9);
color: var(--jp-ui-font-color2, #64748b);
user-select: none;
font-size: var(--jp-ui-font-size0, 11px);
}
.opencode-inline-prompt .opencode-msg-block-content {
padding: 4px 8px;
font-family: var(--jp-code-font-family, monospace);
white-space: pre-wrap;
word-break: break-all;
color: var(--jp-ui-font-color1, #334155);
max-height: 240px;
overflow-y: auto;
}
/* Reasoning: green tint (matches demo.html). */
.opencode-inline-prompt .opencode-msg-block-reasoning > summary {
background: #f0fdf4;
color: #15803d;
}
/* Tool call: yellow tint. */
.opencode-inline-prompt .opencode-msg-block-tool > summary {
background: #fef3c7;
color: #b45309;
}
/* Permission / question: pink tint. */
.opencode-inline-prompt .opencode-msg-block-interaction > summary {
background: #fce7f3;
color: #be185d;
}
/* Permission block: description + 3 buttons. */
.opencode-inline-prompt .opencode-perm-desc {
margin-bottom: 6px;
font-size: var(--jp-ui-font-size0, 11px);
color: var(--jp-ui-font-color1, #334155);
}
.opencode-inline-prompt .opencode-perm-buttons {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 4px;
}
.opencode-inline-prompt .opencode-perm-btn-allow,
.opencode-inline-prompt .opencode-perm-btn-reject {
border: none;
padding: 4px 10px;
border-radius: 3px;
cursor: pointer;
font-size: var(--jp-ui-font-size0, 11px);
color: white;
font-weight: 500;
}
.opencode-inline-prompt .opencode-perm-btn-allow {
background: #10b981;
}
.opencode-inline-prompt .opencode-perm-btn-allow:hover {
background: #059669;
}
.opencode-inline-prompt .opencode-perm-btn-reject {
background: #ef4444;
}
.opencode-inline-prompt .opencode-perm-btn-reject:hover {
background: #dc2626;
}
/* Question block: text + input + submit. */
.opencode-inline-prompt .opencode-q-desc {
margin-bottom: 6px;
font-size: var(--jp-ui-font-size0, 11px);
color: var(--jp-ui-font-color1, #334155);
}
.opencode-inline-prompt .opencode-q-input-row {
display: flex;
gap: 6px;
margin-top: 4px;
}
.opencode-inline-prompt .opencode-q-input {
flex: 1;
padding: 3px 6px;
border: 1px solid var(--jp-border-color2, #ccc);
border-radius: 3px;
font-size: var(--jp-ui-font-size0, 11px);
font-family: inherit;
background: var(--jp-layout-color1, #fff);
}
.opencode-inline-prompt .opencode-q-submit {
border: 1px solid var(--jp-border-color2, #ccc);
background: var(--jp-layout-color2, #f0f0f0);
padding: 3px 10px;
border-radius: 3px;
cursor: pointer;
font-size: var(--jp-ui-font-size0, 11px);
}
.opencode-inline-prompt .opencode-q-submit:hover {
background: var(--jp-layout-color3, #e0e0e0);
}
/* ------------------------------------------------------------------
Session selector (multi-session UI) top of the inline prompt
------------------------------------------------------------------ */
.opencode-inline-prompt .opencode-prompt-sessions {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
margin: 2px 0 4px;
}
.opencode-inline-prompt .opencode-prompt-sessions label {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--jp-ui-font-size1);
color: var(--jp-ui-font-color1, #333);
flex: 1;
min-width: 0;
}
.opencode-inline-prompt .opencode-session-select {
flex: 1;
min-width: 0;
padding: 2px 4px;
font-size: var(--jp-ui-font-size1);
font-family: inherit;
border: 1px solid var(--jp-border-color2, #ccc);
border-radius: 3px;
background: var(--jp-layout-color1, #fff);
}
.opencode-inline-prompt .opencode-session-btn-new,
.opencode-inline-prompt .opencode-session-btn-refresh {
border: 1px solid var(--jp-border-color2, #ccc);
background: var(--jp-layout-color2, #f0f0f0);
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
font-size: var(--jp-ui-font-size0, 11px);
color: var(--jp-ui-font-color1, #333);
}
.opencode-inline-prompt .opencode-session-btn-new:hover,
.opencode-inline-prompt .opencode-session-btn-refresh:hover {
background: var(--jp-layout-color3, #e0e0e0);
}
.opencode-inline-prompt .opencode-session-btn-new {
background: #dbeafe;
color: #1e40af;
border-color: #93c5fd;
}