Files
notebook-ai-extension/src/__tests__/opencode_floating_panel.spec.ts
T
tao.chen c0eba7e0e6 feat: replace per-cell AI button with global floating panel
The per-cell toolbar AI button (OpenCodeCellActions) is removed. A
single round button is mounted on document.body at the bottom-right of
the JupyterLab shell; clicking it opens a floating panel containing
the same OpenCodeInlinePrompt widget. Clicking again closes it.

The panel resolves the active notebook from app.shell.currentWidget
at open time and re-resolves it whenever currentChanged fires while
the panel is open. The active cell (used by the 📋 插入单元格内容,
↪ Insert, and ⟳ Replace cell actions) is resolved via a new
getActiveCell callback in OpenCodeInlinePrompt, decoupling the prompt
itself from any specific CodeCell instance.

Files:
- src/components/opencode_floating_panel.ts (new) — global panel
- src/components/opencode_cell_actions.ts (deleted) — logic moved up
- src/components/opencode_inline_prompt.ts — getActiveCell option
- src/index.ts — wire floating panel, drop toolbar factory
- style/base.css — .opencode-floating-* styles
- src/__tests__/opencode_floating_panel.spec.ts — new tests (renamed)

All existing functionality preserved: multi-session UI, SSE
streaming, permission/question interaction, history reload.
2026-07-28 19:34:52 +08:00

1525 lines
51 KiB
TypeScript

/**
* Unit tests for OpenCodeFloatingPanel (global AI button + panel) and OpenCodeInlinePrompt
* (inline prompt inside the floating panel). v4: 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/application', () => {
class JupyterFrontEnd {
public shell: any = {
currentWidget: null,
currentChanged: {
connect: jest.fn(),
disconnect: jest.fn()
}
};
}
return { JupyterFrontEnd };
});
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 {
OpenCodeFloatingPanel
} from '../components/opencode_floating_panel';
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';
import { Widget } from '@lumino/widgets';
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 makeFakeApp(notebookPath: string | null, activeCell: any | null): any {
const currentChangedSlot: any[] = [];
return {
shell: {
currentWidget: notebookPath
? {
context: { path: notebookPath },
content: { activeCell }
}
: null,
currentChanged: {
connect: (_slot: any) => {
currentChangedSlot.push(_slot);
},
disconnect: jest.fn()
},
_fireCurrentChanged() {
for (const s of currentChangedSlot) {
s();
}
}
}
};
}
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('OpenCodeFloatingPanel', () => {
it('allows server settings to be injected', () => {
});
it('constructs with a single round floating button and no visible panel', () => {
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
expect(panel.node.querySelector('.opencode-floating-button')).not.toBeNull();
const panelEl = panel.node.querySelector('.opencode-floating-panel') as HTMLElement;
expect(panelEl.hidden).toBe(true);
});
it('clicking the button shows the panel and attaches the prompt', () => {
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
Widget.attach(panel, document.body);
panel.toggle();
const panelEl = panel.node.querySelector('.opencode-floating-panel') as HTMLElement;
expect(panelEl.hidden).toBe(false);
expect(panelEl.querySelector('.opencode-inline-prompt')).not.toBeNull();
expect((panel as any)._prompt).not.toBeNull();
});
it('clicking the button again hides the panel and disposes the prompt', () => {
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
Widget.attach(panel, document.body);
panel.toggle();
panel.toggle();
const panelEl = panel.node.querySelector('.opencode-floating-panel') as HTMLElement;
expect(panelEl.hidden).toBe(true);
expect((panel as any)._prompt).toBeNull();
});
it('with no currentWidget, the prompt renders with the input disabled', () => {
const app = makeFakeApp(null, null);
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
Widget.attach(panel, document.body);
panel.toggle();
const prompt = (panel as any)._prompt;
expect(prompt).not.toBeNull();
expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(true);
});
it('with a currentWidget but no active cell, the insert-cell-source button is disabled', () => {
const app = makeFakeApp('foo.ipynb', null);
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
Widget.attach(panel, document.body);
panel.toggle();
const prompt = (panel as any)._prompt;
const insertBtn = prompt.node.querySelector(
'.opencode-btn-insert-cell'
) as HTMLButtonElement;
expect(insertBtn.disabled).toBe(true);
});
it('with a currentWidget AND active cell, the insert button is enabled and clicking it appends the source', () => {
const cell = makeFakeCell('x = 1');
const app = makeFakeApp('foo.ipynb', cell);
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
Widget.attach(panel, document.body);
panel.toggle();
const prompt = (panel as any)._prompt;
const insertBtn = prompt.node.querySelector(
'.opencode-btn-insert-cell'
) as HTMLButtonElement;
expect(insertBtn.disabled).toBe(false);
insertBtn.click();
const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
expect(textarea.value).toContain('x = 1');
});
it('onSubmit: posts to /edit, binds SSE, does NOT modify the cell source, clears input', async () => {
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 app = makeFakeApp('foo.ipynb', cell);
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
Widget.attach(panel, document.body);
panel.toggle();
const prompt = (panel as any)._prompt;
const textarea = prompt._textarea as HTMLTextAreaElement;
textarea.value = 'make it faster';
(prompt._sendBtn as HTMLButtonElement).click();
await new Promise(r => setTimeout(r, 5));
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');
expect(subscribeCalls).toHaveLength(1);
expect(subscribeCalls[0].sid).toBe('ses-abc');
expect(typeof subscribeCalls[0].handlers.onEvent).toBe('function');
expect((cell as any).model.sharedModel.setSource).not.toHaveBeenCalled();
expect(textarea.value).toBe('');
});
it('SSE idle event closes the stream and re-enables the input', async () => {
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 app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
Widget.attach(panel, document.body);
panel.toggle();
const prompt = (panel as any)._prompt;
(prompt._textarea as HTMLTextAreaElement).value = 'go';
(prompt._sendBtn as HTMLButtonElement).click();
await new Promise(r => setTimeout(r, 5));
captured.onEvent({
type: 'session.idle',
properties: { sessionID: 'ses-1' }
});
expect(fakeSse.close).toHaveBeenCalled();
expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
});
it('SSE error is logged but does not break the input', async () => {
mockedCallOpenCodeEdit.mockResolvedValue({
ok: true,
sessionId: 'ses-1',
notebookPath: 'foo.ipynb'
});
let captured: any = null;
mockedSubscribeOpenCodeEvents.mockImplementation(
((_sid: string, _s: any, handlers: any) => {
captured = handlers;
return { close: jest.fn() };
}) as any
);
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
Widget.attach(panel, document.body);
panel.toggle();
const prompt = (panel as any)._prompt;
(prompt._textarea as HTMLTextAreaElement).value = 'go';
(prompt._sendBtn as HTMLButtonElement).click();
await new Promise(r => setTimeout(r, 5));
captured.onError(new Error('sse timeout'));
expect(warnSpy).toHaveBeenCalledWith(
'opencode_bridge: SSE error',
expect.any(Error)
);
warnSpy.mockRestore();
});
it('onSubmit failure: re-enables input and shows an error notification', async () => {
mockedCallOpenCodeEdit.mockResolvedValue({
ok: false,
error: 'opencode down'
});
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
Widget.attach(panel, document.body);
panel.toggle();
const prompt = (panel 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);
});
it('shell currentChanged hides and re-shows the panel to refresh context', () => {
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
Widget.attach(panel, document.body);
panel.toggle();
expect((panel as any)._prompt).not.toBeNull();
const oldPrompt = (panel as any)._prompt;
(app.shell as any)._fireCurrentChanged();
const newPrompt = (panel as any)._prompt;
expect(newPrompt).not.toBeNull();
expect(newPrompt).not.toBe(oldPrompt);
});
it('dispose: closes SSE, disposes prompt, disconnects shell signal', async () => {
mockedCallOpenCodeEdit.mockResolvedValue({
ok: true,
sessionId: 'ses-1',
notebookPath: 'foo.ipynb'
});
const fakeSse = { close: jest.fn() };
mockedSubscribeOpenCodeEvents.mockImplementation(
((_sid: string, _s: any, _handlers: any) => {
return fakeSse;
}) as any
);
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null });
Widget.attach(panel, document.body);
panel.toggle();
const prompt = (panel as any)._prompt;
(prompt._textarea as HTMLTextAreaElement).value = 'go';
(prompt._sendBtn as HTMLButtonElement).click();
await new Promise(r => setTimeout(r, 5));
panel.dispose();
expect(fakeSse.close).toHaveBeenCalled();
expect(prompt.isDisposed).toBe(true);
expect(app.shell.currentChanged.disconnect).toHaveBeenCalled();
});
it('renders provider and model selects when providers are supplied', () => {
const providers = {
providers: [
{
id: 'anthropic',
name: 'Anthropic',
models: {
'claude-sonnet-4-20250514': { id: 'claude-sonnet-4-20250514' },
'claude-haiku-4-5': { id: 'claude-haiku-4-5' }
}
}
],
default: { anthropic: 'claude-sonnet-4-20250514' }
};
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1'));
const panel = new OpenCodeFloatingPanel({
app,
serverSettings: {} as any,
providers: providers as any
});
Widget.attach(panel, document.body);
panel.toggle();
const prompt = (panel as any)._prompt;
const providerSel = prompt.node.querySelector(
'.opencode-provider-select'
) as HTMLSelectElement;
const modelSel = prompt.node.querySelector(
'.opencode-model-select'
) as HTMLSelectElement;
expect(providerSel).not.toBeNull();
expect(modelSel).not.toBeNull();
expect(providerSel.value).toBe('anthropic');
expect(modelSel.value).toBe('claude-sonnet-4-20250514');
});
it('renders the session selector when onListSessions is provided', async () => {
mockedListAllSessions.mockResolvedValue({
ok: true,
sessions: [
{ id: 'sess-1', title: 'old chat' },
{ id: 'sess-2', title: 'current chat' }
]
});
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1', [], 'foo.ipynb'));
const panel = new OpenCodeFloatingPanel({
app,
serverSettings: {} as any,
providers: null
});
Widget.attach(panel, document.body);
panel.toggle();
const prompt = (panel as any)._prompt;
const row = prompt.node.querySelector('.opencode-prompt-sessions');
expect(row).not.toBeNull();
const select = prompt.node.querySelector('.opencode-session-select');
expect(select).not.toBeNull();
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 app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1', [], 'foo.ipynb'));
const panel = new OpenCodeFloatingPanel({
app,
serverSettings: {} as any,
providers: null
});
Widget.attach(panel, document.body);
panel.toggle();
await new Promise(r => setTimeout(r, 5));
expect(mockedListAllSessions).toHaveBeenCalled();
const prompt = (panel as any)._prompt;
const select = prompt.node.querySelector(
'.opencode-session-select'
) as HTMLSelectElement;
const labels = Array.from(select.options).map(o => o.textContent);
expect(labels.length).toBe(3);
});
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 app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1', [], 'foo.ipynb'));
const panel = new OpenCodeFloatingPanel({
app,
serverSettings: {} as any,
providers: null
});
Widget.attach(panel, document.body);
panel.toggle();
await new Promise(r => setTimeout(r, 5));
const prompt = (panel 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();
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 app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1', [], 'foo.ipynb'));
const panel = new OpenCodeFloatingPanel({
app,
serverSettings: {} as any,
providers: null
});
Widget.attach(panel, document.body);
panel.toggle();
await new Promise(r => setTimeout(r, 5));
const prompt = (panel as any)._prompt;
prompt.setSessionId('sess-A');
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));
expect(mockedBindExistingSession).toHaveBeenCalled();
expect(mockedSetActiveSession).not.toHaveBeenCalled();
expect((prompt as any)._sessionId).toBe('sess-B');
expect(mockedSessionMessages).toHaveBeenCalledWith(
'foo.ipynb',
expect.anything()
);
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' }
]
});
mockedBindExistingSession.mockResolvedValue({
ok: false,
notebookPath: 'foo.ipynb',
error: 'upstream down'
});
const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1', [], 'foo.ipynb'));
const panel = new OpenCodeFloatingPanel({
app,
serverSettings: {} as any,
providers: null
});
Widget.attach(panel, document.body);
panel.toggle();
await new Promise(r => setTimeout(r, 5));
const prompt = (panel 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));
expect(select.value).toBe('sess-A');
expect((prompt as any)._sessionId).toBe('sess-A');
expect(mockedNotification.error).toHaveBeenCalledWith('切换会话失败');
});
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
// 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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit,
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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('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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
onPermissionReply,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
onQuestionReply,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
onQuestionReply,
getActiveCell: () => cell
});
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({
onSubmit: jest.fn(),
onCancel: jest.fn(),
disabled: false,
providers: null,
getActiveCell: () => cell
});
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);
});
});