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

@@ -22,7 +22,7 @@ import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepsee
import type { HostFrame } from '../src/api/index.ts'
import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
import { RpcId } from '../src/api/rpc.ts'
import { createApiProxy } from '../src/api-proxy.ts'
import { API_GATEWAY_SETTINGS_NAMESPACE, createApiProxy } from '../src/api-proxy.ts'
const DEFAULTS = { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
@@ -398,6 +398,25 @@ describe('settings domain', () => {
expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }])
})
it('invalidates the model catalog when the gateway default route changes', async () => {
const ctx = await harness()
const route = ctx.settings.register(API_GATEWAY_SETTINGS_NAMESPACE, z.object({
provider: z.string().required(),
model: z.string().required(),
}), { base: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } })
const api = createApiProxy(ctx, DEFAULTS)
// The gateway's own section names the route every session with no logged
// one resolves to, so an externally edited default — another tab, a
// hand-edited settings.yaml — has to reach an open selector as well.
const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 2, async () => {
await route.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
})
expect(frames).toEqual([
{ type: 'host/settings-changed', ns: 'api-gateway' },
{ type: 'host/models-changed' },
])
})
it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => {
const ctx = await harness()
ctx.settings.register(NS, AdapterConfig)