create_session picks the wrong provider when two agents advertise the same model
- Dominant language
- TypeScript
- Stars
- 193k
- Forks
- 42.4k
- PR merge metrics
- PR metrics pending
Description
When an agent calls the `create_session` host tool and asks for a model by name, the new session can end up on the wrong agent.
- Ask a **Claude** session to spawn a child using "Claude Opus 4.6" → you get a **Copilot** session.
- Ask a **Codex** session for a Codex model → the call **throws**.
There are two separate bugs here. Fixing the first one does not fix the second.
---
## Bug 1: the model list forgets who owns each model
`getModels()` (`src/vs/platform/agentHost/node/agentService.ts:836-842`) mashes every agent's models into one flat list, in the order the agents were registered. Copilot is registered first — `agentHostMain.ts:245` (Copilot), `:260` (Claude), `:276` (Codex).
`resolveModel()` (`src/vs/platform/agentHost/node/shared/sessionServerTools.ts:311-320`) then grabs the **first** entry whose `id` or `name` matches. Copilot always wins.
```mermaid
flowchart TD
A["Claude session calls create_session
model: 'Claude Opus 4.6'"] --> B["getModels()
agentService.ts:836"]
B --> C["Flatten every agent into ONE list,
in registration order"]
C --> D["1. copilotcli models
id: claude-opus-4.6
name: Claude Opus 4.6"]
C --> E["2. claude models
id: @provider=copilot:claude-opus-4.6
name: Claude Opus 4.6"]
C --> F["3. codex models"]
D --> G["resolveModel takes the FIRST match
on id OR name
sessionServerTools.ts:315"]
E -.->|never reached| G
G --> H["provider = model.provider = 'copilotcli'
sessionServerTools.ts:601"]
H --> I["Child session runs on Copilot ❌"]
```
The last step is `sessionServerTools.ts:601`:
```ts
const provider = args.model?.provider ?? defaults?.provider;
```
The model's own `provider` field beats the calling session's provider. So the child gets stamped `copilotcli`.
### It's the **name** that collides now, not the id
The original report said the model **ids** collide. That was true when it was written (Aug 4). It isn't anymore:
- Codex started prefixing its ids on Aug 4 (`a12a57df529`) → `@provider=vscode-proxy:gpt-5.1`
- Claude started prefixing its ids **today** (`511fd2d2ee4`, 08:29Z) → `@provider=copilot:claude-opus-4.6`
Both only rewrote `id`. Neither touched `name` (`claudeModelSelection.ts:166-171`). And `resolveModel` matches on **either** (`sessionServerTools.ts:315`):
```ts
models.find(candidate => candidate.id === modelName || candidate.name === modelName)
```
Copilot's entry matches on both id and name. Claude's matches on name. Copilot is first, so Copilot wins.
| Agent asks for | Copilot entry (wins) | Real owner (loses) |
|---|---|---|
| `claude-opus-4.6` / "Claude Opus 4.6" | id **and** name → `copilotcli` | Claude — name only |
| `gpt-5.1` / "GPT-5.1" | id **and** name → `copilotcli` | Codex — name only |
| Claude native (BYO-Anthropic) models | no Copilot entry | resolves fine ✅ |
**This is not an Anthropic-only problem.** Codex re-advertises Copilot's OpenAI models too (`codexAgent.ts:1366-1385`), so `gpt-5.1` collides exactly the same way.
Worth noting: in the E2E stub, `name` is set to the id (`capiStubs.ts:75`), so under test every name is also an id and the collision surface is at its widest.
---
## Bug 2: Codex's models don't carry a real agent id at all
This one is nastier and easy to miss.
`IAgentModelInfo.provider` is supposed to mean "which agent owns this model", and it's used for routing. That's documented in `agentModelSource.ts:36-44` and `claudeModelSelection.ts:158-165`. Claude follows the rule — its models keep `provider: 'claude'`, and the picker-grouping token goes into `_meta.modelGroupId` instead.
Codex doesn't follow it:
- `codexAgent.ts:1370` → `provider: 'copilot'`
- `codexAgent.ts:1421` → `provider: pickerProvider` (`'openai'`, `'chatgpt'`, or whatever the codex config says)
None of those are registered agents. The registered ones are `copilotcli`, `claude`, `codex`.
So even with **no collision at all** — a unique, fully-qualified Codex model id — the call still dies:
```mermaid
flowchart TD
A["Codex session asks for
@provider=openai:gpt-5.1-codex
(unique — no collision)"] --> B["resolveModel finds the RIGHT model ✅"]
B --> C["model.provider = 'openai'
codexAgent.ts:1421"]
C --> D["copied into the session config
sessionServerTools.ts:601"]
D --> E["createSession looks up 'openai'
agentService.ts:1200-1204"]
E --> F["Registered agents:
copilotcli · claude · codex"]
F --> G["💥 Error: No agent provider
registered for: openai"]
```
**Fixing Bug 1 does not fix this.** The "match on (provider, id)" idea from the original writeup would still copy `'openai'` into the config.
Small related nit: the doc comment on `CLAUDE_PROVIDER_COPILOT` in `claudeProviders.ts` still claims the token gets stamped onto `IAgentModelInfo.provider`. `mergeClaudeModelCatalogs` deliberately does the opposite. The comment is stale.
---
## `create_chat` has the same bug, just quieter
`create_chat` uses the same `resolveModel` (`sessionServerTools.ts:669`). It doesn't stamp a provider — it uses the target session's (`:688`) — but it does forward the wrong model id (`:689`).
So a Claude session's `create_chat` asking for "Claude Opus 4.6" gets handed Copilot's bare `claude-opus-4.6`. `parseClaudeModelSelection` (`claudeModelSelection.ts:48-51`) reads a bare id as legacy and quietly falls back to the default transport instead of the one that was picked. Same root cause, same fix.
---
## The E2E test is broken too, for its own reasons
Test: `server tool: create_session materializes a selected-model child session and starts its prompt` (`serverToolsSuite.ts:673-716`), gated behind `supportsProviderModelSessionCreation` (`:81`, copilotcli-only).
Two problems, both caused by the id-prefixing described above:
1. `:680` does `models.find(m => m.id === 'claude-opus-4.6')` inside the calling agent's own model list. For Claude and Codex nothing has that bare id anymore, so `assert.ok(model)` at `:681` fails **before `create_session` is ever called**.
2. `:714` asserts the child's wire model equals `model.id`. For a Claude session the wire id is the SDK-normalized bare id (`toSdkModelId`, `claudeModelId.ts:66-71`), not the `@provider=…` selection id.
So fixing the product bug will **not** turn this test green on its own. The test needs updating before the gate can lift.
---
## Proposed fix
```mermaid
flowchart TD
subgraph AFTER["✅ Proposed"]
A2["provider.models.get()"] --> B2["list of { owner, model }
owner kept"]
B2 --> C2["prefer the caller's provider on a tie,
then an optional 'provider' argument"]
C2 --> D2["use owner
— always a real registered agent"]
end
subgraph BEFORE["❌ Today"]
A1["provider.models.get()"] --> B1["flat list
owner thrown away"]
B1 --> C1["first match wins"]
C1 --> D1["use model.provider
— may be wrong, or not an agent at all"]
end
```
1. **Keep the owner.** Have `getModels()` return `{ owner, model }` pairs and make `sessionServerTools.ts:601` read `owner` instead of `model.provider`. This fixes the misroute **and** the Codex throw in one go, without touching how Codex stamps its models. The nesting already exists for root state (`agentSideEffects.ts:355-377`) — `getModels()` is just throwing it away.
2. **Prefer the caller's provider when several models match.** `defaults?.provider` (`agentService.ts:873`) is already the calling session's provider. Cheapest and highest-value change: no schema change, fully backward compatible (a Copilot session asking for `claude-opus-4.6` still gets Copilot's).
3. **Add an optional `provider` to the tool schema** (`sessionServerTools.ts:67` and `:83`) for the cross-provider case — e.g. a Claude session spawning a Codex child. Today there's no way to name a provider at all. Also worth making the error at `:317` say what's ambiguous.
4. **Fix Codex's stamping** (`codexAgent.ts:1370`, `:1421`) to `provider: this.id` plus `createAgentModelGroupMeta(pickerProvider)` in `_meta`, mirroring what `mergeClaudeModelCatalogs` does. Safe — the picker reads `_meta` first (`agentHostLanguageModelProvider.ts:171-173`), so grouping doesn't move. Checked: no workbench code references `'openai'` / `'chatgpt'` / `'vscode-proxy'` as vendor tokens.
5. **Update the E2E test**, then lift the gate.
Steps 1–3 are the product fix. Steps 4–5 are cleanup and test work and could land separately.
---
## Does the gate lift?
- **Claude** — yes, after steps 1–2 (product) plus step 5 (test).
- **Codex** — not fully. `KNOWN_ISSUES.md:320` names a second symptom: Codex runs `create_session` without surfacing its required pending confirmation (asserted at `serverToolsSuite.ts:711`). That's unrelated to model resolution and needs its own fix.
---
## Files involved
| File | What's there |
|---|---|
| `src/vs/platform/agentHost/node/shared/sessionServerTools.ts` | `resolveModel` `:311-320`, schema `:67` / `:83`, provider stamp `:601` |
| `src/vs/platform/agentHost/node/agentService.ts` | `getModels` flatten `:836-842`, provider lookup `:1200-1204` |
| `src/vs/platform/agentHost/node/agentHostMain.ts` | registration order `:245` / `:260` / `:276` |
| `src/vs/platform/agentHost/node/codex/codexAgent.ts` | wrong provider stamp `:1370`, `:1421` |
| `src/vs/platform/agentHost/node/claude/claudeModelSelection.ts` | id prefixing `:166-171`, the rule this is all based on `:158-165` |
| `src/vs/platform/agentHost/node/copilot/copilotAgent.ts` | CAPI catalog stamp `:1911-1913` |
| `src/vs/platform/agentHost/test/node/e2e/suites/serverToolsSuite.ts` | gate `:81`, test `:673-716` |
| `src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md` | entry at `:315-330` |
Repro (per `KNOWN_ISSUES.md`):
```bash
AGENT_HOST_REPLAY_RECORD=1 ./scripts/test-integration.sh --run \
src/vs/platform/agentHost/test/node/e2e/providers/claudeAgentHostE2E.integrationTest.ts \
--grep "server tool: create_session materializes"
```
Line numbers are against `28a37ffe0f3`.
Contributor guide
Assessment
This issue has not been assessed yet.