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
+67
View File
@@ -30,6 +30,10 @@ import { prepareNotificationLastMessage } from './lib/notifications/index.js';
import { registerTtsRoutes } from './lib/tts/routes.js';
import { detectSayTtsCapability } from './lib/tts/capability-runtime.js';
import { createTerminalRuntime } from './lib/terminal/runtime.js';
import {
createGlobalUiEventBroadcaster,
createMessageStreamWsRuntime,
} from './lib/event-stream/index.js';
import { createFsSearchRuntime as createFsSearchRuntimeFactory } from './lib/fs/search.js';
import { createOpenCodeLifecycleRuntime } from './lib/opencode/lifecycle.js';
import { createOpenCodeEnvRuntime } from './lib/opencode/env-runtime.js';
@@ -76,6 +80,7 @@ const __dirname = path.dirname(__filename);
const DEFAULT_PORT = 3000;
const DESKTOP_NOTIFY_PREFIX = '[OpenChamberDesktopNotify] ';
const uiNotificationClients = new Set();
const uiNotificationWsClients = new Set();
const uiOpenChamberEventClients = new Set();
const HEALTH_CHECK_INTERVAL = 15000;
const SHUTDOWN_TIMEOUT = 10000;
@@ -306,15 +311,22 @@ const notificationEmitterRuntime = createNotificationEmitterRuntime({
getDesktopNotifyEnabled: () => ENV_DESKTOP_NOTIFY,
desktopNotifyPrefix: DESKTOP_NOTIFY_PREFIX,
getUiNotificationClients: () => uiNotificationClients,
getBroadcastGlobalUiEvent: () => broadcastGlobalUiEvent,
});
const writeSseEvent = (...args) => notificationEmitterRuntime.writeSseEvent(...args);
const emitDesktopNotification = (...args) => notificationEmitterRuntime.emitDesktopNotification(...args);
const broadcastGlobalUiEvent = createGlobalUiEventBroadcaster({
sseClients: uiNotificationClients,
wsClients: uiNotificationWsClients,
writeSseEvent,
});
const broadcastUiNotification = (...args) => notificationEmitterRuntime.broadcastUiNotification(...args);
const sessionRuntime = createSessionRuntime({
writeSseEvent,
getNotificationClients: () => uiNotificationClients,
broadcastEvent: broadcastGlobalUiEvent,
});
const projectConfigRuntime = createProjectConfigRuntime({
@@ -359,6 +371,7 @@ const tunnelAuthController = createTunnelAuth();
let runtimeManagedRemoteTunnelToken = '';
let runtimeManagedRemoteTunnelHostname = '';
let terminalRuntime = null;
let messageStreamRuntime = null;
const userProvidedOpenCodePassword = hmrStateRuntime.getUserProvidedOpenCodePassword(hmrState);
const initialOpenCodeAuthState = hmrStateRuntime.resolveOpenCodeAuthFromState({
hmrState,
@@ -597,6 +610,50 @@ const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({
},
});
const processForwardedEventPayload = (payload, emitSyntheticEvent) => {
if (!payload || typeof payload !== 'object' || typeof emitSyntheticEvent !== 'function') {
return;
}
maybeCacheSessionInfoFromEvent(payload);
if (payload.type !== 'session.status') {
return;
}
const properties = payload.properties && typeof payload.properties === 'object' ? payload.properties : {};
const info = properties.info && typeof properties.info === 'object' ? properties.info : {};
const sessionId = typeof properties.sessionID === 'string' ? properties.sessionID.trim() : '';
const status = typeof info.type === 'string' ? info.type.trim() : '';
if (!sessionId || !status) {
return;
}
emitSyntheticEvent({
type: 'openchamber:session-status',
properties: {
sessionId,
status,
timestamp: Date.now(),
metadata: {
attempt: typeof info.attempt === 'number' ? info.attempt : undefined,
message: typeof info.message === 'string' ? info.message : undefined,
next: typeof info.next === 'number' ? info.next : undefined,
},
needsAttention: false,
},
});
emitSyntheticEvent({
type: 'openchamber:session-activity',
properties: {
sessionId,
phase: status === 'busy' || status === 'retry' ? 'busy' : 'idle',
},
});
};
const serverUtilsRuntime = createServerUtilsRuntime({
fs,
@@ -704,6 +761,7 @@ const tunnelWiringRuntime = createTunnelWiringRuntime({
});
const startupPipelineRuntime = createStartupPipelineRuntime({
createTerminalRuntime,
createMessageStreamWsRuntime,
createServerStartupRuntime,
});
@@ -842,6 +900,10 @@ const gracefulShutdownRuntime = createGracefulShutdownRuntime({
setTerminalRuntime: (value) => {
terminalRuntime = value;
},
getMessageStreamRuntime: () => messageStreamRuntime,
setMessageStreamRuntime: (value) => {
messageStreamRuntime = value;
},
shouldSkipOpenCodeStop: () => ENV_SKIP_OPENCODE_START || isExternalOpenCode,
getOpenCodePort: () => openCodePort,
getOpenCodeProcess: () => openCodeProcess,
@@ -1028,6 +1090,10 @@ async function main(options = {}) {
isExecutable,
isRequestOriginAllowed,
rejectWebSocketUpgrade,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
processForwardedEventPayload,
messageStreamWsClients: uiNotificationWsClients,
terminalHeartbeatIntervalMs: TERMINAL_INPUT_WS_HEARTBEAT_INTERVAL_MS,
terminalRebindWindowMs: TERMINAL_INPUT_WS_REBIND_WINDOW_MS,
terminalMaxRebindsPerWindow: TERMINAL_INPUT_WS_MAX_REBINDS_PER_WINDOW,
@@ -1058,6 +1124,7 @@ async function main(options = {}) {
attachSignals,
});
terminalRuntime = startupPipelineResult.terminalRuntime;
messageStreamRuntime = startupPipelineResult.messageStreamRuntime;
try {
await scheduledTasksRuntime.start();
@@ -0,0 +1,43 @@
# Event Stream Module Documentation
## Purpose
This module contains the OpenChamber message-stream WebSocket protocol and runtime bridge. It keeps the browser-facing WebSocket transport separate from the upstream OpenCode SSE transport.
## Entrypoints and structure
- `packages/web/server/lib/event-stream/index.js`: public entrypoint re-exporting protocol and runtime helpers.
- `packages/web/server/lib/event-stream/protocol.js`: path constants, SSE envelope parsing, and WebSocket frame serialization helpers.
- `packages/web/server/lib/event-stream/runtime.js`: WebSocket server runtime, upgrade handling, SSE-to-WS bridging, and global event broadcasting.
- `packages/web/server/lib/event-stream/protocol.test.js`: unit tests for protocol helpers.
- `packages/web/server/lib/event-stream/runtime.test.js`: unit tests for runtime-side broadcaster behavior.
## Public exports
### Protocol helpers
- `MESSAGE_STREAM_GLOBAL_WS_PATH`: `/api/global/event/ws`
- `MESSAGE_STREAM_DIRECTORY_WS_PATH`: `/api/event/ws`
- `MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS`: heartbeat interval for browser-facing WS connections.
- `parseSseEventEnvelope(block)`: parses an SSE block into `{ eventId, directory, payload }`.
- `sendMessageStreamWsFrame(socket, payload)`: serializes and sends a JSON WS frame.
- `sendMessageStreamWsEvent(socket, payload, options)`: sends an event frame with optional `eventId` and `directory`.
### Runtime helpers
- `createGlobalUiEventBroadcaster({ sseClients, wsClients, writeSseEvent })`: returns a broadcaster that fans out the same synthetic UI event to SSE and WS clients.
- `createMessageStreamWsRuntime(...)`: mounts the message-stream WS server, upgrade handler, and SSE-to-WS bridge onto the web HTTP server.
## Runtime behavior
- Browser clients connect to the WS endpoints above.
- OpenChamber still fetches OpenCode upstream event streams over SSE.
- Each WS connection proxies one upstream SSE stream.
- Global synthetic events such as `openchamber:session-status`, `openchamber:session-activity`, `openchamber:notification`, and `openchamber:heartbeat` are preserved on the WS path.
- Global UI broadcasts are fan-out capable across both SSE and WS clients.
## Notes for contributors
- Keep protocol helpers pure and small so they can be unit tested without spinning up a server.
- Keep runtime wiring in this module instead of `packages/web/server/index.js` unless the logic is strictly route-local glue.
- Do not change upstream OpenCode transport assumptions here; OpenCode remains SSE-based.
- If replay support is added later, add it here rather than growing `index.js`.
## Testing
- Run `bun test packages/web/server/lib/event-stream/protocol.test.js`
- Run `bun test packages/web/server/lib/event-stream/runtime.test.js`
- Run repo validation before finalizing: `bun run type-check`, `bun run lint`, `bun run build`
@@ -0,0 +1,13 @@
export {
MESSAGE_STREAM_GLOBAL_WS_PATH,
MESSAGE_STREAM_DIRECTORY_WS_PATH,
MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS,
parseSseEventEnvelope,
sendMessageStreamWsFrame,
sendMessageStreamWsEvent,
} from './protocol.js';
export {
createGlobalUiEventBroadcaster,
createMessageStreamWsRuntime,
} from './runtime.js';
@@ -0,0 +1,82 @@
export const MESSAGE_STREAM_GLOBAL_WS_PATH = '/api/global/event/ws';
export const MESSAGE_STREAM_DIRECTORY_WS_PATH = '/api/event/ws';
export const MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS = 15 * 1000;
export function parseSseEventEnvelope(block) {
if (!block || typeof block !== 'string') {
return null;
}
const eventId = block
.split('\n')
.find((line) => line.startsWith('id:'))
?.slice(3)
.trim() || null;
const dataLines = block
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).replace(/^\s/, ''));
if (dataLines.length === 0) {
return null;
}
const payloadText = dataLines.join('\n').trim();
if (!payloadText) {
return null;
}
try {
const parsed = JSON.parse(payloadText);
if (
parsed &&
typeof parsed === 'object' &&
typeof parsed.payload === 'object' &&
parsed.payload !== null
) {
return {
eventId,
directory: typeof parsed.directory === 'string' && parsed.directory.length > 0 ? parsed.directory : null,
payload: parsed.payload,
};
}
const directory =
typeof parsed?.directory === 'string' && parsed.directory.length > 0
? parsed.directory
: typeof parsed?.properties?.directory === 'string' && parsed.properties.directory.length > 0
? parsed.properties.directory
: null;
return {
eventId,
directory,
payload: parsed,
};
} catch {
return null;
}
}
export function sendMessageStreamWsFrame(socket, payload) {
if (!socket || socket.readyState !== 1) {
return false;
}
try {
socket.send(JSON.stringify(payload));
return true;
} catch {
return false;
}
}
export function sendMessageStreamWsEvent(socket, payload, options = {}) {
return sendMessageStreamWsFrame(socket, {
type: 'event',
payload,
...(typeof options.eventId === 'string' && options.eventId.length > 0 ? { eventId: options.eventId } : {}),
...(typeof options.directory === 'string' && options.directory.length > 0 ? { directory: options.directory } : {}),
});
}
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'bun:test';
import {
MESSAGE_STREAM_DIRECTORY_WS_PATH,
MESSAGE_STREAM_GLOBAL_WS_PATH,
parseSseEventEnvelope,
sendMessageStreamWsEvent,
sendMessageStreamWsFrame,
} from './protocol.js';
describe('event stream protocol helpers', () => {
it('exports stable websocket paths', () => {
expect(MESSAGE_STREAM_GLOBAL_WS_PATH).toBe('/api/global/event/ws');
expect(MESSAGE_STREAM_DIRECTORY_WS_PATH).toBe('/api/event/ws');
});
it('parses wrapped SSE payloads with event id and directory', () => {
const envelope = parseSseEventEnvelope(
'id: evt-1\n' +
'event: message\n' +
'data: {"directory":"/tmp/project","payload":{"type":"session.updated"}}\n'
);
expect(envelope).toEqual({
eventId: 'evt-1',
directory: '/tmp/project',
payload: { type: 'session.updated' },
});
});
it('derives directory from payload properties when not wrapped', () => {
const envelope = parseSseEventEnvelope(
'data: {"type":"openchamber:notification","properties":{"directory":"/tmp/project"}}\n'
);
expect(envelope).toEqual({
eventId: null,
directory: '/tmp/project',
payload: {
type: 'openchamber:notification',
properties: { directory: '/tmp/project' },
},
});
});
it('returns null for malformed SSE blocks', () => {
expect(parseSseEventEnvelope('event: message\n')).toBeNull();
expect(parseSseEventEnvelope('data: {oops}\n')).toBeNull();
});
it('serializes generic websocket frames', () => {
let rawPayload = null;
const socket = {
readyState: 1,
send(payload) {
rawPayload = payload;
},
};
const sent = sendMessageStreamWsFrame(socket, { type: 'ready' });
expect(sent).toBe(true);
expect(rawPayload).toBe('{"type":"ready"}');
});
it('serializes event frames with routing metadata', () => {
let rawPayload = null;
const socket = {
readyState: 1,
send(payload) {
rawPayload = payload;
},
};
const sent = sendMessageStreamWsEvent(
socket,
{ type: 'openchamber:heartbeat', timestamp: 1 },
{ eventId: 'evt-2', directory: '/tmp/project' }
);
expect(sent).toBe(true);
expect(JSON.parse(rawPayload)).toEqual({
type: 'event',
payload: { type: 'openchamber:heartbeat', timestamp: 1 },
eventId: 'evt-2',
directory: '/tmp/project',
});
});
});
@@ -0,0 +1,288 @@
import { WebSocketServer } from 'ws';
import { parseRequestPathname } from '../terminal/index.js';
import {
MESSAGE_STREAM_DIRECTORY_WS_PATH,
MESSAGE_STREAM_GLOBAL_WS_PATH,
MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS,
parseSseEventEnvelope,
sendMessageStreamWsEvent,
sendMessageStreamWsFrame,
} from './protocol.js';
export function createGlobalUiEventBroadcaster({
sseClients,
wsClients,
writeSseEvent,
}) {
return (payload, options = {}) => {
const hasSseClients = sseClients.size > 0;
const hasWsClients = wsClients.size > 0;
if (!hasSseClients && !hasWsClients) {
return;
}
if (hasSseClients) {
for (const res of sseClients) {
try {
writeSseEvent(res, payload);
} catch {
}
}
}
if (hasWsClients) {
for (const socket of Array.from(wsClients)) {
const sent = sendMessageStreamWsEvent(socket, payload, {
directory: typeof options.directory === 'string' && options.directory.length > 0 ? options.directory : 'global',
eventId: typeof options.eventId === 'string' && options.eventId.length > 0 ? options.eventId : undefined,
});
if (!sent) {
wsClients.delete(socket);
}
}
}
};
}
export function createMessageStreamWsRuntime({
server,
uiAuthController,
isRequestOriginAllowed,
rejectWebSocketUpgrade,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
processForwardedEventPayload,
wsClients,
fetchImpl = fetch,
}) {
const wsServer = new WebSocketServer({
noServer: true,
});
wsServer.on('connection', (socket, req) => {
const rawUrl = typeof req?.url === 'string' ? req.url : MESSAGE_STREAM_GLOBAL_WS_PATH;
const pathname = parseRequestPathname(rawUrl);
const requestUrl = new URL(rawUrl, 'http://127.0.0.1');
const isGlobalStream = pathname === MESSAGE_STREAM_GLOBAL_WS_PATH;
const requestedLastEventId = requestUrl.searchParams.get('lastEventId')?.trim() || '';
const requestedDirectory = requestUrl.searchParams.get('directory')?.trim() || '';
const controller = new AbortController();
const cleanup = () => {
if (!controller.signal.aborted) {
controller.abort();
}
wsClients.delete(socket);
};
const pingInterval = setInterval(() => {
if (socket.readyState !== 1) {
return;
}
try {
socket.ping();
} catch {
}
}, MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS);
const heartbeatInterval = setInterval(() => {
sendMessageStreamWsEvent(socket, { type: 'openchamber:heartbeat', timestamp: Date.now() }, { directory: 'global' });
}, MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS);
socket.on('close', () => {
clearInterval(pingInterval);
clearInterval(heartbeatInterval);
cleanup();
});
socket.on('error', () => {
void 0;
});
const run = async () => {
let targetUrl;
try {
targetUrl = new URL(buildOpenCodeUrl(isGlobalStream ? '/global/event' : '/event', ''));
} catch {
sendMessageStreamWsFrame(socket, { type: 'error', message: 'OpenCode service unavailable' });
socket.close(1011, 'OpenCode service unavailable');
return;
}
if (!isGlobalStream && requestedDirectory) {
targetUrl.searchParams.set('directory', requestedDirectory);
}
const headers = {
Accept: 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
...getOpenCodeAuthHeaders(),
};
if (requestedLastEventId) {
headers['Last-Event-ID'] = requestedLastEventId;
}
let upstream;
try {
upstream = await fetchImpl(targetUrl.toString(), {
headers,
signal: controller.signal,
});
} catch {
if (!controller.signal.aborted) {
sendMessageStreamWsFrame(socket, { type: 'error', message: 'Failed to connect to OpenCode event stream' });
socket.close(1011, 'Failed to connect to OpenCode event stream');
}
return;
}
if (!upstream.ok || !upstream.body) {
sendMessageStreamWsFrame(socket, {
type: 'error',
message: `OpenCode event stream unavailable (${upstream.status})`,
});
socket.close(1011, 'OpenCode event stream unavailable');
return;
}
sendMessageStreamWsFrame(socket, {
type: 'ready',
scope: isGlobalStream ? 'global' : 'directory',
});
if (isGlobalStream) {
wsClients.add(socket);
}
const decoder = new TextDecoder();
const reader = upstream.body.getReader();
let buffer = '';
const forwardBlock = (block) => {
if (!block) {
return;
}
const envelope = parseSseEventEnvelope(block);
const payload = envelope?.payload ?? null;
if (!payload) {
return;
}
const directory = isGlobalStream
? (typeof envelope?.directory === 'string' && envelope.directory.length > 0 ? envelope.directory : 'global')
: (requestedDirectory || envelope?.directory || 'global');
sendMessageStreamWsEvent(socket, payload, {
directory,
eventId: typeof envelope?.eventId === 'string' && envelope.eventId.length > 0 ? envelope.eventId : undefined,
});
processForwardedEventPayload(payload, (syntheticPayload) => {
sendMessageStreamWsEvent(socket, syntheticPayload, { directory: 'global' });
});
};
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n');
let separatorIndex = buffer.indexOf('\n\n');
while (separatorIndex !== -1) {
const block = buffer.slice(0, separatorIndex);
buffer = buffer.slice(separatorIndex + 2);
forwardBlock(block);
separatorIndex = buffer.indexOf('\n\n');
}
}
if (buffer.trim().length > 0) {
forwardBlock(buffer.trim());
}
} catch (error) {
if (!controller.signal.aborted) {
console.warn('Message stream WS proxy error:', error);
sendMessageStreamWsFrame(socket, { type: 'error', message: 'Message stream proxy error' });
socket.close(1011, 'Message stream proxy error');
}
} finally {
cleanup();
try {
if (socket.readyState === 1 || socket.readyState === 0) {
socket.close();
}
} catch {
}
}
};
void run();
});
const upgradeHandler = (req, socket, head) => {
const pathname = parseRequestPathname(req.url);
if (pathname !== MESSAGE_STREAM_GLOBAL_WS_PATH && pathname !== MESSAGE_STREAM_DIRECTORY_WS_PATH) {
return;
}
const handleUpgrade = async () => {
try {
if (uiAuthController?.enabled) {
const sessionToken = await uiAuthController?.ensureSessionToken?.(req, null);
if (!sessionToken) {
rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
return;
}
const originAllowed = await isRequestOriginAllowed(req);
if (!originAllowed) {
rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
return;
}
}
wsServer.handleUpgrade(req, socket, head, (ws) => {
wsServer.emit('connection', ws, req);
});
} catch {
rejectWebSocketUpgrade(socket, 500, 'Upgrade failed');
}
};
void handleUpgrade();
};
server.on('upgrade', upgradeHandler);
return {
wsServer,
async close() {
server.off('upgrade', upgradeHandler);
try {
for (const client of wsServer.clients) {
try {
client.terminate();
} catch {
}
}
await new Promise((resolve) => {
wsServer.close(() => resolve());
});
} catch {
} finally {
wsClients.clear();
}
},
};
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'bun:test';
import { createGlobalUiEventBroadcaster } from './runtime.js';
describe('event stream broadcaster', () => {
it('fans out synthetic events to SSE and WS clients', () => {
const sseEvents = [];
const wsPayloads = [];
const sseClient = { id: 'sse-1' };
const wsClient = {
readyState: 1,
send(payload) {
wsPayloads.push(JSON.parse(payload));
},
};
const broadcast = createGlobalUiEventBroadcaster({
sseClients: new Set([sseClient]),
wsClients: new Set([wsClient]),
writeSseEvent(res, payload) {
sseEvents.push({ res, payload });
},
});
broadcast({ type: 'openchamber:session-status' }, { eventId: 'evt-1', directory: '/tmp/project' });
expect(sseEvents).toEqual([
{
res: sseClient,
payload: { type: 'openchamber:session-status' },
},
]);
expect(wsPayloads).toEqual([
{
type: 'event',
payload: { type: 'openchamber:session-status' },
eventId: 'evt-1',
directory: '/tmp/project',
},
]);
});
it('removes websocket clients that fail to receive a payload', () => {
const wsClients = new Set([
{
readyState: 1,
send() {
throw new Error('socket write failed');
},
},
]);
const broadcast = createGlobalUiEventBroadcaster({
sseClients: new Set(),
wsClients,
writeSseEvent() {
throw new Error('should not be called');
},
});
broadcast({ type: 'openchamber:notification' });
expect(wsClients.size).toBe(0);
});
});
@@ -4,6 +4,7 @@ export const createNotificationEmitterRuntime = (dependencies) => {
getDesktopNotifyEnabled,
desktopNotifyPrefix,
getUiNotificationClients,
getBroadcastGlobalUiEvent,
} = dependencies;
const writeSseEvent = (res, payload) => {
@@ -34,6 +35,25 @@ export const createNotificationEmitterRuntime = (dependencies) => {
return;
}
const syntheticPayload = {
type: 'openchamber:notification',
properties: {
...payload,
// Tell the UI whether the sidecar stdout notification channel is active.
// When true, the desktop UI should skip this SSE notification to avoid duplicates.
// When false (e.g. tauri dev), the UI must handle this SSE notification itself.
desktopStdoutActive: desktopNotifyEnabled,
},
};
const broadcastGlobalUiEvent = typeof getBroadcastGlobalUiEvent === 'function'
? getBroadcastGlobalUiEvent()
: null;
if (broadcastGlobalUiEvent) {
broadcastGlobalUiEvent(syntheticPayload);
return;
}
const clients = getUiNotificationClients();
if (clients.size === 0) {
return;
@@ -41,16 +61,7 @@ export const createNotificationEmitterRuntime = (dependencies) => {
for (const res of clients) {
try {
writeSseEvent(res, {
type: 'openchamber:notification',
properties: {
...payload,
// Tell the UI whether the sidecar stdout notification channel is active.
// When true, the desktop UI should skip this SSE notification to avoid duplicates.
// When false (e.g. tauri dev), the UI must handle this SSE notification itself.
desktopStdoutActive: desktopNotifyEnabled,
},
});
writeSseEvent(res, syntheticPayload);
} catch {
// ignore
}
@@ -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,
};
};