From 71690d25cda3cc6cd45920d943f977539975bf4d Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Thu, 10 Sep 2026 15:22:55 +0800 Subject: [PATCH] fix(agent-adapter): reconcile claude-code served model with the picked id --- packages/host/agent-adapter/AGENTS.md | 1 + .../src/__tests__/claude-code-effort.test.ts | 63 +++++++++++++++++++ .../agent-adapter/src/native/claude-code.ts | 11 +++- 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index a796bcb8f..db3166a4a 100644 --- a/packages/host/agent-adapter/AGENTS.md +++ b/packages/host/agent-adapter/AGENTS.md @@ -61,6 +61,7 @@ Every new adapter MUST honor these (`base.ts`); downstream relies on them, they - **opencode**: `session.command` and `session.shell` are BLOCKING (no `*Async` variants exist) — fire-and-not-await like the TUI does, streaming rides the same SSE pipeline as prompts. Wire-level required fields the class wrappers type as optional: `command.arguments` and `shell.agent` (missing → HTTP 400). `session.command`'s `model` is a plain string, unlike prompt/shell's `{providerID, modelID}` object. Shell runs NO model call (the OpenAPI description "return the AI's response" is wrong — verified in upstream `shellImpl`); the shell agent name comes from `app.agents({directory})` (first `mode:'primary'`, else first, else literal `'build'`), and the HTTP-resolve settle backstop covers the unverified "does `session.status` fire for shell turns" gap. Busy sessions 409 (`SessionBusyError`) → turn failure. - **Streaming-input mode** (one persistent `Query` fed by an `AsyncMessageQueue`): the older single-message-per-turn + resume design silently ignored a changed model option on resume, so live model/permission/effort switching is ONLY possible in streaming mode — do not revert to `query()`+resume. Live switches: `Query#setModel`, `Query#setPermissionMode`, `Query#applyFlagSettings`. State is emitted only AFTER the CLI accepts a switch; a rejected one is not rolled back optimistically. +- **The served model is re-qualified into the picked id's vocabulary before it is emitted.** A gateway account picks provider-qualified ids (`anthropic/claude-sonnet-5`) while the gateway forwards the bare vendor slug upstream and never rewrites the response, so init/assistant frames echo `claude-sonnet-5`; re-broadcasting both makes the displayed model flip-flop across turns and Query rebuilds, and the picker cannot match the raw form. `syncModel` therefore prefixes an unqualified served id with the qualifier of `StartOptions.model` (same reconciliation as pi's `advertisedModelId`); an unqualified pick — raw Anthropic subscription/API-key accounts — passes through untouched. - **Thinking is API-redacted by default — the adapter must request summaries** (CODE-273): on Opus 4.6+ models `thinking.display` defaults to `'omitted'`, so thinking blocks/deltas arrive with EMPTY text (signature only) and `emitThought`'s empty-text guard drops them — no thought ever reaches the UI, while transcripts store the same empty text (pre-fix history is unrecoverable). The TUI sees thinking only because interactive mode requests summaries (`showThinkingSummaries`). The adapter passes `extraArgs: {'thinking-display': 'summarized'}` — the raw flag, NOT the typed `options.thinking`, which would pin `--thinking adaptive` and override the CLI's per-model thinking resolution (verified live on the 0.3.206 × 2.1.212 pair; a detected CLI predating the flag fails loudly at spawn — nothing screens for that until the CODE-77 compat manifest lands). - **Approval policies** (CODE-78) map 1:1 onto the SDK `PermissionMode` in Claude Desktop's menu order: `default` (Ask), `acceptEdits` (Accept edits), `plan` (Plan mode), `auto` (Auto mode), `bypassPermissions` (Bypass). The SDK's `dontAsk` tier is deliberately off the menu. Claude models permissions and plan as ONE axis, so `plan` rides the approval-policy channel (not the generic set-mode axis codex uses). approval-policy is a locked, orthogonal SECOND axis; the engine caches the latest state and replays it on `session.attach` — without the replay the menu vanishes after reconnect. - **Startup permissionMode trap**: the SDK-driven CLI pins startup `permissionMode` to `'default'` unless `options.permissionMode` is passed — unlike the interactive CLI it does NOT read `settings.json` `permissions.defaultMode` itself (verified on the 0.3.179 CLI even with explicit `settingSources`). The adapter resolves the default via `settingsDefaultMode(cwd)` (`.claude/settings.local.json` > `.claude/settings.json` > `~/.claude/settings.json`) and passes it as `options.permissionMode` when the Query is built — never hardcode a default. `allowDangerouslySkipPermissions:true` must ALWAYS be set at startup: it is only the gate for `--allow-dangerously-skip-permissions`, and a later live switch to Bypass is rejected if the gate was off. diff --git a/packages/host/agent-adapter/src/__tests__/claude-code-effort.test.ts b/packages/host/agent-adapter/src/__tests__/claude-code-effort.test.ts index 3e8331601..7eca5eb7f 100644 --- a/packages/host/agent-adapter/src/__tests__/claude-code-effort.test.ts +++ b/packages/host/agent-adapter/src/__tests__/claude-code-effort.test.ts @@ -673,6 +673,69 @@ describe('ClaudeCodeAdapter model/effort reflection', () => { }); }); + it('reports the served model in the vocabulary the client picked from', async () => { + const adapter = new ClaudeCodeAdapter(); + const events: AgentEvent[] = []; + adapter.onEvent((event) => events.push(event)); + await adapter.start({ + kind: 'claude-code', + cwd: '/tmp/repo', + model: 'anthropic/claude-sonnet-5', + }); + await prompt(adapter); + // A gateway forwards the bare vendor slug upstream and never rewrites the response, so the + // real Anthropic echo carries the unprefixed id the picker cannot match. + queries[0].push({ + type: 'system', + subtype: 'init', + permissionMode: 'default', + model: 'claude-sonnet-5', + }); + queries[0].push({ + type: 'assistant', + parent_tool_use_id: null, + uuid: 'uuid-1', + message: { + model: 'claude-sonnet-5', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'Read', input: {} }], + }, + }); + await vi.waitFor(() => { + expect(events.some((event) => event.type === 'tool-call')).toBe(true); + }); + + expect(events.filter((event) => event.type === 'model-update')).toEqual([ + { type: 'model-update', model: 'anthropic/claude-sonnet-5' }, + ]); + await adapter.stop(); + }); + + it('leaves the served model alone for an account whose ids are already unprefixed', async () => { + const adapter = new ClaudeCodeAdapter(); + const events: AgentEvent[] = []; + adapter.onEvent((event) => events.push(event)); + await adapter.start({ kind: 'claude-code', cwd: '/tmp/repo', model: 'claude-sonnet-5' }); + await prompt(adapter); + queries[0].push({ + type: 'assistant', + parent_tool_use_id: null, + uuid: 'uuid-1', + message: { + model: 'claude-opus-4-8', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'Read', input: {} }], + }, + }); + await vi.waitFor(() => { + expect(events.some((event) => event.type === 'tool-call')).toBe(true); + }); + + expect(events.filter((event) => event.type === 'model-update')).toEqual([ + { type: 'model-update', model: 'claude-sonnet-5' }, + { type: 'model-update', model: 'claude-opus-4-8' }, + ]); + await adapter.stop(); + }); + it('reconciles the displayed effort with what the Stop hook says actually ran', async () => { const { adapter, events } = await makeAdapter(); await prompt(adapter); diff --git a/packages/host/agent-adapter/src/native/claude-code.ts b/packages/host/agent-adapter/src/native/claude-code.ts index b77c19372..e3c2eb0db 100644 --- a/packages/host/agent-adapter/src/native/claude-code.ts +++ b/packages/host/agent-adapter/src/native/claude-code.ts @@ -597,7 +597,16 @@ export class ClaudeCodeAdapter extends BaseAgentAdapter { /** Reflect the served model the CLI reports (init message + every assistant frame) so the client * shows the true model even when the session started without a requested one. */ private syncModel(model: string | undefined): void { - if (model) this.emitModel(model); + if (model) this.emitModel(this.advertisedModelId(model)); + } + + /** The served id in the client's own vocabulary: a gateway account picks provider-qualified ids + * while the vendor echoes its bare slug, and emitting both leaves the picker unable to match. */ + private advertisedModelId(served: string): string { + const picked = this.opts?.model; + if (picked === undefined || served.includes('/')) return served; + const qualifier = picked.slice(0, Math.max(0, picked.indexOf('/'))); + return qualifier ? `${qualifier}/${served}` : served; } /** Read-only `Stop` hook: learns the CLI's *resolved* effort after any per-model downgrade. The