fix(tui): defer model-context resolution on the adapter-registration race

Loader activation is service-driven, so the TUI can mount before a
configured adapter plugin registers its provider routes; every fresh
session then printed 'Could not resolve model context: no adapter
registered for provider …' for a working configuration.

The model controller now treats a NO_ADAPTER rejection of the
context-window resolution as transient: it parks the resolution
silently and re-resolves on the next llm/adapters-updated commit. A
commit that still lacks the route parks the wait again; any target
change clears it; all other resolution errors still surface. A wrong
provider name keeps failing loudly at dispatch, where it is actionable.
This commit is contained in:
Turtle
2026-07-31 10:22:58 +08:00
parent 1351565681
commit 899d25dfb3
6 changed files with 151 additions and 2 deletions

View File

@@ -8,7 +8,7 @@
*/
import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent'
import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain, LlmError, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { TuiOverlaySession } from '../extension/types.ts'
import { displayText } from '../components/text.ts'
import {
@@ -55,8 +55,15 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
let modelOverlay: TuiOverlaySession | undefined
let modelCommands = Promise.resolve()
// A route whose adapter has not registered yet. Loader activation order is
// service-driven, so the TUI can mount before a configured adapter plugin
// activates; that transient NO_ADAPTER is not an error — the resolution
// waits for the next `llm/adapters-updated` commit instead of surfacing it.
let awaitingAdapter = false
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
contextWindow = undefined
awaitingAdapter = false
const resolution: Promise<ContextResolution> = selected === undefined
? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
: ctx.llm.resolveModelInfo(selected.provider, selected.model).then(
@@ -67,6 +74,10 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
void resolution.then((result) => {
if (contextResolution !== resolution) return
if (result.kind === 'error') {
if (selected !== undefined && result.error instanceof LlmError && result.error.code === 'NO_ADAPTER') {
awaitingAdapter = true
return
}
deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error')
return
}
@@ -74,6 +85,14 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
deps.requestRender()
})
}
// The wait cannot go stale against `target.current`: every target change
// re-enters resolveContextWindow, which clears it. A commit that still
// lacks the route parks the resolution again rather than erroring, so
// unrelated topology changes stay silent.
ctx.on('llm/adapters-updated', () => {
if (deps.isDisposed() || !awaitingAdapter) return
resolveContextWindow(target.current)
})
resolveContextWindow(target.current)
const selectModel = (

View File

@@ -10,6 +10,7 @@ import AgentRegistry, {
} from '@deepseek-ai/dsh-agent'
import { createUserMessage,
createToolResultMessage,
LlmError,
ReasoningEffortId,
type LlmCallConfig,
type LlmModelReasoningInfo,
@@ -3630,6 +3631,71 @@ describe('pi-tui chat lifecycle and transcript', () => {
await dispose(reasoningFailed)
})
it('defers a NO_ADAPTER context resolution until the provider registers instead of surfacing an error', async () => {
// Loader activation order is service-driven: the TUI can mount before a
// configured adapter plugin activates, so the initial resolveModelInfo
// fails with NO_ADAPTER. That transient state must not print an error;
// the resolution retries on llm/adapters-updated.
const adapters = new Set<string>()
const result = await setup({
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
contextTokens: 50_000,
catalog: {
providers: [],
models: [],
resolveModelInfo: () => adapters.has('openai-codex')
? Promise.resolve({ context: { contextWindow: 100_000 } })
: Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')),
},
})
await tick()
expect(result.terminal.output).not.toContain('Could not resolve model context')
// A topology commit that still lacks the route parks the wait again.
result.ctx.emit('llm/adapters-updated')
await tick()
expect(result.terminal.output).not.toContain('% context')
expect(result.terminal.output).not.toContain('Could not resolve model context')
adapters.add('openai-codex')
result.ctx.emit('llm/adapters-updated')
await vi.waitFor(() => {
expect(result.terminal.output).toContain('% context')
})
expect(result.terminal.output).not.toContain('Could not resolve model context')
// A commit after satisfaction is a no-op for the resolved value.
result.ctx.emit('llm/adapters-updated')
await tick()
expect(result.terminal.output).not.toContain('Could not resolve model context')
await dispose(result)
})
it('drops a deferred NO_ADAPTER resolution when the target moved before the adapter registered', async () => {
const result = await setup({
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
catalog: {
providers: [{ id: 'alpha', name: 'Alpha' }],
models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
resolveModelInfo: provider => provider === 'alpha'
? Promise.resolve({ context: { contextWindow: 64_000 } })
: Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')),
},
})
await tick()
// Switching the model re-resolves and clears the deferred wait, so the
// stale route's adapter arriving afterwards must be a no-op.
result.terminal.send('/model alpha/a1')
result.terminal.send('\r')
await vi.waitFor(() => {
expect(result.terminal.output).toContain('Model selected: alpha/a1')
})
result.ctx.emit('llm/adapters-updated')
await tick()
expect(result.terminal.output).not.toContain('Could not resolve model context')
await dispose(result)
})
it('does not render a model catalog that resolves after TUI disposal', async () => {
const deferred = Promise.withResolvers<never[]>()
const result = await setup({