Files
slopus--happy/docs/plans/metadata-driven-model-mode-selection.md
wehub-resource-sync 98e40dac97
CLI Smoke Test / smoke-test-linux (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-linux (24) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (24) (push) Has been cancelled
Expo App TypeScript typecheck / typecheck (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:40:49 +08:00

239 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Metadata-Driven Model and Mode Selection on Client
## Overview
- 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
- Principle: if metadata provides options, use them; otherwise fall back to hardcoded defaults
- Models: always prefer metadata when available (all agent types)
- Modes: hardcoded for claude/codex, metadata-driven for others when available
- Send model selection via message meta for all agent types (not just Gemini)
- 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
## Context (from discovery)
- Backend already emits `config_options_update`, `modes_update`, `models_update` events and populates `metadata.models[]`, `metadata.operatingModes[]`, `metadata.currentModelCode`, `metadata.currentOperatingModeCode`
- Metadata shape: `{ code: string; value: string; description?: string | null }` — maps as `{ key: code, name: value, description }`
- Frontend currently hardcodes: Claude (sonnet/opus), Codex (gpt-5-*), Gemini (gemini-2.5-*)
- `ModelMode` type is currently a flat string union
- `PermissionMode` type is currently a flat string union (`'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'`)
- `Session.modelMode` is restricted to `'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite'`
- `Session.permissionMode` is restricted to the `PermissionMode` string union
- `updateSessionModelMode()` and `updateSessionPermissionMode()` in storage only accept those specific strings
- `sendMessage()` only sends model in meta for Gemini sessions
- Both mode and model selectors are hardcoded per agent type in AgentInput.tsx
## Files involved
| File | Change |
|------|--------|
| `packages/happy-app/sources/components/PermissionModeSelector.tsx` | Change both `ModelMode` and `PermissionMode` to `{ key, name, description }` struct |
| `packages/happy-app/sources/sync/storageTypes.ts` | Change `Session.modelMode` and `Session.permissionMode` to `string | null` (store key only) |
| `packages/happy-app/sources/sync/storage.ts` | Widen both `updateSessionModelMode()` and `updateSessionPermissionMode()` to accept `string` |
| `packages/happy-app/sources/components/AgentInput.tsx` | Accept structs for both, render from metadata or hardcoded, show `name` in UI |
| `packages/happy-app/sources/-session/SessionView.tsx` | Build structs from metadata for both model and mode, pass to AgentInput |
| `packages/happy-app/sources/sync/sync.ts` | Send model key in meta for all agent types, send permission mode key |
| `packages/happy-app/sources/sync/typesMessageMeta.ts` | Ensure meta schema accepts any string for permissionMode |
| `packages/happy-app/sources/app/(app)/new/index.tsx` | Adapt to structs for both model and mode |
## Development Approach
- **Testing approach**: Regular (code first, then tests)
- Complete each task fully before moving to the next
- Make small, focused changes
- **CRITICAL: every task MUST include new/updated tests** for code changes in that task
- **CRITICAL: all tests must pass before starting next task**
- **CRITICAL: update this plan file when scope changes during implementation**
- Run tests after each change
- Maintain backward compatibility
## Testing Strategy
- **Unit tests**: required for every task (see Development Approach above)
## Progress Tracking
- Mark completed items with `[x]` immediately when done
- Add newly discovered tasks with prefix
- Document issues/blockers with ⚠️ prefix
- Update plan if implementation deviates from original scope
- Keep plan in sync with actual work done
- ⚠️ `yarn workspace happy-app lint` is unavailable because the package has no `lint` script
## Implementation Steps
### Task 1: Change ModelMode and PermissionMode types to structs
- [x] Change `ModelMode` type in `PermissionModeSelector.tsx` from flat string union to `{ key: string; name: string; description?: string | null }`
- [x] Change `PermissionMode` type in `PermissionModeSelector.tsx` from flat string union to `{ key: string; name: string; description?: string | null }`
- [x] Change `Session.modelMode` in `storageTypes.ts` from hardcoded union to `string | null` (stores key only)
- [x] Change `Session.permissionMode` in `storageTypes.ts` from hardcoded union to `string | null` (stores key only)
- [x] Change `updateSessionModelMode()` in `storage.ts` to accept `string`
- [x] Change `updateSessionPermissionMode()` in `storage.ts` to accept `string`
- [x] Update `permissionMode` in `MessageMetaSchema` in `typesMessageMeta.ts` from enum to `z.string()` (keys are now arbitrary strings)
- [x] Fix TypeScript compilation errors from type changes across the codebase
- [x] Run tests - must pass before next task
### Task 2: Build hardcoded PermissionMode and ModelMode struct lists
- [x] Create helper constants for hardcoded Claude permission modes as struct arrays: `[{ key: 'default', name: 'Default' }, { key: 'acceptEdits', name: 'Accept Edits' }, ...]`
- [x] Create helper constants for hardcoded Codex/Gemini permission modes as struct arrays: `[{ key: 'default', name: 'Default' }, { key: 'read-only', name: 'Read Only' }, ...]`
- [x] Create helper constants for hardcoded model modes per agent type (Claude: sonnet/opus, Codex: gpt-5-*, Gemini: gemini-2.5-*)
- [x] Mode `name` is simply the `key` capitalized (e.g. `"plan"``"Plan"`, `"build"``"Build"`) — no translation keys needed for mode names
- [x] Write tests for struct constants having correct keys and names
- [x] Run tests - must pass before next task
### Task 3: Update SessionView to build structs from metadata
- [x] Build `ModelMode` struct for current model: look up `session.modelMode` key in `metadata.models[]`, fall back to `metadata.currentModelCode`, or null
- [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)
- [x] Build `availableModels` list: use `metadata.models[]` if non-empty, else hardcoded fallback for known agents
- [x] Build `availableModes` list: use hardcoded for claude/codex, use `metadata.operatingModes[]` for others if non-empty
- [x] Update `updateModelMode` callback: extract `key` from struct, call `updateSessionModelMode(key)`
- [x] Update `updatePermissionMode` callback: extract `key` from struct, call `updateSessionPermissionMode(key)`
- [x] Pass structs and available lists to `AgentInput`
- [x] Write tests for struct construction from metadata
- [x] Write tests for fallback when metadata is empty
- [x] Run tests - must pass before next task
### Task 4: Update AgentInput to render from structs
- [x] Update props: `modelMode``ModelMode | null`, `permissionMode``PermissionMode | null`
- [x] Add `availableModels` and `availableModes` props (arrays of structs)
- [x] In model section: render from `availableModels`, show `name` as label, `description` as subtitle, compare by `key`
- [x] In mode section: render from `availableModes`, show `name` as label, compare by `key`
- [x] On model selection: call `onModelModeChange(struct)` with full struct
- [x] On mode selection: call `onPermissionModeChange(struct)` with full struct
- [x] Update status bar: show `modelMode.name` and `permissionMode.name` instead of hardcoded label lookups
- [x] Update keyboard shortcut (Shift+Tab) to cycle through `availableModes` structs
- [x] Write tests for rendering from struct arrays
- [x] Write tests for selection callbacks passing full structs
- [x] Run tests - must pass before next task
### Task 5: Send model and mode keys in message meta for all agent types
- [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
- [x] Read `session.permissionMode` (key string) and send in `meta.permissionMode`
- [x] Remove Gemini-specific model logic and hardcoded default fallbacks
- [x] Write tests for sendMessage including model key for non-Gemini agents
- [x] Write tests for sendMessage sending permission mode key
- [x] Run tests - must pass before next task
### Task 6: Update new session wizard
- [x] Update `modelMode` state to `ModelMode | null` struct
- [x] Update `permissionMode` state to `PermissionMode` struct
- [x] Build structs from hardcoded defaults per agent type (metadata not available at creation time)
- [x] On session creation, call `updateSessionModelMode(modelMode.key)` and `updateSessionPermissionMode(permissionMode.key)`
- [x] Update `lastUsedModelMode` / `lastUsedPermissionMode` settings to store/restore keys
- [x] Write tests for struct construction in wizard
- [x] Run tests - must pass before next task
### Task 7: Verify acceptance criteria
- [x] Verify model selector shows `name` from metadata when metadata.models is populated
- [x] Verify model selector falls back to hardcoded names when metadata is empty
- [x] Verify mode selector shows `name` from metadata for non-claude/codex agents
- [x] Verify mode selector shows hardcoded names for claude/codex
- [x] Verify model `key` (not name) is sent in meta.model for all agent types
- [x] Verify permission mode `key` (not name) is sent in meta.permissionMode
- [x] Verify `name` is shown in status bar, selectors, and badges — never raw `key`
- [x] Run full test suite (unit tests)
- [ ] Run linter - all issues must be fixed
### Task 8: [Final] Update documentation
- [x] Update README.md if needed
- [x] Update project knowledge docs if new patterns discovered
## Technical Details
### Shared struct type for both Model and Mode
```typescript
// Both ModelMode and PermissionMode use the same shape
type ModelMode = {
key: string; // Technical ID sent to backend (e.g. "gemini-2.5-pro")
name: string; // Display name shown in UI (e.g. "Gemini 2.5 Pro")
description?: string | null; // Optional subtitle (e.g. "Most capable")
};
type PermissionMode = {
key: string; // Technical ID sent to backend (e.g. "plan", "build")
name: string; // Display name = key capitalized (e.g. "Plan", "Build")
description?: string | null; // Optional subtitle
};
```
### Mapping from metadata
```typescript
// metadata.models[] → ModelMode[]
metadata.models.map(m => ({
key: m.code,
name: m.value,
description: m.description
}))
// metadata.operatingModes[] → PermissionMode[]
metadata.operatingModes.map(m => ({
key: m.code,
name: m.value,
description: m.description
}))
```
### Hardcoded fallbacks (examples)
```typescript
// Claude permission modes — name is just key capitalized
const CLAUDE_PERMISSION_MODES: PermissionMode[] = [
{ key: 'default', name: 'Default' },
{ key: 'acceptEdits', name: 'Accept Edits' },
{ key: 'plan', name: 'Plan' },
{ key: 'bypassPermissions', name: 'Bypass Permissions' },
];
// Gemini models (fallback when metadata not available)
const GEMINI_MODELS: ModelMode[] = [
{ key: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', description: 'Most capable' },
{ key: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', description: 'Fast & efficient' },
{ key: 'gemini-2.5-flash-lite', name: 'Gemini 2.5 Flash Lite', description: 'Fastest' },
];
```
### Storage: stores key only
```typescript
// storageTypes.ts
Session.modelMode?: string | null; // Key only (e.g. "gemini-2.5-pro")
Session.permissionMode?: string | null; // Key only (e.g. "acceptEdits")
// storage.ts
updateSessionModelMode(sessionId: string, key: string)
updateSessionPermissionMode(sessionId: string, key: string)
```
### Data flow (after changes)
```
Backend emits metadata:
metadata.models = [{ code: "gemini-2.5-pro", value: "Gemini 2.5 Pro", description: "Most capable" }, ...]
metadata.currentModelCode = "gemini-2.5-pro"
metadata.operatingModes = [{ code: "default", value: "Default" }, ...]
metadata.currentOperatingModeCode = "default"
SessionView builds structs:
modelMode = { key: "gemini-2.5-pro", name: "Gemini 2.5 Pro", description: "Most capable" }
permissionMode = { key: "default", name: "Default", description: null }
availableModels = metadata.models → ModelMode[]
availableModes = hardcoded (claude/codex) OR metadata.operatingModes → PermissionMode[]
AgentInput renders:
Shows "Gemini 2.5 Pro" (name) in model selector and status bar
Shows "Default" (name) in mode selector and status bar
On selection → calls onChange with full { key, name, description } struct
SessionView handles change:
Extracts key → calls updateSessionModelMode("gemini-2.5-pro")
Extracts key → calls updateSessionPermissionMode("acceptEdits")
sendMessage():
Reads session.modelMode ("gemini-2.5-pro") → sends as meta.model (ALL agents)
Reads session.permissionMode ("acceptEdits") → sends as meta.permissionMode
```
## Post-Completion
**Manual verification:**
- Test with a Gemini ACP session to verify metadata-driven model and mode selectors show names
- Test with a Claude session to verify hardcoded mode names are preserved
- Test with a custom ACP agent that provides metadata to verify dynamic rendering
- Verify keys (not names) are sent in message meta and received by backend
- Verify names (not keys) are shown in all UI locations (selectors, status bar, badges)