diff --git a/ui-tui/src/__tests__/gatewayClient.test.ts b/ui-tui/src/__tests__/gatewayClient.test.ts index f1228e56fbeb..33bf8ad6e497 100644 --- a/ui-tui/src/__tests__/gatewayClient.test.ts +++ b/ui-tui/src/__tests__/gatewayClient.test.ts @@ -97,10 +97,13 @@ describe('GatewayClient websocket attach mode', () => { const originalWebSocket = globalThis.WebSocket let originalGatewayUrl: string | undefined let originalSidecarUrl: string | undefined + let originalDisableUndiciWebSocket: string | undefined beforeEach(() => { originalGatewayUrl = process.env.HERMES_TUI_GATEWAY_URL originalSidecarUrl = process.env.HERMES_TUI_SIDECAR_URL + originalDisableUndiciWebSocket = process.env.HERMES_TUI_DISABLE_UNDICI_WEBSOCKET + delete process.env.HERMES_TUI_DISABLE_UNDICI_WEBSOCKET FakeWebSocket.reset() ;(globalThis as { WebSocket?: unknown }).WebSocket = FakeWebSocket as unknown as typeof WebSocket }) @@ -118,6 +121,12 @@ describe('GatewayClient websocket attach mode', () => { process.env.HERMES_TUI_SIDECAR_URL = originalSidecarUrl } + if (originalDisableUndiciWebSocket === undefined) { + delete process.env.HERMES_TUI_DISABLE_UNDICI_WEBSOCKET + } else { + process.env.HERMES_TUI_DISABLE_UNDICI_WEBSOCKET = originalDisableUndiciWebSocket + } + FakeWebSocket.reset() if (originalWebSocket) { @@ -271,6 +280,7 @@ describe('GatewayClient websocket attach mode', () => { it('redacts query string secrets in attach failure logs and events', () => { process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=hunter2&channel=secret' + process.env.HERMES_TUI_DISABLE_UNDICI_WEBSOCKET = '1' delete (globalThis as { WebSocket?: unknown }).WebSocket const gw = new GatewayClient() @@ -301,7 +311,7 @@ describe('GatewayClient websocket attach mode', () => { const secretUrl = 'ws://gateway.test/api/ws?token=hunter2&channel=secret' process.env.HERMES_TUI_GATEWAY_URL = secretUrl - ;(globalThis as { WebSocket?: unknown }).WebSocket = class ThrowingWebSocket extends FakeWebSocket { + ;(globalThis as { WebSocket?: unknown }).WebSocket = class ThrowingWebSocket { constructor(url: string) { throw new TypeError(`Invalid URL: ${url}`) } @@ -328,11 +338,11 @@ describe('GatewayClient websocket attach mode', () => { process.env.HERMES_TUI_SIDECAR_URL = sidecarUrl ;(globalThis as { WebSocket?: unknown }).WebSocket = class ThrowingSidecarWebSocket extends FakeWebSocket { constructor(url: string) { + super(url) + if (url.includes('/api/pub')) { throw new TypeError(`Invalid URL: ${url}`) } - - super(url) } } as unknown as typeof WebSocket @@ -363,6 +373,7 @@ describe('GatewayClient websocket attach mode', () => { expect(() => new URL(fixture)).toThrow() process.env.HERMES_TUI_GATEWAY_URL = fixture + process.env.HERMES_TUI_DISABLE_UNDICI_WEBSOCKET = '1' delete (globalThis as { WebSocket?: unknown }).WebSocket const gw = new GatewayClient() diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 987518a4460c..5fa9ceaee5f6 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -190,10 +190,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: agentsNudgedThisTurn = false } - // Kick off the config fetch eagerly at handler creation so the flag is - // resolved well before the first delegation of any real session (which - // only happens after gateway.ready + a user turn). - ensureAgentsNudgeConfig() + // Do not fetch the nudge config while creating the handler. This factory is + // called from React render via useMemo; a synchronous gateway failure can + // call sys() -> appendMessage() -> setHistoryItems() during render and + // trigger React #301. maybeNudgeAgents() fetches the flag lazily before the + // first delegation event, which is early enough for the feature. const refreshDelegationStatus = (force = false) => { const now = Date.now() diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 37bfa881aba5..c0e7d7123a9e 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -19,6 +19,26 @@ const WS_OPEN = 1 const WS_CLOSING = 2 const WS_CLOSED = 3 +type RuntimeWebSocketCtor = typeof globalThis.WebSocket + +const resolveRuntimeWebSocket = (): RuntimeWebSocketCtor | undefined => { + if (typeof globalThis.WebSocket !== 'undefined') { + return globalThis.WebSocket + } + + if (process.env.HERMES_TUI_DISABLE_UNDICI_WEBSOCKET === '1') { + return undefined + } + + try { + const undici = require('undici') as { WebSocket?: RuntimeWebSocketCtor } + + return undici.WebSocket + } catch { + return undefined + } +} + const truncateLine = (line: string) => line.length > MAX_LOG_LINE_BYTES ? `${line.slice(0, MAX_LOG_LINE_BYTES)}… [truncated ${line.length} bytes]` : line @@ -266,14 +286,16 @@ export class GatewayClient extends EventEmitter { return } - if (typeof WebSocket === 'undefined') { + const WebSocketCtor = resolveRuntimeWebSocket() + + if (!WebSocketCtor) { this.pushLog(`[sidecar] WebSocket unavailable; skipping mirror to ${redactUrl(this.sidecarUrl)}`) return } try { - const ws = new WebSocket(this.sidecarUrl) + const ws = new WebSocketCtor(this.sidecarUrl) this.sidecarWs = ws ws.addEventListener('close', () => { @@ -406,7 +428,9 @@ export class GatewayClient extends EventEmitter { const safeAttachUrl = redactUrl(attachUrl) this.startReadyTimer('websocket', safeAttachUrl) - if (typeof WebSocket === 'undefined') { + const WebSocketCtor = resolveRuntimeWebSocket() + + if (!WebSocketCtor) { const line = `[startup] WebSocket API unavailable; cannot attach to ${safeAttachUrl}` this.pushLog(line) @@ -417,7 +441,7 @@ export class GatewayClient extends EventEmitter { } try { - const ws = new WebSocket(attachUrl) + const ws = new WebSocketCtor(attachUrl) let settled = false this.ws = ws