Bug: after switching to a new session via the SessionSelector, the
next /edit would not get a reply. Root cause: applyEvent dropped
ANY event whose sessionID didn't match the prompt's _sessionId,
including text deltas / reasoning / tool / idle. OpenCode's sessionID
extraction from event payloads is not perfectly consistent across
event types, so the prompt could end up with a stale _sessionId
that doesn't match the events coming in, and EVERY event got
filtered out -> no reply.
Fix: the cross-session filter is now strict ONLY for
permission.asked / question.asked (so the user can't accidentally
reply to another session's prompt). Content events always pass
through. The server's /events handler is the single source of
truth for session filtering (it has the URL ?session= param); the
client's filter would only ever mask the user's interaction with
their own active session.
Also: drop the cell-context auto-injection. The OpenCodeRequest
now carries only {notebookPath}. The user explicitly attaches
whatever they want via the new '📋 插入单元格内容' button
(inserts the cell source as a markdown code block into the input).
This makes the LLM context match user intent and stops the
OpenCode prompt from being polluted with stale previousCode /
traceback snapshots.
Backend
-------
- _build_request_body: now takes only the prompt, returns
parts=[{text: prompt}]. No more <previous_cell>/<traceback>/<cell>
tag wrapping.
Frontend
-------
- types.ts: CellContext collapsed to {notebookPath}. ErrorOutput /
cellId / source / previousCode / error / cellIndex / totalCells
/ language all removed.
- opencode_cell_actions.ts: extractCellContextFromCell() replaced
with extractNotebookPathFromCell(). _context field renamed to
_notebookPath. NotebookPanel / extractCellContext / context/
cell_context imports all removed.
- src/context/cell_context.ts + src/__tests__/cell_context.spec.ts:
deleted.
New feature: '📋 插入单元格内容' button
----------------------------------------
The button sits at the start of the actions row (visually
left-aligned via margin-right:auto) and appends the current cell's
source as a markdown code fence to the textarea. Caret is moved
to the end so the user can keep typing. The fence info string is
the cell's model type (falls back to a plain fence if the type
isn't a clean language identifier). No-op for empty cells.
Tests
-----
- 72 jest (was 78: -8 cell-context tests + 1 SSE filter test +
3 insert-cell tests + rewrites). 73 pytest (unchanged). Build
green.
1664 lines
60 KiB
TypeScript
1664 lines
60 KiB
TypeScript
/**
|
|
* Unit tests for OpenCodeCellActions (single AI button) and OpenCodeInlinePrompt
|
|
* (inline input panel inside the cell). v3-final: no settings — the model
|
|
* picker is fully dynamic; default selection is the first provider/model.
|
|
*
|
|
* Mocks @jupyterlab/cells, @jupyterlab/apputils, and @lumino/widgets at the
|
|
* test boundary so the real ESM packages are not loaded.
|
|
*/
|
|
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 id = '';
|
|
public parent: Widget | null = null;
|
|
private _isDisposed = false;
|
|
public get isDisposed(): boolean {
|
|
return this._isDisposed;
|
|
}
|
|
public addClass(cls: string): void {
|
|
this.node.classList.add(cls);
|
|
}
|
|
public dispose(): void {
|
|
this._isDisposed = true;
|
|
if (this.node.parentNode) {
|
|
this.node.parentNode.removeChild(this.node);
|
|
}
|
|
}
|
|
public static attach(widget: Widget, host: HTMLElement): void {
|
|
host.appendChild(widget.node);
|
|
}
|
|
public onAfterAttach(_msg: any): void {
|
|
// no-op
|
|
}
|
|
}
|
|
return { Widget };
|
|
});
|
|
|
|
jest.mock('marked', () => ({
|
|
__esModule: true,
|
|
marked: {
|
|
// Minimal but realistic mock: produce a real <pre><code> for
|
|
// fenced code blocks (so the per-code-block toolbar
|
|
// post-processing can find them) and <h1>/<p> for headings/text.
|
|
// Not a full markdown renderer — just enough for the prompt tests.
|
|
parse: (md: string) => {
|
|
let html = '';
|
|
let inFence = false;
|
|
let fenceLang = '';
|
|
let fenceBuf: string[] = [];
|
|
const lines = md.split('\n');
|
|
for (const line of lines) {
|
|
const open = !inFence && /^```(\w*)\s*$/.exec(line);
|
|
const close = inFence && /^```\s*$/.test(line);
|
|
if (open) {
|
|
inFence = true;
|
|
fenceLang = open[1] || '';
|
|
fenceBuf = [];
|
|
continue;
|
|
}
|
|
if (close) {
|
|
html +=
|
|
'<pre><code class="language-' +
|
|
fenceLang +
|
|
'">' +
|
|
fenceBuf.join('\n') +
|
|
'</code></pre>';
|
|
inFence = false;
|
|
continue;
|
|
}
|
|
if (inFence) {
|
|
fenceBuf.push(line);
|
|
continue;
|
|
}
|
|
if (/^# /.test(line)) {
|
|
html += '<h1>' + line.slice(2) + '</h1>';
|
|
} else if (line.trim()) {
|
|
html += '<p>' + line + '</p>';
|
|
}
|
|
}
|
|
return html || '<p>' + md + '</p>';
|
|
}
|
|
}
|
|
}));
|
|
|
|
// jsdom doesn't implement navigator.clipboard; stub it so the copy
|
|
// button's happy path can be exercised in tests.
|
|
beforeAll(() => {
|
|
Object.defineProperty(globalThis.navigator, 'clipboard', {
|
|
value: { writeText: jest.fn().mockResolvedValue(undefined) },
|
|
configurable: true
|
|
});
|
|
});
|
|
|
|
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.
|
|
jest.mock('../api/opencode_client', () => ({
|
|
callOpenCodeEdit: jest.fn(),
|
|
callOpenCodeProviders: jest.fn(),
|
|
callOpenCodeSessionMessages: jest.fn().mockResolvedValue({ messages: [] }),
|
|
callOpenCodeReplyPermission: jest.fn().mockResolvedValue({ ok: true }),
|
|
callOpenCodeReplyQuestion: jest.fn().mockResolvedValue({ ok: true }),
|
|
callOpenCodeListAllSessions: jest.fn().mockResolvedValue({ sessions: [] }),
|
|
callOpenCodeCreateNotebookSession: jest.fn().mockResolvedValue({
|
|
ok: true,
|
|
notebookPath: '',
|
|
sessionId: 'newly-created',
|
|
activeSessionId: 'newly-created',
|
|
}),
|
|
callOpenCodeSetActiveSession: jest.fn().mockResolvedValue({
|
|
ok: true,
|
|
notebookPath: '',
|
|
activeSessionId: '',
|
|
}),
|
|
callOpenCodeBindExistingSession: jest.fn().mockResolvedValue({
|
|
ok: true,
|
|
notebookPath: '',
|
|
sessionId: 'sess-B',
|
|
}),
|
|
subscribeOpenCodeEvents: jest.fn().mockReturnValue({ close: jest.fn() })
|
|
}));
|
|
|
|
import {
|
|
OpenCodeCellActions,
|
|
setOpenCodeProviders,
|
|
setOpenCodeServerSettings
|
|
} from '../components/opencode_cell_actions';
|
|
import { OpenCodeInlinePrompt } from '../components/opencode_inline_prompt';
|
|
import { CodeCell } from '@jupyterlab/cells';
|
|
import {
|
|
callOpenCodeEdit,
|
|
callOpenCodeListAllSessions,
|
|
callOpenCodeCreateNotebookSession,
|
|
callOpenCodeSetActiveSession,
|
|
callOpenCodeBindExistingSession,
|
|
callOpenCodeSessionMessages,
|
|
subscribeOpenCodeEvents
|
|
} from '../api/opencode_client';
|
|
import { Notification } from '@jupyterlab/apputils';
|
|
|
|
const mockedCallOpenCodeEdit = callOpenCodeEdit as jest.MockedFunction<
|
|
typeof callOpenCodeEdit
|
|
>;
|
|
const mockedSubscribeOpenCodeEvents =
|
|
subscribeOpenCodeEvents as jest.MockedFunction<
|
|
typeof subscribeOpenCodeEvents
|
|
>;
|
|
const mockedListAllSessions = callOpenCodeListAllSessions as jest.MockedFunction<
|
|
typeof callOpenCodeListAllSessions
|
|
>;
|
|
const mockedCreateSession = callOpenCodeCreateNotebookSession as jest.MockedFunction<
|
|
typeof callOpenCodeCreateNotebookSession
|
|
>;
|
|
const mockedSetActiveSession = callOpenCodeSetActiveSession as jest.MockedFunction<
|
|
typeof callOpenCodeSetActiveSession
|
|
>;
|
|
const mockedBindExistingSession = callOpenCodeBindExistingSession as jest.MockedFunction<
|
|
typeof callOpenCodeBindExistingSession
|
|
>;
|
|
const mockedSessionMessages = callOpenCodeSessionMessages as jest.MockedFunction<
|
|
typeof callOpenCodeSessionMessages
|
|
>;
|
|
const mockedNotification = Notification as jest.Mocked<typeof Notification>;
|
|
|
|
function makeFakeOutputs(items: any[]): any {
|
|
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,
|
|
setSource: jest.fn()
|
|
},
|
|
outputs: makeFakeOutputs(errorOutputs),
|
|
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;
|
|
(cell as any).node = document.createElement('div');
|
|
// Minimal editor stub: cursor always at (line 0, column 0) so the
|
|
// "insert at cursor" tests produce a deterministic offset.
|
|
(cell as any).editor = {
|
|
getCursorPosition: () => ({ line: 0, column: 0 })
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
Object.defineProperty(cell, 'parent', {
|
|
value: notebook,
|
|
configurable: true
|
|
});
|
|
Object.defineProperty(notebook, 'parent', {
|
|
value: panel,
|
|
configurable: true
|
|
});
|
|
|
|
return cell as CodeCell;
|
|
}
|
|
|
|
describe('OpenCodeCellActions', () => {
|
|
it('allows server settings to be injected', () => {
|
|
setOpenCodeServerSettings({} as any);
|
|
});
|
|
|
|
it('renders a single AI button', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const actions = new OpenCodeCellActions(cell);
|
|
const btns = actions.node.querySelectorAll('button');
|
|
expect(btns.length).toBe(1);
|
|
expect(btns[0].textContent).toContain('AI');
|
|
});
|
|
|
|
it('disables the button when notebook panel cannot be resolved', () => {
|
|
const cell = new (CodeCell as unknown as new () => CodeCell)();
|
|
(cell as any).model = {
|
|
id: 'orphan',
|
|
type: 'code',
|
|
sharedModel: { getSource: () => 'x = 1', setSource: jest.fn() },
|
|
outputs: makeFakeOutputs([]),
|
|
contentChanged: { connect: jest.fn(), disconnect: jest.fn() },
|
|
stateChanged: { connect: jest.fn(), disconnect: jest.fn() }
|
|
};
|
|
const actions = new OpenCodeCellActions(cell);
|
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
|
expect(btn.disabled).toBe(true);
|
|
});
|
|
|
|
it('clicking the button attaches an OpenCodeInlinePrompt to the cell', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const actions = new OpenCodeCellActions(cell);
|
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
|
(actions as any).onAfterAttach({} as any);
|
|
|
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
|
btn.click();
|
|
|
|
const prompt = cell.node.querySelector(
|
|
'.opencode-inline-prompt'
|
|
) as HTMLElement;
|
|
expect(prompt).not.toBeNull();
|
|
});
|
|
|
|
it('clicking the button twice detaches the inline prompt', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const actions = new OpenCodeCellActions(cell);
|
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
|
(actions as any).onAfterAttach({} as any);
|
|
|
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
|
btn.click();
|
|
btn.click();
|
|
expect(cell.node.querySelector('.opencode-inline-prompt')).toBeNull();
|
|
});
|
|
|
|
it('disconnects model signals on dispose', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const actions = new OpenCodeCellActions(cell);
|
|
actions.dispose();
|
|
const model = (cell as any).model;
|
|
expect(model.contentChanged.disconnect).toHaveBeenCalled();
|
|
expect(model.stateChanged.disconnect).toHaveBeenCalled();
|
|
});
|
|
|
|
it('renders provider and model selects; model default uses default[provider] when it matches', () => {
|
|
setOpenCodeProviders({
|
|
providers: [
|
|
{
|
|
id: 'anthropic',
|
|
name: 'Anthropic',
|
|
models: {
|
|
'claude-sonnet-4-20250514': { id: 'claude-sonnet-4-20250514' },
|
|
'claude-haiku-4-5': { id: 'claude-haiku-4-5' }
|
|
}
|
|
},
|
|
{ id: 'openai', models: { 'gpt-5': { id: 'gpt-5' } } }
|
|
],
|
|
default: {
|
|
anthropic: 'claude-sonnet-4-20250514',
|
|
openai: 'gpt-5'
|
|
}
|
|
});
|
|
const cell = makeFakeCell('x = 1');
|
|
const actions = new OpenCodeCellActions(cell);
|
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
|
(actions as any).onAfterAttach({} as any);
|
|
|
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
|
btn.click();
|
|
|
|
const prompt = cell.node.querySelector(
|
|
'.opencode-inline-prompt'
|
|
) as HTMLElement;
|
|
const providerSel = prompt.querySelector(
|
|
'.opencode-provider-select'
|
|
) as HTMLSelectElement;
|
|
const modelSel = prompt.querySelector(
|
|
'.opencode-model-select'
|
|
) as HTMLSelectElement;
|
|
expect(providerSel).not.toBeNull();
|
|
expect(modelSel).not.toBeNull();
|
|
|
|
// Provider: first provider selected by default.
|
|
expect(providerSel.options.length).toBe(2);
|
|
expect(providerSel.options[0].value).toBe('anthropic');
|
|
expect(providerSel.options[0].textContent).toContain('anthropic');
|
|
expect(providerSel.options[0].textContent).toContain('Anthropic');
|
|
expect(providerSel.value).toBe('anthropic');
|
|
|
|
// Model: options are that provider's model keys, and the default
|
|
// matches default['anthropic'] = 'claude-sonnet-4-20250514'.
|
|
expect(modelSel.options.length).toBe(2);
|
|
expect(modelSel.options[0].value).toBe('claude-sonnet-4-20250514');
|
|
expect(modelSel.options[1].value).toBe('claude-haiku-4-5');
|
|
expect(modelSel.value).toBe('claude-sonnet-4-20250514');
|
|
|
|
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: [
|
|
{ id: 'bailian', models: { 'kimi/kimi-k2.7-code': { id: 'kimi/kimi-k2.7-code' } } }
|
|
],
|
|
// default['bailian'] references a modelID not present in models.
|
|
default: { bailian: 'qwen3.7-plus' }
|
|
});
|
|
const cell = makeFakeCell('x = 1');
|
|
const actions = new OpenCodeCellActions(cell);
|
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
|
(actions as any).onAfterAttach({} as any);
|
|
|
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
|
btn.click();
|
|
|
|
const modelSel = cell.node.querySelector(
|
|
'.opencode-inline-prompt .opencode-model-select'
|
|
) as HTMLSelectElement;
|
|
expect(modelSel.value).toBe('kimi/kimi-k2.7-code');
|
|
setOpenCodeProviders(null);
|
|
});
|
|
|
|
it('rebuilds the model select when the provider select changes', () => {
|
|
setOpenCodeProviders({
|
|
providers: [
|
|
{
|
|
id: 'anthropic',
|
|
models: { 'claude-sonnet-4-20250514': { id: 'claude-sonnet-4-20250514' } }
|
|
},
|
|
{
|
|
id: 'openai',
|
|
models: { 'gpt-5': { id: 'gpt-5' } }
|
|
}
|
|
],
|
|
default: { openai: 'gpt-5' }
|
|
});
|
|
const cell = makeFakeCell('x = 1');
|
|
const actions = new OpenCodeCellActions(cell);
|
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
|
(actions as any).onAfterAttach({} as any);
|
|
|
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
|
btn.click();
|
|
|
|
const prompt = cell.node.querySelector(
|
|
'.opencode-inline-prompt'
|
|
) as HTMLElement;
|
|
const providerSel = prompt.querySelector(
|
|
'.opencode-provider-select'
|
|
) as HTMLSelectElement;
|
|
const modelSel = prompt.querySelector(
|
|
'.opencode-model-select'
|
|
) as HTMLSelectElement;
|
|
|
|
// Initial: anthropic, its only model.
|
|
expect(providerSel.value).toBe('anthropic');
|
|
expect(modelSel.options.length).toBe(1);
|
|
expect(modelSel.value).toBe('claude-sonnet-4-20250514');
|
|
|
|
// Switch to openai: model select rebuilds with openai's model and
|
|
// default['openai'] = 'gpt-5' is in openai's models so it matches.
|
|
providerSel.value = 'openai';
|
|
providerSel.dispatchEvent(new Event('change'));
|
|
expect(modelSel.options.length).toBe(1);
|
|
expect(modelSel.options[0].value).toBe('gpt-5');
|
|
expect(modelSel.value).toBe('gpt-5');
|
|
|
|
setOpenCodeProviders(null);
|
|
});
|
|
|
|
it('does not render the selector when providers are not cached', () => {
|
|
setOpenCodeProviders(null);
|
|
const cell = makeFakeCell('x = 1');
|
|
const actions = new OpenCodeCellActions(cell);
|
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
|
(actions as any).onAfterAttach({} as any);
|
|
|
|
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
|
btn.click();
|
|
|
|
expect(
|
|
cell.node.querySelector('.opencode-inline-prompt .opencode-provider-select')
|
|
).toBeNull();
|
|
expect(
|
|
cell.node.querySelector('.opencode-inline-prompt .opencode-model-select')
|
|
).toBeNull();
|
|
});
|
|
|
|
it('on submit: posts to /edit, binds SSE, does NOT replace cell source, clears input', async () => {
|
|
setOpenCodeProviders(null);
|
|
mockedCallOpenCodeEdit.mockResolvedValue({
|
|
ok: true,
|
|
sessionId: 'ses-abc',
|
|
notebookPath: 'foo.ipynb'
|
|
});
|
|
const subscribeCalls: any[] = [];
|
|
mockedSubscribeOpenCodeEvents.mockImplementation(
|
|
((sid: string, _s: any, handlers: any) => {
|
|
subscribeCalls.push({ sid, handlers });
|
|
return { close: jest.fn() };
|
|
}) as any
|
|
);
|
|
|
|
const cell = makeFakeCell('x = 1');
|
|
const actions = new OpenCodeCellActions(cell);
|
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
|
(actions as any).onAfterAttach({} as any);
|
|
const aiBtn = actions.node.querySelector('button') as HTMLButtonElement;
|
|
aiBtn.click();
|
|
|
|
// Type into the textarea, then submit.
|
|
const prompt = (actions as any)._prompt;
|
|
const textarea = prompt._textarea as HTMLTextAreaElement;
|
|
textarea.value = 'make it faster';
|
|
(prompt._sendBtn as HTMLButtonElement).click();
|
|
|
|
// Let the async _onSubmit run.
|
|
await new Promise(r => setTimeout(r, 5));
|
|
|
|
// callOpenCodeEdit was called with the right body.
|
|
expect(mockedCallOpenCodeEdit).toHaveBeenCalledTimes(1);
|
|
expect(mockedCallOpenCodeEdit.mock.calls[0][0].prompt).toBe('make it faster');
|
|
expect(mockedCallOpenCodeEdit.mock.calls[0][0].context.notebookPath).toBe('foo.ipynb');
|
|
|
|
// SSE subscription was opened with the returned sessionId.
|
|
expect(subscribeCalls).toHaveLength(1);
|
|
expect(subscribeCalls[0].sid).toBe('ses-abc');
|
|
expect(typeof subscribeCalls[0].handlers.onEvent).toBe('function');
|
|
|
|
// The cell source must remain unchanged.
|
|
expect((cell as any).model.sharedModel.setSource).not.toHaveBeenCalled();
|
|
|
|
// The textarea is cleared so the user can type a follow-up.
|
|
expect(textarea.value).toBe('');
|
|
});
|
|
|
|
it('on submit: forwards SSE events to the prompt and closes the stream on session.idle', async () => {
|
|
setOpenCodeProviders(null);
|
|
mockedCallOpenCodeEdit.mockResolvedValue({
|
|
ok: true,
|
|
sessionId: 'ses-1',
|
|
notebookPath: 'foo.ipynb'
|
|
});
|
|
|
|
const fakeSse = { close: jest.fn() };
|
|
let captured: any = null;
|
|
mockedSubscribeOpenCodeEvents.mockImplementation(
|
|
((_sid: string, _s: any, handlers: any) => {
|
|
captured = handlers;
|
|
return fakeSse;
|
|
}) as any
|
|
);
|
|
|
|
const cell = makeFakeCell('x = 1');
|
|
const actions = new OpenCodeCellActions(cell);
|
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
|
(actions as any).onAfterAttach({} as any);
|
|
const aiBtn = actions.node.querySelector('button') as HTMLButtonElement;
|
|
aiBtn.click();
|
|
const prompt = (actions as any)._prompt;
|
|
const textarea = prompt._textarea as HTMLTextAreaElement;
|
|
textarea.value = 'go';
|
|
(prompt._sendBtn as HTMLButtonElement).click();
|
|
|
|
await new Promise(r => setTimeout(r, 5));
|
|
expect(captured).not.toBeNull();
|
|
|
|
// Simulate a text-delta event arriving.
|
|
const applySpy = jest.spyOn(prompt, 'applyEvent');
|
|
captured.onEvent({
|
|
type: 'session.next.text.delta',
|
|
properties: { sessionID: 'ses-1', delta: 'hel' }
|
|
});
|
|
expect(applySpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({ type: 'session.next.text.delta' })
|
|
);
|
|
|
|
// session.idle should close the stream and re-enable the input.
|
|
captured.onEvent({ type: 'session.idle', properties: { sessionID: 'ses-1' } });
|
|
expect(fakeSse.close).toHaveBeenCalled();
|
|
expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
|
|
});
|
|
|
|
it('on submit: session.status with status.type=idle also closes the stream (idle shape variants)', async () => {
|
|
setOpenCodeProviders(null);
|
|
mockedCallOpenCodeEdit.mockResolvedValue({
|
|
ok: true,
|
|
sessionId: 'ses-1',
|
|
notebookPath: 'foo.ipynb'
|
|
});
|
|
const fakeSse = { close: jest.fn() };
|
|
let captured: any = null;
|
|
mockedSubscribeOpenCodeEvents.mockImplementation(
|
|
((_sid: string, _s: any, handlers: any) => {
|
|
captured = handlers;
|
|
return fakeSse;
|
|
}) as any
|
|
);
|
|
|
|
const cell = makeFakeCell('x = 1');
|
|
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;
|
|
(prompt._textarea as HTMLTextAreaElement).value = 'go';
|
|
(prompt._sendBtn as HTMLButtonElement).click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
|
|
// session.status with status.type === 'idle' should also close the
|
|
// stream (some OpenCode versions emit this shape instead of
|
|
// session.idle).
|
|
captured.onEvent({
|
|
type: 'session.status',
|
|
properties: { sessionID: 'ses-1', status: { type: 'idle' } }
|
|
});
|
|
expect(fakeSse.close).toHaveBeenCalled();
|
|
expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
|
|
});
|
|
|
|
it('on submit: idle nested under payload also closes the stream', async () => {
|
|
setOpenCodeProviders(null);
|
|
mockedCallOpenCodeEdit.mockResolvedValue({
|
|
ok: true,
|
|
sessionId: 'ses-1',
|
|
notebookPath: 'foo.ipynb'
|
|
});
|
|
const fakeSse = { close: jest.fn() };
|
|
let captured: any = null;
|
|
mockedSubscribeOpenCodeEvents.mockImplementation(
|
|
((_sid: string, _s: any, handlers: any) => {
|
|
captured = handlers;
|
|
return fakeSse;
|
|
}) as any
|
|
);
|
|
|
|
const cell = makeFakeCell('x = 1');
|
|
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;
|
|
(prompt._textarea as HTMLTextAreaElement).value = 'go';
|
|
(prompt._sendBtn as HTMLButtonElement).click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
|
|
// Some OpenCode events have type at top level AND in payload.
|
|
captured.onEvent({
|
|
type: 'unknown.wrapper',
|
|
payload: {
|
|
type: 'session.idle',
|
|
properties: { sessionID: 'ses-1' }
|
|
}
|
|
});
|
|
expect(fakeSse.close).toHaveBeenCalled();
|
|
expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
|
|
});
|
|
|
|
it('on submit failure: re-enables the input and shows an error notification', async () => {
|
|
setOpenCodeProviders(null);
|
|
mockedCallOpenCodeEdit.mockResolvedValue({
|
|
ok: false,
|
|
error: 'opencode down'
|
|
});
|
|
|
|
const cell = makeFakeCell('x = 1');
|
|
const actions = new OpenCodeCellActions(cell);
|
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
|
(actions as any).onAfterAttach({} as any);
|
|
const aiBtn = actions.node.querySelector('button') as HTMLButtonElement;
|
|
aiBtn.click();
|
|
const prompt = (actions as any)._prompt;
|
|
const textarea = prompt._textarea as HTMLTextAreaElement;
|
|
textarea.value = 'go';
|
|
(prompt._sendBtn as HTMLButtonElement).click();
|
|
|
|
await new Promise(r => setTimeout(r, 5));
|
|
expect(mockedNotification.error).toHaveBeenCalledWith('OpenCode 错误: opencode down');
|
|
expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('OpenCodeInlinePrompt', () => {
|
|
it('renders a textarea, a send, a cancel, and an insert-cell-source button', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
expect(prompt.node.querySelector('textarea')).not.toBeNull();
|
|
// 3 buttons: 发送, 取消, 📋 插入单元格内容
|
|
expect(prompt.node.querySelectorAll('button').length).toBe(3);
|
|
const insertBtn = prompt.node.querySelector(
|
|
'.opencode-btn-insert-cell'
|
|
) as HTMLButtonElement;
|
|
expect(insertBtn).not.toBeNull();
|
|
expect(insertBtn.textContent).toContain('插入单元格内容');
|
|
});
|
|
|
|
it('"📋 插入单元格内容" appends the cell source as a markdown code block to the textarea', () => {
|
|
const cell = makeFakeCell('print("hello")\nprint("world")');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
const insertBtn = prompt.node.querySelector(
|
|
'.opencode-btn-insert-cell'
|
|
) as HTMLButtonElement;
|
|
insertBtn.click();
|
|
const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
|
|
// Inserted as a fenced code block (the fake cell's type is 'code'
|
|
// so the fence info string is whatever the cell reports — we just
|
|
// assert a fence + the source content, not a specific language).
|
|
expect(textarea.value).toMatch(/```\w*\n/);
|
|
expect(textarea.value).toContain('print("hello")');
|
|
expect(textarea.value).toContain('print("world")');
|
|
expect(textarea.value.trim().endsWith('```')).toBe(true);
|
|
});
|
|
|
|
it('"📋 插入单元格内容" appends to existing textarea content (with a leading newline if needed)', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
|
|
textarea.value = 'Explain:'; // no trailing newline
|
|
const insertBtn = prompt.node.querySelector(
|
|
'.opencode-btn-insert-cell'
|
|
) as HTMLButtonElement;
|
|
insertBtn.click();
|
|
// The original prefix is preserved at the start.
|
|
expect(textarea.value.startsWith('Explain:')).toBe(true);
|
|
// The code block was inserted; the user prompt + the code block
|
|
// are now both present.
|
|
expect(textarea.value).toContain('x = 1');
|
|
expect(textarea.value).toMatch(/```\w*[\s\S]*x = 1/);
|
|
// The cursor was moved to the end.
|
|
expect(textarea.selectionStart).toBe(textarea.value.length);
|
|
});
|
|
|
|
it('"📋 插入单元格内容" is a no-op when the cell is empty', () => {
|
|
const cell = makeFakeCell('');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
const insertBtn = prompt.node.querySelector(
|
|
'.opencode-btn-insert-cell'
|
|
) as HTMLButtonElement;
|
|
const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
|
|
insertBtn.click();
|
|
// Textarea is still empty.
|
|
expect(textarea.value).toBe('');
|
|
});
|
|
|
|
it('renders a history area; setMessages renders user text + assistant markdown and scrolls to bottom', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
const history = prompt.node.querySelector(
|
|
'.opencode-inline-prompt .opencode-inline-history'
|
|
) as HTMLElement;
|
|
expect(history).not.toBeNull();
|
|
// Initially empty (no messages yet).
|
|
expect(history.children.length).toBe(0);
|
|
|
|
prompt.setMessages([
|
|
{ role: 'user', content: 'fix the bug' },
|
|
{
|
|
role: 'assistant',
|
|
content: '# Here you go\n\n```python\nx = 1\n```'
|
|
}
|
|
]);
|
|
expect(history.children.length).toBe(2);
|
|
const userMsg = history.querySelector('.opencode-msg-user') as HTMLElement;
|
|
const asstMsg = history.querySelector('.opencode-msg-assistant') as HTMLElement;
|
|
expect(userMsg).not.toBeNull();
|
|
// User messages are plain text (not rendered as HTML).
|
|
expect(userMsg.textContent).toBe('fix the bug');
|
|
expect(userMsg.innerHTML).not.toContain('<');
|
|
// Assistant content: the marked mock produces a real <h1> + <pre>.
|
|
expect(asstMsg).not.toBeNull();
|
|
expect(asstMsg.innerHTML).toContain('<h1>Here you go</h1>');
|
|
expect(asstMsg.innerHTML).toContain('<pre>');
|
|
});
|
|
|
|
it('renders Copy/Insert/Replace buttons INSIDE each code block (not above the message)', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
// User message: no toolbar.
|
|
prompt.setMessages([{ role: 'user', content: 'help' }]);
|
|
expect(
|
|
prompt.node.querySelector('.opencode-msg-user .opencode-code-toolbar')
|
|
).toBeNull();
|
|
|
|
// Assistant with a fenced code block: toolbar inside .opencode-code-block.
|
|
prompt.setMessages([
|
|
{ role: 'assistant', content: '```py\nprint(1)\n```' }
|
|
]);
|
|
const asst = prompt.node.querySelector(
|
|
'.opencode-msg-assistant'
|
|
) as HTMLElement;
|
|
// The message-level toolbar must NOT exist.
|
|
expect(asst.querySelector('.opencode-msg-toolbar')).toBeNull();
|
|
// The per-code-block wrapper + toolbar.
|
|
const block = asst.querySelector('.opencode-code-block') as HTMLElement;
|
|
expect(block).not.toBeNull();
|
|
const toolbar = block.querySelector('.opencode-code-toolbar') as HTMLElement;
|
|
expect(toolbar).not.toBeNull();
|
|
const buttons = toolbar.querySelectorAll('button');
|
|
expect(buttons.length).toBe(3);
|
|
expect(buttons[0].textContent).toContain('复制');
|
|
expect(buttons[1].textContent).toContain('插入');
|
|
expect(buttons[2].textContent).toContain('替换');
|
|
// The wrapped <pre> is inside the block, below the toolbar.
|
|
expect(block.querySelector('pre')).not.toBeNull();
|
|
});
|
|
|
|
it('assistant with NO code fences has no toolbar', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
prompt.setMessages([
|
|
{ role: 'assistant', content: 'no code here, just an explanation' }
|
|
]);
|
|
const asst = prompt.node.querySelector(
|
|
'.opencode-msg-assistant'
|
|
) as HTMLElement;
|
|
expect(asst.querySelector('.opencode-code-toolbar')).toBeNull();
|
|
expect(asst.querySelector('.opencode-code-block')).toBeNull();
|
|
});
|
|
|
|
it('Replace button overwrites the cell source with the code block content', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
prompt.setMessages([
|
|
{ role: 'assistant', content: '```py\nprint(1)\n```' }
|
|
]);
|
|
const replaceBtn = prompt.node.querySelector(
|
|
'.opencode-msg-assistant .opencode-code-block .opencode-code-toolbar button:nth-child(3)'
|
|
) as HTMLButtonElement;
|
|
replaceBtn.click();
|
|
|
|
const setSource = (cell as any).model.sharedModel.setSource as jest.Mock;
|
|
expect(setSource).toHaveBeenCalledTimes(1);
|
|
// The button acts on the code block's text, not the whole markdown
|
|
// (no surrounding ```fences```).
|
|
expect(setSource).toHaveBeenCalledWith('print(1)');
|
|
});
|
|
|
|
it('Insert button splices the code block at the editor cursor (offset 0)', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
prompt.setMessages([
|
|
{ role: 'assistant', content: '```py\nprint(1)\n```' }
|
|
]);
|
|
const insertBtn = prompt.node.querySelector(
|
|
'.opencode-msg-assistant .opencode-code-block .opencode-code-toolbar button:nth-child(2)'
|
|
) as HTMLButtonElement;
|
|
insertBtn.click();
|
|
|
|
const setSource = (cell as any).model.sharedModel.setSource as jest.Mock;
|
|
expect(setSource).toHaveBeenCalledTimes(1);
|
|
// cursor at (0,0) with source "x = 1" -> code "print(1)" inserted at 0
|
|
expect(setSource).toHaveBeenCalledWith('print(1)x = 1');
|
|
});
|
|
|
|
it('Copy button writes the code block content to the clipboard', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
prompt.setMessages([
|
|
{ role: 'assistant', content: '```py\nprint(1)\n```' }
|
|
]);
|
|
const copyBtn = prompt.node.querySelector(
|
|
'.opencode-msg-assistant .opencode-code-block .opencode-code-toolbar button:nth-child(1)'
|
|
) as HTMLButtonElement;
|
|
copyBtn.click();
|
|
|
|
const writeText = (navigator.clipboard.writeText as unknown as jest.Mock);
|
|
expect(writeText).toHaveBeenCalledWith('print(1)');
|
|
});
|
|
|
|
it('Enter in the textarea submits; Shift+Enter does not', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const onSubmit = jest.fn();
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit,
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
prompt.setMessages([
|
|
{ role: 'user', content: '' },
|
|
{ role: 'assistant', content: 'noop' }
|
|
]);
|
|
const textarea = prompt.node.querySelector(
|
|
'textarea'
|
|
) as HTMLTextAreaElement;
|
|
textarea.value = 'fix the bug';
|
|
|
|
// Plain Enter submits with the current text + the current
|
|
// provider/model selection (both default to "" / first when null).
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'Enter',
|
|
bubbles: true,
|
|
cancelable: true
|
|
})
|
|
);
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onSubmit).toHaveBeenCalledWith('fix the bug', undefined, undefined);
|
|
|
|
// Shift+Enter must NOT submit (chat convention: newline).
|
|
onSubmit.mockClear();
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'Enter',
|
|
shiftKey: true,
|
|
bubbles: true,
|
|
cancelable: true
|
|
})
|
|
);
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
|
|
// Ctrl+Enter also must not submit (newline).
|
|
onSubmit.mockClear();
|
|
textarea.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'Enter',
|
|
ctrlKey: true,
|
|
bubbles: true,
|
|
cancelable: true
|
|
})
|
|
);
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('applyEvent: text deltas accumulate into a single assistant element (real-time streaming)', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
prompt.setSessionId('s1');
|
|
|
|
// Many deltas — they must all append to the SAME DOM node, not
|
|
// create a new one each time (that would defeat real-time rendering).
|
|
const deltas = ['hel', 'lo', ' ', 'wor', 'ld', '!', ' ', 'It works.'];
|
|
for (const d of deltas) {
|
|
prompt.applyEvent({
|
|
type: 'session.next.text.delta',
|
|
properties: { sessionID: 's1', delta: d }
|
|
});
|
|
}
|
|
const all = prompt.node.querySelectorAll(
|
|
'.opencode-inline-prompt .opencode-msg-assistant'
|
|
);
|
|
// Exactly one element — the streaming text is appended, not forked.
|
|
expect(all.length).toBe(1);
|
|
expect(all[0].textContent).toBe('hello world! It works.');
|
|
});
|
|
|
|
it('applyEvent: unrecognized event types are logged but never throw', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
prompt.setSessionId('s1');
|
|
// An event type the dispatcher doesn't recognize.
|
|
expect(() =>
|
|
prompt.applyEvent({
|
|
type: 'some.future.event',
|
|
properties: { sessionID: 's1', payload: 'whatever' }
|
|
})
|
|
).not.toThrow();
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('unrecognized SSE event type'),
|
|
'some.future.event',
|
|
expect.anything()
|
|
);
|
|
warnSpy.mockRestore();
|
|
});
|
|
|
|
it('renders the session selector when onListSessions is provided', () => {
|
|
mockedListAllSessions.mockResolvedValue({
|
|
ok: true,
|
|
sessions: [
|
|
{ id: 'sess-1', title: 'old chat' },
|
|
{ id: 'sess-2', title: 'current chat' },
|
|
]
|
|
});
|
|
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
|
|
const actions = new OpenCodeCellActions(cell);
|
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
|
(actions as any).onAfterAttach({} as any);
|
|
(actions.node.querySelector('button') as HTMLButtonElement).click();
|
|
const prompt = (actions as any)._prompt;
|
|
|
|
// The session selector row + select are present.
|
|
const row = prompt.node.querySelector('.opencode-prompt-sessions');
|
|
expect(row).not.toBeNull();
|
|
const select = prompt.node.querySelector('.opencode-session-select');
|
|
expect(select).not.toBeNull();
|
|
// + 新建 / 🔄 刷新 buttons are present.
|
|
const newBtn = prompt.node.querySelector('.opencode-session-btn-new');
|
|
const refreshBtn = prompt.node.querySelector('.opencode-session-btn-refresh');
|
|
expect(newBtn).not.toBeNull();
|
|
expect(refreshBtn).not.toBeNull();
|
|
});
|
|
|
|
it('initialize() fetches and populates the session list', async () => {
|
|
mockedListAllSessions.mockResolvedValue({
|
|
ok: true,
|
|
sessions: [
|
|
{ id: 'sess-A', title: 'foo' },
|
|
{ id: 'sess-B', title: 'bar' },
|
|
]
|
|
});
|
|
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
|
|
const actions = new OpenCodeCellActions(cell);
|
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
|
(actions as any).onAfterAttach({} as any);
|
|
(actions.node.querySelector('button') as HTMLButtonElement).click();
|
|
// initialize() is called inside _showPrompt.
|
|
await new Promise(r => setTimeout(r, 5));
|
|
expect(mockedListAllSessions).toHaveBeenCalled();
|
|
const prompt = (actions as any)._prompt;
|
|
const select = prompt.node.querySelector(
|
|
'.opencode-session-select'
|
|
) as HTMLSelectElement;
|
|
// Both sessions appear + the "new" sentinel.
|
|
const labels = Array.from(select.options).map(o => o.textContent);
|
|
expect(labels.length).toBe(3); // sess-A, sess-B, + 新建会话…
|
|
});
|
|
|
|
it('clicking + 新建 creates a new session, binds, and reloads history', async () => {
|
|
mockedListAllSessions.mockResolvedValue({ ok: true, sessions: [] });
|
|
mockedCreateSession.mockResolvedValue({
|
|
ok: true,
|
|
notebookPath: 'foo.ipynb',
|
|
sessionId: 'fresh-sid',
|
|
activeSessionId: 'fresh-sid',
|
|
});
|
|
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
|
|
const actions = new OpenCodeCellActions(cell);
|
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
|
(actions as any).onAfterAttach({} as any);
|
|
(actions.node.querySelector('button') as HTMLButtonElement).click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
const prompt = (actions as any)._prompt;
|
|
const newBtn = prompt.node.querySelector(
|
|
'.opencode-session-btn-new'
|
|
) as HTMLButtonElement;
|
|
newBtn.click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
expect(mockedCreateSession).toHaveBeenCalled();
|
|
// The prompt's _sessionId is now the new one.
|
|
expect((prompt as any)._sessionId).toBe('fresh-sid');
|
|
});
|
|
|
|
it('switching session: bind_existing is called, SSE is closed, history is reloaded', async () => {
|
|
mockedListAllSessions.mockResolvedValue({
|
|
ok: true,
|
|
sessions: [
|
|
{ id: 'sess-A', title: 'first' },
|
|
{ id: 'sess-B', title: 'second' },
|
|
]
|
|
});
|
|
mockedBindExistingSession.mockResolvedValue({
|
|
ok: true, notebookPath: 'foo.ipynb', sessionId: 'sess-B',
|
|
});
|
|
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
|
|
const actions = new OpenCodeCellActions(cell);
|
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
|
(actions as any).onAfterAttach({} as any);
|
|
(actions.node.querySelector('button') as HTMLButtonElement).click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
const prompt = (actions as any)._prompt;
|
|
// Mark sess-A as currently active (so we can verify the switch).
|
|
prompt.setSessionId('sess-A');
|
|
// Reset session-messages mock so we can assert it's called again.
|
|
mockedSessionMessages.mockClear();
|
|
mockedSessionMessages.mockResolvedValue({
|
|
messages: [{ role: 'user', content: 'from sess-B' }]
|
|
});
|
|
const select = prompt.node.querySelector(
|
|
'.opencode-session-select'
|
|
) as HTMLSelectElement;
|
|
select.value = 'sess-B';
|
|
select.dispatchEvent(new Event('change'));
|
|
await new Promise(r => setTimeout(r, 5));
|
|
// bind-existing is the single call (it does both bind AND set
|
|
// active on the server). Calling set_active first would 400
|
|
// because the session is not yet bound.
|
|
expect(mockedBindExistingSession).toHaveBeenCalled();
|
|
expect(mockedSetActiveSession).not.toHaveBeenCalled();
|
|
// Prompt's internal _sessionId is now the new one.
|
|
expect((prompt as any)._sessionId).toBe('sess-B');
|
|
// The prompt's history was reloaded for the new session.
|
|
expect(mockedSessionMessages).toHaveBeenCalledWith(
|
|
'foo.ipynb', expect.anything()
|
|
);
|
|
// The new history is rendered (the user sees sess-B's messages,
|
|
// not sess-A's stale DOM).
|
|
const userMsg = prompt.node.querySelector(
|
|
'.opencode-msg-user'
|
|
) as HTMLElement;
|
|
expect(userMsg.textContent).toBe('from sess-B');
|
|
});
|
|
|
|
it('switching session rolls back the dropdown + shows a Notification.error when bind fails', async () => {
|
|
mockedListAllSessions.mockResolvedValue({
|
|
ok: true,
|
|
sessions: [
|
|
{ id: 'sess-A', title: 'first' },
|
|
{ id: 'sess-B', title: 'second' },
|
|
]
|
|
});
|
|
// bind returns ok:false -> the prompt's _handleSwitchSession
|
|
// must roll back the dropdown to the previous active id.
|
|
mockedBindExistingSession.mockResolvedValue({
|
|
ok: false, notebookPath: 'foo.ipynb', error: 'upstream down',
|
|
});
|
|
const cell = makeFakeCell('x = 1', [], 'foo.ipynb');
|
|
const actions = new OpenCodeCellActions(cell);
|
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
|
(actions as any).onAfterAttach({} as any);
|
|
(actions.node.querySelector('button') as HTMLButtonElement).click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
const prompt = (actions as any)._prompt;
|
|
prompt.setSessionId('sess-A');
|
|
const select = prompt.node.querySelector(
|
|
'.opencode-session-select'
|
|
) as HTMLSelectElement;
|
|
select.value = 'sess-B';
|
|
select.dispatchEvent(new Event('change'));
|
|
await new Promise(r => setTimeout(r, 5));
|
|
// Dropdown rolled back to the previous active id (sess-A).
|
|
expect(select.value).toBe('sess-A');
|
|
// Prompt's _sessionId is unchanged.
|
|
expect((prompt as any)._sessionId).toBe('sess-A');
|
|
// User sees a notification.
|
|
expect(mockedNotification.error).toHaveBeenCalledWith('切换会话失败');
|
|
});
|
|
|
|
it('applyEvent: streaming shows raw text in real-time, then renders to HTML on session.idle', () => {
|
|
// Regression: a previous version ran marked.parse on every text
|
|
// delta. That produced glitchy partial-render states (e.g. an
|
|
// opening ```py fence with no closing fence yet is a different
|
|
// DOM shape than the complete code block, so the toolbar
|
|
// post-processing flickered in and out between deltas). The fix
|
|
// is to keep the streaming path dumb (append raw text only) and
|
|
// run the markdown render exactly once at the end of the turn
|
|
// (session.idle). The user sees real-time text grow during the
|
|
// stream, and a clean rendered view the moment the turn ends —
|
|
// matching what setMessages would produce from the stored history.
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
prompt.setSessionId('s1');
|
|
const fullSource =
|
|
'Here is the fix:\n\n```python\nimport pandas as pd\nimport numpy as np\n```\n';
|
|
// Stream every delta of the source.
|
|
for (const d of fullSource.split(/(?=H|```|\n)/)) {
|
|
if (!d) continue;
|
|
prompt.applyEvent({
|
|
type: 'session.next.text.delta',
|
|
properties: { sessionID: 's1', delta: d }
|
|
});
|
|
}
|
|
// During streaming: raw text, NO markdown render yet.
|
|
const asstDuring = prompt.node.querySelector(
|
|
'.opencode-msg-assistant'
|
|
) as HTMLElement;
|
|
expect(asstDuring.textContent).toBe(fullSource);
|
|
// The ```fence``` characters are present as literal text — the
|
|
// user is looking at the source, not the rendered view.
|
|
expect(asstDuring.textContent).toContain('```python');
|
|
// No <pre> element yet (no markdown render was attempted).
|
|
expect(asstDuring.querySelector('pre')).toBeNull();
|
|
// No toolbar yet either.
|
|
expect(asstDuring.querySelector('.opencode-code-toolbar')).toBeNull();
|
|
// Still a single assistant element (no forks per delta).
|
|
expect(
|
|
prompt.node.querySelectorAll(
|
|
'.opencode-msg-assistant'
|
|
).length
|
|
).toBe(1);
|
|
|
|
// Turn end -> session.idle triggers the final markdown render
|
|
// (via _resetStreamPointers, which the prompt invokes before
|
|
// calling onStreamEnd).
|
|
prompt.applyEvent({
|
|
type: 'session.idle',
|
|
properties: { sessionID: 's1' }
|
|
});
|
|
|
|
// After idle: the assistant message is now a fully rendered
|
|
// markdown DOM, identical to what setMessages would produce.
|
|
const pre = prompt.node.querySelector(
|
|
'.opencode-msg-assistant .opencode-msg-content pre'
|
|
) as HTMLElement;
|
|
expect(pre).not.toBeNull();
|
|
const code = pre.querySelector('code') as HTMLElement;
|
|
expect(code.textContent).toBe('import pandas as pd\nimport numpy as np');
|
|
// The Copy / Insert / Replace toolbar is attached to the <pre>
|
|
// block on the final render, same as in setMessages.
|
|
const toolbar = pre.parentElement!.querySelector(
|
|
'.opencode-code-toolbar'
|
|
) as HTMLElement;
|
|
expect(toolbar).not.toBeNull();
|
|
expect(toolbar.querySelectorAll('button').length).toBe(3);
|
|
});
|
|
|
|
it('applyEvent: message.part.delta with field=text routes to the assistant message', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'message.part.delta',
|
|
properties: { sessionID: 's1', field: 'text', delta: 'world' }
|
|
});
|
|
const asst = prompt.node.querySelector(
|
|
'.opencode-inline-prompt .opencode-msg-assistant'
|
|
) as HTMLElement;
|
|
expect(asst.textContent).toBe('world');
|
|
});
|
|
|
|
it('applyEvent: reasoning deltas render into a collapsible green block', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'session.next.reasoning.delta',
|
|
properties: { sessionID: 's1', delta: 'thinking...' }
|
|
});
|
|
const block = prompt.node.querySelector(
|
|
'.opencode-inline-prompt .opencode-msg-block-reasoning'
|
|
) as HTMLElement;
|
|
expect(block).not.toBeNull();
|
|
const content = block.querySelector(
|
|
'.opencode-msg-block-content'
|
|
) as HTMLElement;
|
|
expect(content.textContent).toBe('thinking...');
|
|
});
|
|
|
|
it('applyEvent: tool-call lifecycle creates and updates a tool block', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'session.next.tool.called',
|
|
properties: { sessionID: 's1', name: 'bash' }
|
|
});
|
|
prompt.applyEvent({
|
|
type: 'session.next.tool.input.delta',
|
|
properties: { sessionID: 's1', delta: '{"cmd":' }
|
|
});
|
|
prompt.applyEvent({
|
|
type: 'session.next.tool.success',
|
|
properties: { sessionID: 's1', result: { ok: true } }
|
|
});
|
|
const block = prompt.node.querySelector(
|
|
'.opencode-inline-prompt .opencode-msg-block-tool'
|
|
) as HTMLElement;
|
|
expect(block).not.toBeNull();
|
|
const summary = block.querySelector('summary') as HTMLElement;
|
|
expect(summary.textContent).toContain('bash');
|
|
const content = block.querySelector(
|
|
'.opencode-msg-block-content'
|
|
) as HTMLElement;
|
|
expect(content.textContent).toContain('{"cmd":');
|
|
expect(content.textContent).toContain('"ok": true');
|
|
});
|
|
|
|
it('applyEvent: session.idle resets stream pointers (next text delta starts a fresh assistant element)', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'session.next.text.delta',
|
|
properties: { sessionID: 's1', delta: 'turn1' }
|
|
});
|
|
prompt.applyEvent({
|
|
type: 'session.idle',
|
|
properties: { sessionID: 's1' }
|
|
});
|
|
prompt.applyEvent({
|
|
type: 'session.next.text.delta',
|
|
properties: { sessionID: 's1', delta: 'turn2' }
|
|
});
|
|
const asstBlocks = prompt.node.querySelectorAll(
|
|
'.opencode-inline-prompt .opencode-msg-assistant'
|
|
);
|
|
expect(asstBlocks.length).toBe(2);
|
|
expect(asstBlocks[0].textContent).toBe('turn1');
|
|
expect(asstBlocks[1].textContent).toBe('turn2');
|
|
});
|
|
|
|
it('applyEvent: text deltas pass through regardless of sessionID match (no client-side filter for content events)', () => {
|
|
// Regression: the SSE filter used to drop content events whose
|
|
// sessionID didn't match the prompt's _sessionId. After a session
|
|
// switch, this caused "no reply" because events for the new
|
|
// session came in with a sessionID the prompt wasn't tracking.
|
|
// Fix: only permission/question events are strict (so the user
|
|
// can't reply to a different session's prompt); content events
|
|
// (text, reasoning, tool, idle) always pass through.
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'session.next.text.delta',
|
|
properties: { sessionID: 's2', delta: 'still rendered' }
|
|
});
|
|
const asst = prompt.node.querySelector(
|
|
'.opencode-msg-assistant'
|
|
) as HTMLElement;
|
|
expect(asst).not.toBeNull();
|
|
expect(asst.textContent).toBe('still rendered');
|
|
});
|
|
|
|
it('applyEvent: permission.asked IS still dropped for a different session', () => {
|
|
// The cross-session filter is strict ONLY for permission.asked /
|
|
// question.asked so the user can't accidentally reply to another
|
|
// session's prompt.
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'permission.asked',
|
|
properties: { sessionID: 's2', id: 'perm-X', description: 'rm -rf' }
|
|
});
|
|
const block = prompt.node.querySelector(
|
|
'.opencode-msg-block-interaction'
|
|
);
|
|
expect(block).toBeNull();
|
|
});
|
|
|
|
it('applyEvent: permission.asked creates a permission block with 3 buttons', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'permission.asked',
|
|
properties: { sessionID: 's1', id: 'perm-1', description: 'rm -rf' }
|
|
});
|
|
const block = prompt.node.querySelector(
|
|
'.opencode-inline-prompt .opencode-msg-block-interaction'
|
|
) as HTMLElement;
|
|
expect(block).not.toBeNull();
|
|
expect(block.id).toBe('opencode-perm-perm-1');
|
|
expect(block.textContent).toContain('rm -rf');
|
|
// Three buttons: once / always / reject.
|
|
const buttons = block.querySelectorAll('button');
|
|
expect(buttons.length).toBe(3);
|
|
expect(buttons[0].textContent).toContain('Once');
|
|
expect(buttons[1].textContent).toContain('Always');
|
|
expect(buttons[2].textContent).toContain('Reject');
|
|
});
|
|
|
|
it('applyEvent: clicking a permission button calls onPermissionReply and shows status', async () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const onPermissionReply = jest.fn().mockResolvedValue(true);
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
onPermissionReply
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'permission.asked',
|
|
properties: { sessionID: 's1', id: 'perm-9', description: 'sudo' }
|
|
});
|
|
const rejectBtn = prompt.node.querySelector(
|
|
'.opencode-perm-btn-reject'
|
|
) as HTMLButtonElement;
|
|
rejectBtn.click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
expect(onPermissionReply).toHaveBeenCalledWith('perm-9', 'reject');
|
|
// The button group now shows the success status.
|
|
const block = prompt.node.querySelector(
|
|
'#opencode-perm-perm-9'
|
|
) as HTMLElement;
|
|
expect(block.textContent).toContain('已拒绝');
|
|
});
|
|
|
|
it('applyEvent: question.asked creates a question block with input + submit', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'question.asked',
|
|
properties: { sessionID: 's1', id: 'q-1', question: 'which env?' }
|
|
});
|
|
const block = prompt.node.querySelector(
|
|
'#opencode-q-q-1'
|
|
) as HTMLElement;
|
|
expect(block).not.toBeNull();
|
|
expect(block.textContent).toContain('which env?');
|
|
expect(block.querySelector('input.opencode-q-input')).not.toBeNull();
|
|
expect(block.querySelector('button.opencode-q-submit')).not.toBeNull();
|
|
});
|
|
|
|
it('applyEvent: question submit calls onQuestionReply with the typed answer', async () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const onQuestionReply = jest.fn().mockResolvedValue(true);
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
onQuestionReply
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'question.asked',
|
|
properties: { sessionID: 's1', id: 'q-2', question: 'which version?' }
|
|
});
|
|
const input = prompt.node.querySelector(
|
|
'.opencode-q-input'
|
|
) as HTMLInputElement;
|
|
const submit = prompt.node.querySelector(
|
|
'.opencode-q-submit'
|
|
) as HTMLButtonElement;
|
|
input.value = 'use 3.12';
|
|
submit.click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
expect(onQuestionReply).toHaveBeenCalledWith('q-2', 'use 3.12');
|
|
const block = prompt.node.querySelector('#opencode-q-q-2') as HTMLElement;
|
|
expect(block.textContent).toContain('已回答');
|
|
});
|
|
|
|
it('applyEvent: question submit with empty input is a no-op', async () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const onQuestionReply = jest.fn();
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null,
|
|
onQuestionReply
|
|
});
|
|
prompt.setSessionId('s1');
|
|
prompt.applyEvent({
|
|
type: 'question.asked',
|
|
properties: { sessionID: 's1', id: 'q-3', question: 'color?' }
|
|
});
|
|
const submit = prompt.node.querySelector(
|
|
'.opencode-q-submit'
|
|
) as HTMLButtonElement;
|
|
submit.click();
|
|
await new Promise(r => setTimeout(r, 5));
|
|
expect(onQuestionReply).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('applyEvent: system events (server.* etc.) are NOT rendered', () => {
|
|
const cell = makeFakeCell('x = 1');
|
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
|
onSubmit: jest.fn(),
|
|
onCancel: jest.fn(),
|
|
disabled: false,
|
|
providers: null
|
|
});
|
|
prompt.setSessionId('s1');
|
|
const systemTypes = [
|
|
'server.connected',
|
|
'workspace.updated',
|
|
'lsp.updated',
|
|
'mcp.tools.changed',
|
|
'pty.created',
|
|
'session.next.agent.switched',
|
|
'session.next.model.switched',
|
|
'file.edited'
|
|
];
|
|
for (const t of systemTypes) {
|
|
prompt.applyEvent({ type: t, properties: { sessionID: 's1' } });
|
|
}
|
|
// No system lines, no message blocks, nothing rendered.
|
|
expect(
|
|
prompt.node.querySelector('.opencode-msg-system')
|
|
).toBeNull();
|
|
expect(
|
|
prompt.node.querySelectorAll('.opencode-msg').length
|
|
).toBe(0);
|
|
expect(
|
|
prompt.node.querySelectorAll('.opencode-msg-block').length
|
|
).toBe(0);
|
|
});
|
|
});
|