The composer showed the bare harness "claude-code" instead of the model. Root
cause: NewChat called useChat(undefined, locationState?.model). location.state
is only set by an in-app navigation, so on a refresh or a deep link it is empty
and the model argument was undefined. That is what useChat seeds selectedModel
from, so the composer sent NO model — confirmed live in the log as
"Model selected for chat … clientModel=null" on the owner's own session. The
server then fell back to DEFAULT_MODEL, the bare harness, which names no tier,
so the picker rendered the raw string.
The same file already knew this. Twelve lines down,
initialModel={props.model ?? locationState?.model} carries a comment saying
locationState.model "is written by nothing, so it was the only source and always
undefined on resume". That was applied to the child prop and missed on the
useChat call above it — the call that decides what gets sent. props.model is
detail.officerModel, correct and populated on every refresh.
Why it looked random: clicking a session from the list carries location state,
so the picker was right; only a refresh lost it. And it never healed, because no
model sent means the server re-derives the bare harness and record() rightly
refuses to store a tierless one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: f7a62308-0977-4cf4-aa29-c020853fd420
89 lines
5.9 KiB
Markdown
89 lines
5.9 KiB
Markdown
---
|
|
id: known-bugs/the-model-picker-shows-the-bare-harness
|
|
title: 'The model picker shows `claude-code` instead of the model that is running'
|
|
theme: known-bugs
|
|
status: open
|
|
created: 2026-08-25
|
|
date_source: stated
|
|
order: 5
|
|
---
|
|
|
|
**The model picker shows `claude-code` — the bare harness — instead of a model.** Reported by the owner
|
|
2026-08-25 with a screenshot: the composer's harness pill reads "Claude Code" (correct) and the model
|
|
dropdown beside it reads `claude-code` (useless). He reports it happens often, both mid-turn and idle, and
|
|
cannot reproduce it deliberately. What he wants is simple and worth quoting: **the dropdown should always
|
|
show either the running model, or the model last used when the conversation finished.**
|
|
|
|
**Where the raw string comes from.** `ModelSelector.tsx:329` deliberately prints the raw id when the
|
|
effective model matches no catalogue entry — the alternative is a blank control, and the comment there
|
|
already names `claude-code` as one of the ids that reaches it. So the picker is reporting honestly; the
|
|
question is why it is being handed a harness with no tier.
|
|
|
|
Three sources of a bare `claude-code`, all verified in the tree:
|
|
|
|
1. **`DEFAULT_MODEL` is `claude-code`** (`websocket.ts:48`), used when the client names no model and the
|
|
user has no default (`websocket.ts:829`).
|
|
2. **Every adopted session** — `adoptOrphanedSession(ws, sessionId, DEFAULT_MODEL, '')`
|
|
(`websocket.ts:1631`), the path a browser refresh takes when officer no longer holds the session. That
|
|
record's model is then pinned for its life and sent to the client as `session:init.model`. Observed
|
|
directly in the boot log after a `pm2 restart officer`: two of three adopted sessions logged
|
|
`model=claude-code`, one `model=claude-code/sonnet`.
|
|
3. **It cannot self-heal.** `record()` refuses to store a model that names no tier
|
|
(`websocket.ts:471`, and the comment there explains why: storing it would render blank and then feed
|
|
itself). So a session with no `chat_session_models` row sends no model, the server re-derives the bare
|
|
harness, and nothing ever writes a real one. Sixteen rows exist for a great many transcripts.
|
|
|
|
**The event that would fix it already exists and is thrown away.** `model:resolved` carries exactly this
|
|
answer — the harness's own resolved model, mapped through `officerModelForApiModel` — and the client
|
|
already handles it as display-only, deliberately not touching `selectedModel`
|
|
(`useChat.ts:645`, and the comment is right that it must not invent a choice). It is emitted
|
|
`durable: false` (`turn-stream.ts:163`), so **a refresh never receives it**. That is the shape of the fix:
|
|
on `attach`, tell the client the session's resolved model the same way, rather than leaving it on the bare
|
|
default. Display-only, no choice invented, no new vocabulary.
|
|
|
|
---
|
|
|
|
**One half fixed 2026-08-25: the dashboard chat panel was seeding the picker from the wrong id space.**
|
|
`ChatPanelWrapper.tsx` passed `detail.model` where `/chat` and Command HQ both pass `detail.officerModel`
|
|
— and both of those carry a long comment saying exactly why. `detail.model` is what the API served
|
|
(`claude-fable-5`); the picker offers Officer ids (`claude-code/fable`). The server already resolves the
|
|
right one, falling back to a family guess from the transcript when no row is stored
|
|
(`chat.ts:248`), so the panel was discarding a correct answer and showing a raw API id.
|
|
|
|
Worth more than the label: that value is **sent back**. `isClaudeModel` is `startsWith('claude-code')`
|
|
(`websocket.ts:66`), so `claude-fable-5` fails it and the turn dispatches to **OpenCode**. The clamp above
|
|
it only fires when officer still holds the session in memory (`websocket.ts:845`) — which is precisely not
|
|
the case after a restart, the same condition that produces the bare harness. Not observed in the wild, and
|
|
filed here rather than separately because it is the same one-line cause.
|
|
|
|
---
|
|
|
|
**Root cause found and fixed 2026-08-25 — it was `/chat`, and one argument.**
|
|
|
|
`ChatDetailPanel`'s `NewChat` called `useChat(undefined, locationState?.model, …)`. `location.state` is
|
|
only populated by an in-app navigation, so **on a refresh or a deep link it is empty** and the model
|
|
argument was undefined. That is the value `useChat` seeds `selectedModel` from, so the composer sent no
|
|
model at all — confirmed in the live log, `Model selected for chat … clientModel=null`, on the owner's own
|
|
session while he was looking at it. The server then took source 1 above, `DEFAULT_MODEL`, and the bare
|
|
harness reached the picker.
|
|
|
|
The same file already knew this. Twelve lines below, `initialModel={props.model ?? locationState?.model}`
|
|
carries a comment saying `locationState.model` "is written by nothing, so it was the only source and always
|
|
undefined on resume". That fix was applied to the child prop and missed on the `useChat` call above it —
|
|
and the `useChat` call is the one that decides what gets SENT. `props.model` is `detail.officerModel`,
|
|
correct and populated on every refresh; it was simply not passed.
|
|
|
|
Why it looked random: clicking a session in the list carries location state, so the picker was right. Only
|
|
a **refresh** — or a pasted link — lost it. The owner's screenshot shows the session list reading `opus`
|
|
for the same conversation whose composer read `claude-code`, which is exactly that split: the list has its
|
|
own data, the composer had an empty `location.state`.
|
|
|
|
This also explains why it never healed. No model sent → server falls back to the bare harness → `record()`
|
|
refuses to store a tierless model → still no row → next turn identical.
|
|
|
|
**Still open, and worth doing anyway:** sources 2 and 3. An adopted session is still pinned to
|
|
`DEFAULT_MODEL` for its life, so `session:init` still reports a bare harness to any client that has nothing
|
|
better — this fix means `/chat` now always has something better, rather than making the server's answer
|
|
honest. The `attach` remedy above (answer with `model:resolved`, which is already display-only on the
|
|
client) is the fix for that, and it is independent of this one.
|