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

@@ -91,6 +91,8 @@ function mount(
omitSummaryRow?: boolean
/** Classify the selected child as a subagent instead of an ordinary fork. */
summaryOrigin?: 'subagent'
/** A composer block another plugin raised for this session. */
composerBlock?: { reason: string }
} = {},
) {
const root = sid('root')
@@ -224,6 +226,7 @@ function mount(
useSessions: bindSnapshotSelector(sessions),
useWorkspaces: bindSnapshotSelector(workspaces),
useProjection: (() => undefined),
useComposerBlock: select => select(options.composerBlock),
useInput,
inputActions,
renderSlot,
@@ -248,6 +251,31 @@ describe('Hero chrome', () => {
})
describe('ConversationRoot resident composer', () => {
it('renders the composer inert with the blocker\u2019s own reason', () => {
const b = mount(conversationSnapshot(), undefined, undefined, {
composerBlock: { reason: 'select a model first' },
})
const box = b.view.getByRole('textbox') as HTMLTextAreaElement
// One disabled textarea with the blocker's placeholder, never a second
// tree: the DOM survives the block being raised and cleared.
expect(box.disabled).toBe(true)
expect(box.placeholder).toBe('select a model first')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.sink).not.toHaveBeenCalled()
})
it('lets the no-workspace posture win over a block', () => {
// Picking a workspace is the earlier prerequisite; naming a model first
// would send the user somewhere they cannot act yet.
const b = mount(conversationSnapshot({ composerPhase: 'blank' }), [], undefined, {
summaryBlank: true,
composerBlock: { reason: 'select a model first' },
})
const box = b.view.getByRole('textbox') as HTMLTextAreaElement
expect(box.disabled).toBe(true)
expect(box.placeholder).not.toBe('select a model first')
})
it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => {
const b = mount(conversationSnapshot())
const box = b.view.getByRole('textbox')