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
@@ -17,6 +17,7 @@ import type { ModelTarget } from '@deepseek-ai/dsh-client-connection/client'
import type { CommandContribution, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client'
import type { ModelSelectInjected } from '../src/client/slots.ts'
import { apply, inject } from '../src/client/index.ts'
import { zh } from '../src/client/locales.ts'
const sid = (k: string): SessionId => k as SessionId
@@ -59,7 +60,9 @@ async function bench() {
ctx.provide('connection', { api: { sessions: {
models: () => {
calls.models += 1
return Promise.resolve({ result: { ok: true as const, value: { current, groups: GROUPS, failures: [] } } })
return Promise.resolve({
result: { ok: true as const, value: { current, routable, groups: GROUPS, failures: [] } },
})
},
selectModel: (payload: { provider: string; model: string; reasoningEffort?: string }) => {
calls.select += 1
@@ -73,6 +76,15 @@ async function bench() {
return Promise.resolve({ result: { ok: true as const, value: { selected: current } } })
},
} } })
// Whether the Host reports an adapter for the current route; the composer
// block follows this, never catalog membership.
let routable = true
const blocks = new Map<SessionId, { reason: string } | undefined>()
ctx.provide('conversation', {
blocks: {
set: (id: SessionId, block: { reason: string } | undefined) => { blocks.set(id, block) },
},
})
let contribution: CommandContribution | undefined
ctx.provide('command', {
register(c: CommandContribution) {
@@ -115,6 +127,8 @@ async function bench() {
hostCurrent: () => current,
setHostCurrent: (target: ModelTarget) => { current = target },
address: (id: SessionId) => { addressed.add(id) },
setRoutable: (next: boolean) => { routable = next },
blockOf: (key: string) => blocks.get(sid(key)),
}
}
@@ -217,6 +231,63 @@ describe('ui-model dual entry', () => {
expect(face2.directory).not.toBe(face1.directory)
})
it('blocks the composer only once the Host reports the route unservable', async () => {
const b = await bench()
b.mint('s1')
const face = b.seat().inject!(sid('s1'))
// Before the first load nothing is known. `null` is not `false`: a slow
// or unreachable Host must never lock a working composer.
expect(b.blockOf('s1')).toBeUndefined()
face.load()
await Promise.resolve()
await Promise.resolve()
expect(b.blockOf('s1')).toBeUndefined()
b.setRoutable(false)
b.ctx.emit('models/changed')
await Promise.resolve()
await Promise.resolve()
expect(b.blockOf('s1')?.reason).toBe(zh['blocked.composer'])
// Recovering clears it without a reload of the surface.
b.setRoutable(true)
b.ctx.emit('models/changed')
await Promise.resolve()
await Promise.resolve()
expect(b.blockOf('s1')).toBeUndefined()
})
it('never blocks on catalog membership alone', async () => {
const b = await bench()
b.mint('s1')
const face = b.seat().inject!(sid('s1'))
// A model the route serves but no longer advertises: the seat prompts for
// a selection, the composer stays usable. Blocking here would break a
// supported configuration (a narrowed `models` list over a live route).
b.setHostCurrent({ provider: 'deepseek-official', model: 'unlisted' })
face.load()
await Promise.resolve()
await Promise.resolve()
const snapshot = face.directory.getSnapshot()
expect(snapshot.groups.flatMap(group => group.models.map(model => model.id))).not.toContain('unlisted')
expect(b.blockOf('s1')).toBeUndefined()
})
it('clears its block when the session scope goes', async () => {
const b = await bench()
const scope = b.mint('s1')
b.setRoutable(false)
const face = b.seat().inject!(sid('s1'))
face.load()
await Promise.resolve()
await Promise.resolve()
expect(b.blockOf('s1')).toBeDefined()
await scope.fiber.dispose()
expect(b.blockOf('s1')).toBeUndefined()
})
it('an unknown session fails loud at the seat inject', async () => {
const b = await bench()
expect(() => b.seat().inject!(sid('ghost'))).toThrow(/resolved no scope/)
@@ -32,6 +32,7 @@ const reasoning = {
function state(overrides: Partial<ModelDirectoryState> = {}): ModelDirectoryState {
return {
current: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
routable: true,
groups: [{
id: 'deepseek-official',
name: 'DeepSeek',