Files
aiturk-hermes-ide/apps/desktop/src/plugins/hermes-bots/model-picker.test.tsx
T

160 lines
6.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* #95279: the Bot Mode model picker's catalog read must always SETTLE, and it
* must not churn the network on every surface remount.
*
* The read rides the BOT's own socket — a second, lazily dialed pool backend
* that can wedge (cold pool spawn, dropped remote hop) without the primary
* socket noticing. Two defects made the picker unusable while Bots was active:
*
* 1. The RPC had no deadline, so a wedged dial left it pending forever and
* the spinner never settled ("model picker never settles").
* 2. Every fetch forced `refresh: true`, bypassing the react-query cache, so
* each Bots view remount (tab re-front, dialog reopen, pane visibility
* flip) knocked the picker back into loading and discarded the staged
* selection mid-edit.
*
* Pinned here through the real component: bounded settlement into the
* free-text fallback, one cached unforced read across remounts, and no
* dispatch at all for a row whose connection is gone.
*/
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ModelPicker } from './model-picker'
import type { RosterRow } from './types'
const { hostMock } = vi.hoisted(() => ({
hostMock: {
request: vi.fn(),
requestProfile: vi.fn(),
state: { connectionId: { get: () => 'local' }, gateway: { get: () => 'open' }, profile: { get: () => 'default' } }
}
}))
vi.mock('@hermes/plugin-sdk', async () => {
const { useQuery } = await import('@tanstack/react-query')
return {
Button: (props: React.ComponentProps<'button'>) => <button {...props} />,
GlyphSpinner: () => <span data-testid="spinner" />,
host: hostMock,
Input: (props: React.ComponentProps<'input'>) => <input {...props} />,
Select: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: () => null,
useQuery
}
})
vi.mock('./shared', () => ({ ID: 'hermes-bots' }))
const remoteBot = {
connectionId: 'remote-a',
name: 'default',
remoteSource: true,
route: { connectionId: 'remote-a', mode: 'remote', profile: 'default', targetProfile: 'backend-default' },
sourceScoped: true,
targetProfile: 'backend-default'
} as RosterRow
/** One client per render group, so "did the remount refetch?" is a real
* question about the cache rather than about a fresh store. */
let client: QueryClient
function mount(bot: null | RosterRow) {
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
)
return render(<ModelPicker bot={bot} onChange={vi.fn()} value={{ model: '', provider: '' }} />, { wrapper })
}
/** The fallback the picker paints when the catalog is unavailable. */
const isFreeText = (container: HTMLElement) =>
Boolean(container.querySelector('input[placeholder*="omnirouter"]')) &&
!container.querySelector('[data-testid="spinner"]')
beforeEach(() => {
vi.clearAllMocks()
client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
})
afterEach(() => {
vi.useRealTimers()
})
describe('a bot gateway that never answers', () => {
it('settles into the free-text fallback instead of spinning forever', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
hostMock.requestProfile.mockReturnValue(new Promise(() => undefined))
const { container } = mount(remoteBot)
expect(container.querySelector('[data-testid="spinner"]')).toBeTruthy()
await vi.advanceTimersByTimeAsync(30_000)
await waitFor(() => expect(isFreeText(container)).toBe(true))
})
})
describe('the catalog read', () => {
it('rides the bot’s own route, unforced, and survives a remount from cache', async () => {
hostMock.requestProfile.mockResolvedValue({ providers: [{ models: ['m1'], name: 'Prov', slug: 'prov' }] })
const first = mount(remoteBot)
await waitFor(() => expect(hostMock.requestProfile).toHaveBeenCalledTimes(1))
expect(hostMock.requestProfile).toHaveBeenCalledWith(remoteBot.route, 'model.options', {
explicit_only: false,
include_unconfigured: true
})
// A forced refresh here is what re-entered the loading state on every
// remount and wiped the user's in-progress pick.
expect(hostMock.requestProfile.mock.calls[0][2]).not.toHaveProperty('refresh')
first.unmount()
const second = mount(remoteBot)
await waitFor(() => expect(second.container.querySelector('[data-testid="spinner"]')).toBeNull())
expect(hostMock.requestProfile).toHaveBeenCalledTimes(1)
})
it('never dispatches for a row whose connection was removed', async () => {
const orphan = { name: 'ghost', remoteSource: true } as RosterRow
const { container } = mount(orphan)
await waitFor(() => expect(isFreeText(container)).toBe(true))
expect(hostMock.request).not.toHaveBeenCalled()
expect(hostMock.requestProfile).not.toHaveBeenCalled()
})
it('reads the ambient gateway for a local bot', async () => {
hostMock.request.mockResolvedValue({ providers: [] })
mount({ name: 'default' } as RosterRow)
await waitFor(() => expect(hostMock.request).toHaveBeenCalledTimes(1))
expect(hostMock.request).toHaveBeenCalledWith('model.options', {
explicit_only: false,
include_unconfigured: true
})
})
it('shares one cache entry between two bots on the same route', async () => {
hostMock.requestProfile.mockResolvedValue({ providers: [{ models: ['m1'], slug: 'prov' }] })
mount(remoteBot)
await waitFor(() => expect(hostMock.requestProfile).toHaveBeenCalledTimes(1))
mount({ ...remoteBot, name: 'default' } as RosterRow)
await waitFor(() => expect(hostMock.requestProfile).toHaveBeenCalledTimes(1))
})
})