diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 48e5ef20..3310591c 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -362,6 +362,22 @@ function createMessageHandler(sessionId: string, model: string, resumeSessionId? */ const record = (): void => { if (!transcriptId) return; + /* + Only ever record a model that NAMES A TIER. + + `DEFAULT_MODEL` is the bare harness, `claude-code`, used when the client sends no model and the user has + no default. It routes correctly — `isClaudeModel` is a prefix test, and `split('/')[1]` yields undefined + so the CLI picks its own model — but it is not an id the catalogue offers, so storing it puts a value in + the picker that matches no option and renders as BLANK. + + That failure feeds itself: a blank picker sends no model, so the next turn falls back to the bare + harness again and overwrites whatever real value was there. It destroyed a correctly backfilled + `claude-code/opus` within one turn. + + A bare harness means "no explicit choice", and the honest response to that is to leave the previous + answer alone rather than to overwrite it with a non-answer. + */ + if (!effective.includes('/')) return; void setChatSessionModel(transcriptId, effective).catch((err: unknown) => { logger.warn('Could not record the session model', { sessionId, diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.test.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.test.tsx index 05e30a44..c84c2081 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.test.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.test.tsx @@ -98,3 +98,22 @@ describe('ModelSelector default selection', () => { expect(picked).toEqual([]); }); }); + +describe('ModelSelector display of an unknown model', () => { + test('a value naming no catalogue entry still shows its name', () => { + /* + The blank-trigger bug. The server stored `claude-code` — the bare harness fallback used when a turn + carries no model — which routes correctly but names no catalogue entry, so `SelectValue` rendered + nothing and the trigger went empty. Self-reinforcing, too: a blank picker sends no model, so the next + turn fell back to the bare harness again and overwrote the real value. + */ + mount({ selectedModel: 'claude-code', hasStarted: true }); + expect(screen.getByTitle('claude-code').textContent).toBe('claude-code'); + }); + + test('a known model still renders through the Select', () => { + mount({ selectedModel: 'claude-code/opus', hasStarted: true }); + // No raw-string fallback for a value the catalogue knows — that path is only for unmatchable ones. + expect(screen.queryByTitle('claude-code/opus')).toBeNull(); + }); +}); diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx index d7fa2fa4..36eeeae1 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx @@ -212,7 +212,21 @@ export function ModelSelector({ disabled={isBusy} > - + {/* + `SelectValue` renders the matching ITEM's text, so a value naming no item renders NOTHING — + the trigger goes blank and the only way to find out what is selected is to open the dropdown. + + That is reachable whenever the two id spaces disagree: a stored `claude-code` (the bare + harness fallback, which routes fine but is not a catalogue entry), a model retired from the + list, or an OpenCode model that has since disappeared from its provider. The value is still + meaningful to the server in every one of those cases, so the honest display is the raw + string rather than an empty control. + */} + {displayModel && !availableModels.some((m) => m.id === displayModel) ? ( + {displayModel.split('/').pop()} + ) : ( + + )} {providerModels.map((m) => (