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:
@@ -1,6 +1,12 @@
|
||||
import { ServerConnection } from '@jupyterlab/services';
|
||||
|
||||
import { callOpenCodeEdit, callOpenCodeProviders } from '../api/opencode_client';
|
||||
import {
|
||||
callOpenCodeEdit,
|
||||
callOpenCodeProviders,
|
||||
callOpenCodeReplyPermission,
|
||||
callOpenCodeReplyQuestion,
|
||||
subscribeOpenCodeEvents
|
||||
} from '../api/opencode_client';
|
||||
import type { OpenCodeRequest } from '../types';
|
||||
|
||||
jest.mock('@jupyterlab/services', () => {
|
||||
@@ -82,10 +88,9 @@ describe('callOpenCodeEdit', () => {
|
||||
mockedMakeRequest.mockReset();
|
||||
});
|
||||
|
||||
it('POSTs to /opencode-bridge/edit with JSON body', async () => {
|
||||
it('POSTs to /opencode-bridge/edit and returns async {ok, sessionId, notebookPath}', async () => {
|
||||
const respBody = {
|
||||
ok: true,
|
||||
markdown: '```python\ndef foo(x: int) -> int: return x\n```',
|
||||
sessionId: 'sid',
|
||||
notebookPath: 'foo.ipynb',
|
||||
};
|
||||
@@ -107,7 +112,10 @@ describe('callOpenCodeEdit', () => {
|
||||
);
|
||||
expect(resp.ok).toBe(true);
|
||||
if (resp.ok) {
|
||||
expect(resp.markdown).toContain('int');
|
||||
// Async response has no `markdown` field — the LLM reply arrives
|
||||
// via the SSE stream, not embedded in this response.
|
||||
expect(resp.sessionId).toBe('sid');
|
||||
expect((resp as any).markdown).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -219,3 +227,161 @@ describe('callOpenCodeProviders', () => {
|
||||
).rejects.toThrow(/network error/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callOpenCodeReplyPermission', () => {
|
||||
beforeEach(() => {
|
||||
mockedMakeRequest.mockReset();
|
||||
});
|
||||
|
||||
it('POSTs to /opencode-bridge/permissions/:permId with {response}', async () => {
|
||||
mockedMakeRequest.mockResolvedValue(
|
||||
mockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: JSON.stringify({ ok: true, permissionId: 'perm-1', response: 'once' })
|
||||
})
|
||||
);
|
||||
|
||||
const r = await callOpenCodeReplyPermission(
|
||||
's1',
|
||||
'perm-1',
|
||||
'once',
|
||||
mockServerSettings()
|
||||
);
|
||||
expect(r).toEqual({ ok: true, permissionId: 'perm-1', response: 'once' });
|
||||
expect(mockedMakeRequest).toHaveBeenCalledWith(
|
||||
'http://localhost:8888/opencode-bridge/permissions/perm-1?session=s1',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ response: 'once' })
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on non-2xx', async () => {
|
||||
mockedMakeRequest.mockResolvedValue(
|
||||
mockResponse({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
text: 'bad response'
|
||||
})
|
||||
);
|
||||
await expect(
|
||||
callOpenCodeReplyPermission('s1', 'p', 'reject', mockServerSettings())
|
||||
).rejects.toThrow(/400/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('callOpenCodeReplyQuestion', () => {
|
||||
beforeEach(() => {
|
||||
mockedMakeRequest.mockReset();
|
||||
});
|
||||
|
||||
it('POSTs to /opencode-bridge/questions/:qId/reply with {answer}', async () => {
|
||||
mockedMakeRequest.mockResolvedValue(
|
||||
mockResponse({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: JSON.stringify({ ok: true, questionId: 'q-1' })
|
||||
})
|
||||
);
|
||||
|
||||
const r = await callOpenCodeReplyQuestion(
|
||||
's1',
|
||||
'q-1',
|
||||
'use 3.12',
|
||||
mockServerSettings()
|
||||
);
|
||||
expect(r).toEqual({ ok: true, questionId: 'q-1' });
|
||||
expect(mockedMakeRequest).toHaveBeenCalledWith(
|
||||
'http://localhost:8888/opencode-bridge/questions/q-1/reply?session=s1',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ answer: 'use 3.12' })
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* subscribeOpenCodeEvents uses the global `fetch` (not ServerConnection),
|
||||
* so we mock fetch instead of ServerConnection.makeRequest.
|
||||
*/
|
||||
describe('subscribeOpenCodeEvents', () => {
|
||||
let originalFetch: typeof fetch;
|
||||
let mockReader: { read: jest.Mock };
|
||||
let capturedUrl: string | undefined;
|
||||
let capturedInit: RequestInit | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
mockReader = {
|
||||
read: jest
|
||||
.fn()
|
||||
// First call returns a chunk; second returns done.
|
||||
.mockResolvedValueOnce({
|
||||
value: new TextEncoder().encode(
|
||||
'data: {"type":"session.next.text.delta","properties":{"sessionID":"sid","delta":"hello"}}\n\n' +
|
||||
'data: {"type":"session.idle","properties":{"sessionID":"sid"}}\n\n'
|
||||
),
|
||||
done: false
|
||||
})
|
||||
.mockResolvedValueOnce({ value: undefined, done: true })
|
||||
};
|
||||
globalThis.fetch = jest.fn(async (url: any, init?: RequestInit) => {
|
||||
capturedUrl = String(url);
|
||||
capturedInit = init;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
body: {
|
||||
getReader: () => mockReader
|
||||
}
|
||||
} as unknown as Response;
|
||||
}) as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it('GETs /opencode-bridge/events?session=<sid> and dispatches parsed events', async () => {
|
||||
const onEvent = jest.fn();
|
||||
const settings = mockServerSettings();
|
||||
const sub = subscribeOpenCodeEvents('sid', settings, { onEvent });
|
||||
|
||||
// Let the async fetch + reader resolve.
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
sub.close();
|
||||
|
||||
expect(capturedUrl).toBe(
|
||||
'http://localhost:8888/opencode-bridge/events?session=sid'
|
||||
);
|
||||
expect(capturedInit?.method).toBe('GET');
|
||||
expect((capturedInit?.headers as any).Accept).toBe('text/event-stream');
|
||||
expect((capturedInit?.headers as any)['X-XSRFToken']).toBe('test');
|
||||
|
||||
expect(onEvent).toHaveBeenCalledTimes(2);
|
||||
expect(onEvent.mock.calls[0][0].type).toBe('session.next.text.delta');
|
||||
expect(onEvent.mock.calls[0][0].properties.delta).toBe('hello');
|
||||
expect(onEvent.mock.calls[1][0].type).toBe('session.idle');
|
||||
});
|
||||
|
||||
it('close() is idempotent and aborts the in-flight fetch', async () => {
|
||||
const onEvent = jest.fn();
|
||||
const sub = subscribeOpenCodeEvents('sid', mockServerSettings(), { onEvent });
|
||||
// Let the async fetch actually run and capture the signal.
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
sub.close();
|
||||
sub.close(); // second call must not throw
|
||||
const signal = capturedInit?.signal as AbortSignal | undefined;
|
||||
expect(signal).toBeDefined();
|
||||
expect(signal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user