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:
tao.chen
2026-07-27 17:38:53 +08:00
committed by tao.chen
co-authored by Claude
parent a43174e5bc
commit b7089519fd
12 changed files with 2532 additions and 243 deletions
+170 -4
View File
@@ -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);
});
});