From 6b2c23ae42bcc837471a87c844499bb04741842e Mon Sep 17 00:00:00 2001
From: teknium1 <127238744+teknium1@users.noreply.github.com>
Date: Wed, 23 Sep 2026 19:46:57 -0700
Subject: [PATCH] fix(desktop): model-pill provider must return a string;
non-strings fall through
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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).
---
apps/desktop/src/app/chat/composer/contrib.ts | 5 +++-
.../src/app/chat/composer/model-pill.test.tsx | 27 ++++++++++++-------
.../developer-guide/desktop-plugin-sdk.md | 13 +++++----
3 files changed, 30 insertions(+), 15 deletions(-)
diff --git a/apps/desktop/src/app/chat/composer/contrib.ts b/apps/desktop/src/app/chat/composer/contrib.ts
index b3cfc80124..05b4ef0b14 100644
--- a/apps/desktop/src/app/chat/composer/contrib.ts
+++ b/apps/desktop/src/app/chat/composer/contrib.ts
@@ -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 {
diff --git a/apps/desktop/src/app/chat/composer/model-pill.test.tsx b/apps/desktop/src/app/chat/composer/model-pill.test.tsx
index 819fcdf59f..1e6270d8ab 100644
--- a/apps/desktop/src/app/chat/composer/model-pill.test.tsx
+++ b/apps/desktop/src/app/chat/composer/model-pill.test.tsx
@@ -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(
)
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(
)
- 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()
- // 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()
})
})
diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md
index e474e45e41..0eff36d102 100644
--- a/website/docs/developer-guide/desktop-plugin-sdk.md
+++ b/website/docs/developer-guide/desktop-plugin-sdk.md
@@ -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.