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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
fd8972a7d9
commit
bee9d19f3a
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user