Skip to content

fix(agent-adapter): reconcile claude-code served model with the picked id - #530

Open
xiaoland wants to merge 1 commit into
masterfrom
yihong/code-651
Open

xiaoland wants to merge 1 commit into
masterfrom
yihong/code-651

Conversation

@xiaoland

Copy link
Copy Markdown
Member

Closes CODE-651.

Problem

In a LinkCode Gateway–backed Claude Code session the displayed model flips between anthropic/claude-sonnet-5 and claude-sonnet-5 across turns: session/Query start emits the client's catalog id (provider-qualified, as the gateway's /v1/models lists it), while syncModel re-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 carry claude-sonnet-5. Each Query rebuild (effort-max transition, resume) flips it back.

Fix

syncModel now re-qualifies an unqualified served id with the qualifier of StartOptions.model before emitting, the same reconciliation pi's advertisedModelId already 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 one model-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:ci and pnpm test pass (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.

Copilot AI lite review requested due to automatic review settings September 10, 2026 07:23
@linear-code

linear-code Bot commented Sep 10, 2026

Copy link
Copy Markdown

CODE-651

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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.

  • syncModel re-qualifies the served idadvertisedModelId (claude-code.ts:603-610) prefixes an unqualified served id with the qualifier of StartOptions.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 recordAcceptedsetRunIntent (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:64 calls this "same reconciliation as pi's advertisedModelId", but the two mechanisms differ: pi decides between model.id and provider/id by 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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment on lines +609 to 610
return qualifier ? `${qualifier}/${served}` : served;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
return qualifier ? `${qualifier}/${served}` : served;
}
const slash = picked.indexOf('/');
return slash > 0 ? `${picked.slice(0, slash)}/${served}` : served;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants