Feat/async sse streaming #1
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user