Skip to content
Open
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
57 changes: 52 additions & 5 deletions apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { $desktopBoot } from '@/store/boot'
import { $activeGatewayProfile } from '@/store/profile'
import { $gatewayState } from '@/store/session'

import { takeGatewaySurvivor } from './gateway-hmr-survivor'
Expand Down Expand Up @@ -30,6 +31,7 @@ class FakeWebSocket {
// errors (a dead remote). Mirrors a VPS going away after the first connect.
static mode: 'open' | 'fail' = 'open'
static instances: FakeWebSocket[] = []
static eventOnOpen: null | { payload?: unknown; type: string } = null

readyState = 0
private listeners: Record<string, Set<Listener>> = {}
Expand All @@ -43,6 +45,16 @@ class FakeWebSocket {
if (willOpen) {
this.readyState = FakeWebSocket.OPEN
this.emit('open', {})

if (FakeWebSocket.eventOnOpen) {
this.emit('message', {
data: JSON.stringify({
jsonrpc: '2.0',
method: 'event',
params: FakeWebSocket.eventOnOpen
})
})
}
} else {
this.readyState = FakeWebSocket.CLOSED
this.emit('error', {})
Expand Down Expand Up @@ -76,11 +88,11 @@ class FakeWebSocket {
}
}

function fakeDesktop() {
function fakeDesktop(profile = 'default') {
const conn = {
authMode: 'token' as const,
baseUrl: 'https://vps.example.com',
profile: 'default',
profile,
token: 't',
wsUrl: 'wss://vps.example.com/api/ws?token=t'
}
Expand Down Expand Up @@ -109,17 +121,22 @@ function fakeDesktop() {
onPowerResume: vi.fn(() => () => undefined),
onWindowStateChanged: vi.fn(() => () => undefined),
touchBackend: vi.fn(async () => undefined),
profile: { get: vi.fn(async () => ({ profile: 'default' })) }
profile: { get: vi.fn(async () => ({ profile })) }
}
}

function Harness({
beforeConnectionSwitch = () => undefined,
handleGatewayEvent = () => undefined,
refreshSessions
}: { beforeConnectionSwitch?: () => void; refreshSessions?: () => Promise<void> } = {}) {
}: {
beforeConnectionSwitch?: () => void
handleGatewayEvent?: (event: { profile?: string; type: string }) => void
refreshSessions?: () => Promise<void>
} = {}) {
useGatewayBoot({
beforeConnectionSwitch,
handleGatewayEvent: () => undefined,
handleGatewayEvent,
onConnectionReady: () => undefined,
onGatewayReady: () => undefined,
refreshHermesConfig: async () => undefined,
Expand All @@ -146,10 +163,12 @@ beforeEach(() => {
vi.useFakeTimers()
FakeWebSocket.mode = 'open'
FakeWebSocket.instances = []
FakeWebSocket.eventOnOpen = null
connectionApplied = null
;(globalThis as { WebSocket: unknown }).WebSocket = FakeWebSocket
;(window as { hermesDesktop?: unknown }).hermesDesktop = fakeDesktop()
$gatewayState.set('idle')
$activeGatewayProfile.set('default')
$desktopBoot.set({
error: null,
fakeMode: false,
Expand Down Expand Up @@ -180,6 +199,7 @@ afterEach(() => {
vi.useRealTimers()
;(globalThis as { WebSocket: unknown }).WebSocket = originalWebSocket
delete (window as { hermesDesktop?: unknown }).hermesDesktop
$activeGatewayProfile.set('default')
})

// Let pending microtasks (awaits) AND the queued 0ms socket open/error fire.
Expand All @@ -199,6 +219,33 @@ async function advanceBackoff() {
}

describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () => {
it('tags an immediate primary gateway.ready event with the persisted profile', async () => {
const handleGatewayEvent = vi.fn()
FakeWebSocket.eventOnOpen = { type: 'gateway.ready' }
;(window as { hermesDesktop?: unknown }).hermesDesktop = fakeDesktop('work')

render(<Harness handleGatewayEvent={handleGatewayEvent} />)
await flushAsync()

expect(handleGatewayEvent).toHaveBeenCalledWith(expect.objectContaining({ profile: 'work', type: 'gateway.ready' }))
})

it('reconnects the primary backend by its owner while a secondary profile is active', async () => {
const desktop = fakeDesktop()

;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop

render(<Harness />)
await flushAsync()
expect($gatewayState.get()).toBe('open')

$activeGatewayProfile.set('work')
act(() => FakeWebSocket.instances[0].drop())
await advanceBackoff()

expect(desktop.getConnection).toHaveBeenLastCalledWith('default')
})

it('INITIAL boot against a dead VPS: getConnection hangs (waitForHermes) → app sits in the connecting combo, then fails', async () => {
// The report's actual path: a fresh launch pointed at an unreachable VPS.
// startHermes()'s remote branch awaits waitForHermes() for 45s before it
Expand Down
32 changes: 17 additions & 15 deletions apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ export function useGatewayBoot({
let reconnecting = false
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let reconnectAttempt = 0
// The profile owned by the primary socket. Keep this separate from the
// foreground profile because the user can view a secondary while primary
// events are still arriving in the background.
let primaryProfile = normalizeProfileKey($activeGatewayProfile.get())
// Surface "sign in again" once per disconnect episode, not on every backoff
// tick — a stale OAuth ticket fails every attempt and would otherwise stack
// identical error toasts (and their haptics). Reset on the next clean open.
Expand Down Expand Up @@ -150,7 +154,7 @@ export function useGatewayBoot({
// "Starting Hermes…". The probe is a no-op for a healthy or local backend.
await desktop.revalidateConnection?.().catch(() => undefined)

const conn = await desktop.getConnection($activeGatewayProfile.get())
const conn = await desktop.getConnection(primaryProfile)

if (cancelled) {
return
Expand Down Expand Up @@ -239,11 +243,14 @@ export function useGatewayBoot({
try {
const pref = await desktop.profile?.get?.()
const profileKey = (pref?.profile ?? '').trim() || 'default'
primaryProfile = profileKey
$activeGatewayProfile.set(profileKey)
setPrimaryGateway(gateway, profileKey)
void ensureGatewayForProfile(profileKey)
} catch {
primaryProfile = 'default'
$activeGatewayProfile.set('default')
setPrimaryGateway(gateway, 'default')
}
}

Expand Down Expand Up @@ -285,16 +292,16 @@ export function useGatewayBoot({
}

publish(conn)
// Establish the socket's profile before connect(): gateway.ready can
// arrive immediately after the WS opens, before connect() resolves.
await adoptPrimaryProfile()
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
await gateway.connect(wsUrl)

if (cancelled) {
return
}

// Same shape as boot(): profile first (session scope depends on it),
// then the independent fetches concurrently.
await adoptPrimaryProfile()
await Promise.all([
seedDefaultCwd(),
callbacksRef.current.refreshHermesConfig().catch(() => undefined),
Expand Down Expand Up @@ -360,7 +367,8 @@ export function useGatewayBoot({
const gateway = adoptedFromHmr ? survivor!.gateway : new HermesGateway()

callbacksRef.current.onGatewayReady(gateway)
setPrimaryGateway(gateway, survivor?.profile ?? normalizeProfileKey($activeGatewayProfile.get()))
primaryProfile = normalizeProfileKey(survivor?.profile ?? primaryProfile)
setPrimaryGateway(gateway, primaryProfile)
// Secondary (background-profile) sockets funnel into the same handler.
configureGatewayRegistry({ onEvent: event => callbacksRef.current.handleGatewayEvent(event) })

Expand Down Expand Up @@ -390,10 +398,8 @@ export function useGatewayBoot({
}
})

const sourceProfile = normalizeProfileKey($activeGatewayProfile.get())

const offEvent = gateway.onEvent(event =>
callbacksRef.current.handleGatewayEvent({ ...event, profile: sourceProfile })
callbacksRef.current.handleGatewayEvent({ ...event, profile: primaryProfile })
)

// Wake signals: power resume (macOS/Windows), network coming back, and the
Expand Down Expand Up @@ -479,6 +485,9 @@ export function useGatewayBoot({
progress: 95
})
publish(conn)
// Profile first: the backend may emit gateway.ready in the same turn as
// the open event, so its source tag must be established pre-connect.
await adoptPrimaryProfile()
// Mint a fresh WS URL right before connecting. For OAuth gateways the
// ticket is single-use with a short TTL, so the ticket baked into
// conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it rather than
Expand All @@ -491,13 +500,6 @@ export function useGatewayBoot({
return
}

// Profile adoption must land first: refreshSessions scopes its fetch by
// $profileScope ← $activeGatewayProfile. The remaining three fetches
// (cwd seed, config, sessions) are independent REST calls — running
// them serially added their sum to time-to-populated-sidebar when only
// the max is needed.
await adoptPrimaryProfile()

setDesktopBootStep({
phase: 'renderer.config',
message: translateNow('boot.steps.loadingSettings'),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import { reportInstallMethodWarning } from '@/store/updates'
import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events'
// Leaf import (not the `@/themes` barrel) to avoid pulling the ThemeProvider
// module graph into the gateway event hot path.
import { ingestBackendSkin } from '@/themes/backend-sync'
import { ingestBackendSkin, ingestGatewayReadySkin } from '@/themes/backend-sync'
import type { RpcEvent } from '@/types/hermes'

import type { ClientSessionState } from '../../../types'
Expand Down Expand Up @@ -279,23 +279,26 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
}

if (event.type === 'gateway.ready') {
// Seed the active skin into the desktop theme registry without applying,
// so a fresh connect never overrides the user's persisted desktop theme.
ingestBackendSkin((payload as { skin?: HermesSkin } | undefined)?.skin, { apply: false })
const activeProfile = normalizeProfileKey($activeGatewayProfile.get())
const sourceProfile = normalizeProfileKey(event.profile ?? activeProfile)

// Establish the source profile's first-use preference even when this is
// a prewarmed/background socket. The queued apply persists to that
// profile but ThemeProvider paints only when it is the foreground.
ingestGatewayReadySkin((payload as { skin?: HermesSkin } | undefined)?.skin, sourceProfile)

// Backends with the change watcher broadcast pet/cron/sessions change
// events; consumers demote their legacy polls to slow backstops.
setChangeEventsAvailable(Boolean((payload as { change_events?: boolean } | undefined)?.change_events))

return
} else if (event.type === 'skin.changed') {
// A runtime skin switch (Hermes activating an authored skin, or `/skin`
// on another surface). Only the active profile's change repaints.
const fromActiveProfile =
!event.profile || normalizeProfileKey(event.profile) === normalizeProfileKey($activeGatewayProfile.get())

if (fromActiveProfile) {
ingestBackendSkin(payload as HermesSkin | undefined, { apply: true })
}
// Keep every connected profile current. ThemeProvider persists the
// source profile but repaints only when that profile is foreground.
ingestBackendSkin(payload as HermesSkin | undefined, {
apply: true,
profile: normalizeProfileKey(event.profile ?? $activeGatewayProfile.get())
})

return
} else if (event.type === 'pet.changed' || event.type === 'cron.changed' || event.type === 'sessions.changed') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $activeGatewayProfile } from '@/store/profile'
import { $pendingSkinApplies, __resetBackendSkinSync } from '@/themes/backend-sync'
import type { RpcEvent } from '@/types/hermes'

import { useMessageStream } from './index'

let handleEvent: ((event: RpcEvent) => void) | null = null

function Harness() {
const activeSessionIdRef = useRef<string | null>(null)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const queryClientRef = useRef(new QueryClient())

const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)

return next
}
})

useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])

return null
}

describe('gateway.ready skin adoption', () => {
beforeEach(() => {
handleEvent = null
window.localStorage.clear()
__resetBackendSkinSync()
$activeGatewayProfile.set('default')
})

afterEach(() => {
cleanup()
$activeGatewayProfile.set('default')
})

it('queues first-use adoption when a prewarmed profile connects in the background', async () => {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())

act(() =>
handleEvent!({
payload: {
skin: {
colors: { background: '#101020', ui_accent: '#ff33aa', ui_text: '#eeeeee' },
name: 'neon'
}
},
profile: 'work',
type: 'gateway.ready'
})
)

expect($pendingSkinApplies.get()).toEqual([{ name: 'neon', profile: 'work' }])
})

it('queues a runtime skin change for its background source profile', async () => {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())

act(() =>
handleEvent!({
payload: {
colors: { background: '#202010', ui_accent: '#33ffaa', ui_text: '#eeeeee' },
name: 'forest'
},
profile: 'work',
type: 'skin.changed'
})
)

expect($pendingSkinApplies.get()).toEqual([{ name: 'forest', profile: 'work' }])
})
})
Loading
Loading