fix(desktop): model-pill provider must return a string; non-strings fall through
Review finding (MAJOR) on #120919: useComposerModelPillLabel accepted any truthy return with `if (label)`. ModelPill drops the value straight into JSX with no error boundary, so a plugin returning an object/array/number threw "Objects are not valid as a React child" and blanked the whole composer — exactly the failure mode the hook's throw-swallowing was meant to prevent. Only a non-empty, non-whitespace string is now a label; anything else declines like null. Review finding (minor): the two existing tests are tightened instead of adding new ones. The compact-mode "providers skipped" assertion was `queryByText(...)` on a chevron-only render and passed regardless; it is now a spy that must not be called. The throw test now also covers the non-string return (red on b559ac9c: React child error) and two non-null providers: the first registered string wins and the later provider's spy is never invoked. Docs: arbitration section states the string-only contract and that `reasoningEffort` is always a string ('' when none).
This commit is contained in:
@@ -201,7 +201,10 @@ export function useComposerModelPillLabel({ compact, model, reasoningEffort }: C
|
||||
try {
|
||||
const label = provider?.label?.(ctx)
|
||||
|
||||
if (label) {
|
||||
// Only a non-empty string is a label. ModelPill renders the value
|
||||
// straight into JSX with no error boundary, so an object/array/number
|
||||
// from a plugin would throw and blank the composer — treat it as declining.
|
||||
if (typeof label === 'string' && label.trim() !== '') {
|
||||
return label
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { useContext } from 'react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ChatBarState } from '@/app/chat/composer/types'
|
||||
import { type SessionView, SessionViewProvider } from '@/app/chat/session-view'
|
||||
@@ -10,7 +10,7 @@ import { registry } from '@/contrib/registry'
|
||||
import { formatModelPillLabel } from '@/lib/model-status-label'
|
||||
import { $activeSessionId, $currentModel, setCurrentModel, setCurrentModelSource } from '@/store/session'
|
||||
|
||||
import { COMPOSER_AREAS, type ComposerModelPillProvider } from './contrib'
|
||||
import { COMPOSER_AREAS, type ComposerModelPillContext, type ComposerModelPillProvider } from './contrib'
|
||||
import { requestModelMenuToggle } from './focus'
|
||||
import { ModelPill } from './model-pill'
|
||||
import { RICH_INPUT_SLOT } from './rich-editor'
|
||||
@@ -191,22 +191,26 @@ describe('ModelPill label providers', () => {
|
||||
it('renders a provider-supplied label, and the core label once the provider declines', () => {
|
||||
setCurrentModel('deepseek/deepseek-v4-flash')
|
||||
|
||||
register(({ model, reasoningEffort }) => `${model} · ${reasoningEffort || 'none'}`)
|
||||
const label = vi.fn(({ model, reasoningEffort }: ComposerModelPillContext) => `${model} · ${reasoningEffort || 'none'}`)
|
||||
register(label)
|
||||
|
||||
const { unmount } = render(
|
||||
<ModelPill disabled={false} model={modelState({ model: 'deepseek/deepseek-v4-flash' })} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('deepseek/deepseek-v4-flash · none')).toBeTruthy()
|
||||
expect(label).toHaveBeenCalled()
|
||||
unmount()
|
||||
|
||||
// Floating-composer (compact) mode renders only the chevron: providers are
|
||||
// not consulted, so the provider text must not leak into the DOM.
|
||||
// not consulted at all (the chevron has no text to leak, so only the spy
|
||||
// proves the skip).
|
||||
label.mockClear()
|
||||
const compactRender = render(
|
||||
<ModelPill compact disabled={false} model={modelState({ model: 'deepseek/deepseek-v4-flash' })} />
|
||||
)
|
||||
|
||||
expect(screen.queryByText(/· none/)).toBeNull()
|
||||
expect(label).not.toHaveBeenCalled()
|
||||
compactRender.unmount()
|
||||
|
||||
// Declining provider: the override text is gone, the core label is back.
|
||||
@@ -219,17 +223,22 @@ describe('ModelPill label providers', () => {
|
||||
expect(screen.getByText(formatModelPillLabel('deepseek/deepseek-v4-flash', { fastMode: false }))).toBeTruthy()
|
||||
})
|
||||
|
||||
it('treats a throwing provider as declining and lets the next provider win', () => {
|
||||
it('falls through on throw and on a non-string return; the first string wins and later providers are not asked', () => {
|
||||
setCurrentModel('deepseek/deepseek-v4-flash')
|
||||
|
||||
register(() => {
|
||||
throw new Error('broken provider')
|
||||
}, 'broken')
|
||||
register(() => 'next wins', 'working')
|
||||
// A non-string (object/array/number) is not a label: rendering it would
|
||||
// throw inside ModelPill (no error boundary there) and blank the composer.
|
||||
register(() => ({ text: 'object label' }) as unknown as string, 'wrong-type')
|
||||
register(() => 'first wins', 'first')
|
||||
const second = vi.fn(() => 'second loses')
|
||||
register(second, 'second')
|
||||
|
||||
render(<ModelPill disabled={false} model={modelState({ model: 'deepseek/deepseek-v4-flash' })} />)
|
||||
|
||||
// The broken provider declined; the next one's label renders.
|
||||
expect(screen.getByText('next wins')).toBeTruthy()
|
||||
expect(screen.getByText('first wins')).toBeTruthy()
|
||||
expect(second).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -596,11 +596,14 @@ ctx.register({
|
||||
```
|
||||
|
||||
**Arbitration.** Providers are consulted in registry order and the *first
|
||||
non-null, non-empty string wins*. A provider that returns `null` (or `''`)
|
||||
declines and the next one is asked; a provider that **throws** is treated as
|
||||
declining — the error is swallowed and the pill falls through to the next
|
||||
provider, then to the core label, so a broken plugin can never blank the pill.
|
||||
In compact (floating) mode the pill renders only the chevron and no provider is
|
||||
non-empty string wins*; later providers are not called. Anything else declines
|
||||
and the next provider is asked: `null`, `''`, a whitespace-only string, and any
|
||||
non-string value (an object, array or number is never rendered — the label is
|
||||
placed straight into JSX). A provider that **throws** also declines — the error
|
||||
is swallowed and the pill falls through to the next provider, then to the core
|
||||
label, so a broken plugin can never blank the pill. `reasoningEffort` is always
|
||||
a `string` (`''` when the model has no effort level, never `undefined`). In
|
||||
compact (floating) mode the pill renders only the chevron and no provider is
|
||||
called. `label()` is re-evaluated only when the registry, the model, the effort
|
||||
level or the compact flag changes.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user