Initial commit: opencode_bridge JupyterLab extension (Slices 1-3.5)

A JupyterLab extension that bridges the cell UI to a local OpenCode Serve
process. The extension is a dual package: a Python server extension
exposed under /opencode-bridge/*, plus a TypeScript frontend that
registers per-cell toolbars.

Backend (Python, tornado)
- Slice 1: config + auth + OpenCode HTTP client (tornado.httpclient,
  no aiohttp). 4 settings in schema/plugin.json (url, user, password,
  request timeout).
- Slice 2: handlers for /hello, /health, /providers, /edit.
- Slice 2.1 (correction): SessionManager with 1 notebook = 1 session
  mapping, async-safe via per-path locks, 404 recovery via invalidate().
  Two new endpoints: GET /sessions, DELETE /session?notebook=<path>.
- 32 pytest tests pass.

Frontend (TypeScript, JupyterLab 4.6)
- src/types.ts: CellContext, OpenCodeRequest/Response, OpenCodeSettings.
- src/context/cell_context.ts: extract CellContext from a CodeCell +
  its parent NotebookPanel, structured error collection.
- src/api/opencode_client.ts: callOpenCodeEdit, callOpenCodeProviders.
- src/components/opencode_cell_footer.ts: OpenCodeCellFooter Widget
  implementing ICellFooter with 3 buttons (optimize / fix / edit),
  resolved via this.parent instanceof CodeCell. NOT cellToolbar
  (does not exist in JL 4.6) and NOT Widget.findParent (removed in
  @lumino/widgets 2.x).
- src/components/opencode_cell_factory.ts: Cell.ContentFactory
  subclass returning the OpenCodeCellFooter.
- src/components/opencode_installer.ts: installOpenCodeEverywhere
  patches every notebook (existing + new) to use the custom factory.
- src/index.ts: registers the factory, loads settings, fetches
  /providers on activation and logs the list to the console.
- 23 jest tests pass (mocked JupyterLab boundary, pnpm path safe).

Settings
- 6 fields: 3 auth (url/user/password) + 1 timeout + 2 model selection
  (provider/model). Provider list is fetched at startup from
  /opencode-bridge/providers and printed to the browser console so
  users can copy values into Settings Editor.

Docs
- design.md: 6 sections covering architecture, UI flow, API contract,
  TS skeletons, session management (v0.2.1 correction), and
  provider/model selection (v0.2.2 addition).
- CLAUDE.md: agent guidance for working in this repo.
- TODO.md: remaining work for Slices 4-7 + v0.4+ backlog.

CI
- Gitea release workflow at .github/workflows/build.yml.
- Bark notification helper (non-fatal on failure).

Generated artefacts ignored: opencode_bridge/labextension/, _version.py,
*.tsbuildinfo, junit.xml, test.ipynb scratch notebook.
This commit is contained in:
tao.chen
2026-07-22 19:07:58 +08:00
commit c919c95842
61 changed files with 26642 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY
_commit: v4.6.2
_src_path: https://github.com/jupyterlab/extension-template
advanced: true
author_email: ''
author_name: taochen
has_ai_rules: false
has_binder: false
has_settings: true
kind: frontend-and-server
labextension_name: opencode_bridge
project_short_description: A JupyterLab extension.
python_name: opencode_bridge
repository: http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git
test: true
yarn_linker: pnpm
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# bark-notify.sh — push a notification to the local Bark server.
#
# Usage: bark-notify.sh <title> <body>
#
# The Bark endpoint, device_key, and group are pinned in this script
# (it is a deployment detail, not a secret). The group is "Gitea" —
# change it here if you want to sort notifications by category.
set -euo pipefail
if [ "$#" -ne 2 ]; then
echo "usage: $0 <title> <body>" >&2
exit 1
fi
TITLE="$1"
BODY="$2"
PAYLOAD=$(cat <<EOF
{
"device_key": "6ZFVsxM95cuAjYoNLWSWaN",
"title": "${TITLE}",
"body": "${BODY}",
"group": "Gitea",
"isArchive": 1,
"ttl": 3600
}
EOF
)
curl -s \
-X POST http://101.43.40.124:81/push \
-H "Content-Type: application/json; charset=utf-8" \
-d "${PAYLOAD}"
echo "bark notified: ${TITLE}"
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# create-release.sh — create a Gitea release and attach dist/ assets.
#
# Required env vars (set by the workflow):
# GITEA_TOKEN Gitea API token with release write scope
# TAG_NAME tag name (e.g. v0.0.1-beta)
# API_URL Gitea API base URL (e.g. http://gitea.local/api/v1)
# REPOSITORY owner/repo
# SHA commit SHA the tag points at
set -euo pipefail
PAYLOAD=$(cat <<EOF
{
"tag_name": "${TAG_NAME}",
"target_commitish": "${SHA}",
"name": "Release ${TAG_NAME}",
"body": "Auto-generated release assets.",
"draft": false,
"prerelease": false
}
EOF
)
echo "Creating Gitea release for tag ${TAG_NAME}..."
RELEASE_RESPONSE=$(curl -fsS -X POST "${API_URL}/repos/${REPOSITORY}/releases" \
-H "accept: application/json" \
-H "authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d "${PAYLOAD}")
RELEASE_ID=$(printf '%s' "${RELEASE_RESPONSE}" | grep -oP '"id":\s*\K[0-9]+' | head -n 1)
if [ -z "${RELEASE_ID}" ]; then
echo "ERROR: failed to create release. Response:"
echo "${RELEASE_RESPONSE}"
exit 1
fi
echo "Release created with ID ${RELEASE_ID}"
for asset in dist/snapshot-*.whl dist/snapshot-*.tar.gz; do
if [ -f "${asset}" ]; then
echo "Uploading ${asset}..."
curl -fsS -X POST "${API_URL}/repos/${REPOSITORY}/releases/${RELEASE_ID}/assets" \
-H "accept: application/json" \
-H "authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${asset}"
echo
else
echo "Skipping ${asset} (not found)"
fi
done
+64
View File
@@ -0,0 +1,64 @@
name: CI
on:
push:
# branches:
# - main
tags:
- 'v*'
pull_request:
jobs:
ci:
name: CI
# Standard GitHub-hosted ubuntu runner. uv is installed via the
# astral-sh/setup-uv action (one extra step vs. using a self-hosted
# runner with uv pre-installed, but matches what GitHub-hosted CI
# provides out of the box).
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up uv
uses: https://github.com/astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Build package
env:
# Use the Tsinghua PyPI mirror for build-environment resolution;
# the runner is in China and the default pypi.org is too slow
# / unreliable for build dependency install.
UV_DEFAULT_INDEX: "https://pypi.tuna.tsinghua.edu.cn/simple/"
run: uv build
# Only create a release + attach dist/ assets when pushing a v* tag.
# On branch pushes / pull requests this step is skipped entirely.
# The release logic is in a separate script to avoid embedding a JSON
# heredoc inside a YAML block scalar (the previous version of this
# step was rejected by Gitea's YAML parser at line 51).
- name: Upload release assets via Gitea API
if: startsWith(github.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.ref_name }}
API_URL: ${{ github.api_url }}
REPOSITORY: ${{ github.repository }}
SHA: ${{ github.sha }}
run: ./.github/scripts/create-release.sh
# Always run, regardless of job status. The script is a side-effect;
# if the notification itself fails we swallow the error so CI does
# not turn red on a Bark outage.
- name: Notify Bark
if: always()
run: |
if [ "${{ job.status }}" = "success" ]; then
TITLE="✅ CI: ${{ github.repository }}@${{ github.ref_name }}"
BODY="Build OK for ${{ github.sha }} by ${{ github.actor }}"
else
TITLE="❌ CI: ${{ github.repository }}@${{ github.ref_name }}"
BODY="Build FAILED for ${{ github.sha }} by ${{ github.actor }}"
fi
./.github/scripts/bark-notify.sh "$TITLE" "$BODY" || echo "Bark notify failed (non-fatal)"
+134
View File
@@ -0,0 +1,134 @@
*.bundle.*
lib/
node_modules/
*.log
.eslintcache
.stylelintcache
*.egg-info/
.ipynb_checkpoints
*.tsbuildinfo
opencode_bridge/labextension
# Version file is handled by hatchling
opencode_bridge/_version.py
# Integration tests
ui-tests/test-results/
ui-tests/playwright-report/
# Test artefacts
junit.xml
test.ipynb
# Local scratch / scratchpad notebooks that aren't part of the project.
# Project-level fixtures belong under opencode_bridge/tests/.
# Created by https://www.gitignore.io/api/python
# Edit at https://www.gitignore.io/?templates=python
### Python ###
# Virtual environments
.venv
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage/
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# Mr Developer
.mr.developer.cfg
.project
.pydevproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# End of https://www.gitignore.io/api/python
# OSX files
.DS_Store
# Yarn cache
.yarn/
+8
View File
@@ -0,0 +1,8 @@
node_modules
**/node_modules
**/lib
**/package.json
!/package.json
opencode_bridge
eslint.config.mjs
.venv
+12
View File
@@ -0,0 +1,12 @@
compressionLevel: mixed
enableGlobalCache: false
enableScripts: false
nodeLinker: pnpm
packageExtensions:
"@module-federation/sdk@*":
dependencies:
process: ^0.11.10
+5
View File
@@ -0,0 +1,5 @@
# Changelog
<!-- <START NEW CHANGELOG ENTRY> -->
<!-- <END NEW CHANGELOG ENTRY> -->
+92
View File
@@ -0,0 +1,92 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
`opencode_bridge` is a JupyterLab extension that acts as a thin bridge between the JupyterLab UI and a locally-running **OpenCode Serve** process (an AI coding assistant). The architecture, UI flow, and reference TypeScript skeletons are documented in `design.md` — read it before changing the interaction model. The high-level shape is: floating toolbar injected into each `CodeCell` → context provider (cell source, error traceback, neighbours) → REST/SSE call to the Jupyter server extension → server extension forwards to OpenCode Serve over local socket/HTTP.
This is a dual-package extension (one Python package + one NPM package) scaffolded from the [JupyterLab extension-template](https://github.com/jupyterlab/extension-template) v4.6.2 via Copier (see `.copier-answers.yml`). Do not hand-edit files marked "NEVER EDIT MANUALLY" in that file.
## Repository layout
| Path | Purpose |
| --- | --- |
| `src/` | TypeScript frontend (JupyterLab plugin lives in `src/index.ts`, REST helper in `src/request.ts`) |
| `opencode_bridge/` | Python server extension package; routes registered in `opencode_bridge/routes.py` |
| `schema/plugin.json` | Frontend settings schema (currently empty `properties: {}`) |
| `style/` | CSS entry points; `base.css` is empty, only `index.css` imports it |
| `lib/` | Build output of `src/` (gitignored) |
| `opencode_bridge/labextension/` | Build output of the full labextension bundle (gitignored) |
| `ui-tests/` | Playwright + Galata integration tests (separate `package.json` with its own `jlpm install`) |
| `jupyter-config/server-config/opencode_bridge.json` | Auto-enables the server extension on install |
| `design.md` | Architecture, UI flow, and reference TS skeletons — source of truth for the design |
| `.github/workflows/build.yml` | CI: `uv build` with Tsinghua PyPI mirror, then Gitea release + Bark notify |
The Python and JS package share the name `opencode_bridge` but live in different namespaces: the server extension is mounted at the URL prefix `opencode-bridge/...` (note the hyphen), while the Python/JS module name uses the underscore. Frontend → backend calls must hit `<baseUrl>/opencode-bridge/<endpoint>`.
## Common commands
All JS commands go through `jlpm` (JupyterLab's pinned yarn). The yarn setup uses **pnpm as the node linker** (see `.yarnrc.yml`) and disables `enableScripts`; do not run plain `npm install` on the root.
### Install / dev setup
```bash
python -m venv .venv && source .venv/bin/activate
pip install --editable ".[dev,test]"
jupyter-builder develop . --overwrite # link frontend
jupyter server extension enable opencode_bridge # server ext must be enabled manually in dev
jlpm install
```
### Build
```bash
jlpm build # tsc + jupyter-builder (dev), fast iteration
jlpm build:prod # full clean prod build for distribution
jlpm watch # tsc -w + jupyter-builder watch in parallel
```
### Tests
```bash
jlpm test # jest unit tests (src/__tests__/*) with coverage
pytest # python tests (opencode_bridge/tests/*) — uses pytest-jupyter
# Integration (Playwright + Galata):
(cd ui-tests && jlpm install && jlpm playwright install) # once
jlpm build:prod # required before UI tests
(cd ui-tests && jlpm playwright test) # run
(cd ui-tests && jlpm playwright test -u) # update snapshots
```
Jest config (`jest.config.js`) extends `@jupyterlab/testutils/lib/jest-config`; tests match `src/.*/.*.spec.ts[x]?$`. ESLint ignores `**/*.js`, `**/*.d.ts`, `tests`, `__tests__`, and `ui-tests` — TS files outside `ui-tests` are the only ones linted.
### Lint / format
```bash
jlpm lint # stylelint + prettier + eslint (--fix variants)
jlpm lint:check # CI variant, no --fix
```
Prettier is configured for single quotes, no trailing commas, no arrow parens (see `package.json` overrides). Stylelint enforces kebab-case class selectors.
### Release
`RELEASE.md` documents the manual flow (`hatch version`, `jlpm clean:all`, `python -m build`). The CI workflow auto-builds and creates a Gitea release on `v*` tags, then pushes a Bark notification (`.github/scripts/bark-notify.sh`) — Bark failures are non-fatal by design.
## Conventions specific to this repo
- **Server extension is enabled in two places**: `jupyter-config/server-config/opencode_bridge.json` (auto, on pip install) and must be enabled manually in dev mode. The pytest config (`conftest.py`) re-enables it via the `jp_server_config` fixture.
- **URL prefix vs. package name**: REST endpoints are mounted under `opencode-bridge/` (hyphen), even though the Python and JS packages are named `opencode_bridge` (underscore). `src/request.ts` hardcodes the hyphenated prefix; keep them in sync if you add endpoints.
- **Endpoint handlers must decorate every verb method with `@tornado.web.authenticated`** (see `opencode_bridge/routes.py`); the existing `HelloRouteHandler` only implements `get` — if you add POST/PATCH/PUT/DELETE, decorate each one.
- **Settings**: `schema/plugin.json` currently has no properties; the frontend loads it via `ISettingRegistry` in `src/index.ts`. When you add a setting, update `schema/plugin.json`, the `settingRegistry.load(...)` consumer, and document defaults.
- **Dev install of UI tests is a separate subproject**: `ui-tests/package.json` is not part of the root workspace. Run `jlpm install` inside `ui-tests/` once; do not move those deps to the root.
- **Generated artefacts**: `opencode_bridge/_version.py` is generated by hatch (gitignored) and `opencode_bridge/labextension/` is generated by the labextension build (gitignored). If you see them missing locally, you haven't run `jlpm build` yet.
- **PyPI mirror**: `pyproject.toml` pins the Tsinghua mirror for `uv`/`pip`, and CI sets `UV_DEFAULT_INDEX` to the same. Other users on slow/non-China networks may need to override this.
## Where to start when changing…
- **Adding a new REST endpoint**: add the handler in `opencode_bridge/routes.py` (with `@tornado.web.authenticated` on every verb), register the route under `opencode-bridge/<name>` in `setup_route_handlers`, then call it from the frontend via the `requestAPI` helper in `src/request.ts`.
- **Changing the cell UI / toolbar / inline prompt box**: `design.md` has working reference skeletons for `src/context_provider.ts` and `src/components/cell_toolbar.ts` — implement them; do not invent a new interaction model without updating the design doc.
- **Adding a JupyterLab setting**: `schema/plugin.json` + consume via `ISettingRegistry` in `src/index.ts`.
- **Packaging / release**: `RELEASE.md` + `.github/workflows/build.yml`; remember CI uses Gitea, not GitHub Releases.
+102
View File
@@ -0,0 +1,102 @@
# Contributing
## Development install
Note: You will need Node.js to build the extension package.
You may install it from [nodejs.org](https://nodejs.org/en/download). We
recommend using the latest LTS version of Node.js.
The `jlpm` command is JupyterLab's pinned version of
[yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use
`yarn` or `npm` in lieu of `jlpm` below.
```bash
# Clone the repo to your local environment
# Change directory to the opencode_bridge directory
# Set up a virtual environment and install package in development mode
python -m venv .venv
source .venv/bin/activate
pip install --editable ".[dev,test]"
# Link your development version of the extension with JupyterLab
jupyter-builder develop . --overwrite
# Server extension must be manually installed in develop mode
jupyter server extension enable opencode_bridge
# Rebuild extension Typescript source after making changes
# IMPORTANT: Unlike the steps above which are performed only once, do this step
# every time you make a change.
jlpm build
```
You can watch the source directory and run JupyterLab at the same time in different terminals to watch for changes in the extension's source and automatically rebuild the extension.
```bash
# Watch the source directory in one terminal, automatically rebuilding when needed
jlpm watch
# Run JupyterLab in another terminal
jupyter lab
```
With the watch command running, every saved change will immediately be built locally and available in your running JupyterLab. Refresh JupyterLab to load the change in your browser (you may need to wait several seconds for the extension to be rebuilt).
By default, the `jlpm build` command generates the source maps for this extension to make it easier to debug using the browser dev tools. To also generate source maps for the JupyterLab core extensions, you can run the following command:
```bash
jupyter lab build --minimize=False
```
## Development uninstall
```bash
# Server extension must be manually disabled in develop mode
jupyter server extension disable opencode_bridge
pip uninstall opencode_bridge
```
In development mode, you will also need to remove the symlink created by `jupyter-builder develop`
command. To find its location, you can run `jupyter labextension list` to figure out where the `labextensions`
folder is located. Then you can remove the symlink named `opencode_bridge` within that folder.
## Testing the extension
### Server tests
This extension is using [Pytest](https://docs.pytest.org/) for Python code testing.
Install test dependencies (needed only once):
```sh
pip install -e ".[test]"
# Each time you install the Python package, you need to restore the front-end extension link
jupyter-builder develop . --overwrite
```
To execute them, run:
```sh
pytest -vv -r ap --cov opencode_bridge
```
#### Frontend tests
This extension is using [Jest](https://jestjs.io/) for JavaScript code testing.
To execute them, execute:
```sh
jlpm
jlpm test
```
### Integration tests
This extension uses [Playwright](https://playwright.dev/docs/intro) for the integration tests (aka user level tests).
More precisely, the JupyterLab helper [Galata](https://github.com/jupyterlab/jupyterlab/tree/master/galata) is used to handle testing the extension in JupyterLab.
More information is provided within the [ui-tests](./ui-tests/README.md) README.
## Packaging the extension
See [RELEASE](RELEASE.md)
+29
View File
@@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2026, taochen
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+49
View File
@@ -0,0 +1,49 @@
# opencode_bridge
[![Github Actions Status](http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git/workflows/Build/badge.svg)](http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git/actions/workflows/build.yml)
A JupyterLab extension.
This extension is composed of a Python package named `opencode_bridge`
for the server extension and a NPM package named `opencode_bridge`
for the frontend extension.
## Requirements
- JupyterLab >= 4.0.0
## Install
To install the extension, execute:
```bash
pip install opencode_bridge
```
## Uninstall
To remove the extension, execute:
```bash
pip uninstall opencode_bridge
```
## Troubleshoot
If you are seeing the frontend extension, but it is not working, check
that the server extension is enabled:
```bash
jupyter server extension list
```
If the server extension is installed and enabled, but you are not seeing
the frontend extension, check the frontend extension is installed:
```bash
jupyter labextension list
```
## Contributing
If you would like to contribute to this extension, please refer to the [Contributing Guide](CONTRIBUTING.md).
+88
View File
@@ -0,0 +1,88 @@
# Making a new release of opencode_bridge
The extension can be published to `PyPI` and `npm` manually or using the [Jupyter Releaser](https://github.com/jupyter-server/jupyter_releaser).
## Manual release
### Python package
This extension can be distributed as Python packages. All of the Python
packaging instructions are in the `pyproject.toml` file to wrap your extension in a
Python package. Before generating a package, you first need to install some tools:
```bash
pip install build twine hatch
```
Bump the version using `hatch`. By default this will create a tag.
See the docs on [hatch-nodejs-version](https://github.com/agoose77/hatch-nodejs-version#semver) for details.
```bash
hatch version <new-version>
```
Make sure to clean up all the development files before building the package:
```bash
jlpm clean:all
```
You could also clean up the local git repository:
```bash
git clean -dfX
```
To create a Python source package (`.tar.gz`) and the binary package (`.whl`) in the `dist/` directory, do:
```bash
python -m build
```
> `python setup.py sdist bdist_wheel` is deprecated and will not work for this package.
Then to upload the package to PyPI, do:
```bash
twine upload dist/*
```
### NPM package
To publish the frontend part of the extension as a NPM package, do:
```bash
npm login
npm publish --access public
```
## Automated releases with the Jupyter Releaser
The extension repository should already be compatible with the Jupyter Releaser. But
the GitHub repository and the package managers need to be properly set up. Please
follow the instructions of the Jupyter Releaser [checklist](https://jupyter-releaser.readthedocs.io/en/latest/how_to_guides/convert_repo_from_repo.html).
For the release workflows in this repository, make sure GitHub is configured with:
- a `release` environment
- an `APP_PRIVATE_KEY` secret
- an `APP_ID` repository variable
When using [npm trusted publishing](https://docs.npmjs.com/trusted-publishers), `NPM_TOKEN` is not required (and trusted publishing is recommended). Configure `NPM_TOKEN` only if you are publishing without trusted publishers.
Here is a summary of the steps to cut a new release:
- Go to the Actions panel
- Run the "Step 1: Prep Release" workflow
- Check the draft changelog
- Run the "Step 2: Publish Release" workflow
> [!NOTE]
> Check out the [workflow documentation](https://jupyter-releaser.readthedocs.io/en/latest/get_started/making_release_from_repo.html)
> for more information.
## Publishing to `conda-forge`
If the package is not on conda forge yet, check the documentation to learn how to add it: https://conda-forge.org/docs/maintainer/adding_pkgs.html
Otherwise a bot should pick up the new version publish to PyPI, and open a new PR on the feedstock repository automatically.
+105
View File
@@ -0,0 +1,105 @@
# TODO
剩余开发任务。按依赖顺序切片,每步可独立验证。
---
## Slice 4 — Diff 面板 + Yjs 写入
**目标**:把 `OpenCodeCellFooter._handleResponse` 里的 `Notification.info(...)` 替换成真正的 Diff 面板,支持 Accept / Reject / 微调,写入走 Yjs 事务保护。
**未决问题先回答再写代码**
1. Accept 后是否自动执行 cell?(建议:否,让用户自己决定;slice 5 再说)
2. 多个 cell 同时打开 Diff 面板时如何管理?(每个 footer 实例一个面板 vs 全局单例——建议每 cell 一个)
3. Diff 视图用什么库?JL 自带 `IDiffModel`?还是简单 `diff-match-patch`**零新依赖**优先
**任务**
- [ ]`src/components/diff_panel.ts` 新建 `DiffPanel` widget(继承 `ReactWidget` 或纯 Lumino `Widget`
- [ ] 接受 → `cell.model.sharedModel.transact(() => cell.model.sharedModel.setSource(finalSource), 'opencode-bridge-accept')`
- [ ] 拒绝 → 关闭面板
- [ ] 微调 → 进入 textarea 编辑模式
- [ ]`_handleResponse` 里替换 `Notification.info` 调用,改为打开 `DiffPanel`
- [ ] 新增 `src/__tests__/diff_panel.spec.ts` 测试 accept/reject 路径
**验收**
- `pytest opencode_bridge/tests/` 仍 32/32 pass
- `jest src/__tests__/` 仍 23/23+ pass+ diff_panel 新测试)
- `tsc --noEmit` 干净
---
## Slice 5 — Inline prompt + Edit flow
**目标**:替换 `opencode_cell_footer.ts:127` 里的 `window.prompt(...)`,做内嵌 prompt 框,跟 design.md §2 一致。
**任务**
- [ ]`opencode_cell_footer.ts` 加内嵌 prompt 状态机:`closed → open → streaming → done / error`
- [ ] `🪄 编辑` 按钮点击 → 状态变 `open` → footer 下方滑出 textarea + 发送/取消按钮
- [ ] 提交 → 状态变 `streaming` → 显示进度(用现有 loading 状态) → 完成后切到 `done` → Diff 面板出现
- [ ] 取消 → 状态回到 `closed`
- [ ] 多个 cell 同时 open 的 prompt 互相独立(每个 footer 自己的状态)
**验收**
- 所有现有测试 pass
- 新增 1 个状态机集成测试
---
## Slice 6 — Fix flow 完整闭环
**目标**:🐛 排错按钮从"灰着"变成真能用——点击后调用 `/edit``mode: 'fix'`,自动注入 traceback 到 context(后端 `_build_request_body` 已支持,前端只发就行)。
**任务**
- [ ] 验证 cell 报错后 → fix 按钮可点(已实现)
- [ ] 点击 → 后端在 `_build_request_body` 里用 `error` 字段拼 traceback(已实现)
- [ ] 流式回包后 → Diff 面板显示修复版代码(Slice 4 完成后顺带支持)
- [ ] 验证 acceptance 后 `cell.model.sharedModel.setSource(...)` 后用户能直接重跑
**验收**
- `pytest opencode_bridge/tests/` 加一个 fix-mode 的 testmock OpenCode client 验证 traceback 被拼进 parts
---
## Slice 7 — 边界场景
**目标**:把所有 v0.2/v0.2.1/v0.2.2 设计里"未解决"和"边界"集中处理。
**任务**
- [ ] **OpenCode 不可达**:前端 `HealthHandler` 返回 503 → 3 个按钮全 disabled + Notification 显示
- [ ] **401/403**OpenCode 拒绝 Basic Auth → Notification 提示检查 `opencodeServerPassword` setting
- [ ] **session 失效**OpenCode 返回 404 → 后端 `EditHandler` 调用 `sm.invalidate(notebook_path)` → 下次 `get_or_create` 重建(**后端逻辑已在 v0.2.1 实现**,补前端 Notification 提示)
- [ ] **空 notebook path**`EditHandler` 400 + `{error: "missing context.notebookPath"}`(已实现,前端检查)
- [ ] **长 prompt 截断**:超 4000 chars 的 prompt 提示用户
- [ ] **provider/model 拼错**`/config/providers` 返回 200 但 provider 不存在 → OpenCode 端报错 → 后端 `EditHandler` 502 + 错误信息透传
- [ ] **取消进行中请求**:用户点 ✨ 之后能取消 → AbortController + `client.abort(session_id)` 调 OpenCode `/session/:id/abort`
**验收**
- 每个边界场景有一个对应的 test(pytest 或 jest,看发生在哪层)
- Notification 文案一致
---
## v0.4+(不在本轮迭代范围内)
来自 design.md §5.6
- TTL reapernotebook 关闭后 30 分钟 idle 自动 release session
- 多 server 横向扩展:in-memory state → Redis / sticky session
- 启动清理:Jupyter 重启后调 `GET /session` 列出 OpenCode 端孤儿删除
- 并发 LLM 调用:`asyncio.Semaphore(per_session=1)` 显式控制
- 流式 UX:v0.3 砍掉流式(同步版),v0.4 加 `prompt_async` + `/event` SSE 多路复用
- Provider/model 选值 UX:升级为 JupyterLab command + QuickPick,替代 console.log
来自 design.md §6.2
- Settings UI dynamic enum:等 JupyterLab 支持后把 console.log 替换成真正的 settings 渲染器
---
## 当前统计
- **后端**32 pytest testsconfig + client + routes + session_manager
- **前端**23 jest teststypes + cell_context + opencode_client + opencode_cell_footer
- **Schema 字段**63 auth + 1 timeout + 2 provider/model
- **设计文档**`design.md` v0.2.26 节
- **CLAUDE.md** 已建
总进度:Slices 1–3.5 完成,47 待开发。
+1
View File
@@ -0,0 +1 @@
module.exports = require('@jupyterlab/testutils/lib/babel.config');
+8
View File
@@ -0,0 +1,8 @@
import pytest
pytest_plugins = ("pytest_jupyter.jupyter_server", )
@pytest.fixture
def jp_server_config(jp_server_config):
return {"ServerApp": {"jpserver_extensions": {"opencode_bridge": True}}}
+337
View File
@@ -0,0 +1,337 @@
## 📑 1. 系统总体架构
采用 **前端 UI 注入 + 本地 Server Extension 透传** 的轻量双层架构:
```text
┌────────────────────────────────────────────────────────────────────────┐
│ JupyterLab / Notebook v7 (前端 TS) │
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Cell Component │ │
│ │ [CodeEditor] ─── (挂载) ───► [Cell Action Toolbar] │ │
│ │ │ (点击 🪄 智能编辑) │
│ │ ▼ │
│ │ [Inline Prompt Box] │ │
│ └───────────────────────────────────┬────────────────────────────┘ │
│ │ │
│ Context Provider 收集 │
│ (Target Cell, Error, Neighbors) │
│ │ │
└───────────────────────────────────────┼────────────────────────────────┘
│ REST / SSE Stream
┌────────────────────────────────────────────────────────────────────────┐
│ Jupyter Server Extension (后端 Python) │
│ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ OpenCode Bridge Handler │ │
│ │ - 接收 Context & Prompt │ │
│ │ - 拼装标准 OpenCode API Payload │ │
│ │ - SSE 流式透传代理 (Stream Proxy) │ │
│ └───────────────────────────────────┬────────────────────────────┘ │
└───────────────────────────────────────┼────────────────────────────────┘
│ Local Socket / HTTP
┌────────────────────────────────────────────────────────────────────────┐
│ OpenCode Serve 进程 │
└────────────────────────────────────────────────────────────────────────┘
```
---
## 🎨 2. 交互与UI流程设计
1. **悬浮 Toolbar(常驻)**:在每个 `CodeCell` 顶部渲染微型操作栏:
* `✨ 优化`:自动提炼代码规范与性能。
* `🐛 智能排错`:(**仅当 Cell 有 Error Output 时亮起**)自动抓取 Traceback 并修复。
* `🪄 智能编辑`:展开内嵌 Prompt 输入框。
2. **Inline Prompt 框(按需展开)**
* 用户点击 `🪄 智能编辑` 后,在当前 Cell 下方平滑展开一个微型输入框(带 `Textarea` + `🚀 发送` 按钮)。
3. **流式替换/Diff 预览**
* 点击发送后,支持**打字机实时替换**或在 Cell 下方出现 `[ Accept ]` / `[ Reject ]` 临时 Diff 比对面板。
---
## 🔌 3. 接口契约设计 (API Contract)
### 接口:`POST /opencode/v1/generate` (支持 SSE 流式返回)
**Request Payload (前端 Context Provider 打包发给 Server Extension)**:
```json
{
"action": "custom_edit", // custom_edit | fix_error | optimize
"prompt": "把这段代码改为用 plotly 绘制交互式折线图",
"context": {
"notebook_path": "analysis/demo.ipynb",
"target_cell": {
"index": 3,
"code": "import matplotlib.pyplot as plt\nplt.plot([1, 2, 3])",
"error_output": "ModuleNotFoundError: No module named 'matplotlib'"
},
"surrounding_cells": [
{ "index": 2, "code": "import pandas as pd" }
]
}
}
```
**Response (Server Extension 流式透传 OpenCode Serve 的 SSE 响应)**:
```text
event: chunk
data: {"text": "import plotly.express as px\n"}
event: chunk
data: {"text": "fig = px.line(x=[1, 2, 3], y=[1, 2, 3])\nfig.show()"}
event: done
data: {"status": "success"}
```
---
## 🛠️ 4. 核心前端实现逻辑 (TypeScript)
以下是前端实现“Toolbar 注入”和“上下文抓取”的伪代码骨架,可直接作为开发参考:
### `src/context_provider.ts` (上下文感知模块)
```typescript
import { Notebook, NotebookTracker } from '@jupyterlab/notebook';
import { Cell } from '@jupyterlab/cells';
export interface CellContext {
notebookPath: string;
targetCode: string;
errorOutput?: string;
surroundingCells: string[];
}
export function extractCellContext(tracker: NotebookTracker, targetCell: Cell): CellContext | null {
const currentWidget = tracker.currentWidget;
if (!currentWidget) return null;
const notebook: Notebook = currentWidget.content;
const cells = notebook.widgets;
const targetIndex = cells.findIndex(c => c === targetCell);
// 1. 抓取当前 Cell 的错误输出
let errorMsg = '';
if (targetCell.model.type === 'code') {
const outputs = (targetCell.model as any).outputs;
for (let i = 0; i < outputs.length; i++) {
const out = outputs.get(i);
if (out.type === 'error') {
errorMsg += `${out.ename}: ${out.evalue}\n${(out.traceback || []).join('\n')}`;
}
}
}
// 2. 抓取前一个 Cell 的代码(上下文)
const prevCode = targetIndex > 0 ? cells[targetIndex - 1].model.sharedModel.getSource() : '';
return {
notebookPath: currentWidget.context.path,
targetCode: targetCell.model.sharedModel.getSource(),
errorOutput: errorMsg,
surroundingCells: [prevCode]
};
}
```
### `src/components/cell_toolbar.ts` (工具栏与内嵌框注入)
```typescript
import { Cell } from '@jupyterlab/cells';
export function injectOpenCodeActionBox(cell: Cell, onSendPrompt: (prompt: string) => void) {
// 避免重复注入
if (cell.node.querySelector('.opencode-toolbar')) return;
// 1. 创建 Toolbar 容器
const toolbar = document.createElement('div');
toolbar.className = 'opencode-toolbar';
toolbar.innerHTML = `
<button class="opencode-btn edit-btn">🪄 智能编辑</button>
`;
// 2. 绑定展开事件
toolbar.querySelector('.edit-btn')!.addEventListener('click', () => {
let promptBox = cell.node.querySelector('.opencode-inline-box') as HTMLDivElement;
if (promptBox) {
promptBox.style.display = promptBox.style.display === 'none' ? 'block' : 'none';
return;
}
// 动态创建 Inline Prompt 输入框
promptBox = document.createElement('div');
promptBox.className = 'opencode-inline-box';
promptBox.innerHTML = `
<textarea placeholder="输入提示词(例如:重构这段代码或添加注释..."></textarea>
<div class="actions">
<button class="submit-btn">🚀 发送给 OpenCode</button>
</div>
`;
promptBox.querySelector('.submit-btn')!.addEventListener('click', () => {
const input = promptBox.querySelector('textarea')!.value;
if (input.trim()) onSendPrompt(input);
});
cell.node.appendChild(promptBox);
});
cell.node.insertBefore(toolbar, cell.node.firstChild);
}
```
---
## 🔗 5. Session 管理策略(v0.2.1 修正)
> **2026-07-22 修正**:原 v0.2 设计 `EditHandler` 采用"每请求一个 session + finally 清理"——这是错的。OpenCode 的 session 是有状态的对话上下文,每个 notebook 必须复用同一个 session,否则用户先优化 cell1、再编辑 cell2 时,AI 看不到前一次的结果。
### 5.1 核心规则
| 维度 | 决策 |
|---|---|
| 映射关系 | `notebookPath`1 个)→ OpenCode `sessionID`1 个) |
| 生命周期 | 首次 `EditHandler` 调用时 lazy create;显式 `DELETE /session` 才释放 |
| 并发 | 同 notebook 的并发请求用 `asyncio.Lock` 串行化 session 创建;消息发送并发无锁(OpenCode 端自己排队) |
| 失效恢复 | 当 `send_message_sync``OpenCodeError` 且消息含 404/410 → 自动 invalidate 缓存,下次请求重新 create |
| 清理 | **不做自动释放**。前端 notebook 关闭时**不**调 release(避免两个 tab 共享 session 时误杀)。用户可手动点"重置对话"触发 release |
| 跨重启 | Jupyter server 重启 → in-memory map 丢失 → OpenCode 端孤立 session 接受泄漏(v0.4 加启动清理) |
### 5.2 SessionManager 接口
```python
class SessionManager:
def __init__(self, client_factory: Callable[[], OpenCodeClient]): ...
async def get_or_create(self, notebook_path: str) -> str:
"""Return existing sessionID, or create one. Async-safe via per-path lock."""
async def release(self, notebook_path: str) -> bool:
"""Delete session and remove from map. Returns True if session existed."""
def has_session(self, notebook_path: str) -> bool: ...
def list_sessions(self) -> list[dict]:
"""For debug: [{notebookPath, sessionId}, ...]"""
```
**关键不变量**
- `get_or_create` 永远不返回 None 或抛 "not found"
- 重复调用 `get_or_create(same_path)` → 同一 sessionID
- `release(path)` 后再 `get_or_create(path)` → 新 sessionID
### 5.3 HTTP 端点
| Method | Path | 作用 | 备注 |
|---|---|---|---|
| `GET` | `/opencode-bridge/sessions` | 列出所有 active session | debug 用 |
| `DELETE` | `/opencode-bridge/session?notebook=<path>` | 显式释放指定 notebook 的 session | URL-encode path |
**为什么用 query param 而不是 path param**
- notebook path 可能含 `/``空格``中文`
- `url_path_join` + regex 捕获在含 `/` 的路径上行为不可控
- query string 是 RFC 3986 标准做法,tornado 自动 decode
### 5.4 EditHandler 改造
**改造前(错的)**
```
create_session → send_message_sync → delete_session # 每个请求都这样
```
**改造后(对的)**
```
session_id = session_manager.get_or_create(notebook_path) # 首次创建,复用后续
send_message_sync(session_id, ...) # 不管 session
# 没有 delete — session 留着给下次
```
### 5.5 错误恢复
`send_message_sync` 失败时:
1. 检查 `OpenCodeError` 消息是否含 "404" 或 "session not found"
2. 是 → `session_manager.invalidate(notebook_path)`,下次 `get_or_create` 会重新创建
3. 否 → 透传错误给前端
v0.2.1 不实现自动重试,只 invalidate。前端看到 502 后可重发。
### 5.6 未解决问题(v0.4+
1. **TTL reaper**notebook 关闭后 session 永远不释放 → 改用 mtime 追踪 + 30 分钟 idle 自动 release
2. **多 server 横向扩展**in-memory state 不能 scale → 改成 Redis 或 sticky session
3. **启动清理**Jupyter 重启后调 `GET /session` 列出 OpenCode 端的孤儿,逐个 delete
4. **并发 LLM 调用**:当前两次 `send_message_sync` 到同一 session 是串行(OpenCode 端排队)— 可加 `asyncio.Semaphore(per_session=1)` 显式控制
---
## 🎛 6. Provider/Model 选择(v0.2.2 新增)
> **2026-07-22**:用户要求能在 settings 里选 OpenCode 的 provider 和 model,可选值从 `GET /config/providers` 动态获取。
### 6.1 Schema 扩展
`schema/plugin.json` 加 2 个 string 字段:
```json
"opencodeProvider": {
"type": "string",
"title": "OpenCode Provider",
"description": "Provider id (如 'anthropic' / 'openai')。可选项由启动时从 /opencode-bridge/providers 拉取并打到 console 列出。留空 = 用 OpenCode 默认。",
"default": ""
},
"opencodeModel": {
"type": "string",
"title": "OpenCode Model",
"description": "Model id (如 'claude-sonnet-4-20250514')。可选项见 console 日志或 /opencode-bridge/providers。留空 = 用 provider 默认。",
"default": ""
}
```
### 6.2 选值如何给用户
JupyterLab settings 是静态 JSON schema**没有原生 dynamic enum**。v0.2.2 用最简方案:
- 启动时 fetch `/opencode-bridge/providers` → 把 provider/model 列表 `console.log` 出来
- 用户在 Settings Editor 里手填
- 后续 v0.4 考虑用 QuickPick 命令做交互式选择
### 6.3 数据流
```
Settings (JupyterLab)
├── opencodeProvider = "anthropic"
└── opencodeModel = "claude-sonnet-4-20250514"
▼ setOpenCodeRuntime()
Frontend (opencode_cell_footer.ts)
▼ build request body
POST /opencode-bridge/edit
{ mode, prompt, context, providerId, modelId }
▼ send_message_sync
OpenCode Serve 接受 model = { providerID, modelID }
```
### 6.4 不变量
- 留空字段("")→ 客户端不发 `providerId`/`modelId` → server 也不加 `model` 字段到 OpenCode 请求 → OpenCode 用默认
- 用户在 settings 里设的值**不会自动回写到 OpenCode**——只是 request 级别的覆盖
- 改 settings 立即生效(下次 edit 请求就用新值);不需要重启 Jupyter
+68
View File
@@ -0,0 +1,68 @@
import js from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import prettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import jupyterPlugin from '@jupyter/eslint-plugin';
export default defineConfig([
{
ignores: [
'node_modules',
'dist',
'coverage',
'**/*.js',
'**/*.d.ts',
'.venv',
'tests',
'**/__tests__',
'ui-tests'
]
},
js.configs.recommended,
tseslint.configs.recommended,
jupyterPlugin.configs.recommended,
{
files: ['**/*.ts', '**/*.tsx'],
plugins: {
jupyter: jupyterPlugin
},
languageOptions: {
globals: {
...globals.browser,
...globals.es2015,
...globals.node
},
parserOptions: {
project: 'tsconfig.json',
sourceType: 'module'
}
},
rules: {
'@typescript-eslint/naming-convention': [
'error',
{
selector: 'interface',
format: ['PascalCase'],
custom: {
regex: '^I[A-Z]',
match: true
}
}
],
'@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-namespace': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/quotes': [
'error',
'single',
{ avoidEscape: true, allowTemplateLiterals: false }
],
curly: ['error', 'all'],
eqeqeq: 'error',
'prefer-arrow-callback': 'error'
}
},
prettierRecommended
]);
+5
View File
@@ -0,0 +1,5 @@
{
"packageManager": "python",
"packageName": "opencode_bridge",
"uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package opencode_bridge"
}
+28
View File
@@ -0,0 +1,28 @@
const jestJupyterLab = require('@jupyterlab/testutils/lib/jest-config');
const esModules = [
'@codemirror',
'@jupyter/ydoc',
'@jupyterlab/',
'lib0',
'nanoid',
'vscode-ws-jsonrpc',
'y-protocols',
'y-websocket',
'yjs'
].join('|');
const baseConfig = jestJupyterLab(__dirname);
module.exports = {
...baseConfig,
automock: false,
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/.ipynb_checkpoints/*'
],
coverageReporters: ['lcov', 'text'],
testRegex: 'src/.*/.*.spec.ts[x]?$',
transformIgnorePatterns: [`/node_modules/(?!${esModules}).+`]
};
@@ -0,0 +1,7 @@
{
"ServerApp": {
"jpserver_extensions": {
"opencode_bridge": true
}
}
}
+36
View File
@@ -0,0 +1,36 @@
try:
from ._version import __version__
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
import warnings
warnings.warn("Importing 'opencode_bridge' outside a proper installation.")
__version__ = "dev"
from .routes import setup_route_handlers
def _jupyter_labextension_paths():
return [{
"src": "labextension",
"dest": "opencode_bridge"
}]
def _jupyter_server_extension_points():
return [{
"module": "opencode_bridge"
}]
def _load_jupyter_server_extension(server_app):
"""Registers the API handler to receive HTTP requests from the frontend extension.
Parameters
----------
server_app: jupyterlab.labapp.LabApp
JupyterLab application instance
"""
setup_route_handlers(server_app.web_app)
name = "opencode_bridge"
server_app.log.info(f"Registered {name} server extension")
+51
View File
@@ -0,0 +1,51 @@
"""Configuration resolution for the opencode-bridge extension.
Priority order (highest to lowest):
1. jupyter settings dict (from schema/plugin.json)
2. Environment variables (OPENCODE_BRIDGE_URL, _USER, _PASSWORD)
3. Built-in defaults
"""
from __future__ import annotations
import os
from typing import NamedTuple, Optional, Tuple
ENV_URL = "OPENCODE_BRIDGE_URL"
ENV_USER = "OPENCODE_BRIDGE_USER"
ENV_PASSWORD = "OPENCODE_BRIDGE_PASSWORD"
DEFAULT_URL = "http://127.0.0.1:4096"
DEFAULT_USER = "opencode"
DEFAULT_REQUEST_TIMEOUT = 120
class OpenCodeConfig(NamedTuple):
url: str
user: str
password: str
request_timeout_seconds: int = DEFAULT_REQUEST_TIMEOUT
@property
def auth(self) -> Optional[Tuple[str, str]]:
"""Return (user, password) for HTTP Basic Auth, or None if no password set."""
if not self.password:
return None
return (self.user, self.password)
def resolve_config(jupyter_settings: dict) -> OpenCodeConfig:
"""Resolve OpenCode connection config from jupyter settings + env + defaults."""
bridge = jupyter_settings.get("opencode_bridge", {}) or {}
return OpenCodeConfig(
url=bridge.get("opencodeServerUrl") or os.environ.get(ENV_URL) or DEFAULT_URL,
user=bridge.get("opencodeServerUser") or os.environ.get(ENV_USER) or DEFAULT_USER,
password=(
bridge.get("opencodeServerPassword")
or os.environ.get(ENV_PASSWORD)
or ""
),
request_timeout_seconds=int(
bridge.get("requestTimeoutSeconds", DEFAULT_REQUEST_TIMEOUT)
),
)
+106
View File
@@ -0,0 +1,106 @@
"""Async HTTP client for the local OpenCode server."""
import json
from typing import Any, Optional
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
from tornado.httputil import HTTPHeaders
from .config import OpenCodeConfig
class OpenCodeError(Exception):
"""Raised when an OpenCode API request fails."""
class OpenCodeClient:
def __init__(
self,
config: OpenCodeConfig,
http_client: Optional[AsyncHTTPClient] = None,
) -> None:
self._config = config
self._http_client = http_client or AsyncHTTPClient()
async def health(self) -> dict[str, Any]:
return await self._request("GET", "/global/health")
async def list_providers(self) -> list[dict[str, Any]]:
return await self._request("GET", "/config/providers")
async def create_session(self, title: str) -> dict[str, Any]:
return await self._request("POST", "/session", {"title": title})
async def send_message_sync(
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]:
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(
"POST",
"/session/%s/message" % session_id,
body,
)
async def abort(self, session_id: str) -> bool:
result = await self._request("POST", "/session/%s/abort" % session_id)
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
@property
def endpoint(self) -> str:
return self._config.url
async def _request(
self,
method: str,
path: str,
body: Optional[dict[str, Any]] = None,
) -> Optional[Any]:
url = self._config.url + path
headers = HTTPHeaders(
{
"Content-Type": "application/json",
"Accept": "application/json",
}
)
request_kwargs: dict[str, Any] = {
"method": method,
"headers": headers,
"request_timeout": self._config.request_timeout_seconds,
}
if body is not None:
request_kwargs["body"] = json.dumps(body).encode("utf-8")
request = HTTPRequest(url, **request_kwargs)
fetch_kwargs: dict[str, str] = {}
if self._config.auth is not None:
fetch_kwargs["auth_username"] = self._config.auth[0]
fetch_kwargs["auth_password"] = self._config.auth[1]
response = await self._http_client.fetch(request, **fetch_kwargs)
if 200 <= response.code < 300:
if not response.body:
return True
return json.loads(response.body.decode("utf-8"))
if response.code == 404:
return None
response_body = response.body.decode("utf-8") if response.body else ""
raise OpenCodeError(
"OpenCode request %s %s failed with status %s: %s"
% (method, url, response.code, response_body)
)
+263
View File
@@ -0,0 +1,263 @@
import json
import logging
from jupyter_server.base.handlers import APIHandler
from jupyter_server.utils import url_path_join
import tornado
from .config import resolve_config
from .opencode_client import OpenCodeClient, OpenCodeError
from .session_manager import SessionManager
log = logging.getLogger("opencode_bridge.routes")
MODE_SYSTEM_PROMPTS = {
"optimize": (
"你是一个代码优化专家。请基于用户提供的代码上下文,"
"返回只包含优化后代码的回复,不要任何解释或 markdown 围栏。"
),
"fix": (
"你是一个 Python 排错专家。用户给出了一段产生错误的代码和 traceback,"
"请返回只包含修复后代码的回复,不要任何解释或 markdown 围栏。"
),
"edit": (
"你是一个代码编辑助手。基于用户的指令修改给定代码,"
"返回只包含修改后完整代码的回复,不要任何解释或 markdown 围栏。"
),
}
def make_client(handler: APIHandler) -> OpenCodeClient:
"""Factory for OpenCodeClient. Tests monkey-patch this."""
cfg = resolve_config(handler.settings.get("opencode_bridge", {}))
return OpenCodeClient(cfg)
def get_session_manager(handler: APIHandler) -> SessionManager:
"""Return the SessionManager singleton for this web app, creating on first use.
Stored in handler.settings["opencode_bridge_session_manager"] so it survives
across requests but is per-server-instance. Tests monkey-patch this.
"""
sm = handler.settings.get("opencode_bridge_session_manager")
if sm is None:
def client_factory() -> OpenCodeClient:
cfg = resolve_config(handler.settings.get("opencode_bridge", {}))
return OpenCodeClient(cfg)
sm = SessionManager(client_factory)
handler.settings["opencode_bridge_session_manager"] = sm
return sm
def _build_request_body(mode: str, prompt: str, context: dict) -> dict:
"""Build full request body for OpenCode POST /session/:id/message.
Returns dict with 'parts' (list) and 'system' (str) keys.
"""
system = MODE_SYSTEM_PROMPTS[mode]
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 mode == "edit" and prompt:
parts.append({"type": "text", "text": "<instruction>\n%s\n</instruction>" % prompt})
return {"parts": parts, "system": system}
def _strip_code_fence(s: str) -> str:
"""Strip ```language ... ``` fences from LLM output."""
s = s.strip()
if s.startswith("```"):
lines = s.split("\n")
if lines[0].startswith("```"):
lines = lines[1:]
if lines and lines[-1].startswith("```"):
lines = lines[:-1]
return "\n".join(lines).strip()
return s
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
# Jupyter server
@tornado.web.authenticated
def get(self):
self.finish(json.dumps({
"data": (
"Hello, world!"
" This is the '/opencode-bridge/hello' endpoint."
" Try visiting me in your browser!"
),
}))
class HealthHandler(APIHandler):
@tornado.web.authenticated
async def get(self):
try:
client = make_client(self)
data = await client.health()
self.finish(json.dumps({
"ok": data.get("healthy", False),
"version": data.get("version"),
"endpoint": client.endpoint,
}))
except Exception as e:
log.exception("health check failed")
self.set_status(503)
self.finish(json.dumps({
"ok": False,
"error": str(e),
"endpoint": make_client(self).endpoint,
}))
class ProvidersHandler(APIHandler):
@tornado.web.authenticated
async def get(self):
try:
data = await make_client(self).list_providers()
self.finish(json.dumps(data))
except Exception as e:
log.exception("providers list failed")
self.set_status(502)
self.finish(json.dumps({"error": str(e)}))
class EditHandler(APIHandler):
@tornado.web.authenticated
async def post(self):
try:
body = json.loads(self.request.body)
mode = body["mode"]
prompt = body.get("prompt", "")
context = body["context"]
provider_id = body.get("providerId") or None
model_id = body.get("modelId") or None
notebook_path = context.get("notebookPath", "")
except (KeyError, json.JSONDecodeError) as e:
self.set_status(400)
self.finish(json.dumps({"error": "bad request: %s" % e}))
return
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing context.notebookPath"}))
return
client = make_client(self)
sm = get_session_manager(self)
try:
sid = await sm.get_or_create(notebook_path)
request_body = _build_request_body(mode, prompt, context)
result = await client.send_message_sync(
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"
]
final_source = _strip_code_fence("\n".join(text_parts).strip())
self.finish(json.dumps({
"ok": True,
"mode": mode,
"finalSource": final_source,
"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")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
except Exception as e:
log.exception("edit 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):
"""List all active notebook -> session mappings. Debug endpoint."""
@tornado.web.authenticated
def get(self):
sm = get_session_manager(self)
self.finish(json.dumps({"sessions": sm.list_sessions()}))
class SessionReleaseHandler(APIHandler):
"""Release the OpenCode session for a specific notebook.
Query param: notebook=<notebook path, URL-encoded>
"""
@tornado.web.authenticated
async 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)
deleted = await sm.release(notebook_path)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"deleted": deleted,
}))
def setup_route_handlers(web_app):
host_pattern = ".*$"
base_url = web_app.settings["base_url"]
handlers = [
(url_path_join(base_url, "opencode-bridge", "hello"), HelloRouteHandler),
(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", "sessions"), SessionListHandler),
(url_path_join(base_url, "opencode-bridge", "session"), SessionReleaseHandler),
]
web_app.add_handlers(host_pattern, handlers)
+90
View File
@@ -0,0 +1,90 @@
"""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.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Callable
from .opencode_client import OpenCodeClient
log = logging.getLogger("opencode_bridge.session_manager")
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)
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.
"""
existing = self._sessions.get(notebook_path)
if existing is not None:
return existing
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
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:
return False
try:
client = self._client_factory()
return await client.delete_session(sid)
except Exception:
log.warning(
"failed to delete opencode session %s for %s", sid, notebook_path
)
return False
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).
"""
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 list_sessions(self) -> list[dict]:
return [
{"notebookPath": path, "sessionId": sid}
for path, sid in sorted(self._sessions.items())
]
+1
View File
@@ -0,0 +1 @@
"""Python unit tests for opencode_bridge."""
+149
View File
@@ -0,0 +1,149 @@
"""Tests for opencode_bridge.opencode_client."""
import json
from io import BytesIO
import pytest
import tornado.httpclient
import tornado.httputil
from opencode_bridge.config import OpenCodeConfig
from opencode_bridge.opencode_client import OpenCodeClient, OpenCodeError
class MockHTTPClient:
def __init__(self) -> None:
self.calls: list[dict] = []
self.responses: list[tuple[int, object]] = []
async def fetch(self, request, **kwargs) -> tornado.httpclient.HTTPResponse:
self.calls.append({"request": request, "kwargs": kwargs})
status, body = self.responses.pop(0)
buffer = BytesIO(json.dumps(body).encode("utf-8"))
return tornado.httpclient.HTTPResponse(
request=request,
code=status,
headers=tornado.httputil.HTTPHeaders(),
buffer=buffer,
)
@pytest.fixture
def base_config() -> OpenCodeConfig:
return OpenCodeConfig(
url="http://127.0.0.1:4096",
user="opencode",
password="",
request_timeout_seconds=120,
)
def _make_client(config: OpenCodeConfig, responses: list[tuple[int, object]]) -> tuple[OpenCodeClient, MockHTTPClient]:
mock = MockHTTPClient()
mock.responses = responses
return OpenCodeClient(config, http_client=mock), mock
@pytest.mark.asyncio
async def test_health_gets_global_health(base_config: OpenCodeConfig) -> None:
client, mock = _make_client(base_config, [(200, {"status": "ok"})])
result = await client.health()
assert result == {"status": "ok"}
assert len(mock.calls) == 1
call = mock.calls[0]
assert call["request"].url == "http://127.0.0.1:4096/global/health"
assert call["request"].method == "GET"
@pytest.mark.asyncio
async def test_create_session_posts_title(base_config: OpenCodeConfig) -> None:
client, mock = _make_client(base_config, [(200, {"id": "s1"})])
result = await client.create_session("foo")
assert result == {"id": "s1"}
call = mock.calls[0]
assert call["request"].url == "http://127.0.0.1:4096/session"
assert call["request"].method == "POST"
body = json.loads(call["request"].body.decode("utf-8"))
assert body == {"title": "foo"}
@pytest.mark.asyncio
async def test_auth_in_fetch_kwargs_when_password_set(base_config: OpenCodeConfig) -> None:
config = base_config._replace(password="secret")
client, mock = _make_client(config, [(200, {"status": "ok"})])
await client.health()
call = mock.calls[0]
assert call["kwargs"].get("auth_username") == "opencode"
assert call["kwargs"].get("auth_password") == "secret"
@pytest.mark.asyncio
async def test_auth_not_in_fetch_kwargs_when_password_empty(base_config: OpenCodeConfig) -> None:
client, mock = _make_client(base_config, [(200, {"status": "ok"})])
await client.health()
call = mock.calls[0]
assert "auth_username" not in call["kwargs"]
assert "auth_password" not in call["kwargs"]
@pytest.mark.asyncio
async def test_send_message_sync_includes_model_when_provider_and_model_given(
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, provider_id="p1", model_id="m1")
assert result == {"done": True}
call = mock.calls[0]
assert call["request"].url == "http://127.0.0.1:4096/session/s1/message"
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(
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}
call = mock.calls[0]
body = json.loads(call["request"].body.decode("utf-8"))
assert body["parts"] == parts
assert "model" not in body
@pytest.mark.asyncio
async def test_send_message_sync_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="")
mock = MockHTTPClient()
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")
assert len(mock.calls) == 1
body = json.loads(mock.calls[0]["request"].body.decode("utf-8"))
assert body["system"] == "be brief"
assert body["parts"] == [{"type": "text", "text": "hi"}]
@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)])
assert await client.delete_session("s1") is True
assert await client.delete_session("s1") is False
assert len(mock.calls) == 2
assert mock.calls[0]["request"].method == "DELETE"
assert mock.calls[0]["request"].url == "http://127.0.0.1:4096/session/s1"
@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"})])
with pytest.raises(OpenCodeError) as exc_info:
await client.health()
assert "boom" in str(exc_info.value)
+54
View File
@@ -0,0 +1,54 @@
"""Tests for opencode_bridge.config."""
from opencode_bridge.config import OpenCodeConfig, resolve_config
def test_all_defaults() -> None:
config = resolve_config({})
assert config.url == "http://127.0.0.1:4096"
assert config.user == "opencode"
assert config.password == ""
assert config.request_timeout_seconds == 120
def test_env_url_overrides_default(monkeypatch) -> None:
monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
config = resolve_config({})
assert config.url == "http://x:1"
def test_jupyter_settings_override_env(monkeypatch) -> None:
monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
settings = {"opencode_bridge": {"opencodeServerUrl": "http://y:2"}}
config = resolve_config(settings)
assert config.url == "http://y:2"
def test_all_env_vars(monkeypatch) -> None:
monkeypatch.setenv("OPENCODE_BRIDGE_URL", "http://x:1")
monkeypatch.setenv("OPENCODE_BRIDGE_USER", "u")
monkeypatch.setenv("OPENCODE_BRIDGE_PASSWORD", "p")
config = resolve_config({})
assert config.url == "http://x:1"
assert config.user == "u"
assert config.password == "p"
def test_auth_none_when_password_empty() -> None:
config = resolve_config({})
assert config.auth is None
def test_auth_when_password_set() -> None:
config = OpenCodeConfig(
url="http://127.0.0.1:4096",
user="opencode",
password="secret",
)
assert config.auth == ("opencode", "secret")
def test_request_timeout_from_settings() -> None:
settings = {"opencode_bridge": {"requestTimeoutSeconds": 300}}
config = resolve_config(settings)
assert config.request_timeout_seconds == 300
+198
View File
@@ -0,0 +1,198 @@
import json
class FakeOpenCodeClient:
"""Drop-in replacement for OpenCodeClient with recording + canned responses."""
def __init__(self):
self.calls = []
self.session_id = "fake-session-123"
self.health_response = {"healthy": True, "version": "0.1.0"}
self.providers_response = {"providers": [{"id": "anthropic", "models": [{"id": "claude"}]}]}
self.message_response = {
"info": {"id": "msg-1"},
"parts": [{"type": "text", "text": "def foo():\n return 42\n"}],
}
@property
def endpoint(self):
return "http://fake-opencode"
async def health(self):
self.calls.append(("health",))
return self.health_response
async def list_providers(self):
self.calls.append(("list_providers",))
return self.providers_response
async def create_session(self, title):
self.calls.append(("create_session", title))
return {"id": self.session_id, "title": title}
async def send_message_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 delete_session(self, session_id):
self.calls.append(("delete_session", session_id))
return True
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
async def release(self, notebook_path: str) -> bool:
self.calls.append(("release", notebook_path))
return True
def list_sessions(self) -> list:
return [
{"notebookPath": path, "sessionId": sid}
for path, sid in sorted(self._sessions.items())
]
def invalidate(self, notebook_path: str) -> bool:
self.calls.append(("invalidate", notebook_path))
return True
async def test_hello(jp_fetch):
# When
response = await jp_fetch("opencode-bridge", "hello")
# Then
assert response.code == 200
payload = json.loads(response.body)
assert payload == {
"data": (
"Hello, world!"
" This is the '/opencode-bridge/hello' endpoint."
" Try visiting me in your browser!"
),
}
async def test_health_handler(monkeypatch, jp_fetch):
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
response = await jp_fetch("opencode-bridge", "health")
assert response.code == 200
payload = json.loads(response.body)
assert payload["ok"] is True
assert payload["version"] == "0.1.0"
assert ("health",) in fake.calls
async def test_providers_handler(monkeypatch, jp_fetch):
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
response = await jp_fetch("opencode-bridge", "providers")
assert response.code == 200
payload = json.loads(response.body)
assert "providers" in payload
assert payload["providers"][0]["id"] == "anthropic"
assert ("list_providers",) in fake.calls
async def test_edit_handler(monkeypatch, jp_fetch):
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
fake_sm = FakeSessionManager(session_id="fake-session-123")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
)
body = json.dumps({
"mode": "edit",
"prompt": "Add type hints",
"context": {
"notebookPath": "test.ipynb",
"cellId": "cell-1",
"language": "python",
"cellIndex": 0,
"totalCells": 1,
"source": "def foo(): return 42\n",
"previousCode": None,
"error": None,
},
})
response = await jp_fetch(
"opencode-bridge", "edit",
method="POST",
body=body,
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["ok"] is True
assert payload["finalSource"] == "def foo():\n return 42"
assert payload["sessionId"] == "fake-session-123"
assert payload["notebookPath"] == "test.ipynb"
# 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
# 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
assert "你是一个代码编辑助手" in send_call[3]
# SessionManager.get_or_create was called with the notebook path
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"
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
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
)
response = await jp_fetch("opencode-bridge", "sessions")
assert response.code == 200
payload = json.loads(response.body)
assert "sessions" in payload
paths = {s["notebookPath"] for s in payload["sessions"]}
assert paths == {"foo.ipynb", "bar.ipynb"}
async def test_session_release_handler(monkeypatch, jp_fetch):
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
fake_sm = FakeSessionManager()
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
)
response = await jp_fetch(
"opencode-bridge", "session",
method="DELETE",
params={"notebook": "foo.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload["ok"] is True
assert payload["notebookPath"] == "foo.ipynb"
assert payload["deleted"] is True
assert ("release", "foo.ipynb") in fake_sm.calls
@@ -0,0 +1,135 @@
"""Unit tests for SessionManager — no Jupyter server fixture."""
from __future__ import annotations
import pytest
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:
self._session_id = session_id
self.calls: list = []
async def create_session(self, title: str) -> dict:
self.calls.append(("create_session", title))
return {"id": self._session_id, "title": title}
async def delete_session(self, session_id: str) -> bool:
self.calls.append(("delete_session", session_id))
return True
@pytest.mark.asyncio
async def test_get_or_create_first_call_creates_session():
client = FakeClient()
sm = SessionManager(lambda: client)
sid = await sm.get_or_create("foo.ipynb")
assert sid == "sid-from-fake"
assert sm.has_session("foo.ipynb")
assert client.calls == [("create_session", "jupyter:foo.ipynb")]
@pytest.mark.asyncio
async def test_get_or_create_second_call_returns_same_sid():
client = FakeClient()
sm = SessionManager(lambda: client)
sid1 = await sm.get_or_create("foo.ipynb")
sid2 = await sm.get_or_create("foo.ipynb")
assert sid1 == sid2
# Only ONE create_session call total
create_calls = [c for c in client.calls if c[0] == "create_session"]
assert len(create_calls) == 1
@pytest.mark.asyncio
async def test_different_notebooks_get_different_sessions():
client1 = FakeClient(session_id="sid-1")
client2 = FakeClient(session_id="sid-2")
factories = [client1, client2]
sm = SessionManager(lambda: factories.pop(0) if factories else client1)
sid1 = await sm.get_or_create("foo.ipynb")
sid2 = await sm.get_or_create("bar.ipynb")
assert sid1 == "sid-1"
assert sid2 == "sid-2"
@pytest.mark.asyncio
async def test_release_returns_true_and_clears_session():
client = FakeClient()
sm = SessionManager(lambda: client)
await sm.get_or_create("foo.ipynb")
deleted = await sm.release("foo.ipynb")
assert deleted is True
assert not sm.has_session("foo.ipynb")
assert ("delete_session", "sid-from-fake") in client.calls
@pytest.mark.asyncio
async def test_release_returns_false_when_no_session():
client = FakeClient()
sm = SessionManager(lambda: client)
deleted = await sm.release("never-existed.ipynb")
assert deleted is False
assert client.calls == []
@pytest.mark.asyncio
async def test_get_or_create_after_release_creates_new_session():
client = FakeClient()
sm = SessionManager(lambda: client)
sid1 = await sm.get_or_create("foo.ipynb")
await sm.release("foo.ipynb")
sid2 = await sm.get_or_create("foo.ipynb")
# Same fake client returns same ID, but two create_session calls happened
assert sid1 == sid2
create_calls = [c for c in client.calls if c[0] == "create_session"]
assert len(create_calls) == 2
@pytest.mark.asyncio
async def test_invalidate_drops_cached_sid_without_calling_opencode():
client = FakeClient()
sm = SessionManager(lambda: client)
await sm.get_or_create("foo.ipynb")
calls_before = len(client.calls)
removed = sm.invalidate("foo.ipynb")
assert removed is True
assert not sm.has_session("foo.ipynb")
# No additional calls to client
assert len(client.calls) == calls_before
def test_list_sessions_empty():
sm = SessionManager(lambda: FakeClient())
assert sm.list_sessions() == []
@pytest.mark.asyncio
async def test_list_sessions_returns_all_mappings():
client = FakeClient()
sm = SessionManager(lambda: client)
await sm.get_or_create("a.ipynb")
# Manually inject a second to test listing
sm._sessions["b.ipynb"] = "sid-b"
listing = sm.list_sessions()
paths = {s["notebookPath"] for s in listing}
assert paths == {"a.ipynb", "b.ipynb"}
@pytest.mark.asyncio
async def test_concurrent_get_or_create_does_not_double_create():
"""Two concurrent calls for the same path must share one session."""
import asyncio
client = FakeClient()
sm = SessionManager(lambda: client)
sids = await asyncio.gather(
sm.get_or_create("foo.ipynb"),
sm.get_or_create("foo.ipynb"),
sm.get_or_create("foo.ipynb"),
)
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"
+159
View File
@@ -0,0 +1,159 @@
{
"name": "opencode_bridge",
"version": "0.1.0",
"description": "A JupyterLab extension.",
"keywords": [
"jupyter",
"jupyterlab",
"jupyterlab-extension"
],
"homepage": "http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git",
"bugs": {
"url": "http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git/issues"
},
"license": "BSD-3-Clause",
"author": "taochen",
"files": [
"lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}",
"style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}",
"src/**/*.{ts,tsx}",
"schema/*.json"
],
"main": "lib/index.js",
"types": "lib/index.d.ts",
"style": "style/index.css",
"repository": {
"type": "git",
"url": "http://101.43.40.124:3000/tao.chen/notebook-ai-extension.git"
},
"scripts": {
"build": "jlpm build:lib && jlpm build:labextension:dev",
"build:prod": "jlpm clean && jlpm build:lib:prod && jlpm build:labextension",
"build:labextension": "jupyter-builder build .",
"build:labextension:dev": "jupyter-builder build --development True .",
"build:lib": "tsc --sourceMap",
"build:lib:prod": "tsc",
"clean": "jlpm clean:lib",
"clean:lib": "rimraf lib tsconfig.tsbuildinfo",
"clean:lintcache": "rimraf .eslintcache .stylelintcache",
"clean:labextension": "rimraf opencode_bridge/labextension opencode_bridge/_version.py",
"clean:all": "jlpm clean:lib && jlpm clean:labextension && jlpm clean:lintcache",
"eslint": "jlpm eslint:check --fix",
"eslint:check": "eslint . --cache",
"install:extension": "jlpm build",
"lint": "jlpm stylelint && jlpm prettier && jlpm eslint",
"lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check",
"prettier": "jlpm prettier:base --write --list-different",
"prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"",
"prettier:check": "jlpm prettier:base --check",
"stylelint": "jlpm stylelint:check --fix",
"stylelint:check": "stylelint --cache \"style/**/*.css\"",
"test": "jest --coverage",
"watch": "run-p watch:src watch:labextension",
"watch:src": "tsc -w --sourceMap",
"watch:labextension": "jupyter-builder watch ."
},
"dependencies": {
"@jupyterlab/application": "^4.0.0",
"@jupyterlab/apputils": "^4.0.0",
"@jupyterlab/cells": "^4.0.0",
"@jupyterlab/coreutils": "^6.0.0",
"@jupyterlab/notebook": "^4.0.0",
"@jupyterlab/services": "^7.0.0",
"@jupyterlab/settingregistry": "^4.0.0",
"@lumino/widgets": "^2.0.0"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@jupyter/builder": "^1.0.0",
"@jupyter/eslint-plugin": "^0.0.5",
"@jupyterlab/core-meta": "^4.6.0-beta.0",
"@jupyterlab/testing": "^4.6.1",
"@jupyterlab/testutils": "^4.0.0",
"@module-federation/runtime-tools": "^2.0.0",
"@types/jest": "^29.2.0",
"@types/json-schema": "^7.0.11",
"@types/react": "^18.0.26",
"@types/react-addons-linked-state-mixin": "^0.14.22",
"css-loader": "^6.7.1",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"globals": "^15.0.0",
"jest": "^29.2.0",
"jest-junit": "^17.0.0",
"mkdirp": "^1.0.3",
"npm-run-all2": "^7.0.1",
"prettier": "^3.0.0",
"rimraf": "^5.0.1",
"source-map-loader": "^1.0.2",
"style-loader": "^3.3.1",
"stylelint": "^15.10.1",
"stylelint-config-recommended": "^13.0.0",
"stylelint-config-standard": "^34.0.0",
"stylelint-csstree-validator": "^3.0.0",
"stylelint-prettier": "^4.0.0",
"ts-jest": "^29.4.11",
"typescript": "~5.5.4",
"typescript-eslint": "^8.0.0",
"yjs": "^13.5.0"
},
"resolutions": {
"lib0": "0.2.111",
"webpack": "5.106.0"
},
"sideEffects": [
"style/*.css",
"style/index.js"
],
"styleModule": "style/index.js",
"publishConfig": {
"access": "public"
},
"jupyterlab": {
"discovery": {
"server": {
"managers": [
"pip"
],
"base": {
"name": "opencode_bridge"
}
}
},
"extension": true,
"outputDir": "opencode_bridge/labextension",
"schemaDir": "schema"
},
"prettier": {
"singleQuote": true,
"trailingComma": "none",
"arrowParens": "avoid",
"endOfLine": "auto",
"overrides": [
{
"files": "package.json",
"options": {
"tabWidth": 4
}
}
]
},
"stylelint": {
"extends": [
"stylelint-config-recommended",
"stylelint-config-standard",
"stylelint-prettier/recommended"
],
"plugins": [
"stylelint-csstree-validator"
],
"rules": {
"csstree/validator": true,
"property-no-vendor-prefix": null,
"selector-class-pattern": "^([a-z][A-z\\d]*)(-[A-z\\d]+)*$",
"selector-no-vendor-prefix": null,
"value-no-vendor-prefix": null
}
}
}
+9965
View File
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
[build-system]
requires = ["hatchling>=1.5.0", "hatch-nodejs-version>=0.3.2", "jupyter-builder>=1.0.0,<2"]
build-backend = "hatchling.build"
[project]
name = "opencode_bridge"
readme = "README.md"
license = { file = "LICENSE" }
requires-python = ">=3.10"
classifiers = [
"Framework :: Jupyter",
"Framework :: Jupyter :: JupyterLab",
"Framework :: Jupyter :: JupyterLab :: 4",
"Framework :: Jupyter :: JupyterLab :: Extensions",
"Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt",
"License :: OSI Approved :: BSD License",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = [
"jupyter_server>=2.4.0,<3",
"jupyterlab>=4.6.2",
]
dynamic = ["version", "description", "authors", "urls", "keywords"]
[project.optional-dependencies]
dev = [
"jupyterlab>=4",
"jupyter-builder>=1.0.0",
]
test = [
"coverage",
"pytest",
"pytest-asyncio",
"pytest-cov",
"pytest-jupyter[server]>=0.6.0"
]
[[tool.uv.index]]
url = "https://pypi.tuna.tsinghua.edu.cn/simple/"
default = true
[tool.hatch.version]
source = "nodejs"
[tool.hatch.metadata.hooks.nodejs]
fields = ["description", "authors", "urls", "keywords"]
[tool.hatch.build.targets.sdist]
artifacts = ["opencode_bridge/labextension"]
exclude = [".github", "binder"]
[tool.hatch.build.targets.wheel.shared-data]
"opencode_bridge/labextension" = "share/jupyter/labextensions/opencode_bridge"
"install.json" = "share/jupyter/labextensions/opencode_bridge/install.json"
"jupyter-config/server-config" = "etc/jupyter/jupyter_server_config.d"
[tool.hatch.build.hooks.version]
path = "opencode_bridge/_version.py"
[tool.hatch.build.hooks.jupyter-builder]
dependencies = ["hatch-jupyter-builder>=0.5"]
build-function = "hatch_jupyter_builder.npm_builder"
ensured-targets = [
"opencode_bridge/labextension/static/style.js",
"opencode_bridge/labextension/package.json",
]
skip-if-exists = ["opencode_bridge/labextension/static/style.js"]
[tool.hatch.build.hooks.jupyter-builder.build-kwargs]
build_cmd = "build:prod"
npm = ["jlpm"]
[tool.hatch.build.hooks.jupyter-builder.editable-build-kwargs]
build_cmd = "install:extension"
npm = ["jlpm"]
source_dir = "src"
build_dir = "opencode_bridge/labextension"
[tool.jupyter-releaser.options]
version_cmd = "hatch version"
[tool.jupyter-releaser.hooks]
before-build-npm = [
"python -m pip install 'jupyter-builder>=1.0.0,<2'",
"jlpm",
"jlpm build:prod"
]
before-build-python = ["jlpm clean:all"]
[tool.check-wheel-contents]
ignore = ["W002"]
+46
View File
@@ -0,0 +1,46 @@
{
"jupyter.lab.shortcuts": [],
"title": "opencode_bridge",
"description": "opencode_bridge settings.",
"type": "object",
"properties": {
"opencodeServerUrl": {
"type": "string",
"title": "OpenCode Server URL",
"description": "opencode serve 监听的 HTTP 地址。覆盖 OPENCODE_BRIDGE_URL 环境变量。",
"default": "http://127.0.0.1:4096"
},
"opencodeServerUser": {
"type": "string",
"title": "OpenCode Server 用户名",
"description": "HTTP Basic Auth 用户名。默认 'opencode'。可被 OPENCODE_BRIDGE_USER 环境变量覆盖。",
"default": "opencode"
},
"opencodeServerPassword": {
"type": "string",
"title": "OpenCode Server 密码",
"description": "HTTP Basic Auth 密码。空字符串表示无认证。可被 OPENCODE_BRIDGE_PASSWORD 环境变量覆盖。",
"default": ""
},
"opencodeProvider": {
"type": "string",
"title": "OpenCode Provider",
"description": "Provider id (e.g. 'anthropic' or 'openai'). Get available values from the browser console after activating this extension (logged on startup), or hit GET /opencode-bridge/providers directly. Leave empty to use OpenCode's default.",
"default": ""
},
"opencodeModel": {
"type": "string",
"title": "OpenCode Model",
"description": "Model id (e.g. 'claude-sonnet-4-20250514'). Get available values from the browser console after activation. Leave empty to use the provider's default.",
"default": ""
},
"requestTimeoutSeconds": {
"type": "integer",
"title": "请求超时(秒)",
"default": 120,
"minimum": 5,
"maximum": 600
}
},
"additionalProperties": false
}
+1
View File
@@ -0,0 +1 @@
__import__("setuptools").setup()
+154
View File
@@ -0,0 +1,154 @@
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();
});
});
+33
View File
@@ -0,0 +1,33 @@
import { DEFAULT_OPENCODE_SETTINGS, readOpenCodeSettings } from '../types';
describe('opencode_bridge types', () => {
it('returns defaults when given empty composite', () => {
const s = readOpenCodeSettings({});
expect(s).toEqual(DEFAULT_OPENCODE_SETTINGS);
expect(s.opencodeProvider).toBe('');
expect(s.opencodeModel).toBe('');
});
it('overrides defaults with user values', () => {
const s = readOpenCodeSettings({
opencodeServerUrl: 'http://x:1',
opencodeProvider: 'anthropic',
opencodeModel: 'claude-sonnet-4-20250514'
});
expect(s.opencodeServerUrl).toBe('http://x:1');
expect(s.opencodeProvider).toBe('anthropic');
expect(s.opencodeModel).toBe('claude-sonnet-4-20250514');
expect(s.opencodeServerUser).toBe(
DEFAULT_OPENCODE_SETTINGS.opencodeServerUser
);
});
it('treats empty string provider/model as "use default"', () => {
const s = readOpenCodeSettings({
opencodeProvider: '',
opencodeModel: ''
});
expect(s.opencodeProvider).toBe('');
expect(s.opencodeModel).toBe('');
});
});
+156
View File
@@ -0,0 +1,156 @@
/**
* Unit tests for OpenCodeCellFooter DOM rendering and button disabled states.
*
* Mocks @jupyterlab/cells, @jupyterlab/apputils, and @lumino/widgets at the
* test boundary so the real ESM packages are not loaded (Jest + pnpm can't
* transform the hoisted @jupyterlab/* packages out of the box).
*/
// Mock the heavy JupyterLab modules BEFORE importing the component.
// (jest.mock is hoisted by Jest, but listing it before imports is clearer.)
jest.mock('@jupyterlab/cells', () => ({
CodeCell: class CodeCell {}
}));
jest.mock('@jupyterlab/apputils', () => ({
Notification: {
info: jest.fn(),
error: jest.fn(),
success: jest.fn(),
warning: jest.fn()
}
}));
jest.mock('@lumino/widgets', () => {
class Widget {
public node: HTMLElement = document.createElement('div');
public addClass(cls: string): void {
this.node.classList.add(cls);
}
public id: string = '';
public parent: Widget | null = null;
protected onAfterAttach(_msg: unknown): void {
/* overridden by subclass */
}
protected onBeforeDetach(_msg: unknown): void {
/* overridden by subclass */
}
}
return { Widget };
});
import { OpenCodeCellFooter } from '../components/opencode_cell_footer';
import { CodeCell } from '@jupyterlab/cells';
function makeFakeOutputs(items: any[]): any {
return {
get length() {
return items.length;
},
get(i: number) {
return items[i];
}
};
}
function makeFakeCell(
source: string,
errorOutputs: any[] = [],
notebookPath = 'foo.ipynb',
cellIndex = 0,
totalCells = 1
): CodeCell {
const model: any = {
id: 'cell-test',
type: 'code',
sharedModel: {
getSource: () => source
},
outputs: makeFakeOutputs(errorOutputs),
// Signal stubs so the component can connect/disconnect without crashing.
contentChanged: { connect: jest.fn(), disconnect: jest.fn() },
stateChanged: { connect: jest.fn(), disconnect: jest.fn() }
};
const cell = new (CodeCell as unknown as new () => CodeCell)();
(cell as any).model = model;
// Build a fake parent chain: cell -> notebook -> panel.
const notebook: any = { widgets: [] as CodeCell[] };
const panel: any = {
context: { path: notebookPath },
content: notebook
};
for (let i = 0; i < cellIndex; i++) {
notebook.widgets.push({ model: { sharedModel: { getSource: () => '' } } } as any);
}
notebook.widgets.push(cell);
for (let i = cellIndex + 1; i < totalCells; i++) {
notebook.widgets.push({ model: { sharedModel: { getSource: () => '' } } } as any);
}
// The footer's helper walks `cell.parent` looking for a widget with
// `context` + `content.widgets`. The cell's grandparent (notebook) is what
// matches in real JupyterLab. We mirror that here.
Object.defineProperty(cell, 'parent', { value: notebook, configurable: true });
Object.defineProperty(notebook, 'parent', { value: panel, configurable: true });
return cell as CodeCell;
}
describe('OpenCodeCellFooter', () => {
it('renders 3 buttons', () => {
const footer = new OpenCodeCellFooter();
const btns = footer.node.querySelectorAll('button');
expect(btns.length).toBe(3);
expect(btns[0].textContent).toBe('✨ 优化');
expect(btns[1].textContent).toBe('🐛 排错');
expect(btns[2].textContent).toBe('🪄 编辑');
});
it('disables all buttons when no cell is attached', () => {
const footer = new OpenCodeCellFooter();
const btns = footer.node.querySelectorAll('button');
btns.forEach((b: HTMLButtonElement) => {
expect(b.disabled).toBe(true);
});
});
it('enables optimize and edit when a cell is attached; fix stays disabled without an error', () => {
const cell = makeFakeCell('x = 1', [], 'foo.ipynb', 0, 1);
const footer = new OpenCodeCellFooter();
Object.defineProperty(footer, 'parent', { value: cell, configurable: true });
(footer as any).onAfterAttach({} as any);
const btns = footer.node.querySelectorAll('button');
const optimize = btns[0] as HTMLButtonElement;
const fix = btns[1] as HTMLButtonElement;
const edit = btns[2] as HTMLButtonElement;
expect(optimize.disabled).toBe(false);
expect(fix.disabled).toBe(true);
expect(edit.disabled).toBe(false);
});
it('enables the fix button when the attached cell has an error output', () => {
const cell = makeFakeCell(
'1/0',
[
{
type: 'error',
ename: 'ZeroDivisionError',
evalue: 'div by zero',
traceback: []
}
],
'foo.ipynb',
0,
1
);
const footer = new OpenCodeCellFooter();
Object.defineProperty(footer, 'parent', { value: cell, configurable: true });
(footer as any).onAfterAttach({} as any);
const btns = footer.node.querySelectorAll('button');
const fix = btns[1] as HTMLButtonElement;
expect(fix.disabled).toBe(false);
});
});
+223
View File
@@ -0,0 +1,223 @@
import { ServerConnection } from '@jupyterlab/services';
import { callOpenCodeEdit, callOpenCodeProviders } from '../api/opencode_client';
import type { OpenCodeRequest } from '../types';
jest.mock('@jupyterlab/services', () => {
const actual = jest.requireActual('@jupyterlab/services');
return {
...actual,
ServerConnection: {
...actual.ServerConnection,
makeRequest: jest.fn(),
},
};
});
const mockedMakeRequest = ServerConnection.makeRequest as jest.MockedFunction<
typeof ServerConnection.makeRequest
>;
function mockServerSettings(): ServerConnection.ISettings {
return {
baseUrl: 'http://localhost:8888',
wsUrl: 'ws://localhost:8888',
token: 'test',
init: { headers: { 'X-Test': '1' } },
fetch: {} as unknown as typeof fetch,
RequestClass: {} as unknown as typeof Request,
Headers: {} as unknown as typeof Headers,
appendToken: false,
pageUrl: '',
settings: {} as unknown as ServerConnection.ISettings,
displayName: 'JupyterLab',
handleError: () => undefined,
requestHeaders: {},
wsHeaders: {},
userSettings: {},
} as unknown as ServerConnection.ISettings;
}
function mockResponse(overrides: {
ok: boolean;
status: number;
statusText: string;
text: string;
}): Response {
return {
ok: overrides.ok,
status: overrides.status,
statusText: overrides.statusText,
text: async () => overrides.text,
headers: {} as unknown as Headers,
json: async () => JSON.parse(overrides.text),
url: '',
redirected: false,
type: 'basic',
body: null,
bodyUsed: false,
arrayBuffer: async () => new ArrayBuffer(0),
blob: async () => new Blob(),
formData: async () => new FormData(),
clone: () => mockResponse(overrides),
} as unknown as Response;
}
const sampleRequest: OpenCodeRequest = {
mode: 'edit',
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,
},
};
describe('callOpenCodeEdit', () => {
beforeEach(() => {
mockedMakeRequest.mockReset();
});
it('POSTs to /opencode-bridge/edit with JSON body', async () => {
const respBody = {
ok: true,
mode: 'edit',
finalSource: 'def foo(x: int) -> int: return x',
sessionId: 'sid',
notebookPath: 'foo.ipynb',
};
mockedMakeRequest.mockResolvedValue(
mockResponse({ ok: true, status: 200, statusText: 'OK', text: JSON.stringify(respBody) })
);
const settings = mockServerSettings();
const resp = await callOpenCodeEdit(sampleRequest, settings);
expect(mockedMakeRequest).toHaveBeenCalledWith(
'http://localhost:8888/opencode-bridge/edit',
expect.objectContaining({
method: 'POST',
body: JSON.stringify(sampleRequest),
headers: { 'Content-Type': 'application/json' },
}),
settings
);
expect(resp.ok).toBe(true);
if (resp.ok) {
expect(resp.finalSource).toContain('int');
}
});
it('returns failure response when server returns ok: false', async () => {
mockedMakeRequest.mockResolvedValue(
mockResponse({
ok: false,
status: 502,
statusText: 'Bad Gateway',
text: JSON.stringify({ ok: false, error: 'opencode down' }),
})
);
const resp = await callOpenCodeEdit(sampleRequest, mockServerSettings());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error).toBe('opencode down');
}
});
it('throws on network error', async () => {
mockedMakeRequest.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(
callOpenCodeEdit(sampleRequest, mockServerSettings())
).rejects.toThrow(/network error/);
});
it('throws on empty response body', async () => {
mockedMakeRequest.mockResolvedValue(
mockResponse({ ok: true, status: 200, statusText: 'OK', text: '' })
);
await expect(
callOpenCodeEdit(sampleRequest, mockServerSettings())
).rejects.toThrow(/empty response/);
});
});
describe('callOpenCodeProviders', () => {
beforeEach(() => {
mockedMakeRequest.mockReset();
});
it('GETs /opencode-bridge/providers and returns parsed JSON', async () => {
const respBody = {
providers: [
{ id: 'anthropic', models: [{ id: 'claude-sonnet-4-20250514' }] },
{ id: 'openai', models: [{ id: 'gpt-4' }, { id: 'gpt-3.5-turbo' }] }
]
};
mockedMakeRequest.mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
text: async () => JSON.stringify(respBody),
json: async () => respBody,
headers: {} as any,
url: '',
redirected: false,
type: 'basic',
body: null,
bodyUsed: false,
arrayBuffer: async () => new ArrayBuffer(0),
blob: async () => new Blob(),
formData: async () => new FormData(),
clone: function () { return this; }
} as any);
const settings = mockServerSettings();
const resp = await callOpenCodeProviders(settings);
expect(mockedMakeRequest).toHaveBeenCalledWith(
'http://localhost:8888/opencode-bridge/providers',
expect.objectContaining({ method: 'GET' }),
settings
);
expect(resp.providers).toHaveLength(2);
expect(resp.providers[0].id).toBe('anthropic');
expect(resp.providers[1].models).toHaveLength(2);
});
it('throws on non-2xx response', async () => {
mockedMakeRequest.mockResolvedValue({
ok: false,
status: 502,
statusText: 'Bad Gateway',
text: async () => '',
json: async () => null,
headers: {} as any,
url: '',
redirected: false,
type: 'basic',
body: null,
bodyUsed: false,
arrayBuffer: async () => new ArrayBuffer(0),
blob: async () => new Blob(),
formData: async () => new FormData(),
clone: function () { return this; }
} as any);
await expect(
callOpenCodeProviders(mockServerSettings())
).rejects.toThrow(/502/);
});
it('throws on network error', async () => {
mockedMakeRequest.mockRejectedValue(new Error('ECONNREFUSED'));
await expect(
callOpenCodeProviders(mockServerSettings())
).rejects.toThrow(/network error/);
});
});
+86
View File
@@ -0,0 +1,86 @@
/**
* HTTP client for the opencode-bridge server extension.
*/
import { URLExt } from '@jupyterlab/coreutils';
import { ServerConnection } from '@jupyterlab/services';
import type { OpenCodeProvidersResponse, OpenCodeRequest, OpenCodeResponse } from '../types';
/**
* Call POST /opencode-bridge/edit. Returns parsed response (success or failure).
* Throws on network error or non-JSON response.
*/
export async function callOpenCodeEdit(
request: OpenCodeRequest,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeResponse> {
const url = URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'edit'
);
const init: RequestInit = {
method: 'POST',
body: JSON.stringify(request),
headers: { 'Content-Type': 'application/json' },
};
let response: Response;
try {
response = await ServerConnection.makeRequest(url, init, serverSettings);
} catch (error) {
throw new Error(
`network error calling opencode-bridge: ${(error as Error).message}`
);
}
const text = await response.text();
if (!text) {
throw new Error(
`empty response from opencode-bridge (status ${response.status})`
);
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (e) {
throw new Error(
`non-JSON response from opencode-bridge (status ${response.status})`
);
}
return parsed as OpenCodeResponse;
}
/**
* Call GET /opencode-bridge/providers. Returns the parsed JSON response.
* Throws on network error or non-2xx status.
*/
export async function callOpenCodeProviders(
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeProvidersResponse> {
const url = URLExt.join(
serverSettings.baseUrl,
'opencode-bridge',
'providers'
);
let response: Response;
try {
response = await ServerConnection.makeRequest(url, { method: 'GET' }, serverSettings);
} catch (error) {
throw new Error(
`network error calling opencode-bridge/providers: ${(error as Error).message}`
);
}
if (!response.ok) {
throw new Error(
`opencode-bridge/providers failed: ${response.status} ${response.statusText}`
);
}
return (await response.json()) as OpenCodeProvidersResponse;
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Custom Cell.ContentFactory that adds our OpenCodeCellFooter to every cell.
*
* One factory instance per notebook; install via `installOpenCodeInNotebook`.
*/
import { ICellFooter } from '@jupyterlab/cells';
import { Notebook } from '@jupyterlab/notebook';
import { OpenCodeCellFooter } from './opencode_cell_footer';
export class OpenCodeCellContentFactory extends Notebook.ContentFactory {
createCellFooter(): ICellFooter {
return new OpenCodeCellFooter();
}
}
+199
View File
@@ -0,0 +1,199 @@
/**
* Per-cell footer widget: renders 3 buttons (optimize / fix / edit) and
* wires them to the opencode-bridge server.
*
* Resolves its owning CodeCell at onAfterAttach via `this.parent`.
* Subscribes to the cell model's contentChanged and stateChanged signals
* to keep the context fresh.
*/
import { 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 } from '../api/opencode_client';
import { extractCellContext } from '../context/cell_context';
import type {
CellContext,
OpenCodeMode,
OpenCodeRequest,
OpenCodeResponse,
OpenCodeSettings,
} from '../types';
// Module-level runtime injection (set by index.ts after settings load).
let _settings: OpenCodeSettings | null = null;
let _serverSettings: ServerConnection.ISettings | null = null;
export function setOpenCodeRuntime(args: {
settings: OpenCodeSettings;
serverSettings: ServerConnection.ISettings;
}): void {
_settings = args.settings;
_serverSettings = args.serverSettings;
}
type Status = 'idle' | 'loading' | 'error';
export class OpenCodeCellFooter extends Widget {
private _cell: CodeCell | null = null;
private _context: CellContext | null = null;
private _status: Status = 'idle';
private _errorMessage: string | null = null;
constructor() {
super();
this.addClass('opencode-cell-footer');
this._render();
}
protected onAfterAttach(_msg: unknown): void {
const parent = this.parent;
if (parent instanceof CodeCell) {
this._cell = parent;
this._cell.model.contentChanged.connect(this._onModelChange, this);
this._cell.model.stateChanged.connect(this._onModelChange, this);
this._onModelChange();
}
}
protected onBeforeDetach(_msg: unknown): void {
if (this._cell) {
this._cell.model.contentChanged.disconnect(this._onModelChange, this);
this._cell.model.stateChanged.disconnect(this._onModelChange, this);
}
this._cell = null;
}
private _onModelChange(): void {
this._context = this._cell ? extractCellContextFromCell(this._cell) : null;
this._render();
}
private _render(): void {
const node = this.node;
node.textContent = '';
const hasError = this._context?.error != null;
const loading = this._status === 'loading';
const baseDisabled = !this._context || loading;
const mkBtn = (
label: string,
mode: OpenCodeMode,
disabled: boolean
): HTMLButtonElement => {
const b = document.createElement('button');
b.className = `opencode-btn opencode-btn-${mode}`;
b.textContent = label;
b.disabled = disabled;
b.title = disabled
? loading
? 'OpenCode: 请求中…'
: 'OpenCode: 等待 cell 上下文…'
: label;
b.addEventListener('click', () => {
void this._onClick(mode);
});
return b;
};
node.appendChild(mkBtn('✨ 优化', 'optimize', baseDisabled));
node.appendChild(mkBtn('🐛 排错', 'fix', baseDisabled || !hasError));
node.appendChild(mkBtn('🪄 编辑', 'edit', baseDisabled));
if (this._status === 'error' && this._errorMessage) {
const err = document.createElement('span');
err.className = 'opencode-error';
err.textContent = this._errorMessage;
err.title = this._errorMessage;
node.appendChild(err);
}
}
private async _onClick(mode: OpenCodeMode): Promise<void> {
if (!this._context) {
return;
}
if (!_settings || !_serverSettings) {
Notification.error('OpenCode 运行时未初始化,请检查 settings');
return;
}
let prompt = '';
if (mode === 'edit') {
const input = window.prompt('请输入编辑指令:');
if (input === null) {
return;
}
prompt = input;
}
const request: OpenCodeRequest = {
mode,
prompt,
context: this._context,
providerId: _settings.opencodeProvider || undefined,
modelId: _settings.opencodeModel || undefined
};
this._status = 'loading';
this._errorMessage = null;
this._render();
try {
const resp = await callOpenCodeEdit(request, _serverSettings);
this._handleResponse(resp);
} catch (e) {
this._status = 'error';
this._errorMessage = (e as Error).message;
this._render();
Notification.error(`OpenCode ${mode} 失败: ${this._errorMessage}`);
}
}
private _handleResponse(resp: OpenCodeResponse): void {
if (resp.ok) {
this._status = 'idle';
this._render();
const len = resp.finalSource.length;
Notification.info(
`OpenCode ${resp.mode} 完成 (${len} chars). ` +
`Diff 面板将在 Slice 4 中提供。Session: ${resp.sessionId.slice(0, 8)}`
);
} else {
this._status = 'error';
this._errorMessage = resp.error;
this._render();
Notification.error(`OpenCode 错误: ${resp.error}`);
}
}
}
/**
* Resolve a CodeCell's parent NotebookPanel by walking the parent chain,
* then build the CellContext. The footer uses this rather than `Widget.findParent`
* because that helper was removed in @lumino/widgets 2.x.
*/
function extractCellContextFromCell(cell: CodeCell): CellContext | null {
let node: Widget | null = cell.parent;
let notebookPanel: NotebookPanel | null = null;
while (node) {
// Use duck-typing: a notebook panel has `context.path` and `content.widgets`.
const candidate = node as any;
if (
candidate.context &&
candidate.content &&
Array.isArray(candidate.content.widgets)
) {
notebookPanel = candidate;
break;
}
node = node.parent;
}
if (!notebookPanel) {
return null;
}
return extractCellContext(cell, notebookPanel);
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Install the OpenCode cell factory on every notebook tracked by INotebookTracker.
*
* For already-open notebooks: swap their contentFactory in place. Cells created
* after this point will use the new factory. (Cells already created retain their
* old factory — that's a known limitation; users must reload the notebook to see
* the footer in cells that were created before activation.)
*
* For future notebooks: listen to `tracker.widgetAdded` and swap on addition.
*/
import type { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
import { OpenCodeCellContentFactory } from './opencode_cell_factory';
export function installOpenCodeInNotebook(panel: NotebookPanel): void {
if (panel.content.contentFactory instanceof OpenCodeCellContentFactory) {
return; // already installed
}
const existing = panel.content.contentFactory;
const editorFactory = (existing as any).editorFactory;
const factory = new OpenCodeCellContentFactory({ editorFactory });
// contentFactory is readonly in the typings but writable at runtime.
(panel.content as any).contentFactory = factory;
}
export function installOpenCodeEverywhere(tracker: INotebookTracker): void {
// Patch already-open notebooks.
tracker.forEach(panel => {
installOpenCodeInNotebook(panel);
});
// Patch future notebooks.
tracker.widgetAdded.connect((_, panel) => {
installOpenCodeInNotebook(panel);
});
}
+81
View File
@@ -0,0 +1,81 @@
/**
* 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,
};
}
+81
View File
@@ -0,0 +1,81 @@
import {
JupyterFrontEnd,
JupyterFrontEndPlugin
} from '@jupyterlab/application';
import { INotebookTracker } from '@jupyterlab/notebook';
import { ISettingRegistry } from '@jupyterlab/settingregistry';
import { installOpenCodeEverywhere } from './components/opencode_installer';
import { setOpenCodeRuntime } from './components/opencode_cell_footer';
import { requestAPI } from './request';
import { readOpenCodeSettings } from './types';
import { callOpenCodeProviders } from './api/opencode_client';
/**
* Initialization data for the opencode_bridge extension.
*/
const plugin: JupyterFrontEndPlugin<void> = {
id: 'opencode_bridge:plugin',
description: 'A JupyterLab extension bridging the UI to OpenCode Serve.',
autoStart: true,
optional: [ISettingRegistry],
requires: [INotebookTracker],
activate: (
app: JupyterFrontEnd,
tracker: INotebookTracker,
settingRegistry: ISettingRegistry | null
) => {
console.log('JupyterLab extension opencode_bridge is activated!');
// Install the per-cell footer factory on every notebook.
installOpenCodeEverywhere(tracker);
// Load settings + push into toolbar module.
if (settingRegistry) {
void settingRegistry
.load(plugin.id)
.then(settings => {
const bridge = readOpenCodeSettings(settings.composite);
setOpenCodeRuntime({
settings: bridge,
serverSettings: app.serviceManager.serverSettings
});
console.log('opencode_bridge settings loaded:', bridge);
void callOpenCodeProviders(app.serviceManager.serverSettings)
.then(data => {
const lines: string[] = ['[opencode_bridge] Available OpenCode providers:'];
for (const p of data.providers) {
const models = p.models.map(m => m.id).join(', ');
lines.push(` - ${p.id}: ${models || '(no models)'}`);
}
// eslint-disable-next-line no-console
console.log(lines.join('\n'));
})
.catch(reason => {
// eslint-disable-next-line no-console
console.warn(
'[opencode_bridge] Could not fetch providers (is the opencode-bridge server extension enabled and opencode serve running?):',
reason
);
});
})
.catch(reason => {
console.error('Failed to load settings for opencode_bridge.', reason);
});
}
requestAPI<unknown>('hello', app.serviceManager.serverSettings)
.then(data => {
console.log('hello endpoint:', data);
})
.catch(reason => {
console.error(
`The opencode_bridge server extension appears to be missing.\n${reason}`
);
});
}
};
export default plugin;
+51
View File
@@ -0,0 +1,51 @@
import { URLExt } from '@jupyterlab/coreutils';
import { ServerConnection } from '@jupyterlab/services';
/**
* Call the server extension
*
* @param endPoint API REST end point for the extension
* @param serverSettings The server settings to use for the request
* @param init Initial values for the request
* @returns The response body interpreted as JSON
*/
export async function requestAPI<T>(
endPoint: string,
serverSettings: ServerConnection.ISettings,
init: RequestInit = {}
): Promise<T> {
// Make request to Jupyter API
const requestUrl = URLExt.join(
serverSettings.baseUrl,
'opencode-bridge', // our server extension's API namespace
endPoint
);
let response: Response;
try {
response = await ServerConnection.makeRequest(
requestUrl,
init,
serverSettings
);
} catch (error) {
throw new ServerConnection.NetworkError(error as any);
}
let data: any = await response.text();
if (data.length > 0) {
try {
data = JSON.parse(data);
} catch (error) {
console.log('Not a JSON response body.', response);
}
}
if (!response.ok) {
throw new ServerConnection.ResponseError(response, data.message || data);
}
return data;
}
+92
View File
@@ -0,0 +1,92 @@
/**
* 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.
*/
export interface ErrorOutput {
ename: string;
evalue: string;
traceback: string[];
}
export interface CellContext {
notebookPath: string;
cellId: string;
language: string;
cellIndex: number;
totalCells: number;
source: string;
previousCode: string | null;
error: ErrorOutput | null;
}
export type OpenCodeMode = 'optimize' | 'fix' | 'edit';
export interface OpenCodeRequest {
mode: OpenCodeMode;
prompt: string;
context: CellContext;
providerId?: string;
modelId?: string;
}
export interface OpenCodeSuccess {
ok: true;
mode: OpenCodeMode;
finalSource: string;
sessionId: string;
notebookPath: string;
}
export interface OpenCodeFailure {
ok: false;
error: string;
}
export type OpenCodeResponse = OpenCodeSuccess | OpenCodeFailure;
/** Frontend view of the 4 schema/plugin.json fields. */
export interface OpenCodeSettings {
opencodeServerUrl: string;
opencodeServerUser: string;
opencodeServerPassword: string;
requestTimeoutSeconds: number;
opencodeProvider: string;
opencodeModel: string;
}
export const DEFAULT_OPENCODE_SETTINGS: OpenCodeSettings = {
opencodeServerUrl: 'http://127.0.0.1:4096',
opencodeServerUser: 'opencode',
opencodeServerPassword: '',
requestTimeoutSeconds: 120,
opencodeProvider: '',
opencodeModel: '',
};
export function readOpenCodeSettings(composite: unknown): OpenCodeSettings {
const c = (composite ?? {}) as Partial<OpenCodeSettings>;
return {
opencodeServerUrl: c.opencodeServerUrl || DEFAULT_OPENCODE_SETTINGS.opencodeServerUrl,
opencodeServerUser: c.opencodeServerUser || DEFAULT_OPENCODE_SETTINGS.opencodeServerUser,
opencodeServerPassword: c.opencodeServerPassword || '',
requestTimeoutSeconds:
typeof c.requestTimeoutSeconds === 'number'
? c.requestTimeoutSeconds
: DEFAULT_OPENCODE_SETTINGS.requestTimeoutSeconds,
opencodeProvider: c.opencodeProvider || '',
opencodeModel: c.opencodeModel || '',
};
}
/** Response from GET /opencode-bridge/providers. */
export interface OpenCodeProvider {
id: string;
models: { id: string; [k: string]: unknown }[];
}
export interface OpenCodeProvidersResponse {
providers: OpenCodeProvider[];
}
+5
View File
@@ -0,0 +1,5 @@
/*
See the JupyterLab Developer Guide for useful CSS Patterns:
https://jupyterlab.readthedocs.io/en/stable/developer/css.html
*/
+1
View File
@@ -0,0 +1 @@
@import url('base.css');
+1
View File
@@ -0,0 +1 @@
import './base.css';
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"composite": true,
"declaration": true,
"esModuleInterop": true,
"incremental": true,
"jsx": "react",
"lib": ["DOM", "ES2018", "ES2020.Intl"],
"module": "esnext",
"moduleResolution": "node",
"noEmitOnError": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"preserveWatchOutput": true,
"resolveJsonModule": true,
"outDir": "lib",
"rootDir": "src",
"strict": true,
"strictNullChecks": true,
"target": "ES2018",
"types": ["jest"]
},
"include": ["src/**/*"]
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./tsconfig"
}
+167
View File
@@ -0,0 +1,167 @@
# Integration Testing
This folder contains the integration tests of the extension.
They are defined using [Playwright](https://playwright.dev/docs/intro) test runner
and [Galata](https://github.com/jupyterlab/jupyterlab/tree/main/galata) helper.
The Playwright configuration is defined in [playwright.config.js](./playwright.config.js).
The JupyterLab server configuration to use for the integration test is defined
in [jupyter_server_test_config.py](./jupyter_server_test_config.py).
The default configuration will produce video for failing tests and an HTML report.
> There is a UI mode that you may like; see [that video](https://www.youtube.com/watch?v=jF0yA-JLQW0).
## Run the tests
> All commands are assumed to be executed from the root directory
To run the tests, you need to:
1. Compile the extension:
```sh
jlpm install
jlpm build:prod
```
> Check the extension is installed in JupyterLab.
2. Install test dependencies (needed only once):
```sh
cd ./ui-tests
jlpm install
jlpm playwright install
cd ..
```
3. Execute the [Playwright](https://playwright.dev/docs/intro) tests:
```sh
cd ./ui-tests
jlpm playwright test
```
Test results will be shown in the terminal. In case of any test failures, the test report
will be opened in your browser at the end of the tests execution; see
[Playwright documentation](https://playwright.dev/docs/test-reporters#html-reporter)
for configuring that behavior.
## Update the tests snapshots
> All commands are assumed to be executed from the root directory
If you are comparing snapshots to validate your tests, you may need to update
the reference snapshots stored in the repository. To do that, you need to:
1. Compile the extension:
```sh
jlpm install
jlpm build:prod
```
> Check the extension is installed in JupyterLab.
2. Install test dependencies (needed only once):
```sh
cd ./ui-tests
jlpm install
jlpm playwright install
cd ..
```
3. Execute the [Playwright](https://playwright.dev/docs/intro) command:
```sh
cd ./ui-tests
jlpm playwright test -u
```
> Some discrepancy may occurs between the snapshots generated on your computer and
> the one generated on the CI. To ease updating the snapshots on a PR, you can
> type `please update playwright snapshots` to trigger the update by a bot on the CI.
> Once the bot has computed new snapshots, it will commit them to the PR branch.
## Create tests
> All commands are assumed to be executed from the root directory
To create tests, the easiest way is to use the code generator tool of playwright:
1. Compile the extension:
```sh
jlpm install
jlpm build:prod
```
> Check the extension is installed in JupyterLab.
2. Install test dependencies (needed only once):
```sh
cd ./ui-tests
jlpm install
jlpm playwright install
cd ..
```
3. Start the server:
```sh
cd ./ui-tests
jlpm start
```
4. Execute the [Playwright code generator](https://playwright.dev/docs/codegen) in **another terminal**:
```sh
cd ./ui-tests
jlpm playwright codegen localhost:8888
```
## Debug tests
> All commands are assumed to be executed from the root directory
To debug tests, a good way is to use the inspector tool of playwright:
1. Compile the extension:
```sh
jlpm install
jlpm build:prod
```
> Check the extension is installed in JupyterLab.
2. Install test dependencies (needed only once):
```sh
cd ./ui-tests
jlpm install
jlpm playwright install
cd ..
```
3. Execute the Playwright tests in [debug mode](https://playwright.dev/docs/debug):
```sh
cd ./ui-tests
jlpm playwright test --debug
```
## Upgrade Playwright and the browsers
To update the web browser versions, you must update the package `@playwright/test`:
```sh
cd ./ui-tests
jlpm up "@playwright/test"
jlpm playwright install
```
+12
View File
@@ -0,0 +1,12 @@
"""Server configuration for integration tests.
!! Never use this configuration in production because it
opens the server to the world and provide access to JupyterLab
JavaScript objects through the global window variable.
"""
from jupyterlab.galata import configure_jupyter_server
configure_jupyter_server(c)
# Uncomment to set server log level to debug level
# c.ServerApp.log_level = "DEBUG"
+15
View File
@@ -0,0 +1,15 @@
{
"name": "opencode_bridge-ui-tests",
"version": "1.0.0",
"description": "JupyterLab opencode_bridge Integration Tests",
"private": true,
"scripts": {
"start": "jupyter lab --config jupyter_server_test_config.py",
"test": "jlpm playwright test",
"test:update": "jlpm playwright test --update-snapshots"
},
"devDependencies": {
"@jupyterlab/galata": "^5.0.5",
"@playwright/test": "^1.60.0"
}
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Configuration for Playwright using default from @jupyterlab/galata
*/
const baseConfig = require('@jupyterlab/galata/lib/playwright-config');
module.exports = {
...baseConfig,
webServer: {
command: 'jlpm start',
url: 'http://localhost:8888/lab',
timeout: 120 * 1000,
reuseExistingServer: !process.env.CI
}
};
+21
View File
@@ -0,0 +1,21 @@
import { expect, test } from '@jupyterlab/galata';
/**
* Don't load JupyterLab webpage before running the tests.
* This is required to ensure we capture all log messages.
*/
test.use({ autoGoto: false });
test('should emit an activation console message', async ({ page }) => {
const logs: string[] = [];
page.on('console', message => {
logs.push(message.text());
});
await page.goto();
expect(
logs.filter(s => s === 'JupyterLab extension opencode_bridge is activated!')
).toHaveLength(1);
});
View File
Generated
+2154
View File
File diff suppressed because it is too large Load Diff
+10419
View File
File diff suppressed because it is too large Load Diff