|
| 1 | +# Metadata-Driven Model and Mode Selection on Client |
| 2 | + |
| 3 | +## Overview |
| 4 | +- Make the client use backend-provided metadata fields (`metadata.models[]`, `metadata.operatingModes[]`, `metadata.currentModelCode`, `metadata.currentOperatingModeCode`) for model and mode selection in active sessions, instead of hardcoding options per agent type |
| 5 | +- Principle: if metadata provides options, use them; otherwise fall back to hardcoded defaults |
| 6 | +- Models: always prefer metadata when available (all agent types) |
| 7 | +- Modes: hardcoded for claude/codex, metadata-driven for others when available |
| 8 | +- Send model selection via message meta for all agent types (not just Gemini) |
| 9 | +- Both `ModelMode` and `PermissionMode` become structured types `{ key: string; name: string; description?: string | null }` — UI shows `name` everywhere, `key` is the value sent to backend/stored |
| 10 | + |
| 11 | +## Context (from discovery) |
| 12 | +- Backend already emits `config_options_update`, `modes_update`, `models_update` events and populates `metadata.models[]`, `metadata.operatingModes[]`, `metadata.currentModelCode`, `metadata.currentOperatingModeCode` |
| 13 | +- Metadata shape: `{ code: string; value: string; description?: string | null }` — maps as `{ key: code, name: value, description }` |
| 14 | +- Frontend currently hardcodes: Claude (sonnet/opus), Codex (gpt-5-*), Gemini (gemini-2.5-*) |
| 15 | +- `ModelMode` type is currently a flat string union |
| 16 | +- `PermissionMode` type is currently a flat string union (`'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'`) |
| 17 | +- `Session.modelMode` is restricted to `'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite'` |
| 18 | +- `Session.permissionMode` is restricted to the `PermissionMode` string union |
| 19 | +- `updateSessionModelMode()` and `updateSessionPermissionMode()` in storage only accept those specific strings |
| 20 | +- `sendMessage()` only sends model in meta for Gemini sessions |
| 21 | +- Both mode and model selectors are hardcoded per agent type in AgentInput.tsx |
| 22 | + |
| 23 | +## Files involved |
| 24 | +| File | Change | |
| 25 | +|------|--------| |
| 26 | +| `packages/happy-app/sources/components/PermissionModeSelector.tsx` | Change both `ModelMode` and `PermissionMode` to `{ key, name, description }` struct | |
| 27 | +| `packages/happy-app/sources/sync/storageTypes.ts` | Change `Session.modelMode` and `Session.permissionMode` to `string | null` (store key only) | |
| 28 | +| `packages/happy-app/sources/sync/storage.ts` | Widen both `updateSessionModelMode()` and `updateSessionPermissionMode()` to accept `string` | |
| 29 | +| `packages/happy-app/sources/components/AgentInput.tsx` | Accept structs for both, render from metadata or hardcoded, show `name` in UI | |
| 30 | +| `packages/happy-app/sources/-session/SessionView.tsx` | Build structs from metadata for both model and mode, pass to AgentInput | |
| 31 | +| `packages/happy-app/sources/sync/sync.ts` | Send model key in meta for all agent types, send permission mode key | |
| 32 | +| `packages/happy-app/sources/sync/typesMessageMeta.ts` | Ensure meta schema accepts any string for permissionMode | |
| 33 | +| `packages/happy-app/sources/app/(app)/new/index.tsx` | Adapt to structs for both model and mode | |
| 34 | + |
| 35 | +## Development Approach |
| 36 | +- **Testing approach**: Regular (code first, then tests) |
| 37 | +- Complete each task fully before moving to the next |
| 38 | +- Make small, focused changes |
| 39 | +- **CRITICAL: every task MUST include new/updated tests** for code changes in that task |
| 40 | +- **CRITICAL: all tests must pass before starting next task** |
| 41 | +- **CRITICAL: update this plan file when scope changes during implementation** |
| 42 | +- Run tests after each change |
| 43 | +- Maintain backward compatibility |
| 44 | + |
| 45 | +## Testing Strategy |
| 46 | +- **Unit tests**: required for every task (see Development Approach above) |
| 47 | + |
| 48 | +## Progress Tracking |
| 49 | +- Mark completed items with `[x]` immediately when done |
| 50 | +- Add newly discovered tasks with ➕ prefix |
| 51 | +- Document issues/blockers with ⚠️ prefix |
| 52 | +- Update plan if implementation deviates from original scope |
| 53 | +- Keep plan in sync with actual work done |
| 54 | +- ⚠️ `yarn workspace happy-app lint` is unavailable because the package has no `lint` script |
| 55 | + |
| 56 | +## Implementation Steps |
| 57 | + |
| 58 | +### Task 1: Change ModelMode and PermissionMode types to structs |
| 59 | +- [x] Change `ModelMode` type in `PermissionModeSelector.tsx` from flat string union to `{ key: string; name: string; description?: string | null }` |
| 60 | +- [x] Change `PermissionMode` type in `PermissionModeSelector.tsx` from flat string union to `{ key: string; name: string; description?: string | null }` |
| 61 | +- [x] Change `Session.modelMode` in `storageTypes.ts` from hardcoded union to `string | null` (stores key only) |
| 62 | +- [x] Change `Session.permissionMode` in `storageTypes.ts` from hardcoded union to `string | null` (stores key only) |
| 63 | +- [x] Change `updateSessionModelMode()` in `storage.ts` to accept `string` |
| 64 | +- [x] Change `updateSessionPermissionMode()` in `storage.ts` to accept `string` |
| 65 | +- [x] Update `permissionMode` in `MessageMetaSchema` in `typesMessageMeta.ts` from enum to `z.string()` (keys are now arbitrary strings) |
| 66 | +- [x] Fix TypeScript compilation errors from type changes across the codebase |
| 67 | +- [x] Run tests - must pass before next task |
| 68 | + |
| 69 | +### Task 2: Build hardcoded PermissionMode and ModelMode struct lists |
| 70 | +- [x] Create helper constants for hardcoded Claude permission modes as struct arrays: `[{ key: 'default', name: 'Default' }, { key: 'acceptEdits', name: 'Accept Edits' }, ...]` |
| 71 | +- [x] Create helper constants for hardcoded Codex/Gemini permission modes as struct arrays: `[{ key: 'default', name: 'Default' }, { key: 'read-only', name: 'Read Only' }, ...]` |
| 72 | +- [x] Create helper constants for hardcoded model modes per agent type (Claude: sonnet/opus, Codex: gpt-5-*, Gemini: gemini-2.5-*) |
| 73 | +- [x] Mode `name` is simply the `key` capitalized (e.g. `"plan"` → `"Plan"`, `"build"` → `"Build"`) — no translation keys needed for mode names |
| 74 | +- [x] Write tests for struct constants having correct keys and names |
| 75 | +- [x] Run tests - must pass before next task |
| 76 | + |
| 77 | +### Task 3: Update SessionView to build structs from metadata |
| 78 | +- [x] Build `ModelMode` struct for current model: look up `session.modelMode` key in `metadata.models[]`, fall back to `metadata.currentModelCode`, or null |
| 79 | +- [x] Build `PermissionMode` struct for current mode: look up `session.permissionMode` key in `metadata.operatingModes[]` (for non-claude/codex) or hardcoded list (for claude/codex) |
| 80 | +- [x] Build `availableModels` list: use `metadata.models[]` if non-empty, else hardcoded fallback for known agents |
| 81 | +- [x] Build `availableModes` list: use hardcoded for claude/codex, use `metadata.operatingModes[]` for others if non-empty |
| 82 | +- [x] Update `updateModelMode` callback: extract `key` from struct, call `updateSessionModelMode(key)` |
| 83 | +- [x] Update `updatePermissionMode` callback: extract `key` from struct, call `updateSessionPermissionMode(key)` |
| 84 | +- [x] Pass structs and available lists to `AgentInput` |
| 85 | +- [x] Write tests for struct construction from metadata |
| 86 | +- [x] Write tests for fallback when metadata is empty |
| 87 | +- [x] Run tests - must pass before next task |
| 88 | + |
| 89 | +### Task 4: Update AgentInput to render from structs |
| 90 | +- [x] Update props: `modelMode` → `ModelMode | null`, `permissionMode` → `PermissionMode | null` |
| 91 | +- [x] Add `availableModels` and `availableModes` props (arrays of structs) |
| 92 | +- [x] In model section: render from `availableModels`, show `name` as label, `description` as subtitle, compare by `key` |
| 93 | +- [x] In mode section: render from `availableModes`, show `name` as label, compare by `key` |
| 94 | +- [x] On model selection: call `onModelModeChange(struct)` with full struct |
| 95 | +- [x] On mode selection: call `onPermissionModeChange(struct)` with full struct |
| 96 | +- [x] Update status bar: show `modelMode.name` and `permissionMode.name` instead of hardcoded label lookups |
| 97 | +- [x] Update keyboard shortcut (Shift+Tab) to cycle through `availableModes` structs |
| 98 | +- [x] Write tests for rendering from struct arrays |
| 99 | +- [x] Write tests for selection callbacks passing full structs |
| 100 | +- [x] Run tests - must pass before next task |
| 101 | + |
| 102 | +### Task 5: Send model and mode keys in message meta for all agent types |
| 103 | +- [x] In `sync.ts` `sendMessage()`, read `session.modelMode` (key string) and send in `meta.model` when set and not `'default'` — for ALL agent types, not just Gemini |
| 104 | +- [x] Read `session.permissionMode` (key string) and send in `meta.permissionMode` |
| 105 | +- [x] Remove Gemini-specific model logic and hardcoded default fallbacks |
| 106 | +- [x] Write tests for sendMessage including model key for non-Gemini agents |
| 107 | +- [x] Write tests for sendMessage sending permission mode key |
| 108 | +- [x] Run tests - must pass before next task |
| 109 | + |
| 110 | +### Task 6: Update new session wizard |
| 111 | +- [x] Update `modelMode` state to `ModelMode | null` struct |
| 112 | +- [x] Update `permissionMode` state to `PermissionMode` struct |
| 113 | +- [x] Build structs from hardcoded defaults per agent type (metadata not available at creation time) |
| 114 | +- [x] On session creation, call `updateSessionModelMode(modelMode.key)` and `updateSessionPermissionMode(permissionMode.key)` |
| 115 | +- [x] Update `lastUsedModelMode` / `lastUsedPermissionMode` settings to store/restore keys |
| 116 | +- [x] Write tests for struct construction in wizard |
| 117 | +- [x] Run tests - must pass before next task |
| 118 | + |
| 119 | +### Task 7: Verify acceptance criteria |
| 120 | +- [x] Verify model selector shows `name` from metadata when metadata.models is populated |
| 121 | +- [x] Verify model selector falls back to hardcoded names when metadata is empty |
| 122 | +- [x] Verify mode selector shows `name` from metadata for non-claude/codex agents |
| 123 | +- [x] Verify mode selector shows hardcoded names for claude/codex |
| 124 | +- [x] Verify model `key` (not name) is sent in meta.model for all agent types |
| 125 | +- [x] Verify permission mode `key` (not name) is sent in meta.permissionMode |
| 126 | +- [x] Verify `name` is shown in status bar, selectors, and badges — never raw `key` |
| 127 | +- [x] Run full test suite (unit tests) |
| 128 | +- [ ] Run linter - all issues must be fixed |
| 129 | + |
| 130 | +### Task 8: [Final] Update documentation |
| 131 | +- [x] Update README.md if needed |
| 132 | +- [x] Update project knowledge docs if new patterns discovered |
| 133 | + |
| 134 | +## Technical Details |
| 135 | + |
| 136 | +### Shared struct type for both Model and Mode |
| 137 | + |
| 138 | +```typescript |
| 139 | +// Both ModelMode and PermissionMode use the same shape |
| 140 | +type ModelMode = { |
| 141 | + key: string; // Technical ID sent to backend (e.g. "gemini-2.5-pro") |
| 142 | + name: string; // Display name shown in UI (e.g. "Gemini 2.5 Pro") |
| 143 | + description?: string | null; // Optional subtitle (e.g. "Most capable") |
| 144 | +}; |
| 145 | + |
| 146 | +type PermissionMode = { |
| 147 | + key: string; // Technical ID sent to backend (e.g. "plan", "build") |
| 148 | + name: string; // Display name = key capitalized (e.g. "Plan", "Build") |
| 149 | + description?: string | null; // Optional subtitle |
| 150 | +}; |
| 151 | +``` |
| 152 | + |
| 153 | +### Mapping from metadata |
| 154 | + |
| 155 | +```typescript |
| 156 | +// metadata.models[] → ModelMode[] |
| 157 | +metadata.models.map(m => ({ |
| 158 | + key: m.code, |
| 159 | + name: m.value, |
| 160 | + description: m.description |
| 161 | +})) |
| 162 | + |
| 163 | +// metadata.operatingModes[] → PermissionMode[] |
| 164 | +metadata.operatingModes.map(m => ({ |
| 165 | + key: m.code, |
| 166 | + name: m.value, |
| 167 | + description: m.description |
| 168 | +})) |
| 169 | +``` |
| 170 | + |
| 171 | +### Hardcoded fallbacks (examples) |
| 172 | + |
| 173 | +```typescript |
| 174 | +// Claude permission modes — name is just key capitalized |
| 175 | +const CLAUDE_PERMISSION_MODES: PermissionMode[] = [ |
| 176 | + { key: 'default', name: 'Default' }, |
| 177 | + { key: 'acceptEdits', name: 'Accept Edits' }, |
| 178 | + { key: 'plan', name: 'Plan' }, |
| 179 | + { key: 'bypassPermissions', name: 'Bypass Permissions' }, |
| 180 | +]; |
| 181 | + |
| 182 | +// Gemini models (fallback when metadata not available) |
| 183 | +const GEMINI_MODELS: ModelMode[] = [ |
| 184 | + { key: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', description: 'Most capable' }, |
| 185 | + { key: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', description: 'Fast & efficient' }, |
| 186 | + { key: 'gemini-2.5-flash-lite', name: 'Gemini 2.5 Flash Lite', description: 'Fastest' }, |
| 187 | +]; |
| 188 | +``` |
| 189 | + |
| 190 | +### Storage: stores key only |
| 191 | + |
| 192 | +```typescript |
| 193 | +// storageTypes.ts |
| 194 | +Session.modelMode?: string | null; // Key only (e.g. "gemini-2.5-pro") |
| 195 | +Session.permissionMode?: string | null; // Key only (e.g. "acceptEdits") |
| 196 | + |
| 197 | +// storage.ts |
| 198 | +updateSessionModelMode(sessionId: string, key: string) |
| 199 | +updateSessionPermissionMode(sessionId: string, key: string) |
| 200 | +``` |
| 201 | + |
| 202 | +### Data flow (after changes) |
| 203 | + |
| 204 | +``` |
| 205 | +Backend emits metadata: |
| 206 | + metadata.models = [{ code: "gemini-2.5-pro", value: "Gemini 2.5 Pro", description: "Most capable" }, ...] |
| 207 | + metadata.currentModelCode = "gemini-2.5-pro" |
| 208 | + metadata.operatingModes = [{ code: "default", value: "Default" }, ...] |
| 209 | + metadata.currentOperatingModeCode = "default" |
| 210 | +
|
| 211 | +SessionView builds structs: |
| 212 | + modelMode = { key: "gemini-2.5-pro", name: "Gemini 2.5 Pro", description: "Most capable" } |
| 213 | + permissionMode = { key: "default", name: "Default", description: null } |
| 214 | + availableModels = metadata.models → ModelMode[] |
| 215 | + availableModes = hardcoded (claude/codex) OR metadata.operatingModes → PermissionMode[] |
| 216 | +
|
| 217 | +AgentInput renders: |
| 218 | + Shows "Gemini 2.5 Pro" (name) in model selector and status bar |
| 219 | + Shows "Default" (name) in mode selector and status bar |
| 220 | + On selection → calls onChange with full { key, name, description } struct |
| 221 | +
|
| 222 | +SessionView handles change: |
| 223 | + Extracts key → calls updateSessionModelMode("gemini-2.5-pro") |
| 224 | + Extracts key → calls updateSessionPermissionMode("acceptEdits") |
| 225 | +
|
| 226 | +sendMessage(): |
| 227 | + Reads session.modelMode ("gemini-2.5-pro") → sends as meta.model (ALL agents) |
| 228 | + Reads session.permissionMode ("acceptEdits") → sends as meta.permissionMode |
| 229 | +``` |
| 230 | + |
| 231 | +## Post-Completion |
| 232 | + |
| 233 | +**Manual verification:** |
| 234 | +- Test with a Gemini ACP session to verify metadata-driven model and mode selectors show names |
| 235 | +- Test with a Claude session to verify hardcoded mode names are preserved |
| 236 | +- Test with a custom ACP agent that provides metadata to verify dynamic rendering |
| 237 | +- Verify keys (not names) are sent in message meta and received by backend |
| 238 | +- Verify names (not keys) are shown in all UI locations (selectors, status bar, badges) |
0 commit comments