Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/host/agent-adapter/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 10 additions & 1 deletion packages/host/agent-adapter/src/native/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +609 to 610

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;


/** Read-only `Stop` hook: learns the CLI's *resolved* effort after any per-model downgrade. The
Expand Down
Loading