opentui(v6): dedupe model.options prefetch with /model open
This commit is contained in:
parent
036e863e4a
commit
b3efafcc73
@ -42,6 +42,7 @@ import {
|
|||||||
mapModelOptions,
|
mapModelOptions,
|
||||||
planCompletion,
|
planCompletion,
|
||||||
readReplaceFrom,
|
readReplaceFrom,
|
||||||
|
registerModelPrefetch,
|
||||||
type SlashContext
|
type SlashContext
|
||||||
} from '../logic/slash.ts'
|
} from '../logic/slash.ts'
|
||||||
import { createSessionStore, type SessionStore } from '../logic/store.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
|
// 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
|
// 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.
|
// (or the RPC is missing/old), /model falls back to fetching on first open.
|
||||||
const modelOpts = yield* gateway
|
// 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 })
|
.request<unknown>('model.options', { session_id: sid })
|
||||||
.pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
.pipe(Effect.catchCause(() => Effect.succeed(undefined)))
|
||||||
|
).then(modelOpts => {
|
||||||
const modelItems = mapModelOptions(modelOpts)
|
const modelItems = mapModelOptions(modelOpts)
|
||||||
if (modelItems.length) store.setModelItems(modelItems)
|
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) }))))
|
}).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")`. */
|
/** 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) ?? []
|
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).
|
/** 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 ✓). */
|
* A successful switch refreshes the cached rows in the background (fresh ✓). */
|
||||||
async function switchModel(ctx: SlashContext, name: string): Promise<void> {
|
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.
|
/** `/model` — bare opens the model picker; `/model <name>` switches directly.
|
||||||
* Opens from the CACHED catalog when present — zero RPCs, same-frame paint
|
* 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) => {
|
const modelCmd: ClientHandler = async (arg, ctx) => {
|
||||||
if (arg.trim()) {
|
if (arg.trim()) {
|
||||||
await switchModel(ctx, arg.trim())
|
await switchModel(ctx, arg.trim())
|
||||||
@ -368,6 +394,14 @@ const modelCmd: ClientHandler = async (arg, ctx) => {
|
|||||||
open(cached)
|
open(cached)
|
||||||
return
|
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() }))
|
const items = mapModelOptions(await ctx.request('model.options', { session_id: ctx.sessionId() }))
|
||||||
// Unavailable hint rows alone are not a usable catalog — keep the notice.
|
// Unavailable hint rows alone are not a usable catalog — keep the notice.
|
||||||
if (!items.some(i => !i.unavailable)) {
|
if (!items.some(i => !i.unavailable)) {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
pickerTabs,
|
pickerTabs,
|
||||||
planCompletion,
|
planCompletion,
|
||||||
readReplaceFrom,
|
readReplaceFrom,
|
||||||
|
registerModelPrefetch,
|
||||||
registerPickerRefresh,
|
registerPickerRefresh,
|
||||||
registerPickerTabs,
|
registerPickerTabs,
|
||||||
runPickerRefresh,
|
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' }]
|
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(() => {
|
afterEach(() => {
|
||||||
registerPickerRefresh(undefined)
|
registerPickerRefresh(undefined)
|
||||||
registerPickerTabs(undefined)
|
registerPickerTabs(undefined)
|
||||||
|
registerModelPrefetch(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
/** A `model.options` payload: two authed providers + two unconfigured skeleton
|
/** 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([])
|
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 () => {
|
test('/model <name> switches directly without opening the picker', async () => {
|
||||||
const p = makeCtx(async () => ({ output: 'ok' }))
|
const p = makeCtx(async () => ({ output: 'ok' }))
|
||||||
await dispatchSlash('/model anthropic/claude-opus-4.6', p.ctx)
|
await dispatchSlash('/model anthropic/claude-opus-4.6', p.ctx)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user