Files
notebook-ai-extension/src/__tests__/opencode_cell_actions.spec.ts
T
tao.chen 310d50759a fix: SSE filter no longer drops content events for a different session
Bug: after switching to a new session via the SessionSelector, the
next /edit would not get a reply. Root cause: applyEvent dropped
ANY event whose sessionID didn't match the prompt's _sessionId,
including text deltas / reasoning / tool / idle. OpenCode's sessionID
extraction from event payloads is not perfectly consistent across
event types, so the prompt could end up with a stale _sessionId
that doesn't match the events coming in, and EVERY event got
filtered out -> no reply.

Fix: the cross-session filter is now strict ONLY for
permission.asked / question.asked (so the user can't accidentally
reply to another session's prompt). Content events always pass
through. The server's /events handler is the single source of
truth for session filtering (it has the URL ?session= param); the
client's filter would only ever mask the user's interaction with
their own active session.

Also: drop the cell-context auto-injection. The OpenCodeRequest
now carries only {notebookPath}. The user explicitly attaches
whatever they want via the new '📋 插入单元格内容' button
(inserts the cell source as a markdown code block into the input).
This makes the LLM context match user intent and stops the
OpenCode prompt from being polluted with stale previousCode /
traceback snapshots.

Backend
-------
- _build_request_body: now takes only the prompt, returns
  parts=[{text: prompt}]. No more <previous_cell>/<traceback>/<cell>
  tag wrapping.

Frontend
-------
- types.ts: CellContext collapsed to {notebookPath}. ErrorOutput /
  cellId / source / previousCode / error / cellIndex / totalCells
  / language all removed.
- opencode_cell_actions.ts: extractCellContextFromCell() replaced
  with extractNotebookPathFromCell(). _context field renamed to
  _notebookPath. NotebookPanel / extractCellContext / context/
  cell_context imports all removed.
- src/context/cell_context.ts + src/__tests__/cell_context.spec.ts:
  deleted.

New feature: '📋 插入单元格内容' button
----------------------------------------
The button sits at the start of the actions row (visually
left-aligned via margin-right:auto) and appends the current cell's
source as a markdown code fence to the textarea. Caret is moved
to the end so the user can keep typing. The fence info string is
the cell's model type (falls back to a plain fence if the type
isn't a clean language identifier). No-op for empty cells.

Tests
-----
- 72 jest (was 78: -8 cell-context tests + 1 SSE filter test +
  3 insert-cell tests + rewrites). 73 pytest (unchanged). Build
  green.
2026-07-28 18:02:21 +08:00

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);
});
});