refactor: organize model definitions and flavor capabilities into dedicated modules (#400)

This commit is contained in:
Junmo Kim
2026-04-05 22:49:29 +08:00
committed by GitHub
parent 4ffcb4cfdb
commit ea09663cdc
10 changed files with 263 additions and 60 deletions
+3 -6
View File
@@ -1,3 +1,4 @@
import { getFlavorLabel, isKnownFlavor } from '@hapi/protocol'
import type { Session } from '../sync/syncEngine'
export function getSessionName(session: Session): string {
@@ -12,10 +13,6 @@ export function getSessionName(session: Session): string {
export function getAgentName(session: Session): string {
const flavor = session.metadata?.flavor
if (flavor === 'claude') return 'Claude'
if (flavor === 'codex') return 'Codex'
if (flavor === 'cursor') return 'Cursor'
if (flavor === 'gemini') return 'Gemini'
if (flavor === 'opencode') return 'OpenCode'
return 'Agent'
if (!flavor || !isKnownFlavor(flavor)) return 'Agent'
return getFlavorLabel(flavor)
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, test } from 'bun:test'
import {
Capabilities,
getFlavorLabel,
hasCapability,
isKnownFlavor,
supportsEffort,
supportsModelChange,
} from './flavors'
describe('hasCapability', () => {
test('claude supports model-change', () => {
expect(hasCapability('claude', Capabilities.ModelChange)).toBe(true)
})
test('claude supports effort', () => {
expect(hasCapability('claude', Capabilities.Effort)).toBe(true)
})
test('gemini supports model-change but not effort', () => {
expect(hasCapability('gemini', Capabilities.ModelChange)).toBe(true)
expect(hasCapability('gemini', Capabilities.Effort)).toBe(false)
})
test('codex has no capabilities', () => {
expect(hasCapability('codex', Capabilities.ModelChange)).toBe(false)
expect(hasCapability('codex', Capabilities.Effort)).toBe(false)
})
test('cursor has no capabilities', () => {
expect(hasCapability('cursor', Capabilities.ModelChange)).toBe(false)
expect(hasCapability('cursor', Capabilities.Effort)).toBe(false)
})
test('opencode has no capabilities', () => {
expect(hasCapability('opencode', Capabilities.ModelChange)).toBe(false)
expect(hasCapability('opencode', Capabilities.Effort)).toBe(false)
})
test('unknown flavor returns false', () => {
expect(hasCapability('unknown-flavor', Capabilities.ModelChange)).toBe(false)
})
test('null/undefined flavor returns false', () => {
expect(hasCapability(null, Capabilities.ModelChange)).toBe(false)
expect(hasCapability(undefined, Capabilities.ModelChange)).toBe(false)
})
})
describe('getFlavorLabel', () => {
test('known flavors return display names', () => {
expect(getFlavorLabel('claude')).toBe('Claude')
expect(getFlavorLabel('gemini')).toBe('Gemini')
expect(getFlavorLabel('codex')).toBe('Codex')
expect(getFlavorLabel('cursor')).toBe('Cursor')
expect(getFlavorLabel('opencode')).toBe('OpenCode')
})
test('unknown flavor returns Unknown', () => {
expect(getFlavorLabel('some-new-cli')).toBe('Unknown')
})
test('null/undefined returns Unknown', () => {
expect(getFlavorLabel(null)).toBe('Unknown')
expect(getFlavorLabel(undefined)).toBe('Unknown')
})
})
describe('isKnownFlavor', () => {
test('returns true for registered flavors', () => {
expect(isKnownFlavor('claude')).toBe(true)
expect(isKnownFlavor('gemini')).toBe(true)
expect(isKnownFlavor('codex')).toBe(true)
expect(isKnownFlavor('cursor')).toBe(true)
expect(isKnownFlavor('opencode')).toBe(true)
})
test('returns false for unknown/null/undefined', () => {
expect(isKnownFlavor('foo')).toBe(false)
expect(isKnownFlavor(null)).toBe(false)
expect(isKnownFlavor(undefined)).toBe(false)
})
})
describe('convenience functions', () => {
test('supportsModelChange matches hasCapability', () => {
expect(supportsModelChange('claude')).toBe(true)
expect(supportsModelChange('gemini')).toBe(true)
expect(supportsModelChange('cursor')).toBe(false)
expect(supportsModelChange(null)).toBe(false)
})
test('supportsEffort matches hasCapability', () => {
expect(supportsEffort('claude')).toBe(true)
expect(supportsEffort('codex')).toBe(false)
expect(supportsEffort('gemini')).toBe(false)
expect(supportsEffort(null)).toBe(false)
})
})
+51
View File
@@ -0,0 +1,51 @@
import type { AgentFlavor } from './modes'
// --- Capability constants (prevent literal scattering) ---
export const Capabilities = {
ModelChange: 'model-change',
Effort: 'effort',
} as const
export type Capability = typeof Capabilities[keyof typeof Capabilities]
// --- Per-flavor capability sets ---
const FLAVOR_CAPS: Record<AgentFlavor, ReadonlySet<Capability>> = {
claude: new Set([Capabilities.ModelChange, Capabilities.Effort]),
gemini: new Set([Capabilities.ModelChange]),
codex: new Set([]),
cursor: new Set([]),
opencode: new Set([]),
}
// --- Flavor display names ---
const FLAVOR_LABELS: Record<AgentFlavor, string> = {
claude: 'Claude',
gemini: 'Gemini',
codex: 'Codex',
cursor: 'Cursor',
opencode: 'OpenCode',
}
// --- Query functions ---
export function isKnownFlavor(flavor: string | null | undefined): flavor is AgentFlavor {
return typeof flavor === 'string' && Object.hasOwn(FLAVOR_CAPS, flavor)
}
export function hasCapability(flavor: string | null | undefined, cap: Capability): boolean {
if (!isKnownFlavor(flavor)) return false
return FLAVOR_CAPS[flavor].has(cap)
}
export function getFlavorLabel(flavor: string | null | undefined): string {
if (!isKnownFlavor(flavor)) return 'Unknown'
return FLAVOR_LABELS[flavor]
}
// --- Convenience functions ---
export function supportsModelChange(flavor: string | null | undefined): boolean {
return hasCapability(flavor, Capabilities.ModelChange)
}
export function supportsEffort(flavor: string | null | undefined): boolean {
return hasCapability(flavor, Capabilities.Effort)
}
+2
View File
@@ -1,4 +1,6 @@
export * from './messages'
export * from './flavors'
export * from './models'
export * from './modes'
export * from './socket'
export * from './sessionSummary'
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, test } from 'bun:test'
import {
CLAUDE_MODEL_PRESETS,
CLAUDE_MODEL_LABELS,
DEFAULT_GEMINI_MODEL,
GEMINI_MODEL_LABELS,
GEMINI_MODEL_PRESETS,
getClaudeModelLabel,
isClaudeModelPreset,
} from './models'
describe('isClaudeModelPreset', () => {
test('accepts valid presets', () => {
for (const preset of CLAUDE_MODEL_PRESETS) {
expect(isClaudeModelPreset(preset)).toBe(true)
}
})
test('rejects unknown model string', () => {
expect(isClaudeModelPreset('haiku')).toBe(false)
})
test('rejects null and undefined', () => {
expect(isClaudeModelPreset(null)).toBe(false)
expect(isClaudeModelPreset(undefined)).toBe(false)
})
})
describe('getClaudeModelLabel', () => {
test('returns label for known presets', () => {
expect(getClaudeModelLabel('sonnet')).toBe('Sonnet')
expect(getClaudeModelLabel('opus')).toBe('Opus')
expect(getClaudeModelLabel('opus[1m]')).toBe('Opus 1M')
})
test('trims whitespace before lookup', () => {
expect(getClaudeModelLabel(' sonnet ')).toBe('Sonnet')
})
test('returns null for unknown model', () => {
expect(getClaudeModelLabel('haiku')).toBeNull()
})
test('returns null for empty/whitespace-only string', () => {
expect(getClaudeModelLabel('')).toBeNull()
expect(getClaudeModelLabel(' ')).toBeNull()
})
})
describe('model constants consistency', () => {
test('every CLAUDE_MODEL_PRESET has a label', () => {
for (const preset of CLAUDE_MODEL_PRESETS) {
expect(CLAUDE_MODEL_LABELS[preset]).toBeDefined()
}
})
test('every GEMINI_MODEL_PRESET has a label', () => {
for (const preset of GEMINI_MODEL_PRESETS) {
expect(GEMINI_MODEL_LABELS[preset]).toBeDefined()
}
})
test('DEFAULT_GEMINI_MODEL is a valid preset', () => {
expect(GEMINI_MODEL_PRESETS).toContain(DEFAULT_GEMINI_MODEL)
})
})
+34
View File
@@ -0,0 +1,34 @@
export const CLAUDE_MODEL_LABELS = {
sonnet: 'Sonnet',
'sonnet[1m]': 'Sonnet 1M',
opus: 'Opus',
'opus[1m]': 'Opus 1M'
} as const
export type ClaudeModelPreset = keyof typeof CLAUDE_MODEL_LABELS
export const CLAUDE_MODEL_PRESETS = Object.keys(CLAUDE_MODEL_LABELS) as ClaudeModelPreset[]
export const GEMINI_MODEL_LABELS = {
'gemini-3.1-pro-preview': 'Gemini 3.1 Pro Preview',
'gemini-3-flash-preview': 'Gemini 3 Flash Preview',
'gemini-2.5-pro': 'Gemini 2.5 Pro',
'gemini-2.5-flash': 'Gemini 2.5 Flash',
'gemini-2.5-flash-lite': 'Gemini 2.5 Flash Lite',
} as const
export type GeminiModelPreset = keyof typeof GEMINI_MODEL_LABELS
export const GEMINI_MODEL_PRESETS = Object.keys(GEMINI_MODEL_LABELS) as GeminiModelPreset[]
export const DEFAULT_GEMINI_MODEL: GeminiModelPreset = 'gemini-2.5-pro'
export function isClaudeModelPreset(model: string | null | undefined): model is ClaudeModelPreset {
return typeof model === 'string' && Object.hasOwn(CLAUDE_MODEL_LABELS, model)
}
export function getClaudeModelLabel(model: string): string | null {
const trimmedModel = model.trim()
if (!trimmedModel) {
return null
}
return CLAUDE_MODEL_LABELS[trimmedModel as ClaudeModelPreset] ?? null
}
-35
View File
@@ -35,23 +35,8 @@ export const PERMISSION_MODES = [
] as const
export type PermissionMode = typeof PERMISSION_MODES[number]
export const CLAUDE_MODEL_PRESETS = ['sonnet', 'sonnet[1m]', 'opus', 'opus[1m]'] as const
export type ClaudeModelPreset = typeof CLAUDE_MODEL_PRESETS[number]
export type AgentFlavor = 'claude' | 'codex' | 'gemini' | 'opencode' | 'cursor'
export const GEMINI_MODEL_LABELS = {
'gemini-3.1-pro-preview': 'Gemini 3.1 Pro Preview',
'gemini-3-flash-preview': 'Gemini 3 Flash Preview',
'gemini-2.5-pro': 'Gemini 2.5 Pro',
'gemini-2.5-flash': 'Gemini 2.5 Flash',
'gemini-2.5-flash-lite': 'Gemini 2.5 Flash Lite',
} as const
export type GeminiModelPreset = keyof typeof GEMINI_MODEL_LABELS
export const GEMINI_MODEL_PRESETS = Object.keys(GEMINI_MODEL_LABELS) as GeminiModelPreset[]
export const DEFAULT_GEMINI_MODEL: GeminiModelPreset = 'gemini-2.5-pro'
export const PERMISSION_MODE_LABELS: Record<PermissionMode, string> = {
default: 'Default',
acceptEdits: 'Accept Edits',
@@ -87,31 +72,11 @@ export type CodexCollaborationModeOption = {
label: string
}
export const CLAUDE_MODEL_LABELS: Record<ClaudeModelPreset, string> = {
sonnet: 'Sonnet',
'sonnet[1m]': 'Sonnet 1M',
opus: 'Opus',
'opus[1m]': 'Opus 1M'
}
export const CODEX_COLLABORATION_MODE_LABELS: Record<CodexCollaborationMode, string> = {
default: 'Default',
plan: 'Plan'
}
export function isClaudeModelPreset(model: string | null | undefined): model is ClaudeModelPreset {
return typeof model === 'string' && CLAUDE_MODEL_PRESETS.includes(model as ClaudeModelPreset)
}
export function getClaudeModelLabel(model: string): string | null {
const trimmedModel = model.trim()
if (!trimmedModel) {
return null
}
return CLAUDE_MODEL_LABELS[trimmedModel as ClaudeModelPreset] ?? null
}
export function getPermissionModeLabel(mode: PermissionMode): string {
return PERMISSION_MODE_LABELS[mode]
}
+2 -1
View File
@@ -27,8 +27,9 @@ export type {
CursorPermissionMode,
GeminiPermissionMode,
OpencodePermissionMode,
ClaudeModelPreset,
PermissionMode,
PermissionModeOption,
PermissionModeTone
} from './modes'
export type { ClaudeModelPreset, GeminiModelPreset } from './models'
@@ -20,7 +20,7 @@ import { useActiveSuggestions } from '@/hooks/useActiveSuggestions'
import { applySuggestion } from '@/utils/applySuggestion'
import { usePlatform } from '@/hooks/usePlatform'
import { usePWAInstall } from '@/hooks/usePWAInstall'
import { isClaudeFlavor, supportsModelChange } from '@/lib/agentFlavorUtils'
import { supportsEffort, supportsModelChange } from '@hapi/protocol'
import { markSkillUsed } from '@/lib/recent-skills'
import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay'
import { Autocomplete } from '@/components/ChatInput/Autocomplete'
@@ -440,7 +440,7 @@ export function HappyComposer(props: {
const showCollaborationSettings = Boolean(onCollaborationModeChange && collaborationModeOptions.length > 0)
const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0)
const showModelSettings = Boolean(onModelChange && supportsModelChange(agentFlavor))
const showEffortSettings = Boolean(onEffortChange && isClaudeFlavor(agentFlavor))
const showEffortSettings = Boolean(onEffortChange && supportsEffort(agentFlavor))
const showSettingsButton = Boolean(showCollaborationSettings || showPermissionSettings || showModelSettings || showEffortSettings)
const showAbortButton = true
const voiceEnabled = Boolean(onVoiceToggle)
+4 -16
View File
@@ -1,19 +1,7 @@
// Re-export from shared package for backwards compatibility
export { isKnownFlavor, supportsModelChange, supportsEffort } from '@hapi/protocol'
// Flavor-family helper not yet in shared — keep here until next migration
export function isCodexFamilyFlavor(flavor?: string | null): boolean {
return flavor === 'codex' || flavor === 'gemini' || flavor === 'opencode'
}
export function isClaudeFlavor(flavor?: string | null): boolean {
return flavor === 'claude'
}
export function isCursorFlavor(flavor?: string | null): boolean {
return flavor === 'cursor'
}
export function isKnownFlavor(flavor?: string | null): boolean {
return isClaudeFlavor(flavor) || isCodexFamilyFlavor(flavor) || isCursorFlavor(flavor)
}
export function supportsModelChange(flavor?: string | null): boolean {
return flavor === 'claude' || flavor === 'gemini'
}