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:
@@ -650,8 +650,11 @@ describe('provider rows', () => {
|
||||
})
|
||||
|
||||
describe('hand-declared providers', () => {
|
||||
function mountCard(overrides: Partial<Parameters<typeof CustomProviderCard>[0]> = {}) {
|
||||
const scripted = scriptedFace()
|
||||
function mountCard(
|
||||
overrides: Partial<Parameters<typeof CustomProviderCard>[0]> = {},
|
||||
wire: Parameters<typeof scriptedFace>[0] = {},
|
||||
) {
|
||||
const scripted = scriptedFace(wire)
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<CustomProviderCard
|
||||
@@ -736,6 +739,60 @@ describe('hand-declared providers', () => {
|
||||
expect(firstMutate(second.mutate).ops[0]).not.toHaveProperty('value.reasoning')
|
||||
})
|
||||
|
||||
it('retries only the key after the profile landed, and reports the provider on cancel', async () => {
|
||||
const set = vi.fn()
|
||||
.mockResolvedValueOnce(fail('credential store is read-only', 'credential-rejected'))
|
||||
.mockResolvedValueOnce(ok({}))
|
||||
const { mutate, onClose } = mountCard({}, { set })
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' gw-key ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
|
||||
// The profile landed; only the key failed. The card says so and stays open.
|
||||
await waitFor(() => { expect(screen.getByText('credential store is read-only')).toBeTruthy() })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(mutate).toHaveBeenCalledTimes(1)
|
||||
// The key is stored trimmed, matching the editor.
|
||||
expect(set).toHaveBeenNthCalledWith(1, { ref: 'ACME_API_KEY', value: 'gw-key' })
|
||||
|
||||
// The provider exists now, so the fields describing it are settled and
|
||||
// only the key can still be corrected.
|
||||
expect(screen.getByLabelText<HTMLInputElement>(en.customRoute).disabled).toBe(true)
|
||||
expect(screen.getByLabelText<HTMLInputElement>(en.baseUrl).disabled).toBe(true)
|
||||
expect(screen.getByLabelText<HTMLInputElement>(en.keyInput).disabled).toBe(false)
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key-2' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
|
||||
// Re-running the profile write would carry the revision this card's own
|
||||
// first write superseded, so the Host would answer settings-conflict and
|
||||
// the key could never be stored from here at all.
|
||||
expect(mutate).toHaveBeenCalledTimes(1)
|
||||
expect(set).toHaveBeenNthCalledWith(2, { ref: 'ACME_API_KEY', value: 'gw-key-2' })
|
||||
})
|
||||
|
||||
it('reports the created provider when cancelled after its profile landed', async () => {
|
||||
const set = vi.fn().mockResolvedValue(fail('nope', 'credential-rejected'))
|
||||
const { onClose } = mountCard({}, { set })
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
await waitFor(() => { expect(screen.getByText('nope')).toBeTruthy() })
|
||||
|
||||
// Walking away leaves a real provider behind; reporting no change would
|
||||
// leave the page without the row it now has.
|
||||
fireEvent.click(screen.getByText(en.cancel))
|
||||
expect(onClose).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('names the blocked gate under the form, and nothing once it is satisfied', () => {
|
||||
mountCard()
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
|
||||
Reference in New Issue
Block a user