/** * 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
 for
    // fenced code blocks (so the per-code-block toolbar
    // post-processing can find them) and 

/

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 += '

' +
            fenceBuf.join('\n') +
            '
'; inFence = false; continue; } if (inFence) { fenceBuf.push(line); continue; } if (/^# /.test(line)) { html += '

' + line.slice(2) + '

'; } else if (line.trim()) { html += '

' + line + '

'; } } return html || '

' + md + '

'; } } })); // 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; 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('hide+show cycles do not accumulate prompt nodes in the panel (regression)', () => { // Bug: using _panelEl.appendChild(prompt.node) to attach the // prompt left the prompt's Lumino parent unset, so prompt.dispose() // in _hidePrompt did not remove the DOM node. Each hide+show // cycle would append a new prompt while the old one stayed in the // DOM — the user saw the panel content multiply on every click. // Fix: use Widget.attach so Lumino tracks the parent and dispose // can clean up. const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1')); const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null }); Widget.attach(panel, document.body); for (let i = 0; i < 5; i++) { panel.toggle(); // show panel.toggle(); // hide } panel.toggle(); // show one more time to inspect const panelEl = panel.node.querySelector('.opencode-floating-panel') as HTMLElement; // Exactly one prompt in the panel, regardless of how many // show/hide cycles happened. const prompts = panelEl.querySelectorAll('.opencode-inline-prompt'); expect(prompts.length).toBe(1); }); 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

+
.
    expect(asstMsg).not.toBeNull();
    expect(asstMsg.innerHTML).toContain('

Here you go

'); expect(asstMsg.innerHTML).toContain('
');
  });

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