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
23 changes: 23 additions & 0 deletions console/web/src/hooks/use-llm-router-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import {
isLlmRouterAvailable,
LLM_ROUTER_WATCH_FN,
LLM_ROUTER_WORKER_NAME,
} from './use-llm-router-status'

describe('llm-router presence probe wiring', () => {
it('probes the llm-router worker with its own watch handler id', () => {
// The model picker's router::* reads gate on THIS worker — gating on the
// harness blanked the picker whenever the harness was slow or absent.
expect(LLM_ROUTER_WORKER_NAME).toBe('llm-router')
// Must be unique per presence probe so the browser-local `worker` trigger
// handlers never collide (shell uses console::shell-watch).
expect(LLM_ROUTER_WATCH_FN).toBe('console::llm-router-watch')
})

it('gates on both presence and the initial probe settling', () => {
expect(isLlmRouterAvailable({ present: true, loading: false })).toBe(true)
expect(isLlmRouterAvailable({ present: true, loading: true })).toBe(false)
expect(isLlmRouterAvailable({ present: false, loading: false })).toBe(false)
})
})
43 changes: 43 additions & 0 deletions console/web/src/hooks/use-llm-router-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {
isWorkerPresent,
useWorkerPresence,
type WorkerPresence,
} from './use-worker-presence'

/**
* Presence probe for the `llm-router` worker. The router owns every
* `router::*` RPC the model picker reads (`provider::list`, `models::list`)
* and the change triggers it subscribes to, so provider/model UI gates on
* THIS worker's presence — gating on the harness starved the picker whenever
* the harness was slow or absent while the router was healthy. Thin wrapper
* over the generic worker-presence probe.
*/

/** Engine worker name for the llm-router worker. */
export const LLM_ROUTER_WORKER_NAME = 'llm-router'
/** Base id for the browser-local handler bound to the `worker` trigger. */
export const LLM_ROUTER_WATCH_FN = 'console::llm-router-watch'

export type LlmRouterStatus = WorkerPresence

/**
* @param enabled - only run against the real backend; pass `false` for the
* mock/Storybook backend (treats the router as present so the picker shows
* in isolation).
*/
export function useLlmRouterStatus(enabled: boolean): LlmRouterStatus {
return useWorkerPresence({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useWorkerPresence only performs the workers-list read at mount and then relies on lifecycle events. If llm-router is absent for that first probe and is added while the browser socket is disconnected, the add event is lost and present remains false indefinitely. useModelPickerSource cannot repair this case because its reconnect listener is only installed when routerAvailable is already true. Please make this presence probe reconnect-aware so an absent-to-present transition cannot require a page reload.

workerName: LLM_ROUTER_WORKER_NAME,
watchFnId: LLM_ROUTER_WATCH_FN,
enabled,
})
}

/**
* Whether the router's `router::*` functions are registered and safe to
* trigger. False during the initial presence probe and while the router is
* absent.
*/
export function isLlmRouterAvailable(status: LlmRouterStatus): boolean {
return isWorkerPresent(status)
}
157 changes: 101 additions & 56 deletions console/web/src/hooks/use-model-picker-source.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { onHarnessConfigSaved } from '@/lib/harness-config-events'
import { getIiiClient } from '@/lib/iii-client'
import {
catalogRowsToModelOptions,
fetchModelsCatalog,
Expand All @@ -23,11 +24,15 @@ import type { ModelOption } from '@/types/chat'
* current by provider lifecycle events. Catalog refreshes never poll the
* provider list.
*
* `harnessAvailable` gates harness-owned RPCs until the worker is connected.
* `routerAvailable` gates the router-owned RPCs on the llm-router worker
* being connected — every read here is `router::*`. When it flips false→true
* (router installed, restarted, or just slow to boot) every effect below
* re-runs, so the picker recovers without a page reload. A WebSocket
* reconnect re-pulls both reads for the same reason.
*/
export function useModelPickerSource(
backendId: string,
harnessAvailable = true,
routerAvailable = true,
): {
modelOptions: ModelOption[]
catalogKeys: string[]
Expand All @@ -40,8 +45,14 @@ export function useModelPickerSource(
[],
)
const providerEventVersion = useRef(0)
// Mirror of `presentProviders` for event handlers: React state updaters may
// run deferred, so membership checks must not live inside them.
const providersRef = useRef<ProviderListEntry[]>([])
useEffect(() => {
providersRef.current = presentProviders
}, [presentProviders])
const [catalogLoading, setCatalogLoading] = useState(
backendId === 'real' && harnessAvailable,
backendId === 'real' && routerAvailable,
)

const refresh = useCallback(async () => {
Expand All @@ -50,7 +61,7 @@ export function useModelPickerSource(
setCatalogLoading(false)
return
}
if (!harnessAvailable) {
if (!routerAvailable) {
setModelOptions([])
setCatalogLoading(false)
return
Expand All @@ -64,43 +75,46 @@ export function useModelPickerSource(
} finally {
setCatalogLoading(false)
}
}, [backendId, harnessAvailable])
}, [backendId, routerAvailable])

useEffect(() => {
void refresh()
}, [refresh])

// One initial snapshot. Subsequent availability changes are applied from
// `router::provider::changed`, so model refreshes never re-read this list.
useEffect(() => {
if (backendId !== 'real' || !harnessAvailable) {
// Re-read `router::provider::list`, dropping the result if a newer provider
// event (or a newer snapshot) has advanced the version since we started.
const refreshProviders = useCallback(async () => {
if (backendId !== 'real' || !routerAvailable) {
setPresentProviders([])
return
}
let cancelled = false
const snapshotVersion = providerEventVersion.current
void fetchProviderList()
.then((providers) => {
if (!cancelled && providerEventVersion.current === snapshotVersion) {
setPresentProviders(providers)
}
})
.catch(() => {
if (!cancelled && providerEventVersion.current === snapshotVersion) {
setPresentProviders([])
}
})
return () => {
cancelled = true
try {
const providers = await fetchProviderList()
if (providerEventVersion.current === snapshotVersion) {
setPresentProviders(providers)
}
} catch {
if (providerEventVersion.current === snapshotVersion) {
setPresentProviders([])
}
}
}, [backendId, harnessAvailable])
}, [backendId, routerAvailable])

// Live updates: re-pull the catalog when the harness signals a model change
// (provider configured/cleared, refresh_models, CLI edits). The harness
// Initial snapshot (re-run when the router (re)appears). Availability flips
// are applied from `router::provider::changed`; an event for a provider the
// snapshot has never seen triggers a full re-read instead, so late-arriving
// providers render with their declared display name and capabilities.
useEffect(() => {
void refreshProviders()
}, [refreshProviders])

// Live updates: re-pull the catalog when the router signals a model change
// (provider configured/cleared, refresh_models, CLI edits). The router
// coalesces bursts; the short trailing debounce here collapses any remaining
// back-to-back pushes into a single re-read.
useEffect(() => {
if (backendId !== 'real' || !harnessAvailable) return
if (backendId !== 'real' || !routerAvailable) return
let disposed = false
const disposers: (() => void)[] = []
let timer: ReturnType<typeof setTimeout> | null = null
Expand All @@ -113,50 +127,81 @@ export function useModelPickerSource(
}, 150)
}

void subscribeModelChanges(onModelsChanged).then((dispose) => {
if (disposed) dispose()
else disposers.push(dispose)
})
void subscribeModelChanges(onModelsChanged)
.then((dispose) => {
if (disposed) dispose()
else disposers.push(dispose)
})
// Setup failure degrades to manual refresh; never an unhandled rejection.
.catch(() => {})

void subscribeProviderChanges(({ provider, op }) => {
providerEventVersion.current += 1
setPresentProviders((current) => {
const available = op !== 'unavailable'
const existing = current.find((entry) => entry.id === provider)
if (existing) {
if (existing.available === available) return current
return current.map((entry) =>
entry.id === provider ? { ...entry, available } : entry,
)
}
return [
...current,
{
id: provider,
display_name: provider,
supports_model_listing: true,
available,
},
]
})
}).then((dispose) => {
if (disposed) dispose()
else disposers.push(dispose)
if (op === 'unregister') {
setPresentProviders((current) =>
current.filter((entry) => entry.id !== provider),
)
return
}
// A provider the snapshot never saw: re-read the list rather than
// inventing a degraded entry (raw id as display name, guessed
// capabilities).
if (!providersRef.current.some((entry) => entry.id === provider)) {
void refreshProviders()
return
}
const available = op !== 'unavailable'
setPresentProviders((current) =>
current.map((entry) =>
entry.id === provider && entry.available !== available
? { ...entry, available }
: entry,
),
)
})
.then((dispose) => {
if (disposed) dispose()
else disposers.push(dispose)
})
// Setup failure degrades to snapshot re-reads; never an unhandled rejection.
.catch(() => {})

return () => {
disposed = true
if (timer !== null) clearTimeout(timer)
for (const d of disposers) d()
}
}, [backendId, harnessAvailable, refresh])
}, [backendId, routerAvailable, refresh, refreshProviders])

// A WebSocket drop loses any change events fired while disconnected;
// re-pull both reads when the connection comes back.
useEffect(() => {
if (backendId !== 'real' || !routerAvailable) return
let disposed = false
let offConn: (() => void) | null = null
getIiiClient()
.then((client) => {
if (disposed) return
offConn = client.addConnectionStateListener((state) => {
if (state === 'connected') {
void refresh()
void refreshProviders()
}
})
})
.catch(() => {})
return () => {
disposed = true
offConn?.()
}
}, [backendId, routerAvailable, refresh, refreshProviders])

useEffect(() => {
if (backendId !== 'real' || !harnessAvailable) return
if (backendId !== 'real' || !routerAvailable) return
return onHarnessConfigSaved(() => {
void refresh()
})
}, [backendId, harnessAvailable, refresh])
}, [backendId, routerAvailable, refresh])

const catalogKeys = useMemo(
() => modelOptions.map((o) => o.id),
Expand Down
17 changes: 12 additions & 5 deletions console/web/src/lib/conversations-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ import {
} from '@/hooks/use-conversations'
import {
type HarnessStatus,
isHarnessAvailable,
useHarnessStatus,
} from '@/hooks/use-harness-status'
import {
isLlmRouterAvailable,
useLlmRouterStatus,
} from '@/hooks/use-llm-router-status'
import { isMemoryAvailable, useMemoryStatus } from '@/hooks/use-memory-status'
import { useModelPickerSource } from '@/hooks/use-model-picker-source'
import { isShellAvailable, useShellStatus } from '@/hooks/use-shell-status'
Expand Down Expand Up @@ -102,7 +105,11 @@ export function ConversationsProvider({
children,
}: ConversationsProviderProps) {
const harnessStatus = useHarnessStatus(backend.id === 'real')
const harnessAvailable = isHarnessAvailable(harnessStatus)
// The model picker reads router-owned RPCs; gate them on llm-router, not
// the harness — the harness being slow or absent must not blank the picker.
const routerAvailable = isLlmRouterAvailable(
useLlmRouterStatus(backend.id === 'real'),
)
const approvalGateAvailable = isApprovalGateAvailable(
useApprovalGateStatus(backend.id === 'real'),
)
Expand All @@ -119,7 +126,7 @@ export function ConversationsProvider({
catalogLoading,
presentProviders,
refresh,
} = useModelPickerSource(backend.id, harnessAvailable)
} = useModelPickerSource(backend.id, routerAvailable)
// Conversations are backed by the session-manager worker on the real
// backend; mocks stay in-memory.
const api = useConversations(
Expand All @@ -130,7 +137,7 @@ export function ConversationsProvider({

const [refreshingModels, setRefreshingModels] = useState(false)
const refreshModels = useCallback(async () => {
if (!harnessAvailable) return
if (!routerAvailable) return
setRefreshingModels(true)
try {
if (backend.id === 'real') {
Expand All @@ -146,7 +153,7 @@ export function ConversationsProvider({
} finally {
setRefreshingModels(false)
}
}, [harnessAvailable, refresh, presentProviders])
}, [routerAvailable, refresh, presentProviders])

const value: ConversationsContextValue = {
...api,
Expand Down
Loading
Loading