feat(web): add WebSocket transport for message event streaming with SSE fallback (#764)

* feat: add websocket message stream transport

* fix: avoid false missing session directories in sidebar

* fix: re-probe project root session directories

* refactor: use button group for message stream transport

* fix: resolve chat input hook dependency warning

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Yifan
2026-04-17 11:07:26 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent fd8972a7d9
commit bee9d19f3a
26 changed files with 1515 additions and 163 deletions
@@ -3,6 +3,7 @@ import { createEventPipeline } from '../event-pipeline';
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
const originalWebSocket = globalThis.WebSocket;
function installDomStubs() {
globalThis.document = {
@@ -12,14 +13,52 @@ function installDomStubs() {
};
globalThis.window = {
location: {
href: 'http://127.0.0.1:3000/',
origin: 'http://127.0.0.1:3000',
},
addEventListener() {},
removeEventListener() {},
};
}
class FakeWebSocket {
static instances = [];
constructor(url) {
this.url = url;
this.readyState = 0;
this.onopen = null;
this.onmessage = null;
this.onerror = null;
this.onclose = null;
FakeWebSocket.instances.push(this);
}
close() {
this.readyState = 3;
}
emitOpen() {
this.readyState = 1;
this.onopen?.();
}
emitMessage(payload) {
this.onmessage?.({ data: JSON.stringify(payload) });
}
emitClose() {
this.readyState = 3;
this.onclose?.();
}
}
afterEach(() => {
globalThis.document = originalDocument;
globalThis.window = originalWindow;
globalThis.WebSocket = originalWebSocket;
FakeWebSocket.instances = [];
});
function createSdkWithSingleEvent(event, hold) {
@@ -473,6 +512,160 @@ describe('createEventPipeline', () => {
expect(received[0].payload.type).toBe('message.part.updated');
expect(received[0].payload.properties.part.text).toBe('next');
});
it('consumes websocket message stream frames when transport is ws', async () => {
installDomStubs();
globalThis.WebSocket = FakeWebSocket;
const received = [];
const sdk = {
global: {
event: async () => {
throw new Error('SSE should not be used in ws mode');
},
},
};
const delivered = new Promise((resolve) => {
const { cleanup } = createEventPipeline({
sdk,
transport: 'ws',
onEvent: (directory, payload) => {
received.push({ directory, payload });
cleanup();
resolve();
},
});
});
await Promise.resolve();
const socket = FakeWebSocket.instances[0];
expect(socket?.url).toContain('/api/global/event/ws');
socket.emitOpen();
socket.emitMessage({ type: 'ready', scope: 'global' });
socket.emitMessage({
type: 'event',
eventId: 'evt-1',
directory: '/tmp/project',
payload: {
type: 'session.status',
properties: {
sessionID: 'session-1',
},
},
});
await delivered;
expect(received).toEqual([
{
directory: '/tmp/project',
payload: {
type: 'session.status',
properties: {
sessionID: 'session-1',
},
},
},
]);
});
it('falls back to SSE when websocket closes before ready in auto mode', async () => {
installDomStubs();
globalThis.WebSocket = FakeWebSocket;
let releaseStream;
const hold = new Promise((resolve) => {
releaseStream = resolve;
});
const received = [];
const sdk = createSdkWithSingleEvent({
payload: {
type: 'server.connected',
properties: {},
},
}, hold);
const delivered = new Promise((resolve) => {
const { cleanup } = createEventPipeline({
sdk,
transport: 'auto',
onEvent: (directory, payload) => {
received.push({ directory, payload });
cleanup();
releaseStream();
resolve();
},
});
});
await Promise.resolve();
const socket = FakeWebSocket.instances[0];
socket.emitClose();
await delivered;
expect(received).toEqual([
{
directory: 'global',
payload: {
type: 'server.connected',
properties: {},
},
},
]);
});
it('falls back to SSE when websocket does not become ready in auto mode', async () => {
installDomStubs();
globalThis.WebSocket = FakeWebSocket;
let releaseStream;
const hold = new Promise((resolve) => {
releaseStream = resolve;
});
const received = [];
const sdk = createSdkWithSingleEvent({
payload: {
type: 'server.connected',
properties: {},
},
}, hold);
const delivered = new Promise((resolve) => {
const { cleanup } = createEventPipeline({
sdk,
transport: 'auto',
onEvent: (directory, payload) => {
received.push({ directory, payload });
cleanup();
releaseStream();
resolve();
},
});
});
await Promise.resolve();
const socket = FakeWebSocket.instances[0];
socket.emitOpen();
await new Promise((resolve) => setTimeout(resolve, 2300));
await delivered;
expect(received).toEqual([
{
directory: 'global',
payload: {
type: 'server.connected',
properties: {},
},
},
]);
});
});
// ---------------------------------------------------------------------------