Conversation
There was a problem hiding this comment.
ℹ️ No correctness issues found — one readability nit inline, plus one gap worth a decision.
Reviewed changes — the claude-code adapter's served-model reconciliation, its two new adapter tests, and the AGENTS.md bullet documenting the rule.
syncModelre-qualifies the served id —advertisedModelId(claude-code.ts:603-610) prefixes an unqualified served id with the qualifier ofStartOptions.model, leaving an already-qualified served id and an unqualified pick untouched.- Two adapter tests — a qualified pick whose init + assistant frames echo the bare slug, and an unqualified pick that still reflects raw served ids across a mid-session change.
- One AGENTS.md bullet recording why the reconciliation exists and which accounts it skips.
I verified the claim that the first test fails without the fix: reverting line 600 to this.emitModel(model) produces a second model-update carrying claude-sonnet-5 and the assertion fails. The second test passes either way, which is the right shape for a control.
I also traced the paths where a wrong qualifier could leak and found none. onSetModel writes opts.model before the next syncModel (claude-code.ts:1056), so a Query rebuild, an effort-max transition, resumeHistory and branchHistory all read a qualifier matching the picker's current vocabulary. A reflected id is never persisted — the only writer to the run's model is recordAccepted → setRunIntent (engine/src/session/lifecycle-service.ts:438-447), gated on an accepted user input — so a fabricated qualified id cannot corrupt session state. And multi-segment ids are not producible: AccountModel.id is stored verbatim from the service's model list and nothing prepends a second qualifier, so the first-slash split is safe.
ℹ️ History replay still emits the raw slug, so a resumed thread splits old turns from new ones
The reconciliation lives only on the live path. createClaudeHistoryEventMapper (claude-code.ts:2107-2111) emits model-update straight from the transcript row's message.model, and nothing between the adapter and the screen normalises it. In a resumed gateway thread that means the pre-resume turn footers read claude-sonnet-5 while new turns read anthropic/claude-sonnet-5 — the same two-vocabulary display CODE-651 is about, now split by turn age rather than flipping over time.
This is cosmetic and the PR is a clear net improvement, so I am not treating it as blocking. But the mapper is a standalone exported function invoked on never-started adapter instances, so the picked id genuinely is not reachable there — which makes this a scope call rather than an oversight, and worth stating explicitly on the issue either way.
Technical details
# claude-code history replay emits the unreconciled served model
## Affected sites
- `packages/host/agent-adapter/src/native/claude-code.ts:2107-2111` — the history mapper pushes
`{type:'model-update', model}` with the raw transcript value; `advertisedModelId` is never applied.
- `packages/client/core/src/conversation-store.ts:156-178` — the seed folds before live events and
`model-update` is not in the pre-cut dedupe set, so the live qualified id wins for session-level
`currentModel`; the seeded ids still stamp the seeded messages.
- `packages/client/core/src/conversation.ts:328` — a message's `model` stamp is frozen from
`currentModel` at item creation, so seeded turns keep the bare slug permanently.
- `packages/presentation/ui/src/chat/turn-actions.tsx:51` — the stamp renders as a raw string; no
`resolveModel` lookup happens here (that is composer-only, `shell/composer.tsx:806`).
## Required outcome
Either a claude-code thread's replayed turns carry the same identifier vocabulary its live turns do,
or the decision to leave cold history in the vendor's vocabulary is recorded where the next reader
of this code will find it.
## Open questions for the human
- Is per-turn footer consistency in scope for CODE-651, or does it belong to whatever ends up owning
cold-history model display?
- If it is in scope, the picked id has to reach the mapper from outside — `readHistory` has
`this.opts` in scope at `claude-code.ts:707`, but a history read on a never-started instance does
not. Should the engine supply the session's recorded model to the mapper instead, so every agent's
replay is reconciled in one place rather than per adapter?ℹ️ Nitpicks
AGENTS.md:64calls this "same reconciliation as pi'sadvertisedModelId", but the two mechanisms differ: pi decides betweenmodel.idandprovider/idby comparing the model's provider to the account's (pi/adapter.ts:102-104), whereas this copies the picked id's qualifier onto whatever the vendor served. The goal is shared; the rule is not. Worth rewording so a future reader doesn't port the wrong half.
Claude Opus | 𝕏
| return qualifier ? `${qualifier}/${served}` : served; | ||
| } |
There was a problem hiding this comment.
The Math.max(0, …) + truthiness pair is doing indexOf sentinel handling in two steps — testing the index directly says the same thing in one, and slash > 0 also rules out the leading-slash case explicitly rather than by way of an empty string.
| return qualifier ? `${qualifier}/${served}` : served; | |
| } | |
| const slash = picked.indexOf('/'); | |
| return slash > 0 ? `${picked.slice(0, slash)}/${served}` : served; |

Closes CODE-651.
Problem
In a LinkCode Gateway–backed Claude Code session the displayed model flips between
anthropic/claude-sonnet-5andclaude-sonnet-5across turns: session/Query start emits the client's catalog id (provider-qualified, as the gateway's/v1/modelslists it), whilesyncModelre-broadcasts whatever the vendor echoes — and the gateway forwards the bare vendor slug upstream and never rewrites the response body, so init and every assistant frame carryclaude-sonnet-5. Each Query rebuild (effort-maxtransition, resume) flips it back.Fix
syncModelnow re-qualifies an unqualified served id with the qualifier ofStartOptions.modelbefore emitting, the same reconciliation pi'sadvertisedModelIdalready does — one stable identifier per session, always in the vocabulary the picker offers. A pick that carries no qualifier (raw Anthropic subscription / API-key accounts, where start and echo already agree) passes through untouched, and a served id that is already qualified is left alone.Tests
Two cases in
claude-code-effort.test.ts: a qualified pick whose init + assistant frames echo the bare slug emits exactly onemodel-update(the qualified id); an unqualified pick still reflects the raw served ids, including a mid-session change. The first fails without the fix.pnpm check:ciandpnpm testpass (3041 tests).Verification note
The end-to-end gateway session in the acceptance criteria needs CODE-646 (the gateway's native Anthropic route), which is not on master yet — no account on master can produce the qualified-pick × bare-echo divergence, so this branch is covered by the adapter-level tests above and should be re-checked live once CODE-646 lands.