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) => (