From ea09663cdc41f261ec9a1195a217701e3c2ced57 Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Sun, 5 Apr 2026 23:49:29 +0900 Subject: [PATCH] refactor: organize model definitions and flavor capabilities into dedicated modules (#400) --- hub/src/notifications/sessionInfo.ts | 9 +- shared/src/flavors.test.ts | 99 +++++++++++++++++++ shared/src/flavors.ts | 51 ++++++++++ shared/src/index.ts | 2 + shared/src/models.test.ts | 66 +++++++++++++ shared/src/models.ts | 34 +++++++ shared/src/modes.ts | 35 ------- shared/src/types.ts | 3 +- .../AssistantChat/HappyComposer.tsx | 4 +- web/src/lib/agentFlavorUtils.ts | 20 +--- 10 files changed, 263 insertions(+), 60 deletions(-) create mode 100644 shared/src/flavors.test.ts create mode 100644 shared/src/flavors.ts create mode 100644 shared/src/models.test.ts create mode 100644 shared/src/models.ts diff --git a/hub/src/notifications/sessionInfo.ts b/hub/src/notifications/sessionInfo.ts index f9d3ddeb..aab0f7ce 100644 --- a/hub/src/notifications/sessionInfo.ts +++ b/hub/src/notifications/sessionInfo.ts @@ -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) } diff --git a/shared/src/flavors.test.ts b/shared/src/flavors.test.ts new file mode 100644 index 00000000..a8efe5e9 --- /dev/null +++ b/shared/src/flavors.test.ts @@ -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) + }) +}) diff --git a/shared/src/flavors.ts b/shared/src/flavors.ts new file mode 100644 index 00000000..817d3dd9 --- /dev/null +++ b/shared/src/flavors.ts @@ -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> = { + 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 = { + 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) +} diff --git a/shared/src/index.ts b/shared/src/index.ts index e9e6e350..f1829b1a 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,4 +1,6 @@ export * from './messages' +export * from './flavors' +export * from './models' export * from './modes' export * from './socket' export * from './sessionSummary' diff --git a/shared/src/models.test.ts b/shared/src/models.test.ts new file mode 100644 index 00000000..69d08be9 --- /dev/null +++ b/shared/src/models.test.ts @@ -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) + }) +}) diff --git a/shared/src/models.ts b/shared/src/models.ts new file mode 100644 index 00000000..c0199610 --- /dev/null +++ b/shared/src/models.ts @@ -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 +} diff --git a/shared/src/modes.ts b/shared/src/modes.ts index 76b0fb00..d59320a9 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -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 = { default: 'Default', acceptEdits: 'Accept Edits', @@ -87,31 +72,11 @@ export type CodexCollaborationModeOption = { label: string } -export const CLAUDE_MODEL_LABELS: Record = { - sonnet: 'Sonnet', - 'sonnet[1m]': 'Sonnet 1M', - opus: 'Opus', - 'opus[1m]': 'Opus 1M' -} - export const CODEX_COLLABORATION_MODE_LABELS: Record = { 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] } diff --git a/shared/src/types.ts b/shared/src/types.ts index 37333a60..69caa1c9 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -27,8 +27,9 @@ export type { CursorPermissionMode, GeminiPermissionMode, OpencodePermissionMode, - ClaudeModelPreset, PermissionMode, PermissionModeOption, PermissionModeTone } from './modes' + +export type { ClaudeModelPreset, GeminiModelPreset } from './models' diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index fd8ec9b8..101cca2d 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -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) diff --git a/web/src/lib/agentFlavorUtils.ts b/web/src/lib/agentFlavorUtils.ts index d835b350..45479210 100644 --- a/web/src/lib/agentFlavorUtils.ts +++ b/web/src/lib/agentFlavorUtils.ts @@ -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' -}