Feat/async sse streaming #1

Merged
tao.chen merged 3 commits from feat/async-sse-streaming into main 2026-07-27 17:39:23 +08:00
5 changed files with 395 additions and 5 deletions
Showing only changes of commit cae1eed1b1 - Show all commits
+99
View File
@@ -0,0 +1,99 @@
/**
* Tests for the localStorage-backed model selection persistence helper.
* jsdom provides a real (per-test-class) localStorage.
*/
import {
clearModelSelection,
loadModelSelection,
saveModelSelection
} from '../components/model_selection';
describe('loadModelSelection', () => {
beforeEach(() => {
localStorage.clear();
});
it('returns null when no entry exists', () => {
expect(loadModelSelection('foo.ipynb')).toBeNull();
});
it('returns null for an empty notebookPath (defensive)', () => {
expect(loadModelSelection('')).toBeNull();
});
it('returns the parsed entry when storage is valid', () => {
localStorage.setItem(
'opencode_bridge:model-selection:foo.ipynb',
JSON.stringify({ providerId: 'anthropic', modelId: 'claude-sonnet-4-20250514' })
);
expect(loadModelSelection('foo.ipynb')).toEqual({
providerId: 'anthropic',
modelId: 'claude-sonnet-4-20250514'
});
});
it('returns null for malformed JSON (does not throw)', () => {
localStorage.setItem(
'opencode_bridge:model-selection:foo.ipynb',
'{not json'
);
expect(loadModelSelection('foo.ipynb')).toBeNull();
});
it('returns null when the entry is missing required fields', () => {
localStorage.setItem(
'opencode_bridge:model-selection:foo.ipynb',
JSON.stringify({ providerId: 'anthropic' })
);
expect(loadModelSelection('foo.ipynb')).toBeNull();
});
it('keys entries independently per notebookPath', () => {
saveModelSelection('a.ipynb', { providerId: 'p1', modelId: 'm1' });
saveModelSelection('b.ipynb', { providerId: 'p2', modelId: 'm2' });
expect(loadModelSelection('a.ipynb')).toEqual({
providerId: 'p1',
modelId: 'm1'
});
expect(loadModelSelection('b.ipynb')).toEqual({
providerId: 'p2',
modelId: 'm2'
});
});
});
describe('saveModelSelection + clearModelSelection', () => {
beforeEach(() => {
localStorage.clear();
});
it('writes a JSON entry that can be re-read', () => {
saveModelSelection('foo.ipynb', { providerId: 'p1', modelId: 'm1' });
expect(loadModelSelection('foo.ipynb')).toEqual({
providerId: 'p1',
modelId: 'm1'
});
});
it('overwrites an existing entry', () => {
saveModelSelection('foo.ipynb', { providerId: 'p1', modelId: 'm1' });
saveModelSelection('foo.ipynb', { providerId: 'p2', modelId: 'm2' });
expect(loadModelSelection('foo.ipynb')).toEqual({
providerId: 'p2',
modelId: 'm2'
});
});
it('clearModelSelection removes the entry', () => {
saveModelSelection('foo.ipynb', { providerId: 'p1', modelId: 'm1' });
clearModelSelection('foo.ipynb');
expect(loadModelSelection('foo.ipynb')).toBeNull();
});
it('no-op for an empty notebookPath (defensive)', () => {
expect(() =>
saveModelSelection('', { providerId: 'p1', modelId: 'm1' })
).not.toThrow();
expect(() => clearModelSelection('')).not.toThrow();
});
});
+122
View File
@@ -103,6 +103,12 @@ beforeAll(() => {
});
});
beforeEach(() => {
// Each test starts with a clean selection store so persistence tests
// are deterministic.
localStorage.clear();
});
// Mock the network-touching API module so jsdom tests don't try to hit
// a real notebook server (and to keep the refresh-history logic decoupled
// from the network). The cell_actions tests focus on widget behavior.
@@ -323,6 +329,122 @@ describe('OpenCodeCellActions', () => {
setOpenCodeProviders(null);
});
it('restores the user\'s previously-saved (provider, model) when still valid in the providers payload', () => {
// Seed localStorage with a prior selection that IS valid.
localStorage.setItem(
'opencode_bridge:model-selection:foo.ipynb',
JSON.stringify({ providerId: 'openai', modelId: 'gpt-5' })
);
setOpenCodeProviders({
providers: [
{ id: 'anthropic', models: { 'claude-sonnet-4-20250514': { id: 'x' } } },
{ id: 'openai', models: { 'gpt-5': { id: 'x' }, 'gpt-4': { id: 'x' } } }
],
default: { anthropic: 'claude-sonnet-4-20250514' }
});
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
(actions.node.querySelector('button') as HTMLButtonElement).click();
const prompt = (actions as any)._prompt;
const providerSel = prompt.node.querySelector(
'.opencode-provider-select'
) as HTMLSelectElement;
const modelSel = prompt.node.querySelector(
'.opencode-model-select'
) as HTMLSelectElement;
// Stored selection was applied, NOT the default (first provider).
expect(providerSel.value).toBe('openai');
expect(modelSel.value).toBe('gpt-5');
setOpenCodeProviders(null);
});
it('falls back to default when the stored provider is no longer present', () => {
localStorage.setItem(
'opencode_bridge:model-selection:foo.ipynb',
JSON.stringify({ providerId: 'removed-provider', modelId: 'm1' })
);
setOpenCodeProviders({
providers: [
{ id: 'anthropic', models: { 'claude-sonnet-4-20250514': { id: 'x' } } }
]
});
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
(actions.node.querySelector('button') as HTMLButtonElement).click();
const providerSel = (actions as any)._prompt.node.querySelector(
'.opencode-provider-select'
) as HTMLSelectElement;
// Provider is gone → fall back to first available.
expect(providerSel.value).toBe('anthropic');
setOpenCodeProviders(null);
});
it('falls back to default model when the stored model is no longer present under the same provider', () => {
localStorage.setItem(
'opencode_bridge:model-selection:foo.ipynb',
JSON.stringify({ providerId: 'openai', modelId: 'gpt-99-removed' })
);
setOpenCodeProviders({
providers: [
{ id: 'openai', models: { 'gpt-5': { id: 'x' }, 'gpt-4': { id: 'x' } } }
],
default: { openai: 'gpt-5' }
});
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
(actions.node.querySelector('button') as HTMLButtonElement).click();
const modelSel = (actions as any)._prompt.node.querySelector(
'.opencode-model-select'
) as HTMLSelectElement;
// Model is gone → fall back to default[openai] = gpt-5.
expect(modelSel.value).toBe('gpt-5');
setOpenCodeProviders(null);
});
it('saves the new selection to localStorage when the model select changes', () => {
setOpenCodeProviders({
providers: [
{ id: 'anthropic', models: { 'claude-sonnet-4-20250514': { id: 'x' } } },
{ id: 'openai', models: { 'gpt-5': { id: 'x' } } }
]
});
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
const actions = new OpenCodeCellActions(cell);
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
(actions.node.querySelector('button') as HTMLButtonElement).click();
const prompt = (actions as any)._prompt;
const providerSel = prompt.node.querySelector(
'.opencode-provider-select'
) as HTMLSelectElement;
const modelSel = prompt.node.querySelector(
'.opencode-model-select'
) as HTMLSelectElement;
// Switch provider to openai (whose model list includes gpt-5).
providerSel.value = 'openai';
providerSel.dispatchEvent(new Event('change'));
// Now change the model select to gpt-5.
modelSel.value = 'gpt-5';
modelSel.dispatchEvent(new Event('change'));
const stored = JSON.parse(
localStorage.getItem('opencode_bridge:model-selection:foo.ipynb') || 'null'
);
expect(stored).toEqual({ providerId: 'openai', modelId: 'gpt-5' });
setOpenCodeProviders(null);
});
it('falls back to the first model when default[provider] is not in models', () => {
setOpenCodeProviders({
providers: [
+82
View File
@@ -0,0 +1,82 @@
/**
* Persistence of the user's (provider, model) selection in the inline
* prompt, keyed by notebook path. Stored in localStorage so the
* preference survives panel close/reopen and JupyterLab restart.
*
* The selection is ALWAYS validated against the current providers
* payload before being applied: if the stored provider (or the model
* under it) is no longer present, the caller falls back to its
* default-selection logic. We never silently apply a stale value that
* would result in an empty / disabled <select>.
*
* Storage is best-effort: localStorage may be disabled (private mode),
* the quota may be exhausted, or the value may be corrupt. All
* failure modes return null / no-op rather than throw.
*/
const STORAGE_PREFIX = 'opencode_bridge:model-selection:';
export interface ModelSelection {
providerId: string;
modelId: string;
}
export function loadModelSelection(notebookPath: string): ModelSelection | null {
if (!notebookPath) {
return null;
}
try {
if (typeof localStorage === 'undefined') {
return null;
}
const raw = localStorage.getItem(STORAGE_PREFIX + notebookPath);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (
parsed &&
typeof parsed === 'object' &&
typeof (parsed as ModelSelection).providerId === 'string' &&
typeof (parsed as ModelSelection).modelId === 'string'
) {
return {
providerId: (parsed as ModelSelection).providerId,
modelId: (parsed as ModelSelection).modelId
};
}
return null;
} catch {
return null;
}
}
export function saveModelSelection(
notebookPath: string,
sel: ModelSelection
): void {
if (!notebookPath) {
return;
}
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(STORAGE_PREFIX + notebookPath, JSON.stringify(sel));
} catch {
// Ignore quota exceeded / disabled storage.
}
}
export function clearModelSelection(notebookPath: string): void {
if (!notebookPath) {
return;
}
try {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.removeItem(STORAGE_PREFIX + notebookPath);
} catch {
// ignore
}
}
+1
View File
@@ -127,6 +127,7 @@ export class OpenCodeCellActions extends Widget {
const prompt = new OpenCodeInlinePrompt(this._cell, {
disabled: !this._context || this._status === 'loading',
providers: _providers,
notebookPath: this._context?.notebookPath,
onSubmit: (text: string, providerId?: string, modelId?: string) => {
void this._onSubmit(text, providerId, modelId);
},
+90 -4
View File
@@ -30,12 +30,23 @@ import type {
OpenCodeMessage,
OpenCodeProvidersResponse
} from '../types';
import {
loadModelSelection,
saveModelSelection
} from './model_selection';
export interface IOpenCodeInlinePromptOptions {
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
onCancel: () => void;
disabled: boolean;
providers: OpenCodeProvidersResponse | null;
/**
* Notebook path used to key the persisted (provider, model) selection
* in localStorage. If omitted (or the stored selection is no longer
* valid in the current `providers` payload), the prompt falls back
* to the default selection (first provider / `default[pid]` model).
*/
notebookPath?: string;
/**
* Respond to a `permission.asked` event. Returns a promise that resolves
* to true on success, false on failure (the prompt updates the UI in
@@ -143,6 +154,12 @@ export class OpenCodeInlinePrompt extends Widget {
this._providerModels = flat.providers;
this._defaultMap = flat.defaultMap;
// Resolve the initial selection: prefer a previously-saved
// (providerId, modelId) IF it's still valid in the current
// providers payload; otherwise fall back to the default
// (first provider / `default[pid]` model).
const stored = this._resolveStoredSelection(options.notebookPath);
if (this._providerModels.length > 0) {
this._providerSelect = document.createElement('select');
this._providerSelect.className = 'opencode-provider-select';
@@ -152,16 +169,25 @@ export class OpenCodeInlinePrompt extends Widget {
o.textContent = p.name ? `${p.id} (${p.name})` : p.id;
this._providerSelect.appendChild(o);
}
this._providerSelect.value = this._providerModels[0].id;
this._providerSelect.value = stored
? stored.providerId
: this._providerModels[0].id;
this._providerSelect.addEventListener('change', () => {
this._rebuildModelSelect(this._providerSelect!.value);
this._persistSelection();
});
}
if (this._providerModels.length > 0) {
this._modelSelect = document.createElement('select');
this._modelSelect.className = 'opencode-model-select';
this._rebuildModelSelect(this._providerSelect?.value);
this._rebuildModelSelect(
this._providerSelect?.value,
stored ? stored.modelId : undefined
);
this._modelSelect.addEventListener('change', () => {
this._persistSelection();
});
}
this._textarea = document.createElement('textarea');
@@ -230,7 +256,10 @@ export class OpenCodeInlinePrompt extends Widget {
this.node.appendChild(actions);
}
private _rebuildModelSelect(providerId: string | undefined): void {
private _rebuildModelSelect(
providerId: string | undefined,
preferredModelId?: string
): void {
if (!this._modelSelect) {
return;
}
@@ -258,8 +287,65 @@ export class OpenCodeInlinePrompt extends Widget {
this._modelSelect.appendChild(o);
}
this._modelSelect.disabled = false;
// Honor a preferred model (e.g. from a stored selection), but only
// if it actually exists in this provider's model list. Otherwise
// fall back to the default.
if (
preferredModelId &&
Object.prototype.hasOwnProperty.call(provider.models, preferredModelId)
) {
this._modelSelect.value = preferredModelId;
} else {
this._modelSelect.value =
pickDefaultModelId(providerId, provider.models, this._defaultMap) ?? keys[0];
pickDefaultModelId(providerId, provider.models, this._defaultMap) ??
keys[0];
}
}
/**
* Read the stored (providerId, modelId) for this notebook and
* validate it against the current providers payload. Returns the
* stored selection only when BOTH the provider and the model still
* exist; otherwise returns null and the caller uses the default.
*/
private _resolveStoredSelection(
notebookPath: string | undefined
): { providerId: string; modelId: string } | null {
if (!notebookPath) {
return null;
}
const sel = loadModelSelection(notebookPath);
if (!sel) {
return null;
}
const provider = this._providerModels.find(p => p.id === sel.providerId);
if (!provider) {
// The provider was removed since the user last picked.
return null;
}
if (!Object.prototype.hasOwnProperty.call(provider.models, sel.modelId)) {
// The model was removed (or renamed) under a still-valid provider.
return null;
}
return sel;
}
/**
* Persist the current (providerSelect.value, modelSelect.value) for
* this notebook. No-op if the prompt has no notebookPath, no
* selects, or the values are empty.
*/
private _persistSelection(): void {
const notebookPath = this._options.notebookPath;
if (!notebookPath || !this._providerSelect || !this._modelSelect) {
return;
}
const providerId = this._providerSelect.value;
const modelId = this._modelSelect.value;
if (!providerId || !modelId) {
return;
}
saveModelSelection(notebookPath, { providerId, modelId });
}
setDisabled(disabled: boolean): void {