From 773f0e1c8bd6c27f4d8eb97c2cd4a9a096a12b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 17 Aug 2026 18:06:08 +0000 Subject: [PATCH] never store a model the picker cannot render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The selected model went blank after a turn: visible only by opening the dropdown. `officerModel` was coming back as `claude-code` — the bare harness. That is `DEFAULT_MODEL`, used when the client sends no model and the user has no default. It ROUTES fine (isClaudeModel is a prefix test, and split('/')[1] is undefined so the CLI picks its own), but it names no catalogue entry, so Radix's `SelectValue` matched no item and rendered nothing. It fed itself. A blank picker sends no model, so the next turn fell back to the bare harness again and overwrote the real value — it destroyed a correctly backfilled `claude-code/opus` within one turn. My own write path did that, added this afternoon. Two changes. The server refuses to record a model with no tier: a bare harness means "no explicit choice", and the honest response is to leave the previous answer alone rather than overwrite it with a non-answer. And the trigger no longer trusts that its value names an item — an unmatchable value renders as its own string instead of as an empty control, which also covers a retired model or a vanished OpenCode provider. Repaired the one corrupted row in place by deleting it, so the transcript family fallback answers for that session until its next real turn; it now reports claude-code/opus again. --- src/servers/api/chat/websocket.ts | 16 ++++++++++++++++ .../Chat/components/ModelSelector.test.tsx | 19 +++++++++++++++++++ .../apps/Chat/components/ModelSelector.tsx | 16 +++++++++++++++- 3 files changed, 50 insertions(+), 1 deletion(-) 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) => (