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
@@ -80,7 +80,7 @@ This module provides OpenCode server integration utilities for the web server ru
- Keeps route behavior independent from composition root; `index.js` now supplies dependencies only.
## Public exports (session-runtime.js)
- `createSessionRuntime({ writeSseEvent, getNotificationClients })`: creates runtime-owned state machine and APIs for session status.
- `createSessionRuntime({ writeSseEvent, getNotificationClients, broadcastEvent? })`: creates runtime-owned state machine and APIs for session status.
- Returned API:
- `processOpenCodeSsePayload(payload)`
- `getSessionActivitySnapshot()`
@@ -42,7 +42,7 @@ const deriveSessionActivityTransitions = (payload) => {
return [];
};
export const createSessionRuntime = ({ writeSseEvent, getNotificationClients }) => {
export const createSessionRuntime = ({ writeSseEvent, getNotificationClients, broadcastEvent }) => {
const sessionActivityPhases = new Map();
const sessionActivityCooldowns = new Map();
const sessionStates = new Map();
@@ -93,6 +93,16 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients })
sessionActivityCooldowns.set(sessionId, timer);
}
if (typeof broadcastEvent === 'function') {
broadcastEvent({
type: 'openchamber:session-activity',
properties: {
sessionId,
phase,
},
});
}
return true;
};
@@ -132,21 +142,27 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients })
const attentionState = sessionAttentionStates.get(sessionId);
const attentionChanged = !!attentionState && existingAttentionState?.needsAttention !== attentionState.needsAttention;
const clients = getNotificationClients();
if (clients.size > 0 && (!existing || existing.status !== status || attentionChanged)) {
if (!existing || existing.status !== status || attentionChanged) {
const state = sessionStates.get(sessionId);
for (const res of clients) {
try {
writeSseEvent(res, {
type: 'openchamber:session-status',
properties: {
sessionId,
status: state.status,
timestamp: state.lastUpdateAt,
metadata: state.metadata,
needsAttention: attentionState?.needsAttention ?? false,
},
});
} catch {
const syntheticPayload = {
type: 'openchamber:session-status',
properties: {
sessionId,
status: state.status,
timestamp: state.lastUpdateAt,
metadata: state.metadata,
needsAttention: attentionState?.needsAttention ?? false,
},
};
if (typeof broadcastEvent === 'function') {
broadcastEvent(syntheticPayload);
} else if (clients.size > 0) {
for (const res of clients) {
try {
writeSseEvent(res, syntheticPayload);
} catch {
}
}
}
}
@@ -183,20 +199,27 @@ export const createSessionRuntime = ({ writeSseEvent, getNotificationClients })
if (wasNeedsAttention) {
state.needsAttention = false;
const clients = getNotificationClients();
for (const res of clients) {
try {
writeSseEvent(res, {
type: 'openchamber:session-status',
properties: {
sessionId,
status: state.status,
timestamp: Date.now(),
metadata: {},
needsAttention: false,
},
});
} catch {
const syntheticPayload = {
type: 'openchamber:session-status',
properties: {
sessionId,
status: state.status,
timestamp: Date.now(),
metadata: {},
needsAttention: false,
},
};
if (typeof broadcastEvent === 'function') {
broadcastEvent(syntheticPayload);
} else {
const clients = getNotificationClients();
for (const res of clients) {
try {
writeSseEvent(res, syntheticPayload);
} catch {
}
}
}
}
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { createSessionRuntime } from './session-runtime.js';
describe('session runtime', () => {
const runtimes = [];
afterEach(() => {
for (const runtime of runtimes) {
runtime.dispose();
}
runtimes.length = 0;
});
it('broadcasts attention clears through the shared broadcaster', () => {
const events = [];
const runtime = createSessionRuntime({
writeSseEvent() {
throw new Error('SSE fallback should not be used when broadcastEvent is provided');
},
getNotificationClients: () => new Set(),
broadcastEvent: (payload) => {
events.push(payload);
},
});
runtimes.push(runtime);
runtime.processOpenCodeSsePayload({
type: 'session.status',
properties: {
sessionID: 'session-1',
info: {
type: 'busy',
},
},
});
runtime.markUserMessageSent('session-1');
runtime.processOpenCodeSsePayload({
type: 'session.status',
properties: {
sessionID: 'session-1',
info: {
type: 'idle',
},
},
});
runtime.markSessionViewed('session-1', 'client-1');
expect(events).toContainEqual({
type: 'openchamber:session-status',
properties: expect.objectContaining({
sessionId: 'session-1',
status: 'idle',
needsAttention: true,
}),
});
expect(events.at(-1)).toEqual({
type: 'openchamber:session-status',
properties: {
sessionId: 'session-1',
status: 'idle',
timestamp: expect.any(Number),
metadata: {},
needsAttention: false,
},
});
});
});
@@ -325,6 +325,12 @@ export const createSettingsHelpers = (dependencies) => {
result.chatRenderMode = mode;
}
}
if (typeof candidate.messageStreamTransport === 'string') {
const mode = candidate.messageStreamTransport.trim();
if (mode === 'auto' || mode === 'ws' || mode === 'sse') {
result.messageStreamTransport = mode;
}
}
if (typeof candidate.activityRenderMode === 'string') {
const mode = candidate.activityRenderMode.trim();
if (mode === 'collapsed' || mode === 'summary') {
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'bun:test';
import { createSettingsHelpers } from './settings-helpers.js';
const createTestHelpers = () => createSettingsHelpers({
normalizePathForPersistence: (value) => value,
normalizeDirectoryPath: (value) => value,
normalizeTunnelBootstrapTtlMs: (value) => value,
normalizeTunnelSessionTtlMs: (value) => value,
normalizeTunnelProvider: (value) => value,
normalizeTunnelMode: (value) => value,
normalizeOptionalPath: (value) => value,
normalizeManagedRemoteTunnelHostname: (value) => value,
normalizeManagedRemoteTunnelPresets: () => undefined,
normalizeManagedRemoteTunnelPresetTokens: () => undefined,
sanitizeTypographySizesPartial: () => undefined,
normalizeStringArray: (input) => input,
sanitizeModelRefs: () => undefined,
sanitizeSkillCatalogs: () => undefined,
sanitizeProjects: () => undefined,
});
describe('settings helpers', () => {
it('accepts messageStreamTransport as a persisted shared setting', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'ws' })).toEqual({
messageStreamTransport: 'ws',
});
expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'sse' })).toEqual({
messageStreamTransport: 'sse',
});
expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'auto' })).toEqual({
messageStreamTransport: 'auto',
});
});
it('rejects invalid messageStreamTransport values', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'websocket' })).toEqual({});
});
});
@@ -13,6 +13,8 @@ export const createGracefulShutdownRuntime = (dependencies) => {
clearHealthCheckInterval,
getTerminalRuntime,
setTerminalRuntime,
getMessageStreamRuntime,
setMessageStreamRuntime,
shouldSkipOpenCodeStop,
getOpenCodePort,
getOpenCodeProcess,
@@ -54,6 +56,16 @@ export const createGracefulShutdownRuntime = (dependencies) => {
}
}
const messageStreamRuntime = getMessageStreamRuntime();
if (messageStreamRuntime) {
try {
await messageStreamRuntime.close();
} catch {
} finally {
setMessageStreamRuntime(null);
}
}
if (!shouldSkipOpenCodeStop()) {
const portToKill = getOpenCodePort();
const openCodeProcess = getOpenCodeProcess();
@@ -1,6 +1,7 @@
export const createStartupPipelineRuntime = (dependencies) => {
const {
createTerminalRuntime,
createMessageStreamWsRuntime,
createServerStartupRuntime,
} = dependencies;
@@ -17,6 +18,10 @@ export const createStartupPipelineRuntime = (dependencies) => {
isExecutable,
isRequestOriginAllowed,
rejectWebSocketUpgrade,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
processForwardedEventPayload,
messageStreamWsClients,
terminalHeartbeatIntervalMs,
terminalRebindWindowMs,
terminalMaxRebindsPerWindow,
@@ -62,6 +67,17 @@ export const createStartupPipelineRuntime = (dependencies) => {
TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW: terminalMaxRebindsPerWindow,
});
const messageStreamRuntime = createMessageStreamWsRuntime({
server,
uiAuthController,
isRequestOriginAllowed,
rejectWebSocketUpgrade,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
processForwardedEventPayload,
wsClients: messageStreamWsClients,
});
setupProxy(app);
scheduleOpenCodeApiDetection();
void bootstrapOpenCodeAtStartup();
@@ -98,6 +114,7 @@ export const createStartupPipelineRuntime = (dependencies) => {
return {
terminalRuntime,
messageStreamRuntime,
};
};