Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions ui-tui/src/__tests__/gatewayClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand All @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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}`)
}
Expand All @@ -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

Expand Down Expand Up @@ -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()
Expand Down
9 changes: 5 additions & 4 deletions ui-tui/src/app/createGatewayEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
32 changes: 28 additions & 4 deletions ui-tui/src/gatewayClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down