feat: async + SSE message flow with interactive permission/question UI
Replace the synchronous /edit (wait for full markdown response) with an
async + SSE flow per demo.html so the user sees text stream in real-time
and can interact with permission/question events the agent raises.
Backend
-------
- EditHandler now calls OpenCode /session/:id/prompt_async and returns
immediately with {ok, sessionId, notebookPath}; the LLM reply is no
longer embedded in this response.
- New GlobalEventHandler proxies OpenCode /global/event as
text/event-stream. Server forwards ALL events; the client filters.
A too-eager server-side ?session= filter was silently dropping events
the client would have accepted, so it was removed.
- New PermissionReplyHandler + QuestionReplyHandler forward user
replies (once/always/reject and freeform answer) back to OpenCode
Serve at /session/:sid/permissions/:permId and
/session/:sid/question/:qId/reply.
- OpenCodeClient gains send_message_async(), stream_global_events()
(async generator over the SSE feed via tornado streaming_callback),
reply_permission() and reply_question(). Legacy send_message_sync
removed.
Frontend
--------
- subscribeOpenCodeEvents() opens a fetch+reader SSE client (XSRF
token injected from serverSettings); AbortController-backed close()
is idempotent.
- OpenCodeInlinePrompt.applyEvent() routes events into the streaming
UI: text delta -> assistant message (re-rendered as markdown on
every delta so the user sees formatted <pre><code> blocks in
real-time, not raw fence source); reasoning/tool/permission/question
get collapsible details blocks. session.idle resets stream pointers
and fires onStreamEnd.
- Permission/question blocks render real interactive UI: three
buttons (once/always/reject) for permission, a text input + submit
for question. Click handlers post through the new API routes and
show success/failure status in place.
- Centralized idle detection (4 shapes: top-level + payload-nested
x {session.idle, session.status/idle}) inside the prompt; cell
action reacts via onStreamEnd callback rather than re-parsing
event types.
- System / workspace / pty / lsp / mcp / installation events AND
session-level control events (agent.switched, model.switched,
file.edited) are not rendered in the frontend.
Tests
-----
- 52 backend pytest (was 43)
- 58 frontend jest (was 46)
design.md section 3 updated for the new async /edit response shape
and the new GET /events SSE endpoint.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -109,7 +109,10 @@ beforeAll(() => {
|
||||
jest.mock('../api/opencode_client', () => ({
|
||||
callOpenCodeEdit: jest.fn(),
|
||||
callOpenCodeProviders: jest.fn(),
|
||||
callOpenCodeSessionMessages: jest.fn().mockResolvedValue({ messages: [] })
|
||||
callOpenCodeSessionMessages: jest.fn().mockResolvedValue({ messages: [] }),
|
||||
callOpenCodeReplyPermission: jest.fn().mockResolvedValue({ ok: true }),
|
||||
callOpenCodeReplyQuestion: jest.fn().mockResolvedValue({ ok: true }),
|
||||
subscribeOpenCodeEvents: jest.fn().mockReturnValue({ close: jest.fn() })
|
||||
}));
|
||||
|
||||
import {
|
||||
@@ -119,6 +122,20 @@ import {
|
||||
} from '../components/opencode_cell_actions';
|
||||
import { OpenCodeInlinePrompt } from '../components/opencode_inline_prompt';
|
||||
import { CodeCell } from '@jupyterlab/cells';
|
||||
import {
|
||||
callOpenCodeEdit,
|
||||
subscribeOpenCodeEvents
|
||||
} from '../api/opencode_client';
|
||||
import { Notification } from '@jupyterlab/apputils';
|
||||
|
||||
const mockedCallOpenCodeEdit = callOpenCodeEdit as jest.MockedFunction<
|
||||
typeof callOpenCodeEdit
|
||||
>;
|
||||
const mockedSubscribeOpenCodeEvents =
|
||||
subscribeOpenCodeEvents as jest.MockedFunction<
|
||||
typeof subscribeOpenCodeEvents
|
||||
>;
|
||||
const mockedNotification = Notification as jest.Mocked<typeof Notification>;
|
||||
|
||||
function makeFakeOutputs(items: any[]): any {
|
||||
return {
|
||||
@@ -395,58 +412,197 @@ describe('OpenCodeCellActions', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('on a successful response, does NOT replace the cell source and triggers a history refresh', () => {
|
||||
it('on submit: posts to /edit, binds SSE, does NOT replace cell source, clears input', async () => {
|
||||
setOpenCodeProviders(null);
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
// Click once to create the inline prompt (so the history refresh
|
||||
// has a target and the cell source check has a baseline).
|
||||
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||
(actions as any).onAfterAttach({} as any);
|
||||
const btn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||
btn.click();
|
||||
|
||||
// The history refresh on show is async; spy on _refreshHistory to
|
||||
// confirm it gets called (without depending on the network).
|
||||
const refreshSpy = jest.spyOn(actions as any, '_refreshHistory');
|
||||
|
||||
(actions as any)._handleResponse({
|
||||
mockedCallOpenCodeEdit.mockResolvedValue({
|
||||
ok: true,
|
||||
markdown: '# AI says hi\n\n```python\nprint("hi")\n```',
|
||||
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
|
||||
);
|
||||
|
||||
// The cell source must remain unchanged.
|
||||
expect((cell as any).model.sharedModel.setSource).not.toHaveBeenCalled();
|
||||
// A history refresh was triggered (to pick up the new assistant msg).
|
||||
expect(refreshSpy).toHaveBeenCalledWith('foo.ipynb');
|
||||
});
|
||||
|
||||
it('clears the input textarea after a successful response', () => {
|
||||
setOpenCodeProviders(null);
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||
(actions as any).onAfterAttach({} as any);
|
||||
|
||||
// Open the prompt and type something into the textarea.
|
||||
const aiBtn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||
aiBtn.click();
|
||||
|
||||
// Type into the textarea, then submit.
|
||||
const prompt = (actions as any)._prompt;
|
||||
expect(prompt).not.toBeNull();
|
||||
const textarea = prompt._textarea as HTMLTextAreaElement;
|
||||
textarea.value = 'make it faster';
|
||||
(prompt._sendBtn as HTMLButtonElement).click();
|
||||
|
||||
// A successful response clears the input so the user can type a
|
||||
// follow-up message without manually deleting the previous prompt.
|
||||
(actions as any)._handleResponse({
|
||||
// Let the async _onSubmit run.
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
|
||||
// callOpenCodeEdit was called with the right body.
|
||||
expect(mockedCallOpenCodeEdit).toHaveBeenCalledTimes(1);
|
||||
expect(mockedCallOpenCodeEdit.mock.calls[0][0].prompt).toBe('make it faster');
|
||||
expect(mockedCallOpenCodeEdit.mock.calls[0][0].context.notebookPath).toBe('foo.ipynb');
|
||||
|
||||
// SSE subscription was opened with the returned sessionId.
|
||||
expect(subscribeCalls).toHaveLength(1);
|
||||
expect(subscribeCalls[0].sid).toBe('ses-abc');
|
||||
expect(typeof subscribeCalls[0].handlers.onEvent).toBe('function');
|
||||
|
||||
// The cell source must remain unchanged.
|
||||
expect((cell as any).model.sharedModel.setSource).not.toHaveBeenCalled();
|
||||
|
||||
// The textarea is cleared so the user can type a follow-up.
|
||||
expect(textarea.value).toBe('');
|
||||
});
|
||||
|
||||
it('on submit: forwards SSE events to the prompt and closes the stream on session.idle', async () => {
|
||||
setOpenCodeProviders(null);
|
||||
mockedCallOpenCodeEdit.mockResolvedValue({
|
||||
ok: true,
|
||||
markdown: 'ok',
|
||||
sessionId: 'ses-1',
|
||||
notebookPath: 'foo.ipynb'
|
||||
});
|
||||
expect(textarea.value).toBe('');
|
||||
|
||||
const fakeSse = { close: jest.fn() };
|
||||
let captured: any = null;
|
||||
mockedSubscribeOpenCodeEvents.mockImplementation(
|
||||
((_sid: string, _s: any, handlers: any) => {
|
||||
captured = handlers;
|
||||
return fakeSse;
|
||||
}) as any
|
||||
);
|
||||
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||
(actions as any).onAfterAttach({} as any);
|
||||
const aiBtn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||
aiBtn.click();
|
||||
const prompt = (actions as any)._prompt;
|
||||
const textarea = prompt._textarea as HTMLTextAreaElement;
|
||||
textarea.value = 'go';
|
||||
(prompt._sendBtn as HTMLButtonElement).click();
|
||||
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
expect(captured).not.toBeNull();
|
||||
|
||||
// Simulate a text-delta event arriving.
|
||||
const applySpy = jest.spyOn(prompt, 'applyEvent');
|
||||
captured.onEvent({
|
||||
type: 'session.next.text.delta',
|
||||
properties: { sessionID: 'ses-1', delta: 'hel' }
|
||||
});
|
||||
expect(applySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'session.next.text.delta' })
|
||||
);
|
||||
|
||||
// session.idle should close the stream and re-enable the input.
|
||||
captured.onEvent({ type: 'session.idle', properties: { sessionID: 'ses-1' } });
|
||||
expect(fakeSse.close).toHaveBeenCalled();
|
||||
expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('on submit: session.status with status.type=idle also closes the stream (idle shape variants)', async () => {
|
||||
setOpenCodeProviders(null);
|
||||
mockedCallOpenCodeEdit.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionId: 'ses-1',
|
||||
notebookPath: 'foo.ipynb'
|
||||
});
|
||||
const fakeSse = { close: jest.fn() };
|
||||
let captured: any = null;
|
||||
mockedSubscribeOpenCodeEvents.mockImplementation(
|
||||
((_sid: string, _s: any, handlers: any) => {
|
||||
captured = handlers;
|
||||
return fakeSse;
|
||||
}) as any
|
||||
);
|
||||
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||
(actions as any).onAfterAttach({} as any);
|
||||
(actions.node.querySelector('button') as HTMLButtonElement).click();
|
||||
const prompt = (actions as any)._prompt;
|
||||
(prompt._textarea as HTMLTextAreaElement).value = 'go';
|
||||
(prompt._sendBtn as HTMLButtonElement).click();
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
|
||||
// session.status with status.type === 'idle' should also close the
|
||||
// stream (some OpenCode versions emit this shape instead of
|
||||
// session.idle).
|
||||
captured.onEvent({
|
||||
type: 'session.status',
|
||||
properties: { sessionID: 'ses-1', status: { type: 'idle' } }
|
||||
});
|
||||
expect(fakeSse.close).toHaveBeenCalled();
|
||||
expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('on submit: idle nested under payload also closes the stream', async () => {
|
||||
setOpenCodeProviders(null);
|
||||
mockedCallOpenCodeEdit.mockResolvedValue({
|
||||
ok: true,
|
||||
sessionId: 'ses-1',
|
||||
notebookPath: 'foo.ipynb'
|
||||
});
|
||||
const fakeSse = { close: jest.fn() };
|
||||
let captured: any = null;
|
||||
mockedSubscribeOpenCodeEvents.mockImplementation(
|
||||
((_sid: string, _s: any, handlers: any) => {
|
||||
captured = handlers;
|
||||
return fakeSse;
|
||||
}) as any
|
||||
);
|
||||
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||
(actions as any).onAfterAttach({} as any);
|
||||
(actions.node.querySelector('button') as HTMLButtonElement).click();
|
||||
const prompt = (actions as any)._prompt;
|
||||
(prompt._textarea as HTMLTextAreaElement).value = 'go';
|
||||
(prompt._sendBtn as HTMLButtonElement).click();
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
|
||||
// Some OpenCode events have type at top level AND in payload.
|
||||
captured.onEvent({
|
||||
type: 'unknown.wrapper',
|
||||
payload: {
|
||||
type: 'session.idle',
|
||||
properties: { sessionID: 'ses-1' }
|
||||
}
|
||||
});
|
||||
expect(fakeSse.close).toHaveBeenCalled();
|
||||
expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('on submit failure: re-enables the input and shows an error notification', async () => {
|
||||
setOpenCodeProviders(null);
|
||||
mockedCallOpenCodeEdit.mockResolvedValue({
|
||||
ok: false,
|
||||
error: 'opencode down'
|
||||
});
|
||||
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const actions = new OpenCodeCellActions(cell);
|
||||
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||
(actions as any).onAfterAttach({} as any);
|
||||
const aiBtn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||
aiBtn.click();
|
||||
const prompt = (actions as any)._prompt;
|
||||
const textarea = prompt._textarea as HTMLTextAreaElement;
|
||||
textarea.value = 'go';
|
||||
(prompt._sendBtn as HTMLButtonElement).click();
|
||||
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
expect(mockedNotification.error).toHaveBeenCalledWith('OpenCode 错误: opencode down');
|
||||
expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -674,4 +830,397 @@ describe('OpenCodeInlinePrompt', () => {
|
||||
);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applyEvent: text deltas accumulate into a single assistant element (real-time streaming)', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
prompt.setSessionId('s1');
|
||||
|
||||
// Many deltas — they must all append to the SAME DOM node, not
|
||||
// create a new one each time (that would defeat real-time rendering).
|
||||
const deltas = ['hel', 'lo', ' ', 'wor', 'ld', '!', ' ', 'It works.'];
|
||||
for (const d of deltas) {
|
||||
prompt.applyEvent({
|
||||
type: 'session.next.text.delta',
|
||||
properties: { sessionID: 's1', delta: d }
|
||||
});
|
||||
}
|
||||
const all = prompt.node.querySelectorAll(
|
||||
'.opencode-inline-prompt .opencode-msg-assistant'
|
||||
);
|
||||
// Exactly one element — the streaming text is appended, not forked.
|
||||
expect(all.length).toBe(1);
|
||||
expect(all[0].textContent).toBe('hello world! It works.');
|
||||
});
|
||||
|
||||
it('applyEvent: unrecognized event types are logged but never throw', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
prompt.setSessionId('s1');
|
||||
// An event type the dispatcher doesn't recognize.
|
||||
expect(() =>
|
||||
prompt.applyEvent({
|
||||
type: 'some.future.event',
|
||||
properties: { sessionID: 's1', payload: 'whatever' }
|
||||
})
|
||||
).not.toThrow();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('unrecognized SSE event type'),
|
||||
'some.future.event',
|
||||
expect.anything()
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('applyEvent: streaming markdown is rendered as HTML in real-time (not raw ```fence``` source)', () => {
|
||||
// Regression: previously the streaming path appended deltas to
|
||||
// .textContent, so the user saw raw ```py\n...\n``` source while
|
||||
// the model was still typing. Only after closing+reopening the
|
||||
// panel (which goes through setMessages + marked.parse) did the
|
||||
// code block render as <pre><code>. The fix: re-render via
|
||||
// marked.parse on every delta.
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
prompt.setSessionId('s1');
|
||||
// Simulate a streaming reply that contains a fenced code block.
|
||||
const deltas = [
|
||||
'Here is the fix:\n\n',
|
||||
'```python\n',
|
||||
'import pandas as pd\n',
|
||||
'import numpy as np\n',
|
||||
'```\n'
|
||||
];
|
||||
for (const d of deltas) {
|
||||
prompt.applyEvent({
|
||||
type: 'session.next.text.delta',
|
||||
properties: { sessionID: 's1', delta: d }
|
||||
});
|
||||
}
|
||||
// The <pre><code> block is rendered DURING streaming, not just at
|
||||
// the end. The raw ```fence``` characters must NOT appear as visible
|
||||
// text content in the message body.
|
||||
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).not.toBeNull();
|
||||
expect(code.textContent).toBe('import pandas as pd\nimport numpy as np');
|
||||
// The Copy / Insert / Replace toolbar is attached to the <pre>
|
||||
// block during streaming, 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);
|
||||
// The wrapper still has a single assistant element (not one per delta).
|
||||
expect(
|
||||
prompt.node.querySelectorAll(
|
||||
'.opencode-inline-prompt .opencode-msg-assistant'
|
||||
).length
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it('applyEvent: message.part.delta with field=text routes to the assistant message', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
prompt.setSessionId('s1');
|
||||
prompt.applyEvent({
|
||||
type: 'message.part.delta',
|
||||
properties: { sessionID: 's1', field: 'text', delta: 'world' }
|
||||
});
|
||||
const asst = prompt.node.querySelector(
|
||||
'.opencode-inline-prompt .opencode-msg-assistant'
|
||||
) as HTMLElement;
|
||||
expect(asst.textContent).toBe('world');
|
||||
});
|
||||
|
||||
it('applyEvent: reasoning deltas render into a collapsible green block', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
prompt.setSessionId('s1');
|
||||
prompt.applyEvent({
|
||||
type: 'session.next.reasoning.delta',
|
||||
properties: { sessionID: 's1', delta: 'thinking...' }
|
||||
});
|
||||
const block = prompt.node.querySelector(
|
||||
'.opencode-inline-prompt .opencode-msg-block-reasoning'
|
||||
) as HTMLElement;
|
||||
expect(block).not.toBeNull();
|
||||
const content = block.querySelector(
|
||||
'.opencode-msg-block-content'
|
||||
) as HTMLElement;
|
||||
expect(content.textContent).toBe('thinking...');
|
||||
});
|
||||
|
||||
it('applyEvent: tool-call lifecycle creates and updates a tool block', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
prompt.setSessionId('s1');
|
||||
prompt.applyEvent({
|
||||
type: 'session.next.tool.called',
|
||||
properties: { sessionID: 's1', name: 'bash' }
|
||||
});
|
||||
prompt.applyEvent({
|
||||
type: 'session.next.tool.input.delta',
|
||||
properties: { sessionID: 's1', delta: '{"cmd":' }
|
||||
});
|
||||
prompt.applyEvent({
|
||||
type: 'session.next.tool.success',
|
||||
properties: { sessionID: 's1', result: { ok: true } }
|
||||
});
|
||||
const block = prompt.node.querySelector(
|
||||
'.opencode-inline-prompt .opencode-msg-block-tool'
|
||||
) as HTMLElement;
|
||||
expect(block).not.toBeNull();
|
||||
const summary = block.querySelector('summary') as HTMLElement;
|
||||
expect(summary.textContent).toContain('bash');
|
||||
const content = block.querySelector(
|
||||
'.opencode-msg-block-content'
|
||||
) as HTMLElement;
|
||||
expect(content.textContent).toContain('{"cmd":');
|
||||
expect(content.textContent).toContain('"ok": true');
|
||||
});
|
||||
|
||||
it('applyEvent: session.idle resets stream pointers (next text delta starts a fresh assistant element)', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
prompt.setSessionId('s1');
|
||||
prompt.applyEvent({
|
||||
type: 'session.next.text.delta',
|
||||
properties: { sessionID: 's1', delta: 'turn1' }
|
||||
});
|
||||
prompt.applyEvent({
|
||||
type: 'session.idle',
|
||||
properties: { sessionID: 's1' }
|
||||
});
|
||||
prompt.applyEvent({
|
||||
type: 'session.next.text.delta',
|
||||
properties: { sessionID: 's1', delta: 'turn2' }
|
||||
});
|
||||
const asstBlocks = prompt.node.querySelectorAll(
|
||||
'.opencode-inline-prompt .opencode-msg-assistant'
|
||||
);
|
||||
expect(asstBlocks.length).toBe(2);
|
||||
expect(asstBlocks[0].textContent).toBe('turn1');
|
||||
expect(asstBlocks[1].textContent).toBe('turn2');
|
||||
});
|
||||
|
||||
it('applyEvent: drops events for a different session (defensive filter)', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
prompt.setSessionId('s1');
|
||||
prompt.applyEvent({
|
||||
type: 'session.next.text.delta',
|
||||
properties: { sessionID: 's2', delta: 'should be ignored' }
|
||||
});
|
||||
const asst = prompt.node.querySelector(
|
||||
'.opencode-inline-prompt .opencode-msg-assistant'
|
||||
);
|
||||
expect(asst).toBeNull();
|
||||
});
|
||||
|
||||
it('applyEvent: permission.asked creates a permission block with 3 buttons', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
prompt.setSessionId('s1');
|
||||
prompt.applyEvent({
|
||||
type: 'permission.asked',
|
||||
properties: { sessionID: 's1', id: 'perm-1', description: 'rm -rf' }
|
||||
});
|
||||
const block = prompt.node.querySelector(
|
||||
'.opencode-inline-prompt .opencode-msg-block-interaction'
|
||||
) as HTMLElement;
|
||||
expect(block).not.toBeNull();
|
||||
expect(block.id).toBe('opencode-perm-perm-1');
|
||||
expect(block.textContent).toContain('rm -rf');
|
||||
// Three buttons: once / always / reject.
|
||||
const buttons = block.querySelectorAll('button');
|
||||
expect(buttons.length).toBe(3);
|
||||
expect(buttons[0].textContent).toContain('Once');
|
||||
expect(buttons[1].textContent).toContain('Always');
|
||||
expect(buttons[2].textContent).toContain('Reject');
|
||||
});
|
||||
|
||||
it('applyEvent: clicking a permission button calls onPermissionReply and shows status', async () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const onPermissionReply = jest.fn().mockResolvedValue(true);
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null,
|
||||
onPermissionReply
|
||||
});
|
||||
prompt.setSessionId('s1');
|
||||
prompt.applyEvent({
|
||||
type: 'permission.asked',
|
||||
properties: { sessionID: 's1', id: 'perm-9', description: 'sudo' }
|
||||
});
|
||||
const rejectBtn = prompt.node.querySelector(
|
||||
'.opencode-perm-btn-reject'
|
||||
) as HTMLButtonElement;
|
||||
rejectBtn.click();
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
expect(onPermissionReply).toHaveBeenCalledWith('perm-9', 'reject');
|
||||
// The button group now shows the success status.
|
||||
const block = prompt.node.querySelector(
|
||||
'#opencode-perm-perm-9'
|
||||
) as HTMLElement;
|
||||
expect(block.textContent).toContain('已拒绝');
|
||||
});
|
||||
|
||||
it('applyEvent: question.asked creates a question block with input + submit', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
prompt.setSessionId('s1');
|
||||
prompt.applyEvent({
|
||||
type: 'question.asked',
|
||||
properties: { sessionID: 's1', id: 'q-1', question: 'which env?' }
|
||||
});
|
||||
const block = prompt.node.querySelector(
|
||||
'#opencode-q-q-1'
|
||||
) as HTMLElement;
|
||||
expect(block).not.toBeNull();
|
||||
expect(block.textContent).toContain('which env?');
|
||||
expect(block.querySelector('input.opencode-q-input')).not.toBeNull();
|
||||
expect(block.querySelector('button.opencode-q-submit')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('applyEvent: question submit calls onQuestionReply with the typed answer', async () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const onQuestionReply = jest.fn().mockResolvedValue(true);
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null,
|
||||
onQuestionReply
|
||||
});
|
||||
prompt.setSessionId('s1');
|
||||
prompt.applyEvent({
|
||||
type: 'question.asked',
|
||||
properties: { sessionID: 's1', id: 'q-2', question: 'which version?' }
|
||||
});
|
||||
const input = prompt.node.querySelector(
|
||||
'.opencode-q-input'
|
||||
) as HTMLInputElement;
|
||||
const submit = prompt.node.querySelector(
|
||||
'.opencode-q-submit'
|
||||
) as HTMLButtonElement;
|
||||
input.value = 'use 3.12';
|
||||
submit.click();
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
expect(onQuestionReply).toHaveBeenCalledWith('q-2', 'use 3.12');
|
||||
const block = prompt.node.querySelector('#opencode-q-q-2') as HTMLElement;
|
||||
expect(block.textContent).toContain('已回答');
|
||||
});
|
||||
|
||||
it('applyEvent: question submit with empty input is a no-op', async () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const onQuestionReply = jest.fn();
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null,
|
||||
onQuestionReply
|
||||
});
|
||||
prompt.setSessionId('s1');
|
||||
prompt.applyEvent({
|
||||
type: 'question.asked',
|
||||
properties: { sessionID: 's1', id: 'q-3', question: 'color?' }
|
||||
});
|
||||
const submit = prompt.node.querySelector(
|
||||
'.opencode-q-submit'
|
||||
) as HTMLButtonElement;
|
||||
submit.click();
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
expect(onQuestionReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applyEvent: system events (server.* etc.) are NOT rendered', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
prompt.setSessionId('s1');
|
||||
const systemTypes = [
|
||||
'server.connected',
|
||||
'workspace.updated',
|
||||
'lsp.updated',
|
||||
'mcp.tools.changed',
|
||||
'pty.created',
|
||||
'session.next.agent.switched',
|
||||
'session.next.model.switched',
|
||||
'file.edited'
|
||||
];
|
||||
for (const t of systemTypes) {
|
||||
prompt.applyEvent({ type: t, properties: { sessionID: 's1' } });
|
||||
}
|
||||
// No system lines, no message blocks, nothing rendered.
|
||||
expect(
|
||||
prompt.node.querySelector('.opencode-msg-system')
|
||||
).toBeNull();
|
||||
expect(
|
||||
prompt.node.querySelectorAll('.opencode-msg').length
|
||||
).toBe(0);
|
||||
expect(
|
||||
prompt.node.querySelectorAll('.opencode-msg-block').length
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user