Merge pull request #76417 from NousResearch/bb/kanban-model-picker

Pick a kanban task's model and thinking depth from the board
This commit is contained in:
brooklyn! 2026-08-01 16:55:01 -05:00 committed by GitHub
commit 97971643ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1630 additions and 684 deletions

View File

@ -0,0 +1,108 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { DropdownMenu, DropdownMenuContent } from '@/components/ui/dropdown-menu'
import {
$modelVisibilityOpen,
$visibleModels,
modelVisibilityKey,
setModelVisibilityOpen,
setVisibleModels
} from '@/store/model-visibility'
import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu'
// Radix calls these on open; jsdom doesn't implement them.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
})
const getGlobalModelOptions = vi.fn()
vi.mock('@/hermes', () => ({
getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args),
setApiRequestProfile: vi.fn()
}))
beforeEach(() => {
$visibleModels.set(null)
setModelVisibilityOpen(false)
getGlobalModelOptions.mockResolvedValue({
providers: [{ models: ['gemini-3.1-pro', 'gemini-2.5-flash'], name: 'Google', slug: 'google' }]
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
// A minimal controller — these tests are about the CATALOG's own behaviour
// (what it lists, what it offers), not about what any host does with a pick.
function renderMenu() {
const select = vi.fn()
const controller: ModelMenuController = {
applyPreset: vi.fn(),
current: { effort: '', fast: false, model: '', provider: '' },
presetFor: () => ({}),
select,
setOptions: vi.fn()
}
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={client}>
<DropdownMenu open>
<DropdownMenuContent>
<ModelCatalogMenu controller={controller} />
</DropdownMenuContent>
</DropdownMenu>
</QueryClientProvider>
)
return select
}
// Curation is ONE global preference, so it belongs to the catalog rather than
// to whichever surface mounted it. If a host had to opt in, the composer and
// the kanban board would end up disagreeing about what "my models" means —
// which is exactly the drift extracting this component was meant to prevent.
describe('the catalog owns model curation', () => {
it('honours the stored Edit Models shortlist', async () => {
setVisibleModels(new Set([modelVisibilityKey('google', 'gemini-2.5-flash')]))
renderMenu()
await screen.findByText(/Gemini 2\.5 Flash/i)
expect(screen.queryByText(/Gemini 3\.1 Pro/i)).toBeNull()
})
it('still finds a hidden model by search — curation narrows the default view, not the catalog', async () => {
setVisibleModels(new Set([modelVisibilityKey('google', 'gemini-2.5-flash')]))
renderMenu()
await screen.findByText(/Gemini 2\.5 Flash/i)
const input = screen.getByRole('textbox', { name: 'Search models' })
fireEvent.change(input, { target: { value: 'gemini-3.1' } })
await vi.waitFor(() => {
expect(screen.queryByText(/Gemini 3\.1 Pro/i)).not.toBeNull()
})
})
it('offers Edit Models without the host wiring it up', async () => {
renderMenu()
await screen.findByText(/Gemini 3\.1 Pro/i)
fireEvent.click(screen.getByText('Edit Models…'))
expect($modelVisibilityOpen.get()).toBe(true)
})
})

View File

@ -0,0 +1,589 @@
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
dropdownMenuRow,
DropdownMenuSearch,
dropdownMenuSectionLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { HighlightMatches } from '@/components/ui/highlight-matches'
import { usePointerQuiet } from '@/components/ui/keyboard-first'
import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
import { normalize } from '@/lib/text'
import { cn } from '@/lib/utils'
import {
$visibleModels,
collapseModelFamilies,
DEFAULT_VISIBLE_PER_PROVIDER,
effectiveVisibleKeys,
type ModelFamily,
modelVisibilityKey,
setModelVisibilityOpen
} from '@/store/model-visibility'
import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse'
import { $defaultReasoningEffort } from '@/store/session'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
import { type FastControl, ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
// Lets the host dropdown (model-pill, a kanban field trigger, …) hand the panel
// a way to dismiss itself so clicking a model row commits + closes, while the
// hover-revealed edit submenu (reasoning/fast) stays open to play with (its
// items preventDefault on select).
export const ModelMenuCloseContext = createContext<() => void>(() => {})
/** One model choice, everything a caller needs to act on a selection.
* `effort` is '' for "inherit the default" and 'none' for thinking off. */
export interface ModelChoice {
effort: string
fast: boolean
model: string
provider: string
}
/**
* What a surface DOES with the catalog. The menu renders and navigates; the
* controller owns meaning the composer writes through to a live session,
* the kanban override just holds a value in dialog state.
*
* `presetFor` supplies the remembered settings shown on a non-active row.
* Returning `{}` is fine the row then shows Hermes' defaults.
*/
export interface ModelMenuController {
/** Restore a model's remembered settings after it is selected. Separate from
* `setOptions` because it is one atomic "apply this model's preset" write,
* not a user editing one control surfaces that write through to a session
* need to batch it. Values are already capability-gated by the menu. */
applyPreset: (preset: { effort?: string; fast?: boolean }, row: { model: string; provider: string }) => void
current: ModelChoice
presetFor: (provider: string, model: string) => { effort?: string; fast?: boolean }
/** Commit a model row. Return false to abort (a failed session switch). */
select: (model: string, provider: string) => Promise<boolean | void> | void
/** Edit ONE option on a row. `isActive` says whether it's the current model. */
setOptions: (
patch: { effort?: string; fast?: boolean },
row: { isActive: boolean; model: string; provider: string }
) => void
}
interface ModelCatalogMenuProps {
controller: ModelMenuController
/** Rows appended under the catalog (Refresh Models, Edit Models, …). */
footer?: ReactNode
gateway?: HermesGateway
/** Render the virtual `moa` provider's presets as a selectable section.
* Off for override surfaces, where a MoA preset isn't a worker model. */
includeMoa?: boolean
profile?: string
/** Session whose catalog to fetch. A live session's catalog can differ from
* the profile-global one, and the app invalidates the SESSION-scoped query
* key on model changes a surface bound to a session must pass it or its
* menu goes stale. Detached surfaces (per-task overrides) omit it. */
sessionId?: null | string
}
interface ProviderGroup {
families: ModelFamily[]
provider: ModelOptionProvider
}
/**
* THE model catalog menu: searchable, provider-grouped, `-fast` families
* collapsed to one row, per-row hover submenu for thinking/effort/fast, full
* keyboard selection. Shared verbatim by the composer's model pill and by
* plugin surfaces that pick a model without a session behind it so the two
* can never drift apart.
*/
export function ModelCatalogMenu({
controller,
footer,
gateway,
includeMoa = false,
profile = 'default',
sessionId = null
}: ModelCatalogMenuProps) {
const { t } = useI18n()
const copy = t.shell.modelMenu
const closeMenu = useContext(ModelMenuCloseContext)
const [search, setSearch] = useState('')
const collapsedProviders = useStoreCollapsed()
const defaultEffort = useDefaultEffort()
// Which models the user curated in Edit Models. Read HERE rather than taken
// as a prop: it's one global preference, so every surface that shows a
// catalog must show the same shortlist. A per-caller opt-in is how the board
// and the composer would end up disagreeing about what "my models" means.
const visibleModels = useStore($visibleModels)
const modelOptions = useQuery({
queryKey: modelOptionsQueryKey(profile, sessionId),
// Gateway-first even with no session: a connected (possibly remote)
// gateway owns the model catalog, including virtual providers the local
// REST fallback can't know about (#53817).
queryFn: (): Promise<ModelOptionsResponse> => requestModelOptions({ gateway, sessionId })
})
const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error
? modelOptions.error instanceof Error
? modelOptions.error.message
: String(modelOptions.error)
: null
const providers = modelOptions.data?.providers
// The catalog carries MoA presets as a virtual `moa` provider row. Keep it
// out of the main groups so presets never show up twice.
const moaPresets = useMemo(
() => (includeMoa ? (providers?.find(p => p.slug.toLowerCase() === 'moa')?.models ?? []) : []),
[providers, includeMoa]
)
const pickerProviders = useMemo(
() => providers?.filter(provider => provider.slug.toLowerCase() !== 'moa') ?? [],
[providers]
)
const current = controller.current
// Resolve visibility HERE, against the catalog we actually fetched: an empty
// provider list would otherwise resolve to an empty key set that reads as
// "user hid everything" and blanks the menu on first open.
const shownKeys = useMemo(
() => effectiveVisibleKeys(visibleModels, pickerProviders),
[visibleModels, pickerProviders]
)
const groups = useMemo(
() => groupModels(pickerProviders, search, { model: current.model, provider: current.provider }, shownKeys),
[pickerProviders, search, current.model, current.provider, shownKeys]
)
const q = normalize(search)
// Presets are searchable rows like everything else — an unfiltered preset
// sitting under zero model matches would otherwise become the "first match"
// Enter commits.
const shownMoaPresets = useMemo(
() => (q ? moaPresets.filter(preset => `moa ${preset}`.toLowerCase().includes(q)) : moaPresets),
[moaPresets, q]
)
const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => {
const caps = provider.capabilities?.[family.id]
const preset = controller.presetFor(provider.slug, family.id)
// Variant-fast models (no speed param) express "fast" as a separate `-fast`
// id, so honor the remembered preset by selecting that sibling. Param-fast
// is applied through setOptions below instead.
const variantFast = !(caps?.fast ?? false) && !!family.fastId
const targetId = variantFast && preset.fast === true ? family.fastId! : family.id
if ((await controller.select(targetId, provider.slug)) === false) {
return
}
controller.applyPreset(
{
effort: (caps?.reasoning ?? true) ? (preset.effort ?? defaultEffort) : undefined,
fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined
},
{ model: family.id, provider: provider.slug }
)
}
const selectMoaPreset = async (preset: string) => {
if ((await controller.select(preset, 'moa')) === false) {
return
}
closeMenu()
}
// ── Keyboard selection (cmdk semantics on a Radix menu) ───────────────────
// One flat list mirroring EXACTLY what's rendered (collapse, filter, presets),
// so the selection can never sit on a hidden row.
type KbRow =
| { family: ModelFamily; key: string; kind: 'family'; provider: ModelOptionProvider }
| { key: string; kind: 'moa'; preset: string }
const kbRows = useMemo<KbRow[]>(
() => [
...groups.flatMap(group =>
collapsedProviders.includes(group.provider.slug) && !search
? []
: group.families.map((family): KbRow => ({
family,
key: `${group.provider.slug}:${family.id}`,
kind: 'family',
provider: group.provider
}))
),
...shownMoaPresets.map((preset): KbRow => ({ key: `moa:${preset}`, kind: 'moa', preset }))
],
[groups, collapsedProviders, search, shownMoaPresets]
)
const [kbOverride, setKbOverride] = useState<null | number>(null)
// A parked cursor is not a cursor in use: until the mouse actually moves,
// hover can't take rows out from under the keyboard.
const pointerQuiet = usePointerQuiet()
const currentKey = current.provider === 'moa' ? `moa:${current.model}` : `${current.provider}:${current.model}`
const autoIndex = q
? kbRows.length > 0
? 0
: -1
: kbRows.findIndex(row => row.key === currentKey || (row.kind === 'family' && row.family.fastId === current.model))
const kbIndex = kbOverride !== null && kbOverride < kbRows.length ? kbOverride : autoIndex
const kbActiveKey = kbIndex >= 0 ? kbRows[kbIndex].key : null
const stepKb = (delta: -1 | 1) => {
if (kbRows.length === 0) {
return
}
const from = kbIndex >= 0 ? kbIndex : delta === 1 ? -1 : 0
setKbOverride((from + delta + kbRows.length) % kbRows.length)
}
const commitKbRow = () => {
const row = kbIndex >= 0 ? kbRows[kbIndex] : undefined
if (!row) {
return
}
if (row.kind === 'moa') {
void selectMoaPreset(row.preset)
return
}
if (row.key !== currentKey && row.family.fastId !== current.model) {
void selectFamily(row.family, row.provider)
}
closeMenu()
}
// Keep the selected row in view while arrowing through the scrollable list.
const listRef = useRef<HTMLDivElement>(null)
useEffect(() => {
listRef.current?.querySelector('[data-kb-active]')?.scrollIntoView({ block: 'nearest' })
}, [kbActiveKey])
const kbRowProps = (key: string) => {
const active = kbActiveKey === key
return {
className: cn(dropdownMenuRow, active && 'bg-(--ui-control-active-background) text-foreground'),
...(active ? { 'data-kb-active': '' } : {})
}
}
// Rows are hover-selectable, so they go inert with the pointer.
const quietRows = pointerQuiet && 'pointer-events-none'
return (
<>
<DropdownMenuSearch
aria-label={copy.search}
onKeyDown={event => {
// Claim arrows and Enter from Radix so DOM focus stays in the input
// and Enter commits the highlighted row without a DownArrow first.
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
event.stopPropagation()
stepKb(event.key === 'ArrowDown' ? 1 : -1)
} else if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
commitKbRow()
}
}}
onValueChange={value => {
setSearch(value)
setKbOverride(null)
}}
placeholder={copy.search}
value={search}
/>
<DropdownMenuSeparator className="mx-0" />
{loading ? (
<DropdownMenuGroup className="py-1">
{Array.from({ length: 4 }, (_, index) => (
<DropdownMenuItem
className={dropdownMenuRow}
disabled
key={index}
onSelect={event => event.preventDefault()}
>
<Skeleton className="h-4 w-full" />
</DropdownMenuItem>
))}
</DropdownMenuGroup>
) : error ? (
<DropdownMenuItem className={dropdownMenuRow} disabled>
{error}
</DropdownMenuItem>
) : groups.length === 0 && moaPresets.length === 0 ? (
<DropdownMenuItem className={dropdownMenuRow} disabled>
{copy.noModels}
</DropdownMenuItem>
) : (
<div className={cn('max-h-[max(150px,30dvh)] overflow-y-auto py-0.5', quietRows)} ref={listRef}>
{groups.map(group => {
const slug = group.provider.slug
// Collapsed when the user stored it (and not while searching, which
// spans every model regardless of collapse state).
const collapsed = collapsedProviders.includes(slug) && !search
return (
<DropdownMenuGroup className="py-0.5" key={slug}>
<DropdownMenuItem
className="group/label flex w-full items-center gap-1 px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary) cursor-pointer !bg-transparent focus:!bg-transparent"
onSelect={event => {
event.preventDefault()
toggleCollapsedProvider(slug)
}}
textValue=""
>
<span className="truncate">
<HighlightMatches query={search} text={group.provider.name} />
</span>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/label:opacity-100"
open={!collapsed}
size="0.625rem"
/>
</DropdownMenuItem>
{!collapsed &&
group.families.map(family => {
// The active id may be the base or its -fast sibling; either
// way this one family row represents both.
const activeId =
group.provider.slug === current.provider &&
(current.model === family.id || current.model === family.fastId)
? current.model
: null
const isCurrent = activeId !== null
const name = modelDisplayParts(family.id).name
const caps = group.provider.capabilities?.[family.id]
// Effective settings for this row: the live choice when it's
// the active model, otherwise its remembered preset. Row
// label AND submenu read from these so they never disagree.
const preset = controller.presetFor(group.provider.slug, family.id)
const effEffort = isCurrent ? current.effort : (preset.effort ?? '')
const effFast = isCurrent ? current.fast : (preset.fast ?? false)
const fastControl: FastControl = resolveFastControl(
activeId ?? family.id,
group.provider.models ?? [],
caps?.fast ?? false,
effFast
)
const meta = [
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
(caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort || defaultEffort) : null
]
.filter(Boolean)
.join(' ')
// Clicking the row commits the model and closes; the edit
// submenu (reasoning/fast) is reached by HOVER, so you can
// tweak those without the click dismissing everything.
const activate = () => {
if (!isCurrent) {
void selectFamily(family, group.provider)
}
closeMenu()
}
return (
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
<DropdownMenuSubTrigger
hideChevron
onClick={activate}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
activate()
}
}}
{...kbRowProps(`${group.provider.slug}:${family.id}`)}
>
<span className="min-w-0 flex-1 truncate">
<HighlightMatches query={search} text={name} />
{meta ? <span className="text-(--ui-text-tertiary)"> {meta}</span> : null}
</span>
{isCurrent ? (
<Codicon className="ml-auto text-foreground" name="check" size="0.75rem" />
) : null}
</DropdownMenuSubTrigger>
<ModelEditSubmenu
defaultEffort={defaultEffort}
effort={effEffort}
fastControl={fastControl}
isActive={isCurrent}
model={family.id}
onSelectModel={nextModel => controller.select(nextModel, group.provider.slug)}
onSetOptions={patch =>
controller.setOptions(patch, {
isActive: isCurrent,
model: family.id,
provider: group.provider.slug
})
}
provider={group.provider.slug}
reasoning={caps?.reasoning ?? true}
/>
</DropdownMenuSub>
)
})}
</DropdownMenuGroup>
)
})}
</div>
)}
{shownMoaPresets.length > 0 ? (
<div className={cn(quietRows)}>
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuLabel className={dropdownMenuSectionLabel}>MoA presets</DropdownMenuLabel>
{shownMoaPresets.map(preset => {
const isCurrentMoa = current.provider === 'moa' && current.model === preset
return (
<DropdownMenuItem
key={`moa:${preset}`}
onSelect={event => {
event.preventDefault()
void selectMoaPreset(preset)
}}
{...kbRowProps(`moa:${preset}`)}
>
<span className="min-w-0 flex-1 truncate">
MoA: <HighlightMatches query={search} text={preset} />
</span>
{isCurrentMoa ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
</DropdownMenuItem>
)
})}
</div>
) : null}
{/* Curation belongs to the catalog, not to one host: wherever you can
pick a model you can say which models you want, and the shortlist is
the same everywhere because it's one stored preference. It shares the
host footer's group rather than opening a second one, so a host that
contributes rows (the composer's Refresh Models) keeps the single
trailing block it has always rendered. */}
<DropdownMenuSeparator className="mx-0" />
{footer}
<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
onSelect={() => setModelVisibilityOpen(true)}
>
<Codicon name="settings-gear" size="0.75rem" />
{copy.editModels}
</DropdownMenuItem>
</>
)
}
/** Re-exported so callers building a footer row match the catalog's rows. */
export { dropdownMenuRow }
// Collapsed we show the user's chosen models (or the curated default); typing
// spans every available model so anything is reachable past the cut. A search
// is itself a narrowing action, so we do NOT cap per-provider matches.
function groupModels(
providers: ModelOptionProvider[],
search: string,
current: { model: string; provider: string },
visible: Set<string> | null
): ProviderGroup[] {
const q = normalize(search)
const groups: ProviderGroup[] = []
for (const provider of providers) {
const allFamilies = collapseModelFamilies(provider.models ?? [])
if (allFamilies.length === 0) {
continue
}
const matches = (family: ModelFamily) =>
`${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}`
.toLowerCase()
.includes(q)
let shown: Set<string>
if (q) {
// Search spans every family, regardless of visibility.
shown = new Set(allFamilies.filter(matches).map(family => family.id))
} else if (visible) {
// User has customized which models show — honor their selection exactly.
shown = new Set(
allFamilies.filter(family => visible.has(modelVisibilityKey(provider.slug, family.id))).map(family => family.id)
)
} else {
shown = new Set(allFamilies.slice(0, DEFAULT_VISIBLE_PER_PROVIDER).map(family => family.id))
}
// Always include the active model — but keep every row in the provider's
// stable curated order, so selecting a model can't shuffle the list. While
// SEARCHING the pin is skipped: a query means "show me matches".
const activeId =
!q && provider.slug === current.provider && current.model
? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id
: undefined
const families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId)
if (families.length > 0) {
groups.push({ families, provider })
}
}
// Stable, logical group order: alphabetical by provider name. (The backend
// floats the current provider first, which would reshuffle on every switch.)
groups.sort((a, b) => a.provider.name.localeCompare(b.provider.name))
return groups
}
// Small hooks kept at the bottom so the component reads top-down.
function useStoreCollapsed(): string[] {
return useStore($collapsedProviders)
}
function useDefaultEffort(): string {
return useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT
}

View File

@ -1,5 +1,5 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import {
DropdownMenu,
@ -7,26 +7,9 @@ import {
DropdownMenuSub,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import type * as HermesApi from '@/hermes'
import { $modelPresets, getModelPreset } from '@/store/model-presets'
import {
$activeSessionId,
$currentFastMode,
$currentReasoningEffort,
getCurrentModelSource,
setCurrentFastMode,
setCurrentModelSource,
setCurrentReasoningEffort
} from '@/store/session'
import { type FastControl, ModelEditSubmenu } from './model-edit-submenu'
vi.mock('@/hermes', async importOriginal => {
const actual = await importOriginal<typeof HermesApi>()
return { ...actual, setApiRequestProfile: vi.fn() }
})
// Radix calls these on open; jsdom doesn't implement them.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
@ -34,35 +17,36 @@ beforeAll(() => {
Element.prototype.releasePointerCapture = vi.fn()
})
beforeEach(() => {
$modelPresets.set({})
$activeSessionId.set(null)
setCurrentFastMode(false)
setCurrentModelSource('')
setCurrentReasoningEffort('')
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
// Render the submenu inside an open menu/sub so its content (switches) mounts.
function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; requestGateway: () => Promise<unknown> }) {
function renderSubmenu(opts: {
defaultEffort?: string
effort?: string
fastControl: FastControl
isActive?: boolean
onSelectModel?: (model: string) => void
onSetOptions: (patch: { effort?: string; fast?: boolean }) => void
reasoning: boolean
}) {
return render(
<DropdownMenu open>
<DropdownMenuContent>
<DropdownMenuSub open>
<DropdownMenuSubTrigger>edit</DropdownMenuSubTrigger>
<ModelEditSubmenu
effort="medium"
defaultEffort={opts.defaultEffort ?? 'medium'}
effort={opts.effort ?? 'medium'}
fastControl={opts.fastControl}
isActive
isActive={opts.isActive ?? true}
model="m1"
onSelectModel={vi.fn()}
onSelectModel={opts.onSelectModel ?? vi.fn()}
onSetOptions={opts.onSetOptions}
provider="p1"
reasoning={opts.reasoning}
requestGateway={opts.requestGateway as never}
/>
</DropdownMenuSub>
</DropdownMenuContent>
@ -70,43 +54,72 @@ function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; req
)
}
// Regression: editing the active row before a live session exists must stay
// preset-only — the gateway's config.set falls back to global config when no
// session matches, so it must not be called. (Caught in the second review.)
describe('ModelEditSubmenu no-session guard', () => {
it('param fast: records explicit off in the draft but skips the gateway without a session', () => {
const requestGateway = vi.fn().mockResolvedValue({})
setCurrentFastMode(true)
renderSubmenu({ fastControl: { kind: 'param', on: true }, reasoning: false, requestGateway })
// The submenu is PURE: it reports edits and never writes to a session, a
// preset store, or the gateway. That's the invariant that lets the same
// component drive a live chat session AND a detached per-task override — if it
// ever writes directly again, picking an effort for a kanban card would reach
// over and change the user's live chat.
describe('ModelEditSubmenu reports edits without performing them', () => {
it('param fast: reports the toggle', () => {
const onSetOptions = vi.fn()
renderSubmenu({ fastControl: { kind: 'param', on: true }, onSetOptions, reasoning: false })
fireEvent.click(screen.getByRole('switch'))
expect(getModelPreset('p1', 'm1').fast).toBe(false)
expect($currentFastMode.get()).toBe(false)
expect(getCurrentModelSource()).toBe('manual')
expect(requestGateway).not.toHaveBeenCalled()
expect(onSetOptions).toHaveBeenCalledWith({ fast: false })
})
it('reasoning: records the preset but skips the gateway without a session', () => {
const requestGateway = vi.fn().mockResolvedValue({})
renderSubmenu({ fastControl: { kind: 'none' }, reasoning: true, requestGateway })
it('thinking: toggling off reports the none level', () => {
const onSetOptions = vi.fn()
renderSubmenu({ fastControl: { kind: 'none' }, onSetOptions, reasoning: true })
// Thinking starts on (medium); toggling it off routes through patchReasoning.
// Thinking starts on (medium); toggling it off reports 'none'.
fireEvent.click(screen.getByRole('switch'))
expect(getModelPreset('p1', 'm1').effort).toBe('none')
expect($currentReasoningEffort.get()).toBe('none')
expect(getCurrentModelSource()).toBe('manual')
expect(requestGateway).not.toHaveBeenCalled()
expect(onSetOptions).toHaveBeenCalledWith({ effort: 'none' })
})
it('param fast: pushes to the gateway once a session is active', async () => {
const requestGateway = vi.fn().mockResolvedValue({})
$activeSessionId.set('sess1')
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
it('thinking: toggling back on restores the row level, not the hardcoded default', () => {
const onSetOptions = vi.fn()
renderSubmenu({ defaultEffort: 'high', effort: 'none', fastControl: { kind: 'none' }, onSetOptions, reasoning: true })
fireEvent.click(screen.getByRole('switch'))
expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'fast', session_id: 'sess1', value: 'fast' })
expect(onSetOptions).toHaveBeenCalledWith({ effort: 'high' })
})
it('variant fast: swaps the model only when the row is active', () => {
const onSelectModel = vi.fn()
const onSetOptions = vi.fn()
renderSubmenu({
fastControl: { baseId: 'm1', fastId: 'm1-fast', kind: 'variant', on: false },
isActive: false,
onSelectModel,
onSetOptions,
reasoning: false
})
fireEvent.click(screen.getByRole('switch'))
// Inactive rows stay preference-only — no model switch.
expect(onSetOptions).toHaveBeenCalledWith({ fast: true })
expect(onSelectModel).not.toHaveBeenCalled()
})
it('variant fast: active row swaps to the -fast sibling', () => {
const onSelectModel = vi.fn()
const onSetOptions = vi.fn()
renderSubmenu({
fastControl: { baseId: 'm1', fastId: 'm1-fast', kind: 'variant', on: false },
onSelectModel,
onSetOptions,
reasoning: false
})
fireEvent.click(screen.getByRole('switch'))
expect(onSelectModel).toHaveBeenCalledWith('m1-fast')
})
})

View File

@ -1,6 +1,3 @@
import { useStore } from '@nanostores/react'
import { useSessionView } from '@/app/chat/session-view'
import {
DropdownMenuItem,
DropdownMenuLabel,
@ -13,21 +10,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { Switch } from '@/components/ui/switch'
import { useI18n } from '@/i18n'
import {
DEFAULT_REASONING_EFFORT,
isThinkingEnabled,
REASONING_EFFORTS,
resolveReasoningEffort
} from '@/lib/reasoning-effort'
import { setModelPreset } from '@/store/model-presets'
import { notifyError } from '@/store/notifications'
import {
$defaultReasoningEffort,
markComposerSelectionManual,
setCurrentFastMode,
setCurrentReasoningEffort
} from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import { isThinkingEnabled, REASONING_EFFORTS, resolveReasoningEffort } from '@/lib/reasoning-effort'
// Hermes' real reasoning levels live in lib/reasoning-effort; `none` is owned
// by the Thinking toggle, not the radio.
@ -76,6 +59,9 @@ export function resolveFastControl(
}
interface ModelEditSubmenuProps {
/** The profile's configured default effort what an unset row inherits.
* Passed in (not read from a store) so this submenu stays pure. */
defaultEffort: string
/** This row's effective reasoning effort (live for the active model, else its
* preset) the submenu shows and edits from this, never the raw session. */
effort: string
@ -83,15 +69,19 @@ interface ModelEditSubmenuProps {
fastControl: FastControl
/** Whether this row's model is the active one. */
isActive: boolean
/** This row's model id — edits persist as its global preset. */
/** This row's model id. */
model: string
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
onSelectModel: (model: string) => Promise<boolean> | void
/** This row's provider slug — edits persist as its global preset. */
onSelectModel: (model: string) => Promise<boolean | void> | void
/** Report an option change. This submenu is PURE: it never writes to a
* session, a preset store, or the gateway itself the owning surface's
* controller decides what an edit means. That's what lets the same submenu
* drive a live chat session and a detached per-task override. */
onSetOptions: (patch: { effort?: string; fast?: boolean }) => void
/** This row's provider slug. */
provider: string
/** Whether this model supports reasoning effort. */
reasoning: boolean
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
export function ModelEditSubmenu(props: ModelEditSubmenuProps) {
@ -108,72 +98,26 @@ export function ModelEditSubmenu(props: ModelEditSubmenuProps) {
}
function ModelEditSubmenuBody({
defaultEffort,
effort,
fastControl,
isActive,
model,
onSelectModel,
provider,
reasoning,
requestGateway
onSetOptions,
reasoning
}: ModelEditSubmenuProps) {
const { t } = useI18n()
const copy = t.shell.modelOptions
const view = useSessionView()
const activeSessionId = useStore(view.$runtimeId)
const touchesPrimary = view.kind === 'primary'
const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT
const effortValue = resolveReasoningEffort(effort, defaultEffort)
const thinkingOn = isThinkingEnabled(effort, defaultEffort)
// Editing always records the model's global preset (keyed by provider::model,
// not per-surface — a tile edit re-applies to that model everywhere); the
// active model also gets it pushed onto its OWN session (primary → globals,
// tile → its slice). Non-active edits stay preset-only — no model switch.
const patchReasoning = async (next: string) => {
setModelPreset(provider, model, { effort: next })
if (!isActive) {
return
}
if (touchesPrimary) {
markComposerSelectionManual()
setCurrentReasoningEffort(next)
} else if (activeSessionId) {
sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: next }))
}
// Preset-only without a session: `isActive` holds for the global/default
// row pre-session, and the gateway's `config.set` falls back to global
// config when none matches — so don't reach it (preset + optimistic store
// are the whole effect). Same guard in applyModelPreset / setFast.
if (!activeSessionId) {
return
}
try {
await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next })
} catch (err) {
if (touchesPrimary) {
setCurrentReasoningEffort(effort)
} else if (activeSessionId) {
sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: effort }))
}
setModelPreset(provider, model, { effort })
notifyError(err, copy.updateFailed)
}
}
const setFast = (enabled: boolean) => {
if (fastControl.kind === 'variant') {
// Fast is a separate model id. Record the choice on the base model's
// preset (selectFamily picks the `-fast` sibling later when set), and
// only swap models now if this is the active row — inactive edits must
// stay preset-only, same as the param path below.
setModelPreset(provider, fastControl.baseId, { fast: enabled })
// Fast is a separate model id. Report the choice so the controller can
// record it against the base model, and only swap models now if this is
// the active row — inactive edits stay preference-only.
onSetOptions({ fast: enabled })
if (isActive) {
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
@ -183,41 +127,7 @@ function ModelEditSubmenuBody({
}
if (fastControl.kind === 'param') {
setModelPreset(provider, model, { fast: enabled })
if (!isActive) {
return
}
if (touchesPrimary) {
markComposerSelectionManual()
setCurrentFastMode(enabled)
} else if (activeSessionId) {
sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: enabled }))
}
// Preset-only without a session (see patchReasoning).
if (!activeSessionId) {
return
}
void (async () => {
try {
await requestGateway('config.set', {
key: 'fast',
session_id: activeSessionId,
value: enabled ? 'fast' : 'normal'
})
} catch (err) {
if (touchesPrimary) {
setCurrentFastMode(!enabled)
} else if (activeSessionId) {
sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: !enabled }))
}
setModelPreset(provider, model, { fast: !enabled })
notifyError(err, copy.fastFailed)
}
})()
onSetOptions({ fast: enabled })
}
}
@ -235,7 +145,7 @@ function ModelEditSubmenuBody({
<Switch
checked={thinkingOn}
className="ml-auto"
onCheckedChange={checked => void patchReasoning(checked ? effortValue || defaultEffort : 'none')}
onCheckedChange={checked => onSetOptions({ effort: checked ? effortValue || defaultEffort : 'none' })}
size="xs"
/>
</DropdownMenuItem>
@ -250,7 +160,7 @@ function ModelEditSubmenuBody({
<>
<DropdownMenuSeparator className="mx-0" />
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
<DropdownMenuRadioGroup onValueChange={value => void patchReasoning(value)} value={effortValue}>
<DropdownMenuRadioGroup onValueChange={value => onSetOptions({ effort: value })} value={effortValue}>
{REASONING_EFFORTS.map(value => (
<DropdownMenuRadioItem
className={dropdownMenuRow}

View File

@ -1,51 +1,31 @@
import { useStore } from '@nanostores/react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { useState } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
dropdownMenuRow,
DropdownMenuSearch,
dropdownMenuSectionLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger
} from '@/components/ui/dropdown-menu'
import { HighlightMatches } from '@/components/ui/highlight-matches'
import { usePointerQuiet } from '@/components/ui/keyboard-first'
import { Skeleton } from '@/components/ui/skeleton'
import { DropdownMenuItem, dropdownMenuRow } from '@/components/ui/dropdown-menu'
import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { currentPickerSelection, displayModelName, modelDisplayParts } from '@/lib/model-status-label'
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
import { normalize } from '@/lib/text'
import { currentPickerSelection } from '@/lib/model-status-label'
import { DEFAULT_REASONING_EFFORT } from '@/lib/reasoning-effort'
import { cn } from '@/lib/utils'
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
import { $modelPresets, applyModelPreset, modelPresetKey, setModelPreset } from '@/store/model-presets'
import { $visibleModels } from '@/store/model-visibility'
import { notifyError } from '@/store/notifications'
import {
$visibleModels,
collapseModelFamilies,
DEFAULT_VISIBLE_PER_PROVIDER,
effectiveVisibleKeys,
type ModelFamily,
modelVisibilityKey,
setModelVisibilityOpen
} from '@/store/model-visibility'
import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse'
import { $defaultReasoningEffort } from '@/store/session'
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
$defaultReasoningEffort,
markComposerSelectionManual,
setCurrentFastMode,
setCurrentReasoningEffort
} from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import type { ModelOptionsResponse } from '@/types/hermes'
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu'
// Lets the host dropdown (model-pill) hand the panel a way to dismiss itself so
// clicking a model row commits + closes, while the hover-revealed edit submenu
// (reasoning/fast) stays open to play with (its items preventDefault on select).
export const ModelMenuCloseContext = createContext<() => void>(() => {})
export { ModelMenuCloseContext } from './model-catalog-menu'
export interface ModelSelection {
model: string
@ -62,16 +42,15 @@ interface ModelMenuPanelProps {
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
interface ProviderGroup {
families: ModelFamily[]
provider: ModelOptionProvider
}
/**
* The composer's model menu: `ModelCatalogMenu` (the shared renderer) plus the
* controller that gives a selection its meaning HERE write through to this
* surface's session, remember the pick as a global preset, keep the optimistic
* stores honest, and roll back on a failed gateway write.
*/
export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', requestGateway }: ModelMenuPanelProps) {
const { t } = useI18n()
const copy = t.shell.modelMenu
const closeMenu = useContext(ModelMenuCloseContext)
const [search, setSearch] = useState('')
const [refreshing, setRefreshing] = useState(false)
const queryClient = useQueryClient()
// Bind to THIS surface's SessionView (primary or tile) so each pane's menu
@ -85,13 +64,15 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
const modelPresets = useStore($modelPresets)
const defaultEffort = useStore($defaultReasoningEffort) || DEFAULT_REASONING_EFFORT
const visibleModels = useStore($visibleModels)
const collapsedProviders = useStore($collapsedProviders)
const touchesPrimary = view.kind === 'primary'
// Subscribe to the SAME query the menu runs (identical key ⇒ React Query
// dedupes, no second fetch). It must be a live subscription, not a cache
// peek: with no model in the session store yet, currentPickerSelection falls
// back to the catalog's reported current, and a non-reactive read would
// never repaint that fallback once the catalog resolved.
const modelOptions = useQuery({
queryKey: modelOptionsQueryKey(profile, activeSessionId),
// Gateway-first even with no session yet: a connected (possibly remote)
// gateway owns the model catalog, including virtual providers like `moa`
// that the local REST fallback can't know about (#53817).
queryFn: (): Promise<ModelOptionsResponse> => requestModelOptions({ gateway, sessionId: activeSessionId })
})
@ -100,42 +81,6 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
modelOptions.data
)
const loading = modelOptions.isPending && !modelOptions.data
const error = modelOptions.error
? modelOptions.error instanceof Error
? modelOptions.error.message
: String(modelOptions.error)
: null
const providers = modelOptions.data?.providers
// The catalog carries MoA presets as a virtual `moa` provider row. Render
// them in their dedicated section below and keep the row out of the main
// provider groups so presets don't show up twice.
const moaPresets = useMemo(
() => providers?.find(provider => provider.slug.toLowerCase() === 'moa')?.models ?? [],
[providers]
)
const pickerProviders = useMemo(
() => providers?.filter(provider => provider.slug.toLowerCase() !== 'moa') ?? [],
[providers]
)
const effectiveVisibleModels = useMemo(
() => effectiveVisibleKeys(visibleModels, pickerProviders),
[visibleModels, pickerProviders]
)
// The composer picker never persists the profile default. With a session it
// scopes the switch to that session; with none it's UI state shipped on the
// next session.create (see selectModel). The default lives in Settings → Model.
// Always stamp sessionId from this surface so a tile switch never hits the
// primary (busy) session by accident.
const switchTo = (model: string, provider: string) =>
onSelectModel({ model, provider, sessionId: activeSessionId || null })
// Explicit "Refresh Models": re-fetch the catalog with refresh:true so the
// backend busts its 1h provider-model disk cache and re-pulls each provider's
// live list. Fixes live-only models (e.g. OpenCode Zen free tier) vanishing
@ -162,448 +107,138 @@ export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', re
}
}
// Selecting a model row restores that model's remembered preset onto the
// session (effort/fast), gated by capability. Unset → Hermes defaults.
const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => {
const caps = provider.capabilities?.[family.id]
const preset = modelPresets[modelPresetKey(provider.slug, family.id)] ?? {}
// Push a reasoning change onto the session that owns it, with rollback.
const patchReasoning = async (next: string, previous: string, provider: string, model: string) => {
if (touchesPrimary) {
markComposerSelectionManual()
setCurrentReasoningEffort(next)
} else if (activeSessionId) {
sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: next }))
}
// Variant-fast models (no speed param) express "fast" as a separate `-fast`
// id, so honor the saved preset by selecting that sibling. Param-fast is
// applied via applyModelPreset below instead.
const variantFast = !(caps?.fast ?? false) && !!family.fastId
const targetId = variantFast && preset.fast === true ? family.fastId! : family.id
if ((await switchTo(targetId, provider.slug)) === false) {
// Preset-only without a session: the gateway's `config.set` falls back to
// global config when none matches — so don't reach it (preset + optimistic
// store are the whole effect).
if (!activeSessionId) {
return
}
await applyModelPreset(
{
effort: (caps?.reasoning ?? true) ? (preset.effort ?? defaultEffort) : undefined,
fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined
},
{
try {
await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next })
} catch (err) {
if (touchesPrimary) {
setCurrentReasoningEffort(previous)
} else {
sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, reasoningEffort: previous }))
}
setModelPreset(provider, model, { effort: previous })
notifyError(err, t.shell.modelOptions.updateFailed)
}
}
const patchFast = async (enabled: boolean, provider: string, model: string) => {
if (touchesPrimary) {
markComposerSelectionManual()
setCurrentFastMode(enabled)
} else if (activeSessionId) {
sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: enabled }))
}
if (!activeSessionId) {
return
}
try {
await requestGateway('config.set', {
key: 'fast',
session_id: activeSessionId,
value: enabled ? 'fast' : 'normal'
})
} catch (err) {
if (touchesPrimary) {
setCurrentFastMode(!enabled)
} else {
sessionTileDelegate()?.updateSession(activeSessionId, state => ({ ...state, fast: !enabled }))
}
setModelPreset(provider, model, { fast: !enabled })
notifyError(err, t.shell.modelOptions.fastFailed)
}
}
const controller: ModelMenuController = {
// Selecting a model row restores that model's remembered preset onto the
// session (effort/fast). applyModelPreset owns the batched gateway write.
applyPreset: (preset, row) => {
setModelPreset(row.provider, row.model, preset)
void applyModelPreset(preset, {
failMessage: t.shell.modelOptions.updateFailed,
primary: view.kind === 'primary',
primary: touchesPrimary,
request: requestGateway,
sessionId: activeSessionId
})
},
current: {
effort: currentReasoningEffort,
fast: currentFastMode,
model: optionsModel,
provider: optionsProvider
},
presetFor: (provider, model) => modelPresets[modelPresetKey(provider, model)] ?? {},
// The composer picker never persists the profile default. With a session it
// scopes the switch to that session; with none it's UI state shipped on the
// next session.create. Always stamp sessionId from this surface so a tile
// switch never hits the primary (busy) session by accident.
select: (model, provider) => onSelectModel({ model, provider, sessionId: activeSessionId || null }),
setOptions: (patch, row) => {
// Editing always records the model's global preset (keyed by
// provider::model, not per-surface — a tile edit re-applies to that model
// everywhere); the active model also gets it pushed onto its OWN session.
// Non-active edits stay preset-only — no model switch, no session write.
if (patch.effort !== undefined || patch.fast !== undefined) {
setModelPreset(row.provider, row.model, patch)
}
)
}
// Selecting a MoA preset switches the session to it PERSISTENTLY, using the
// same path real provider selections use (onSelectModel → config.set with
// --session for live sessions → the gateway's persistent switch_model).
// Previously this dispatched the one-shot `/moa` command, which ran a single
// turn through MoA and then silently reverted to the prior model (#54670) —
// the dropdown presented presets like persistent selections but they weren't.
// No session gate: like regular model rows, a pre-session pick is UI state
// shipped on the next session.create.
const selectMoaPreset = async (preset: string) => {
if ((await switchTo(preset, 'moa')) === false) {
return
}
if (!row.isActive) {
return
}
closeMenu()
}
if (patch.effort !== undefined) {
void patchReasoning(patch.effort, currentReasoningEffort, row.provider, row.model)
}
const groups = useMemo(
() =>
groupModels(pickerProviders, search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels),
[pickerProviders, search, optionsModel, optionsProvider, effectiveVisibleModels]
)
const q = normalize(search)
// Presets are searchable rows like everything else — an unfiltered preset
// sitting under zero model matches would otherwise become the "first match"
// Enter commits.
const shownMoaPresets = useMemo(
() => (q ? moaPresets.filter(preset => `moa ${preset}`.toLowerCase().includes(q)) : moaPresets),
[moaPresets, q]
)
// ── Keyboard selection (cmdk semantics on a Radix menu) ───────────────────
// One flat list mirroring EXACTLY what's rendered (collapse, filter, presets),
// so the selection can never sit on a hidden row. The selected index is
// derived — current model with no query (Enter = close), first match while
// typing — with an arrow-key override that resets on every keystroke. Focus
// stays in the search input throughout: ⌘⇧M → type → ↑/↓ → Enter.
type KbRow =
| { family: ModelFamily; key: string; kind: 'family'; provider: ModelOptionProvider }
| { key: string; kind: 'moa'; preset: string }
const kbRows = useMemo<KbRow[]>(
() => [
...groups.flatMap(group =>
collapsedProviders.includes(group.provider.slug) && !search
? []
: group.families.map((family): KbRow => ({
family,
key: `${group.provider.slug}:${family.id}`,
kind: 'family',
provider: group.provider
}))
),
...shownMoaPresets.map((preset): KbRow => ({ key: `moa:${preset}`, kind: 'moa', preset }))
],
[groups, collapsedProviders, search, shownMoaPresets]
)
const [kbOverride, setKbOverride] = useState<null | number>(null)
// A parked cursor is not a cursor in use: until the mouse actually moves,
// hover can't take rows out from under the keyboard (rows re-flow beneath it
// as the filter narrows). One real movement hands hover back.
const pointerQuiet = usePointerQuiet()
const currentKey = optionsProvider === 'moa' ? `moa:${optionsModel}` : `${optionsProvider}:${optionsModel}`
const autoIndex = q
? kbRows.length > 0
? 0
: -1
: kbRows.findIndex(row => row.key === currentKey || (row.kind === 'family' && row.family.fastId === optionsModel))
const kbIndex = kbOverride !== null && kbOverride < kbRows.length ? kbOverride : autoIndex
const kbActiveKey = kbIndex >= 0 ? kbRows[kbIndex].key : null
const stepKb = (delta: -1 | 1) => {
if (kbRows.length === 0) {
return
}
const from = kbIndex >= 0 ? kbIndex : delta === 1 ? -1 : 0
setKbOverride((from + delta + kbRows.length) % kbRows.length)
}
const commitKbRow = () => {
const row = kbIndex >= 0 ? kbRows[kbIndex] : undefined
if (!row) {
return
}
if (row.kind === 'moa') {
void selectMoaPreset(row.preset)
return
}
if (row.key !== currentKey && row.family.fastId !== optionsModel) {
void selectFamily(row.family, row.provider)
}
closeMenu()
}
// Keep the selected row in view while arrowing through the scrollable list.
const listRef = useRef<HTMLDivElement>(null)
useEffect(() => {
listRef.current?.querySelector('[data-kb-active]')?.scrollIntoView({ block: 'nearest' })
}, [kbActiveKey])
// The keyboard-selected row, styled + tagged for scrollIntoView. Pointer
// suppression is NOT here — it belongs on the containers (below), so one
// class covers every row inside them.
const kbRowProps = (key: string) => {
const active = kbActiveKey === key
return {
className: cn(dropdownMenuRow, active && 'bg-(--ui-control-active-background) text-foreground'),
...(active ? { 'data-kb-active': '' } : {})
if (patch.fast !== undefined) {
void patchFast(patch.fast, row.provider, row.model)
}
}
}
// Rows are hover-selectable, so they go inert with the pointer (usePointerQuiet).
const quietRows = pointerQuiet && 'pointer-events-none'
return (
<>
<DropdownMenuSearch
aria-label={copy.search}
onKeyDown={event => {
// Claim arrows and Enter from Radix so DOM focus stays in the input
// and Enter commits the highlighted row without a DownArrow first
// (VS Code's checked-or-first pattern).
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
<ModelCatalogMenu
controller={controller}
footer={
<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
disabled={refreshing}
onSelect={event => {
event.preventDefault()
event.stopPropagation()
stepKb(event.key === 'ArrowDown' ? 1 : -1)
} else if (event.key === 'Enter') {
event.preventDefault()
event.stopPropagation()
commitKbRow()
}
}}
onValueChange={value => {
setSearch(value)
setKbOverride(null)
}}
placeholder={copy.search}
value={search}
/>
<DropdownMenuSeparator className="mx-0" />
{loading ? (
<DropdownMenuGroup className="py-1">
{Array.from({ length: 4 }, (_, index) => (
<DropdownMenuItem
className={dropdownMenuRow}
disabled
key={index}
onSelect={event => event.preventDefault()}
>
<Skeleton className="h-4 w-full" />
</DropdownMenuItem>
))}
</DropdownMenuGroup>
) : error ? (
<DropdownMenuItem className={dropdownMenuRow} disabled>
{error}
void refreshModels()
}}
>
<Codicon className={cn(refreshing && 'animate-spin')} name="sync" size="0.75rem" />
{copy.refreshModels}
</DropdownMenuItem>
) : groups.length === 0 && moaPresets.length === 0 ? (
<DropdownMenuItem className={dropdownMenuRow} disabled>
{copy.noModels}
</DropdownMenuItem>
) : (
<div className={cn('max-h-[max(150px,30dvh)] overflow-y-auto py-0.5', quietRows)} ref={listRef}>
{groups.map(group => {
const slug = group.provider.slug
// Collapsed when the user stored it (and not while searching, which
// spans every model regardless of collapse state).
const collapsed = collapsedProviders.includes(slug) && !search
return (
<DropdownMenuGroup className="py-0.5" key={slug}>
<DropdownMenuItem
className="group/label flex w-full items-center gap-1 px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary) cursor-pointer !bg-transparent focus:!bg-transparent"
onSelect={event => {
event.preventDefault()
toggleCollapsedProvider(slug)
}}
textValue=""
>
<span className="truncate">
<HighlightMatches query={search} text={group.provider.name} />
</span>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/label:opacity-100"
open={!collapsed}
size="0.625rem"
/>
</DropdownMenuItem>
{!collapsed &&
group.families.map(family => {
// The active id may be the base or its -fast sibling; either
// way this one family row represents both.
const activeId =
group.provider.slug === optionsProvider &&
(optionsModel === family.id || optionsModel === family.fastId)
? optionsModel
: null
const isCurrent = activeId !== null
const name = modelDisplayParts(family.id).name
// Capabilities are looked up against the active/base id; the
// -fast variant carries the same param support as its base.
const caps = group.provider.capabilities?.[family.id]
// Effective settings for this row: live session state when it's
// the active model, otherwise its remembered preset (Hermes
// defaults when unset). Row label AND submenu read from these so
// they never disagree.
const preset = modelPresets[modelPresetKey(group.provider.slug, family.id)] ?? {}
const effEffort = isCurrent ? currentReasoningEffort : (preset.effort ?? '')
const effFast = isCurrent ? currentFastMode : (preset.fast ?? false)
const fastControl = resolveFastControl(
activeId ?? family.id,
group.provider.models ?? [],
caps?.fast ?? false,
effFast
)
const meta = [
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
(caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort || defaultEffort) : null
]
.filter(Boolean)
.join(' ')
// Every row is a hover-Edit submenu trigger. Activating it
// (pointer or keyboard) switches to the family's base model and
// restores its preset; the Fast toggle inside swaps to the -fast
// sibling (or flips the speed param). The sub-trigger has no
// `onSelect`, so wire both click and Enter/Space for keyboard parity.
// Clicking the row commits the model and closes the picker; the
// edit submenu (reasoning/fast) is reached by HOVER, so you can
// still tweak those without the click dismissing everything.
const activate = () => {
if (!isCurrent) {
void selectFamily(family, group.provider)
}
closeMenu()
}
return (
<DropdownMenuSub key={`${group.provider.slug}:${family.id}`}>
<DropdownMenuSubTrigger
hideChevron
onClick={activate}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
activate()
}
}}
{...kbRowProps(`${group.provider.slug}:${family.id}`)}
>
<span className="min-w-0 flex-1 truncate">
<HighlightMatches query={search} text={name} />
{meta ? <span className="text-(--ui-text-tertiary)"> {meta}</span> : null}
</span>
{isCurrent ? (
<Codicon className="ml-auto text-foreground" name="check" size="0.75rem" />
) : null}
</DropdownMenuSubTrigger>
<ModelEditSubmenu
effort={effEffort}
fastControl={fastControl}
isActive={isCurrent}
model={family.id}
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
provider={group.provider.slug}
reasoning={caps?.reasoning ?? true}
requestGateway={requestGateway}
/>
</DropdownMenuSub>
)
})}
</DropdownMenuGroup>
)
})}
</div>
)}
<DropdownMenuSeparator className="mx-0" />
{shownMoaPresets.length > 0 ? (
<div className={cn(quietRows)}>
<DropdownMenuLabel className={dropdownMenuSectionLabel}>MoA presets</DropdownMenuLabel>
{shownMoaPresets.map(preset => {
const isCurrentMoa = optionsProvider === 'moa' && optionsModel === preset
return (
<DropdownMenuItem
key={`moa:${preset}`}
onSelect={event => {
event.preventDefault()
void selectMoaPreset(preset)
}}
{...kbRowProps(`moa:${preset}`)}
>
<span className="min-w-0 flex-1 truncate">
MoA: <HighlightMatches query={search} text={preset} />
</span>
{isCurrentMoa ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator className="mx-0" />
</div>
) : null}
<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
disabled={refreshing}
onSelect={event => {
event.preventDefault()
void refreshModels()
}}
>
<Codicon className={cn(refreshing && 'animate-spin')} name="sync" size="0.75rem" />
{copy.refreshModels}
</DropdownMenuItem>
<DropdownMenuItem
className={cn(dropdownMenuRow, 'text-(--ui-text-tertiary)')}
onSelect={() => setModelVisibilityOpen(true)}
>
<Codicon name="settings-gear" size="0.75rem" />
{copy.editModels}
</DropdownMenuItem>
</>
}
gateway={gateway}
includeMoa
profile={profile}
sessionId={activeSessionId}
/>
)
}
// Collapsed we show the user's chosen models (or the curated default); typing
// spans every available model so anything is reachable past the cut. A search
// is itself a narrowing action, so we do NOT cap per-provider matches — a
// provider serving 19 models (e.g. opencode-go) must show all 19 when the user
// searches for it, not a truncated subset. (#47077 follow-up)
function groupModels(
providers: ModelOptionProvider[],
search: string,
current: { model: string; provider: string },
visible: Set<string> | null
): ProviderGroup[] {
const q = normalize(search)
const groups: ProviderGroup[] = []
for (const provider of providers) {
const allFamilies = collapseModelFamilies(provider.models ?? [])
if (allFamilies.length === 0) {
continue
}
const matches = (family: ModelFamily) =>
`${family.id} ${family.fastId ?? ''} ${provider.name} ${provider.slug} ${displayModelName(family.id)}`
.toLowerCase()
.includes(q)
// Which model ids to show (the active one is always added on top of this).
let shown: Set<string>
if (q) {
// Search spans every family, regardless of visibility.
shown = new Set(allFamilies.filter(matches).map(family => family.id))
} else if (visible) {
// User has customized which models show — honor their selection exactly.
shown = new Set(
allFamilies.filter(family => visible.has(modelVisibilityKey(provider.slug, family.id))).map(family => family.id)
)
} else {
// Default: curated top-N families per provider.
shown = new Set(allFamilies.slice(0, DEFAULT_VISIBLE_PER_PROVIDER).map(family => family.id))
}
// Always include the active model — but keep every row in the provider's
// stable curated order (filter `allFamilies`, never reorder), so selecting
// a model can't shuffle the list. While SEARCHING, the pin is skipped: a
// query means "show me matches", and a pinned non-match sitting above them
// reads like the top result (type "grok", see the current Fable first).
const activeId =
!q && provider.slug === current.provider && current.model
? allFamilies.find(family => family.id === current.model || family.fastId === current.model)?.id
: undefined
const families = allFamilies.filter(family => shown.has(family.id) || family.id === activeId)
if (families.length > 0) {
groups.push({ families, provider })
}
}
// Stable, logical group order: alphabetical by provider name. (The backend
// floats the current provider first, which would reshuffle on every switch.)
groups.sort((a, b) => a.provider.name.localeCompare(b.provider.name))
return groups
}

View File

@ -78,6 +78,12 @@ import {
} from './api'
import { BoardSwitcher } from './board-switcher'
import { TaskDrawer } from './drawer'
import {
EMPTY_OVERRIDE,
ModelOverrideField,
overrideCreateFields,
type TaskModelOverride
} from './model-override'
import { OrchestrationPanel } from './orchestration'
import { columnMeta, type KanbanBoard, type KanbanTask, type TaskEstimate } from './types'
import {
@ -570,6 +576,7 @@ function NewTaskDialog({
// a path here overrides just this task. Only meaningful for dir/worktree.
const [workspacePath, setWorkspacePath] = useState('')
const [parent, setParent] = useState('')
const [modelOverride, setModelOverride] = useState<TaskModelOverride>(EMPTY_OVERRIDE)
const [goalMode, setGoalMode] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<null | string>(null)
@ -602,6 +609,7 @@ function NewTaskDialog({
setWorkspaceKind(boardDefaultKind)
setWorkspacePath('')
setParent('')
setModelOverride(EMPTY_OVERRIDE)
setGoalMode(false)
setError(null)
setBusy(false)
@ -637,6 +645,7 @@ function NewTaskDialog({
title: trimmed,
triage: isTriage,
workspace_kind: workspaceKind,
...overrideCreateFields(modelOverride),
// Empty → backend inherits the board's default project dir.
workspace_path: workspaceKind !== 'scratch' && workspacePath.trim() ? workspacePath.trim() : undefined
})
@ -661,7 +670,15 @@ function NewTaskDialog({
return (
<Dialog onOpenChange={open => !open && onClose()} open={Boolean(target)}>
<DialogContent className="w-[min(42rem,94vw)] max-w-none">
{/* `overflow-visible`: DialogContent publishes ITSELF as the portal
container for popovers opened inside it (dialog-portal-context), and
its default `overflow-y-auto` then crops them at the dialog's edge
the model menu below is born inside that scroll box. This dialog
already owns a scroller on its body div, so the shell's clip is
redundant here and dropping it is safe. The general fix to
DialogContent is in flight as #75600; when that lands this override
becomes a no-op and can go. */}
<DialogContent className="w-[min(42rem,94vw)] max-w-none overflow-visible">
<DialogHeader>
<DialogTitle>{target ? k.newTaskIn(columnLabel(k, target)) : k.newTask}</DialogTitle>
</DialogHeader>
@ -742,6 +759,11 @@ function NewTaskDialog({
<Input onChange={event => setSkills(event.target.value)} placeholder={k.skillsPlaceholder} value={skills} />
</Field>
<Field label={k.model}>
<ModelOverrideField onChange={setModelOverride} value={modelOverride} />
<span className="text-[0.625rem] text-(--ui-text-quaternary)">{k.modelHint}</span>
</Field>
{parents.length > 0 && (
<Field label={k.parent}>
<Select onValueChange={v => setParent(v === NO_PARENT ? '' : v)} value={parent || NO_PARENT}>

View File

@ -45,6 +45,7 @@ import {
taskKey,
uploadAttachment
} from './api'
import { ModelOverrideField, overridePatch } from './model-override'
import {
type Diagnostic,
type DiagnosticAction,
@ -770,6 +771,16 @@ export function TaskDrawer({
{task.workspace_path}
</MetaRow>
)}
<MetaRow label={k.model}>
<ModelOverrideField
onChange={next => void mutate(() => patchTask(task.id, overridePatch(next)))()}
value={{
effort: task.reasoning_effort ?? '',
model: task.model_override ?? '',
provider: task.provider_override ?? ''
}}
/>
</MetaRow>
{task.created_by && <MetaRow label={k.metaCreatedBy}>{task.created_by}</MetaRow>}
{ago(task.created_at) && <MetaRow label={k.metaCreated}>{ago(task.created_at)}</MetaRow>}
{running && task.worker_pid ? <MetaRow label={k.metaWorkerPid}>{task.worker_pid}</MetaRow> : null}

View File

@ -65,6 +65,10 @@ type KanbanMessages = {
workspace: string
boardDefaultSuffix: string
workspaceOverride: string
model: string
modelInherit: string
modelClear: string
modelHint: string
workspaceInherit: string
workspaceInheritDir: (dir: string) => string
workspaceInheritGeneric: string
@ -250,6 +254,10 @@ const en: KanbanMessages = {
workspace: 'Workspace',
boardDefaultSuffix: ' · board default',
workspaceOverride: 'Workspace path (optional override)',
model: 'Model',
modelInherit: 'Profile default',
modelClear: 'Clear model override',
modelHint: 'Runs this task on a specific model and thinking depth. Unset uses the assigned profiles own.',
workspaceInherit: 'Inherits the boards project directory',
workspaceInheritDir: dir => `Leave empty to inherit ${dir}`,
workspaceInheritGeneric: 'Leave empty to inherit the boards project directory.',
@ -438,6 +446,10 @@ const ja: KanbanMessages = {
workspace: 'ワークスペース',
boardDefaultSuffix: '・ボード既定',
workspaceOverride: 'ワークスペースパス(任意の上書き)',
model: 'モデル',
modelInherit: 'プロファイル既定',
modelClear: 'モデル指定を解除',
modelHint: 'このタスクを特定のモデルと思考深度で実行します。未設定なら担当プロファイルの設定を使用します。',
workspaceInherit: 'ボードのプロジェクトディレクトリを継承',
workspaceInheritDir: dir => `空欄にすると ${dir} を継承します`,
workspaceInheritGeneric: '空欄にするとボードのプロジェクトディレクトリを継承します。',
@ -624,6 +636,10 @@ const zh: KanbanMessages = {
workspace: '工作区',
boardDefaultSuffix: '・面板默认',
workspaceOverride: '工作区路径(可选覆盖)',
model: '模型',
modelInherit: '配置文件默认',
modelClear: '清除模型覆盖',
modelHint: '让该任务使用指定的模型与思考深度。未设置时使用所指派配置文件自身的设置。',
workspaceInherit: '继承面板的项目目录',
workspaceInheritDir: dir => `留空则继承 ${dir}`,
workspaceInheritGeneric: '留空则继承面板的项目目录。',
@ -808,6 +824,10 @@ const zhHant: KanbanMessages = {
workspace: '工作區',
boardDefaultSuffix: '・面板預設',
workspaceOverride: '工作區路徑(選填覆寫)',
model: '模型',
modelInherit: '設定檔預設',
modelClear: '清除模型覆寫',
modelHint: '讓此任務使用指定的模型與思考深度。未設定時使用所指派設定檔本身的設定。',
workspaceInherit: '繼承面板的專案目錄',
workspaceInheritDir: dir => `留空則繼承 ${dir}`,
workspaceInheritGeneric: '留空則繼承面板的專案目錄。',

View File

@ -0,0 +1,148 @@
import { DropdownMenu, DropdownMenuContent, ModelCatalogMenu, type ModelMenuController } from '@hermes/plugin-sdk'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import {
EMPTY_OVERRIDE,
isInherited,
overrideCreateFields,
overrideLabel,
overridePatch,
type TaskModelOverride
} from './model-override'
// Radix calls these on open; jsdom doesn't implement them.
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
Element.prototype.hasPointerCapture = vi.fn(() => false)
Element.prototype.releasePointerCapture = vi.fn()
})
const getGlobalModelOptions = vi.fn()
vi.mock('@/hermes', () => ({
getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args),
setApiRequestProfile: vi.fn()
}))
beforeEach(() => {
getGlobalModelOptions.mockResolvedValue({
providers: [{ models: ['gemini-3.1-pro', 'gemini-2.5-flash'], name: 'Google', slug: 'google' }]
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('override label', () => {
it('reads as inherited until something is pinned', () => {
expect(isInherited(EMPTY_OVERRIDE)).toBe(true)
expect(overrideLabel(EMPTY_OVERRIDE, 'Profile default')).toBe('Profile default')
})
it('shows provider: model · Effort', () => {
expect(overrideLabel({ effort: 'high', model: 'gemini-3.1-pro', provider: 'google' }, 'x')).toBe(
'google: gemini-3.1-pro · High'
)
})
it('a depth-only pin is still an override', () => {
const depthOnly: TaskModelOverride = { effort: 'ultra', model: '', provider: '' }
expect(isInherited(depthOnly)).toBe(false)
expect(overrideLabel(depthOnly, 'Profile default')).toBe('Profile default · Ultra')
})
})
// The override drives the SHARED composer menu through a controller. This is
// the seam that keeps the two surfaces identical, so exercise the real
// component rather than a stand-in.
describe('the shared catalog menu, driven by an override controller', () => {
function renderMenu(value: TaskModelOverride = EMPTY_OVERRIDE) {
const onChange = vi.fn()
const controller: ModelMenuController = {
applyPreset: (preset, row) => onChange({ effort: preset.effort ?? '', model: row.model, provider: row.provider }),
current: { effort: value.effort, fast: false, model: value.model, provider: value.provider },
presetFor: () => ({}),
select: (model, provider) => onChange({ ...value, model, provider }),
setOptions: (patch, row) => {
if (patch.effort !== undefined) {
onChange({ effort: patch.effort, model: row.model, provider: row.provider })
}
}
}
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={client}>
<DropdownMenu open>
<DropdownMenuContent>
<ModelCatalogMenu controller={controller} />
</DropdownMenuContent>
</DropdownMenu>
</QueryClientProvider>
)
return onChange
}
it('lists the catalog and reports a picked model', async () => {
const onChange = renderMenu()
// Rows render the display name; the controller receives the raw id.
fireEvent.click(await screen.findByText(/Gemini 3\.1 Pro/i))
const picked = onChange.mock.calls.at(-1)![0]
expect(picked.model).toBe('gemini-3.1-pro')
expect(picked.provider).toBe('google')
})
it('never renders MoA presets — a preset is not a worker model', async () => {
getGlobalModelOptions.mockResolvedValue({
providers: [
{ models: ['gemini-3.1-pro'], name: 'Google', slug: 'google' },
{ models: ['BeastMode'], name: 'Mixture of Agents', slug: 'moa' }
]
})
renderMenu()
await screen.findByText(/Gemini 3\.1 Pro/i)
expect(screen.queryByText(/BeastMode/)).toBeNull()
})
})
// A PATCH body can't say "set to NULL" with a missing key, so clears are
// explicit flags; a create should instead OMIT what the user never touched so
// the backend's own defaults still apply.
describe('REST field mapping', () => {
it('create omits untouched fields', () => {
expect(overrideCreateFields(EMPTY_OVERRIDE)).toEqual({})
expect(overrideCreateFields({ effort: 'high', model: 'm', provider: 'p' })).toEqual({
model_override: 'm',
provider_override: 'p',
reasoning_effort: 'high'
})
})
it('patch clears explicitly', () => {
expect(overridePatch(EMPTY_OVERRIDE)).toEqual({ clear_model_override: true, clear_reasoning_effort: true })
})
it('patch can pin depth alone, leaving the model to the profile', () => {
expect(overridePatch({ effort: 'ultra', model: '', provider: '' })).toEqual({
clear_model_override: true,
reasoning_effort: 'ultra'
})
})
it('a provider is never sent without its model', () => {
expect(overrideCreateFields({ effort: '', model: '', provider: 'google' })).toEqual({})
})
})

View File

@ -0,0 +1,170 @@
/**
* Per-task model override the board's half of the composer's model picker.
*
* The MENU is not ours: `ModelCatalogMenu` from the SDK is the same component
* the chat composer renders, so search, provider grouping, `-fast` families,
* and the thinking/effort submenu behave identically here. What differs is
* what a selection MEANS the controller below holds a detached value instead
* of writing to a live session, which is exactly the seam the SDK exposes.
*
* An unset value means "inherit the assigned profile's own model + effort",
* which is what every kanban task did before this existed.
*/
import {
Button,
cn,
Codicon,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
ModelCatalogMenu,
ModelMenuCloseContext,
type ModelMenuController,
reasoningEffortLabel
} from '@hermes/plugin-sdk'
import { useState } from 'react'
import { useKanban } from './ui'
/** A task's model override. Empty strings mean "inherit the profile". */
export interface TaskModelOverride {
/** '' = profile default, 'none' = thinking off, else a reasoning level. */
effort: string
model: string
provider: string
}
export const EMPTY_OVERRIDE: TaskModelOverride = { effort: '', model: '', provider: '' }
/** True when nothing is pinned and the worker profile decides everything. */
export const isInherited = (value: TaskModelOverride): boolean =>
!value.model.trim() && !value.provider.trim() && !value.effort.trim()
/** The trigger's label: `provider: model · High`, or the inherit copy. */
export function overrideLabel(value: TaskModelOverride, inheritCopy: string): string {
if (isInherited(value)) {
return inheritCopy
}
const model = value.model.trim()
const base = model ? (value.provider.trim() ? `${value.provider}: ${model}` : model) : inheritCopy
const effort = value.effort.trim() ? reasoningEffortLabel(value.effort) : ''
return effort ? `${base} · ${effort}` : base
}
/**
* The picker itself. Controlled: the caller owns the value, so the New Task
* dialog can hold it as form state and the drawer can PATCH on change.
*/
export function ModelOverrideField({
onChange,
value
}: {
onChange: (next: TaskModelOverride) => void
value: TaskModelOverride
}) {
const k = useKanban()
const [open, setOpen] = useState(false)
const controller: ModelMenuController = {
// Picking a model seeds the depth from what the user last used for it, so
// the board behaves like the composer. We only READ presets — a per-task
// choice must never rewrite what the composer opens at.
applyPreset: (preset, row) =>
onChange({
effort: preset.effort ?? '',
model: row.model,
provider: row.provider
}),
current: { effort: value.effort, fast: false, model: value.model, provider: value.provider },
// Read-only against Hermes' global presets is deliberate: see applyPreset.
presetFor: () => ({}),
select: (model, provider) => {
onChange({ ...value, model, provider })
},
// Fast mode is a live-session request parameter, not something the worker
// spawn can carry (there is no --fast flag), so the menu is told this
// catalog has no fast support and only effort edits arrive here.
setOptions: (patch, row) => {
if (patch.effort === undefined) {
return
}
onChange({ effort: patch.effort, model: row.model, provider: row.provider })
}
}
return (
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild>
<Button
className={cn(
'h-8 w-full justify-between gap-2 px-2.5 text-[0.75rem] font-normal',
isInherited(value) && 'text-(--ui-text-tertiary)'
)}
type="button"
variant="outline"
>
<span className="min-w-0 truncate">{overrideLabel(value, k.modelInherit)}</span>
<span className="flex shrink-0 items-center gap-1">
{!isInherited(value) && (
<span
aria-label={k.modelClear}
className="grid size-4 place-items-center rounded text-(--ui-text-tertiary) hover:text-foreground"
onClick={event => {
// Clear without opening the menu.
event.preventDefault()
event.stopPropagation()
onChange(EMPTY_OVERRIDE)
}}
role="button"
>
<Codicon name="close" size="0.7rem" />
</span>
)}
<Codicon className="opacity-50" name="chevron-down" size="0.7rem" />
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72 p-0">
<ModelMenuCloseContext.Provider value={() => setOpen(false)}>
<ModelCatalogMenu controller={controller} />
</ModelMenuCloseContext.Provider>
</DropdownMenuContent>
</DropdownMenu>
)
}
/** Translate an override into the REST fields the kanban API expects.
* Used by both create (POST) and edit (PATCH); the explicit clear flags exist
* because `null` in a PATCH body means "field not sent", not "set to NULL". */
export function overridePatch(value: TaskModelOverride): Record<string, unknown> {
const model = value.model.trim()
const effort = value.effort.trim()
return {
...(model
? { model_override: model, provider_override: value.provider.trim() || undefined }
: { clear_model_override: true }),
...(effort ? { reasoning_effort: effort } : { clear_reasoning_effort: true })
}
}
/** The create-time shape: omit rather than clear, so the backend's own
* defaults apply to fields the user never touched. */
export function overrideCreateFields(value: TaskModelOverride): Record<string, unknown> {
const model = value.model.trim()
const effort = value.effort.trim()
return {
...(model ? { model_override: model } : {}),
...(model && value.provider.trim() ? { provider_override: value.provider.trim() } : {}),
...(effort ? { reasoning_effort: effort } : {})
}
}

View File

@ -97,6 +97,11 @@ export interface KanbanAttachment {
export interface KanbanTaskFull extends KanbanTask {
result?: null | string
created_by?: null | string
/** Per-task worker overrides. Null/absent = the assigned profile's own
* model, provider, and reasoning effort decide. */
model_override?: null | string
provider_override?: null | string
reasoning_effort?: null | string
completed_at?: null | number
last_failure_error?: null | string
workspace_kind?: null | string

View File

@ -122,6 +122,18 @@ export { COMPOSER_AREAS, type ComposerAttachmentProvider, type ComposerMiddlewar
export { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
export { type RouteContribution, ROUTES_AREA, SIDEBAR_NAV_AREA, type SidebarNavContribution } from '@/app/routes'
/** THE model catalog menu the same searchable, provider-grouped, family-
* collapsing picker the chat composer uses, including the per-row
* thinking/effort/fast submenu. Drive it with a `ModelMenuController`: the
* menu renders and navigates, your controller decides what a selection MEANS
* (write to a session, hold a per-task override, ). Never fork it a copy
* drifts from the composer the first time either side changes. */
export {
ModelCatalogMenu,
type ModelChoice,
ModelMenuCloseContext,
type ModelMenuController
} from '@/app/shell/model-catalog-menu'
export type { StatusbarItem } from '@/app/shell/statusbar-controls'
export type { TitlebarTool } from '@/app/shell/titlebar-controls'
@ -184,9 +196,6 @@ export { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
export { Textarea } from '@/components/ui/textarea'
export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
export type { GatewayEventListener } from '@/contrib/events'
// -- contracts ----------------------------------------------------------------
export type {
HermesPlugin,
PluginContext,
@ -194,6 +203,9 @@ export type {
PluginRestOptions,
PluginStorage
} from '@/contrib/plugin'
// -- contracts ----------------------------------------------------------------
/** Mount-scoped contribution: while the rendering component is mounted, its
* children render in the target area's slot; unmount disposes it. Use for
* page-owned chrome (a page's titlebar control leaves with the page)
@ -228,12 +240,21 @@ export { type KeybindContribution, KEYBINDS_AREA } from '@/lib/keybinds/actions'
* authors) + its translucent tag fill so plugin-rendered identities read
* the same hue as everywhere else. */
export { profileColor, profileColorSoft } from '@/lib/profile-color'
export const PANES_AREA = 'panes'
/** The shared client itself, for invalidation OUTSIDE React (e.g. a
* `ctx.socket` frame invalidating a query). Inside components keep using
* `useQueryClient`. */
export { queryClient } from '@/lib/query-client'
export const PANES_AREA = 'panes'
/** Hermes' reasoning levels + their compact labels, so a plugin surfacing a
* thinking depth uses the same scale and spelling as the rest of the app. */
export {
DEFAULT_REASONING_EFFORT,
REASONING_EFFORT_VALUES,
REASONING_EFFORTS,
type ReasoningEffort,
reasoningEffortLabel
} from '@/lib/reasoning-effort'
export const STATUSBAR_AREAS = { left: 'statusBar.left', right: 'statusBar.right' } as const
export const TITLEBAR_AREAS = { center: 'titleBar.center', left: 'titleBar.left', right: 'titleBar.right' } as const

19
cli.py
View File

@ -4205,6 +4205,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
model: str = None,
toolsets: List[str] = None,
provider: str = None,
reasoning: str = None,
api_key: str = None,
base_url: str = None,
max_turns: int = None,
@ -4222,6 +4223,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
model: Model to use (default: from env or claude-sonnet)
toolsets: List of toolsets to enable (default: all)
provider: Inference provider ("auto", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax", "minimax-cn")
reasoning: Reasoning effort override for this run (none|minimal|low|medium|high|xhigh|max|ultra). Wins over config.
api_key: API key (default: from environment)
base_url: API base URL (default: OpenRouter)
max_turns: Maximum tool-calling iterations shared with subagents (default: 500)
@ -4487,6 +4489,20 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
# shared chokepoint in hermes_constants (Closes #21256).
from hermes_constants import resolve_reasoning_config
self.reasoning_config = resolve_reasoning_config(CLI_CONFIG, self.model)
# An explicit --reasoning wins over config for this run only (never
# persisted). Kanban's dispatcher uses it to pin a task's thinking
# depth without touching the worker profile's config.yaml. An
# unparseable level is ignored with a warning rather than silently
# swapping in the default — same contract as the config path.
if reasoning is not None and str(reasoning).strip():
_cli_reasoning = _parse_reasoning_config(reasoning)
if _cli_reasoning is None:
logger.warning(
"Unknown --reasoning '%s', keeping the configured level",
reasoning,
)
else:
self.reasoning_config = _cli_reasoning
self.service_tier = _parse_service_tier_config(
CLI_CONFIG["agent"].get("service_tier", "")
)
@ -17842,6 +17858,7 @@ def main(
skills: str | list[str] | tuple[str, ...] = None,
model: str = None,
provider: str = None,
reasoning: str = None,
api_key: str = None,
base_url: str = None,
max_turns: int = None,
@ -17870,6 +17887,7 @@ def main(
skills: Comma-separated or repeated list of skills to preload for the session
model: Model to use (default: anthropic/claude-opus-4-20250514)
provider: Inference provider ("auto", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax", "minimax-cn")
reasoning: Reasoning effort for this run (none|minimal|low|medium|high|xhigh|max|ultra). Overrides agent.reasoning_effort.
api_key: API key for authentication
base_url: Base URL for the API
max_turns: Maximum tool-calling iterations (default: 60)
@ -17984,6 +18002,7 @@ def main(
model=model,
toolsets=toolsets_list,
provider=provider,
reasoning=reasoning,
api_key=api_key,
base_url=base_url,
max_turns=max_turns,

View File

@ -147,6 +147,18 @@ def build_top_level_parser():
"under model.provider — use `hermes setup` or edit the file to change it."
),
)
_inherited_flag(
parser,
"--reasoning",
default=None,
metavar="LEVEL",
help=(
"Reasoning effort for this invocation: none, minimal, low, medium, "
"high, xhigh, max, or ultra. Overrides agent.reasoning_effort in "
"config.yaml for this run only; the persistent level lives there "
"(or per-model under agent.reasoning_overrides)."
),
)
parser.add_argument(
"-t",
"--toolsets",
@ -299,6 +311,17 @@ def build_top_level_parser():
default=argparse.SUPPRESS,
help="Comma-separated toolsets to enable",
)
_inherited_flag(
chat_parser,
"--reasoning",
default=argparse.SUPPRESS,
metavar="LEVEL",
help=(
"Reasoning effort for this session: none, minimal, low, medium, "
"high, xhigh, max, or ultra. Overrides agent.reasoning_effort for "
"this run only (same levels as the /reasoning slash command)."
),
)
_inherited_flag(
chat_parser,
"-s",

View File

@ -133,6 +133,30 @@ VALID_BLOCK_KINDS = {"dependency", "needs_input", "capability", "transient"}
# not dispatcher spawn/crash/timeout failures.
BLOCK_RECURRENCE_LIMIT = 2
VALID_WORKSPACE_KINDS = {"scratch", "worktree", "dir"}
def normalize_reasoning_effort(effort: Optional[str]) -> Optional[str]:
"""Normalize a per-task reasoning effort into a storable level.
Accepts any level in ``hermes_constants.VALID_REASONING_EFFORTS`` plus
``"none"`` (thinking disabled), case-insensitively. Empty / None means
"inherit the worker profile's own ``agent.reasoning_effort``" and stores
NULL. Anything else is rejected rather than silently dropped a typo'd
level must not quietly hand the task back to the profile default.
"""
from hermes_constants import VALID_REASONING_EFFORTS
value = str(effort or "").strip().lower()
if not value:
return None
if value == "none" or value in VALID_REASONING_EFFORTS:
return value
allowed = ", ".join(("none", *VALID_REASONING_EFFORTS))
raise ValueError(
f"reasoning_effort must be one of {allowed}, got {effort!r}"
)
KNOWN_TOOLSET_NAMES = frozenset(name.casefold() for name in get_toolset_names())
_IS_WINDOWS = sys.platform == "win32"
KANBAN_ATTACHMENT_MAX_BYTES = 25 * 1024 * 1024
@ -929,6 +953,12 @@ class Task:
# model (pre-existing behaviour). Solves the "model from provider A,
# profile configured for provider B" mismatch class.
provider_override: Optional[str] = None
# Per-task reasoning effort for the worker (one of
# ``hermes_constants.VALID_REASONING_EFFORTS``, or ``"none"`` for thinking
# off). When set, the dispatcher passes ``--reasoning <level>`` so the
# worker runs at that depth regardless of the profile's
# ``agent.reasoning_effort``. NULL = the worker profile's own setting.
reasoning_effort: Optional[str] = None
# Per-task override for the consecutive-failure circuit breaker.
# The value is the failure count at which the breaker trips — e.g.
# ``max_retries=1`` blocks on the first failure (zero retries),
@ -1032,6 +1062,11 @@ class Task:
if "provider_override" in keys and row["provider_override"]
else None
),
reasoning_effort=(
row["reasoning_effort"]
if "reasoning_effort" in keys and row["reasoning_effort"]
else None
),
max_retries=(
row["max_retries"] if "max_retries" in keys else None
),
@ -1200,6 +1235,11 @@ CREATE TABLE IF NOT EXISTS tasks (
-- worker resolves the model against the right backend instead of the
-- profile's configured provider. NULL = profile provider.
provider_override TEXT,
-- Per-task reasoning effort for the worker (minimal|low|medium|high|
-- xhigh|max|ultra, or 'none' for thinking off). When set, the dispatcher
-- passes --reasoning <level> so the worker runs at that depth regardless
-- of the profile's agent.reasoning_effort. NULL = profile setting.
reasoning_effort TEXT,
-- Per-task override for the consecutive-failure circuit breaker.
-- The value is the failure count at which the breaker trips e.g.
-- ``max_retries=1`` blocks on the first failure. NULL (the common
@ -2388,6 +2428,13 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
conn, "tasks", "provider_override", "provider_override TEXT"
)
if "reasoning_effort" not in cols:
# Per-task thinking depth for the worker. NULL = the worker profile's
# own agent.reasoning_effort, which is what existing rows were getting.
_add_column_if_missing(
conn, "tasks", "reasoning_effort", "reasoning_effort TEXT"
)
if "goal_mode" not in cols:
# Ralph-style goal loop toggle for the dispatched worker. 0 (the
# default) = classic single-shot worker, preserving the behaviour
@ -2851,6 +2898,7 @@ def create_task(
max_retries: Optional[int] = None,
model_override: Optional[str] = None,
provider_override: Optional[str] = None,
reasoning_effort: Optional[str] = None,
goal_mode: bool = False,
goal_max_turns: Optional[int] = None,
initial_status: str = "running",
@ -2887,6 +2935,11 @@ def create_task(
config passed to the worker as ``-m <model> [--provider <name>]``.
``provider_override`` requires ``model_override``.
``reasoning_effort`` pins the worker's thinking depth for this task
(``minimal````ultra``, or ``none`` to disable thinking), passed as
``--reasoning <level>``. It is independent of ``model_override``: a task
can run the profile's own model at a different depth.
``project_source_task_id`` is an internal cross-profile fallback for a
worker-created child. When the active profile cannot resolve ``project_id``
in its own projects.db, a matching canonical project-linked task in this
@ -2895,6 +2948,7 @@ def create_task(
"""
model_override = (model_override or "").strip() or None
provider_override = (provider_override or "").strip() or None
reasoning_effort = normalize_reasoning_effort(reasoning_effort)
if provider_override and not model_override:
raise ValueError("provider_override requires a model_override")
assignee = _canonical_assignee(assignee)
@ -3162,8 +3216,9 @@ def create_task(
branch_name, project_id, tenant, idempotency_key,
max_runtime_seconds,
skills, max_retries, model_override, provider_override,
reasoning_effort,
goal_mode, goal_max_turns, session_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
task_id,
@ -3185,6 +3240,7 @@ def create_task(
int(max_retries) if max_retries is not None else None,
model_override,
provider_override,
reasoning_effort,
1 if goal_mode else 0,
int(goal_max_turns) if goal_max_turns is not None else None,
session_id,
@ -3427,6 +3483,44 @@ def set_model_override(
return True
def set_reasoning_effort(
conn: sqlite3.Connection,
task_id: str,
effort: Optional[str],
) -> bool:
"""Set (or clear) the per-task reasoning effort.
``effort=None`` (or empty) clears the override the worker falls back to
its profile's own ``agent.reasoning_effort``. ``"none"`` is a real value,
not a clear: it pins thinking OFF for this task.
Deliberately independent of :func:`set_model_override`: a task may run the
profile's own model at a different depth, and clearing a model override
must not silently reset the depth the operator chose. Like the model
override, it takes effect on the NEXT dispatch, so it is settable on a
running task. Returns True on success.
"""
effort = normalize_reasoning_effort(effort)
with write_txn(conn):
row = conn.execute(
"SELECT status FROM tasks WHERE id = ?", (task_id,)
).fetchone()
if not row:
return False
if row["status"] == "archived":
raise RuntimeError(
f"cannot set reasoning effort on archived task {task_id}"
)
conn.execute(
"UPDATE tasks SET reasoning_effort = ? WHERE id = ?",
(effort, task_id),
)
_append_event(
conn, task_id, "reasoning_effort_set", {"reasoning_effort": effort}
)
return True
# ---------------------------------------------------------------------------
# Links
# ---------------------------------------------------------------------------
@ -9026,6 +9120,11 @@ def _default_spawn(
# the classic mis-set that stalls a board).
if task.provider_override:
cmd.extend(["--provider", task.provider_override])
# Per-task thinking depth. Independent of the model override — a task can
# run the profile's own model at a different depth — so this is its own
# branch, not a nested one.
if task.reasoning_effort:
cmd.extend(["--reasoning", task.reasoning_effort])
worker_toolsets = _resolve_worker_cli_toolsets(env.get("HERMES_HOME"))
if worker_toolsets:
cmd.extend(["--toolsets", ",".join(worker_toolsets)])

View File

@ -2693,6 +2693,7 @@ def cmd_chat(args):
kwargs = {
"model": args.model,
"provider": getattr(args, "provider", None),
"reasoning": getattr(args, "reasoning", None),
"toolsets": args.toolsets,
"skills": getattr(args, "skills", None),
"verbose": getattr(args, "verbose", None),

View File

@ -610,6 +610,9 @@ class CreateTaskBody(BaseModel):
goal_max_turns: Optional[int] = None
model_override: Optional[str] = None
provider_override: Optional[str] = None
# Per-task thinking depth (none|minimal|…|ultra). None = inherit the
# assigned profile's own agent.reasoning_effort.
reasoning_effort: Optional[str] = None
# Explicit project link; when omitted, create_task inherits the board's
# scoped project (if any) so a project-scoped board anchors every task.
project_id: Optional[str] = None
@ -639,6 +642,7 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
goal_max_turns=payload.goal_max_turns,
model_override=payload.model_override,
provider_override=payload.provider_override,
reasoning_effort=payload.reasoning_effort,
project_id=payload.project_id,
board=board,
)
@ -839,6 +843,12 @@ class UpdateTaskBody(BaseModel):
model_override: Optional[str] = None
provider_override: Optional[str] = None
clear_model_override: bool = False
# Per-task thinking depth. ``"none"`` is a VALUE (thinking off), not a
# clear — use ``clear_reasoning_effort=True`` to fall back to the
# profile's own level. Separate from the model clear so dropping a model
# override doesn't silently reset the depth the operator chose.
reasoning_effort: Optional[str] = None
clear_reasoning_effort: bool = False
@router.patch("/tasks/{task_id}")
@ -934,6 +944,19 @@ def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Qu
if not ok:
raise HTTPException(status_code=404, detail="task not found")
# --- reasoning effort ----------------------------------------------
if payload.clear_reasoning_effort or payload.reasoning_effort is not None:
new_effort = (
None if payload.clear_reasoning_effort
else payload.reasoning_effort
)
try:
ok = kanban_db.set_reasoning_effort(conn, task_id, new_effort)
except (ValueError, RuntimeError) as e:
raise HTTPException(status_code=400, detail=str(e))
if not ok:
raise HTTPException(status_code=404, detail="task not found")
# --- priority -----------------------------------------------------
if payload.priority is not None:
with kanban_db.write_txn(conn):
@ -1199,6 +1222,9 @@ class BulkTaskBody(BaseModel):
model_override: Optional[str] = None
provider_override: Optional[str] = None
clear_model_override: bool = False
# Bulk thinking-depth override — same semantics as UpdateTaskBody.
reasoning_effort: Optional[str] = None
clear_reasoning_effort: bool = False
@router.post("/tasks/bulk")
@ -1304,6 +1330,17 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
entry.update(ok=False, error="model override refused")
except (ValueError, RuntimeError) as e:
entry.update(ok=False, error=str(e))
if payload.clear_reasoning_effort or payload.reasoning_effort is not None:
new_effort = (
None if payload.clear_reasoning_effort
else payload.reasoning_effort
)
try:
ok = kanban_db.set_reasoning_effort(conn, tid, new_effort)
if not ok:
entry.update(ok=False, error="reasoning override refused")
except (ValueError, RuntimeError) as e:
entry.update(ok=False, error=str(e))
except Exception as e: # defensive — one bad id shouldn't kill the batch
entry.update(ok=False, error=str(e))
results.append(entry)

View File

@ -205,3 +205,118 @@ def test_model_options_endpoint_shape(client, monkeypatch):
assert "slug" in row and "label" in row and "models" in row
assert isinstance(row["models"], list)
assert len(row["models"]) >= 1 # empty-model rows are filtered out
# ---------------------------------------------------------------------------
# Per-task reasoning effort — the depth half of the board's model picker
# ---------------------------------------------------------------------------
def test_reasoning_effort_normalizes_and_rejects(conn):
tid = kb.create_task(conn, title="t", assignee="worker", reasoning_effort=" HIGH ")
assert kb.get_task(conn, tid).reasoning_effort == "high"
# "none" is a VALUE (thinking off), not a clear.
assert kb.set_reasoning_effort(conn, tid, "none")
assert kb.get_task(conn, tid).reasoning_effort == "none"
# Empty clears back to "inherit the profile".
assert kb.set_reasoning_effort(conn, tid, "")
assert kb.get_task(conn, tid).reasoning_effort is None
with pytest.raises(ValueError):
kb.set_reasoning_effort(conn, tid, "extremely-hard")
def test_reasoning_effort_survives_clearing_the_model(conn):
"""Depth and model are independent knobs: dropping a model override must
not silently reset the thinking depth the operator chose."""
tid = kb.create_task(
conn, title="t", assignee="worker",
model_override="glm-5", provider_override="openrouter",
reasoning_effort="ultra",
)
assert kb.set_model_override(conn, tid, None)
t = kb.get_task(conn, tid)
assert t.model_override is None
assert t.provider_override is None
assert t.reasoning_effort == "ultra"
def test_reasoning_effort_without_a_model_override(conn):
"""A task may run the profile's OWN model at a different depth."""
tid = kb.create_task(conn, title="t", assignee="worker", reasoning_effort="low")
t = kb.get_task(conn, tid)
assert t.model_override is None
assert t.reasoning_effort == "low"
def test_spawn_passes_reasoning_without_a_model(monkeypatch, tmp_path, conn):
tid = kb.create_task(conn, title="t", assignee="elias", reasoning_effort="high")
task = kb.get_task(conn, tid)
cmd = _spawn_and_capture(monkeypatch, tmp_path, task)
assert "-m" not in cmd
i = cmd.index("--reasoning")
assert cmd[i + 1] == "high"
def test_spawn_omits_reasoning_when_unset(monkeypatch, tmp_path, conn):
tid = kb.create_task(conn, title="t", assignee="elias")
task = kb.get_task(conn, tid)
cmd = _spawn_and_capture(monkeypatch, tmp_path, task)
assert "--reasoning" not in cmd
def test_worker_cli_accepts_the_reasoning_flag():
"""The dispatcher's --reasoning must be a real flag on the worker's CLI —
a spawn arg no parser accepts fails every dispatch."""
from hermes_cli._parser import build_top_level_parser
parser = build_top_level_parser()[0]
args = parser.parse_args(["--cli", "chat", "-q", "hi", "--reasoning", "high"])
assert args.reasoning == "high"
def test_patch_sets_and_clears_reasoning_effort(client):
task = _create(client)
r = client.patch(
f"/api/plugins/kanban/tasks/{task['id']}",
json={"reasoning_effort": "xhigh"},
)
assert r.status_code == 200, r.text
assert r.json()["task"]["reasoning_effort"] == "xhigh"
r = client.patch(
f"/api/plugins/kanban/tasks/{task['id']}",
json={"clear_reasoning_effort": True},
)
assert r.status_code == 200, r.text
assert r.json()["task"]["reasoning_effort"] is None
def test_patch_rejects_an_unknown_level(client):
task = _create(client)
r = client.patch(
f"/api/plugins/kanban/tasks/{task['id']}",
json={"reasoning_effort": "bogus"},
)
assert r.status_code == 400
def test_create_accepts_reasoning_effort(client):
task = _create(client, reasoning_effort="minimal")
assert task["reasoning_effort"] == "minimal"
def test_bulk_reasoning_effort(client):
t1 = _create(client)
t2 = _create(client)
r = client.post(
"/api/plugins/kanban/tasks/bulk",
json={"ids": [t1["id"], t2["id"]], "reasoning_effort": "max"},
)
assert r.status_code == 200, r.text
assert all(entry["ok"] for entry in r.json()["results"])
for tid in (t1["id"], t2["id"]):
got = client.get(f"/api/plugins/kanban/tasks/{tid}").json()["task"]
assert got["reasoning_effort"] == "max"