mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
opentui(v6): dedupe model.options prefetch with /model open
This commit is contained in:
parent
036e863e4a
commit
b3efafcc73
3 changed files with 85 additions and 7 deletions
|
|
@ -42,6 +42,7 @@ import {
|
|||
mapModelOptions,
|
||||
planCompletion,
|
||||
readReplaceFrom,
|
||||
registerModelPrefetch,
|
||||
type SlashContext
|
||||
} from '../logic/slash.ts'
|
||||
import { createSessionStore, type SessionStore } from '../logic/store.ts'
|
||||
|
|
@ -196,11 +197,19 @@ const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, inp
|
|||
// that cost ONCE here in this already-forked bootstrap fiber; `/model` then
|
||||
// paints from memory on the same frame. Best-effort: if this hasn't landed
|
||||
// (or the RPC is missing/old), /model falls back to fetching on first open.
|
||||
const modelOpts = yield* gateway
|
||||
.request<unknown>('model.options', { session_id: sid })
|
||||
.pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||
const modelItems = mapModelOptions(modelOpts)
|
||||
if (modelItems.length) store.setModelItems(modelItems)
|
||||
// The promise is STASHED in the slash seam so a `/model` opened while the
|
||||
// prefetch is still in flight awaits THIS request (bounded) instead of
|
||||
// issuing a second concurrent model.options RPC (prefetch dedupe).
|
||||
const prefetch = Effect.runPromise(
|
||||
gateway
|
||||
.request<unknown>('model.options', { session_id: sid })
|
||||
.pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||
).then(modelOpts => {
|
||||
const modelItems = mapModelOptions(modelOpts)
|
||||
if (modelItems.length) store.setModelItems(modelItems)
|
||||
})
|
||||
registerModelPrefetch(prefetch)
|
||||
yield* Effect.promise(() => prefetch)
|
||||
}).pipe(Effect.catchCause(cause => Effect.sync(() => getLog().warn('bootstrap', 'failed', { cause: String(cause) }))))
|
||||
|
||||
/** The entry Effect. Mirrors opencode `app.tsx:177` `run = Effect.fn("Tui.run")`. */
|
||||
|
|
|
|||
|
|
@ -336,6 +336,30 @@ export function pickerTabs(items: readonly PickerItem[]): string[] {
|
|||
return activePickerTabs?.(items) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* The bootstrap `model.options` prefetch seam (perf: prefetch dedupe). The
|
||||
* entry stashes its in-flight prefetch promise here; a bare `/model` that
|
||||
* finds the cache empty AWAITS it (bounded by `waitMs`) and re-checks the
|
||||
* cache instead of issuing a second concurrent `model.options` RPC. A hung
|
||||
* prefetch only delays the picker by the bound — `/model` then opens via its
|
||||
* own fetch as before.
|
||||
*/
|
||||
let modelPrefetch: { promise: Promise<unknown>; waitMs: number } | undefined
|
||||
|
||||
/** Register (or clear, with `undefined`) the in-flight bootstrap prefetch. */
|
||||
export function registerModelPrefetch(promise: Promise<unknown> | undefined, waitMs = 2000): void {
|
||||
modelPrefetch = promise ? { promise, waitMs } : undefined
|
||||
}
|
||||
|
||||
/** Await the registered prefetch (bounded); resolves immediately when none. */
|
||||
function awaitModelPrefetch(): Promise<void> {
|
||||
const pending = modelPrefetch
|
||||
if (!pending) return Promise.resolve()
|
||||
return Promise.race([pending.promise, new Promise(resolve => setTimeout(resolve, pending.waitMs))]).then(
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
|
||||
/** Switch the model via the server (shared by `/model <name>` and the picker pick).
|
||||
* A successful switch refreshes the cached rows in the background (fresh ✓). */
|
||||
async function switchModel(ctx: SlashContext, name: string): Promise<void> {
|
||||
|
|
@ -350,7 +374,9 @@ async function switchModel(ctx: SlashContext, name: string): Promise<void> {
|
|||
|
||||
/** `/model` — bare opens the model picker; `/model <name>` switches directly.
|
||||
* Opens from the CACHED catalog when present — zero RPCs, same-frame paint
|
||||
* (Epic 7; the catalog is prefetched at bootstrap and refreshed on switch). */
|
||||
* (Epic 7; the catalog is prefetched at bootstrap and refreshed on switch).
|
||||
* An empty cache first awaits the in-flight bootstrap prefetch (bounded) so
|
||||
* an early `/model` never doubles the slow `model.options` RPC. */
|
||||
const modelCmd: ClientHandler = async (arg, ctx) => {
|
||||
if (arg.trim()) {
|
||||
await switchModel(ctx, arg.trim())
|
||||
|
|
@ -368,6 +394,14 @@ const modelCmd: ClientHandler = async (arg, ctx) => {
|
|||
open(cached)
|
||||
return
|
||||
}
|
||||
// Cache empty but the bootstrap prefetch may be in flight — await it
|
||||
// (bounded) and re-check instead of racing a SECOND model.options RPC.
|
||||
await awaitModelPrefetch()
|
||||
const prefetched = ctx.modelItems()
|
||||
if (prefetched?.length) {
|
||||
open(prefetched)
|
||||
return
|
||||
}
|
||||
const items = mapModelOptions(await ctx.request('model.options', { session_id: ctx.sessionId() }))
|
||||
// Unavailable hint rows alone are not a usable catalog — keep the notice.
|
||||
if (!items.some(i => !i.unavailable)) {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
pickerTabs,
|
||||
planCompletion,
|
||||
readReplaceFrom,
|
||||
registerModelPrefetch,
|
||||
registerPickerRefresh,
|
||||
registerPickerTabs,
|
||||
runPickerRefresh,
|
||||
|
|
@ -22,10 +23,11 @@ import type { PickerItem, SessionItem } from '../logic/store.ts'
|
|||
|
||||
const FAKE_SESSIONS: SessionItem[] = [{ id: 's1', messageCount: 5, preview: 'hello there', title: 'First chat' }]
|
||||
|
||||
// the picker-refresh/tabs seams are module-level state — never leak them across tests
|
||||
// the picker-refresh/tabs/prefetch seams are module-level state — never leak them across tests
|
||||
afterEach(() => {
|
||||
registerPickerRefresh(undefined)
|
||||
registerPickerTabs(undefined)
|
||||
registerModelPrefetch(undefined)
|
||||
})
|
||||
|
||||
/** A `model.options` payload: two authed providers + two unconfigured skeleton
|
||||
|
|
@ -399,6 +401,39 @@ describe('dispatchSlash — client commands', () => {
|
|||
expect(pickerTabs(p.pickers[0]!.items)).toEqual([])
|
||||
})
|
||||
|
||||
test('/model during an in-flight bootstrap prefetch performs ZERO additional model.options RPCs', async () => {
|
||||
const p = makeCtx(async () => {
|
||||
throw new Error('no RPC expected — the prefetch owns model.options')
|
||||
})
|
||||
// a slow prefetch (entry/main.tsx shape): resolving it fills the cache
|
||||
let finish: () => void = () => {}
|
||||
registerModelPrefetch(
|
||||
new Promise<void>(resolve => {
|
||||
finish = () => {
|
||||
p.modelCache.value = [
|
||||
{ group: 'Anthropic', haystacks: ['anthropic'], label: 'claude-sonnet-4.6', value: 'a' }
|
||||
]
|
||||
resolve()
|
||||
}
|
||||
}),
|
||||
5000
|
||||
)
|
||||
const dispatched = dispatchSlash('/model', p.ctx)
|
||||
finish() // prefetch lands while /model awaits it
|
||||
await dispatched
|
||||
expect(p.pickers).toHaveLength(1) // opened from the prefetched cache
|
||||
expect(p.pickers[0]!.items).toHaveLength(1)
|
||||
expect(p.calls).toHaveLength(0) // the dedupe: no second model.options
|
||||
})
|
||||
|
||||
test('/model with a HUNG prefetch still opens via its own fetch after the bound', async () => {
|
||||
const p = makeCtx(async method => (method === 'model.options' ? MODEL_OPTIONS : { output: 'switched' }))
|
||||
registerModelPrefetch(new Promise(() => {}), 10) // never settles; tiny test bound
|
||||
await dispatchSlash('/model', p.ctx)
|
||||
expect(p.pickers).toHaveLength(1) // fell back to fetching itself
|
||||
expect(p.calls.filter(c => c.method === 'model.options')).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('/model <name> switches directly without opening the picker', async () => {
|
||||
const p = makeCtx(async () => ({ output: 'ok' }))
|
||||
await dispatchSlash('/model anthropic/claude-opus-4.6', p.ctx)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue