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 files changed
+859 -163

No files matched your search

@@ -225,6 +225,7 @@ export const sessionModelsRequestSchema = z.object({
/** session.models response value. */
export const sessionModelsValueSchema = z.object({
current: modelTargetSchema,
routable: z.boolean(),
groups: z.array(modelProviderGroupSchema),
failures: z.array(modelCatalogFailureSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'session.models'>>>
@@ -117,6 +117,15 @@ export interface ModelCatalogFailure {
export interface SessionModels {
/** Target selected for the session's next assembled step. */
current: ModelTarget
/**
* Whether an adapter currently serves `current.provider`, and therefore
* whether this session can start a turn at all. Deliberately NOT derivable
* from `groups`: catalog membership is advisory, so a route serving a model
* it stopped advertising is absent from the groups yet perfectly usable,
* while a route whose adapter is gone can serve nothing. A surface that
* blocks input must read this rather than the groups.
*/
routable: boolean
/** Successfully loaded provider groups. */
groups: ModelProviderGroup[]
/** Provider-local failures; successful groups remain usable. */