feat(ui): make a session that cannot send refuse to accept one

A default naming a route the Models page has since removed left the
composer saying 选择模型 while the input still accepted a message, which
then failed inside the adapter mid-turn.

`session.prompt` now refuses with `model-unavailable` before opening a
turn. That is the enforcement boundary: the method stays callable no
matter what a client disables. `session.models` reports the same fact as
`routable`, and ui-model pushes a block through the new
`ctx.conversation.blocks` registry so the bar renders the disabled
textarea it already renders without a workspace, carrying the blocker's
own reason. The push direction is forced — ui-model already depends on
ui-conversation, so ui-conversation cannot read it back.

The gate is `routable`, not "matches no advertised group": catalog
membership is advisory, so a route serving a model it stopped advertising
is missing from the groups yet perfectly usable, and `null` before the
first load never blocks so a slow Host cannot lock a working composer.

The scaffold gains a route-only adapter for fixture-less keyless
scenarios. Registering zero providers is a test artifact — every product
composition mounts one — and the goldens that froze the seat's fallback
label now show the model those scenarios actually route to.
This commit is contained in:
Yichen Jiang
2026-08-07 15:26:42 +08:00
parent 72618f29b5
commit bb43ff4f37
58 changed files with 859 additions and 163 deletions

View File

@@ -15,6 +15,14 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
export interface ModelDirectoryState {
/** Target the host reports for the next assembled step; null before the first load. */
current: ModelTarget | null
/**
* Whether an adapter serves the current target's route, as the host reports
* it — null before the first load, which is NOT the same as blocked. Read
* this rather than "current matches no group": catalog membership is
* advisory, so a route serving a model it stopped advertising is missing
* from the groups yet perfectly usable.
*/
routable: boolean | null
/** Successfully loaded provider groups (last good load). */
groups: readonly ModelProviderGroup[]
/** Provider-local failures from the last load; usable groups stay usable. */
@@ -29,7 +37,7 @@ export interface ModelDirectoryState {
export class ModelDirectory {
/** The shared snapshot both entries render from (uSES-safe store). */
readonly store: SnapshotStore<ModelDirectoryState> = createSnapshotStore<ModelDirectoryState>({
current: null, groups: [], failures: [], status: 'idle', error: null,
current: null, routable: null, groups: [], failures: [], status: 'idle', error: null,
})
/** Latest operation wins; an older response never overwrites a newer one. */
@@ -65,9 +73,10 @@ export class ModelDirectory {
this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
throw new Error(`session.models failed: ${result.error.code}: ${result.error.message}`)
}
const { current, groups, failures } = result.value
const { current, routable, groups, failures } = result.value
this.store.update((s) => {
s.current = current
s.routable = routable
s.groups = groups
s.failures = failures
s.status = 'ready'
@@ -102,7 +111,14 @@ export class ModelDirectory {
this.store.update((s) => { s.status = 'error'; s.error = `${result.error.code}: ${result.error.message}` })
throw new Error(`session.selectModel failed: ${result.error.code}: ${result.error.message}`)
}
this.store.update((s) => { s.current = result.value.selected; s.status = 'ready'; s.error = null })
// The Host validated the route before accepting it, so a selection that
// landed is by construction one it can serve.
this.store.update((s) => {
s.current = result.value.selected
s.routable = true
s.status = 'ready'
s.error = null
})
}
/**
@@ -115,6 +131,7 @@ export class ModelDirectory {
++this.generation
this.store.update((s) => {
s.current = null
s.routable = null
s.groups = []
s.failures = []
s.status = 'idle'

View File

@@ -105,14 +105,16 @@ export const inject = ['command', 'connection', 'locale', 'sessions', 'slots']
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.plugin(ModelService)
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-model: dictionaries')
// Non-slot faces (the command description, the popup option builder) read
// through the bound translate; the seat component reads the standard seat.
const t = ctx.locale.bind(NS)
// The composer-block reason is this plugin's own copy, read at raise time so
// a locale change reaches the next publish.
ctx.plugin(ModelService, { blockReason: () => t('blocked.composer') })
// Entry 1: the /model popupSelect over the shared directory. The command
// description is registry-held text: it reads t() once at registration and
// refreshes only on re-registration, not on locale change.

View File

@@ -25,6 +25,7 @@ export const zh = {
'action.reload': '重新加载',
'warning.groupLoad': '{name} 加载失败:{message}',
'empty.models': '没有可用的模型。',
'blocked.composer': '当前模型不可用,请先选择模型',
'empty.efforts': '当前模型未提供推理等级。',
} satisfies Record<string, string>
@@ -48,5 +49,6 @@ export const en = {
'action.reload': 'Reload',
'warning.groupLoad': '{name} failed to load: {message}',
'empty.models': 'No models available.',
'blocked.composer': 'This model is unavailable — select one to continue',
'empty.efforts': 'This model provides no reasoning effort levels.',
} satisfies Record<ModelKey, string>

View File

@@ -36,11 +36,16 @@ export class ModelService extends Service {
private readonly live: LiveState = { directories: new Map() }
/** Localized composer-block copy; this plugin owns the string it raises. */
private readonly blockReason: () => string
/**
* @param ctx - owning root context (the service registers itself as `models`).
* @param config - the bound translator for this plugin's own dictionary.
*/
constructor(ctx: Context) {
constructor(ctx: Context, config: { blockReason: () => string }) {
super(ctx, 'models')
this.blockReason = config.blockReason
ctx.on('connection/reset', () => {
for (const directory of this.live.directories.values()) directory.resetConnected()
})
@@ -74,6 +79,27 @@ export class ModelService extends Service {
() => sessions.subagentAddress(sessionId) === undefined,
)
live.directories.set(sessionId, directory)
// The composer cannot read this plugin (the dependency runs one way), so
// the block is pushed: the Host says whether an adapter serves the
// session's route, and only a definite `false` makes the input inert.
// `null` — before the first load, or after one failed — must not, or a
// slow or unreachable Host would lock a working composer.
const conversation = this.ctx.get('conversation')
if (conversation !== undefined) {
const publish = (): void => {
conversation.blocks.set(sessionId, directory.store.getSnapshot().routable === false
? { reason: this.blockReason() }
: undefined)
}
publish()
actx.effect(() => {
const stop = directory.store.subscribe(publish)
return () => {
stop()
conversation.blocks.set(sessionId, undefined)
}
}, 'ui-model: composer block')
}
actx.effect(() => () => {
directory.dispose()
live.directories.delete(sessionId)