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:
teknium1
2026-09-23 19:46:57 -07:00
committed by Teknium
parent 1b77007a36
commit 6b2c23ae42
3 changed files with 30 additions and 15 deletions

View File

@@ -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 {

View File

@@ -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()
})
})

View File

@@ -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.