From 3896083603fd3b3c9865e8e2c924a4e627db2e1c Mon Sep 17 00:00:00 2001 From: danljungstrom Date: Sat, 8 Aug 2026 21:58:41 +0000 Subject: [PATCH 01/11] refactor(claude-models): give the Claude model list a single owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude built its model list twice: the new-session preflight probe adapter and the in-session sessionModelsV1 publisher, which rebuilt it from AGENT_MODEL_CONFIG. Two producers of the same concept can disagree about which models exist and which effort tiers they support. Introduce one owner (backends/claude/models/resolveClaudeModelCatalog): the curated catalog augmented with whatever the Anthropic Models API reports. Both producers read it. Curated entries keep their hand-authored labels and effort defaults, dated snapshot ids collapse onto their static alias, and models from a generation below the curated floor are dropped — the Models API lists everything the account may call, including generations Claude Code can no longer run. Any failure falls back to the curated catalog. Results are cached per resolved account config dir + endpoint + ambient-credential fingerprint, so a session start does not pay a network round trip and a credential swap is not served a previous account's list. Credentials belong to the endpoint they are sent to. The catalog honors ANTHROPIC_BASE_URL, because Happier's built-in Z.AI, DeepSeek, and MiniMax Claude profiles point it at a gateway and pair it with a gateway-issued ANTHROPIC_AUTH_TOKEN; the on-disk Claude Code subscription token is Anthropic-only and is never sent to a third-party gateway. Credential precedence mirrors isolateClaudeRuntimeAuthEnv: for a bound session the ambient auth env keys the spawn would strip are ignored, keeping only ANTHROPIC_API_KEY for the anthropic service. Reading a key the spawn deletes would describe one account for a session that runs as another. The probe adapter is typed as a session controls probe adapter — the shape the caller actually invokes — so it no longer drops connectedServices, and the catalog entry partitions the probe cache by binding. Effort and ultracode require evidence of support rather than trusting an unrecognised model id. reasoningEffort is session-scoped and is not cleared when the model changes, so an unknown id alone is not a reason to forward a carried level. The selected model's reported tiers are resolved once when the mode is built and travel on it as modelEffortLevels, so spawn-time resolution and launch-option hashing see the same value and hashing stays a pure function of the mode. The request is clamped to those tiers; with no tiers nothing is sent. A discovered id that merely contains a curated alias uses its own reported tiers, not the alias table. Curated models keep their static table, so Haiku still never receives --effort or ultracode, and neither does a session with no model selected. Claude stays on dynamicProbe: 'static-only'. Turning the app onto the dynamic model path is a separate, user-visible change and lands on its own. --- apps/cli/src/backends/claude/claudeRemote.ts | 2 + .../backends/claude/cli/terminalOptions.ts | 2 + ...olveClaudeConnectedServiceStableAuthDir.ts | 15 +- apps/cli/src/backends/claude/index.ts | 6 + apps/cli/src/backends/claude/loop.ts | 9 + .../models/resolveClaudeModelCatalog.test.ts | 175 ++++++++ .../models/resolveClaudeModelCatalog.ts | 337 +++++++++++++++ .../preflight/anthropicModelsFetch.test.ts | 128 ++++++ .../claude/preflight/anthropicModelsFetch.ts | 169 ++++++++ .../claudePreflightModelsProbeAdapter.test.ts | 404 +++++++++++------- .../claudePreflightModelsProbeAdapter.ts | 48 ++- .../deriveDiscoveredClaudeModel.test.ts | 93 ++++ .../preflight/deriveDiscoveredClaudeModel.ts | 84 ++++ .../claude/remote/claudeRemoteAgentSdk.ts | 8 +- .../src/backends/claude/remote/modeHash.ts | 6 + apps/cli/src/backends/claude/runClaude.ts | 56 +++ .../resolveClaudeSessionModelsState.ts | 11 +- .../runtimeControlIntegration.ts | 6 +- .../claude/utils/claudeEffort.test.ts | 66 +++ .../src/backends/claude/utils/claudeEffort.ts | 80 +++- .../probes/resolveAgentProbeVariant.test.ts | 53 +++ docs/agents-catalog.md | 38 ++ packages/agents/src/index.ts | 2 + 23 files changed, 1611 insertions(+), 187 deletions(-) create mode 100644 apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts create mode 100644 apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts create mode 100644 apps/cli/src/backends/claude/preflight/anthropicModelsFetch.test.ts create mode 100644 apps/cli/src/backends/claude/preflight/anthropicModelsFetch.ts create mode 100644 apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.test.ts create mode 100644 apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.ts create mode 100644 apps/cli/src/capabilities/probes/resolveAgentProbeVariant.test.ts diff --git a/apps/cli/src/backends/claude/claudeRemote.ts b/apps/cli/src/backends/claude/claudeRemote.ts index 15aad0ba41..1e3a8306a9 100644 --- a/apps/cli/src/backends/claude/claudeRemote.ts +++ b/apps/cli/src/backends/claude/claudeRemote.ts @@ -36,6 +36,7 @@ import { isClaudeLegacyRequiredHookObservationFailure } from './remote/runtimeAc function buildClaudeEffortArgs(params: Readonly<{ modelId: unknown; effort: unknown; + supportedLevels?: readonly string[]; }>): string[] { return buildClaudeEffortCliArgs(params); } @@ -227,6 +228,7 @@ export async function claudeRemote(opts: { const effortArgs = buildClaudeEffortArgs({ modelId: argOverrides.model ?? initial.mode.model, effort: argOverrides.effort ?? initial.mode.reasoningEffort, + supportedLevels: initial.mode.modelEffortLevels, }); const extraArgs = [ ...(opts.hookPluginDir ? ['--plugin-dir', opts.hookPluginDir] : []), diff --git a/apps/cli/src/backends/claude/cli/terminalOptions.ts b/apps/cli/src/backends/claude/cli/terminalOptions.ts index 6c138957de..dec591e416 100644 --- a/apps/cli/src/backends/claude/cli/terminalOptions.ts +++ b/apps/cli/src/backends/claude/cli/terminalOptions.ts @@ -192,6 +192,7 @@ export function resolveClaudeTerminalCliOptions(params: Readonly<{ extraArgs.push(...buildClaudeEffortCliArgs({ modelId: effectiveModel, effort: argOverrides.effort ?? params.mode.reasoningEffort, + supportedLevels: params.mode.modelEffortLevels, })); if (effectiveModel) { extraArgs.push('--model', effectiveModel); @@ -233,6 +234,7 @@ export function resolveClaudeTerminalCliOptions(params: Readonly<{ ultracodeEnabled: resolveClaudeUltracodeForModel({ modelId: effectiveModel, ultracode: params.mode.ultracode, + supportedLevels: params.mode.modelEffortLevels, }), diagnostics: Object.freeze([...diagnostics]), }); diff --git a/apps/cli/src/backends/claude/connectedServices/resolveClaudeConnectedServiceStableAuthDir.ts b/apps/cli/src/backends/claude/connectedServices/resolveClaudeConnectedServiceStableAuthDir.ts index afbcbd118b..adde0151a4 100644 --- a/apps/cli/src/backends/claude/connectedServices/resolveClaudeConnectedServiceStableAuthDir.ts +++ b/apps/cli/src/backends/claude/connectedServices/resolveClaudeConnectedServiceStableAuthDir.ts @@ -10,6 +10,17 @@ import { export type ClaudeConnectedServiceId = 'claude-subscription' | 'anthropic'; +/** + * The part of a resolved selection that decides the stable directory. + * + * `ConnectedServiceResolvedSelection` satisfies this structurally. Callers that only know the + * binding (e.g. the models probe, which never materializes credentials) can pass the narrow shape + * instead of synthesizing a credential record. + */ +export type ClaudeConnectedServiceStableDirSelection = + | Readonly<{ kind: 'profile'; profileId: string }> + | Readonly<{ kind: 'group'; groupId: string }>; + function readClaudeConnectedServiceId(value: ConnectedServiceId): ClaudeConnectedServiceId | null { return value === 'claude-subscription' || value === 'anthropic' ? value : null; } @@ -18,7 +29,7 @@ export function resolveClaudeConnectedServiceStableRootDir(params: Readonly<{ activeServerDir: string; serviceId: ConnectedServiceId; fallbackProfileId: string; - selection: ConnectedServiceResolvedSelection | null | undefined; + selection: ConnectedServiceResolvedSelection | ClaudeConnectedServiceStableDirSelection | null | undefined; }>): string | null { const serviceId = readClaudeConnectedServiceId(params.serviceId); if (!serviceId) return null; @@ -41,7 +52,7 @@ export function resolveClaudeConnectedServiceStableConfigDir(params: Readonly<{ activeServerDir: string; serviceId: ConnectedServiceId; fallbackProfileId: string; - selection: ConnectedServiceResolvedSelection | null | undefined; + selection: ConnectedServiceResolvedSelection | ClaudeConnectedServiceStableDirSelection | null | undefined; }>): string | null { const rootDir = resolveClaudeConnectedServiceStableRootDir(params); return rootDir ? join(rootDir, 'claude-config') : null; diff --git a/apps/cli/src/backends/claude/index.ts b/apps/cli/src/backends/claude/index.ts index 4add470739..9e8af056db 100644 --- a/apps/cli/src/backends/claude/index.ts +++ b/apps/cli/src/backends/claude/index.ts @@ -14,9 +14,11 @@ import { applyClaudeSharedGroupGenerationApplication } from '@/backends/claude/c import { claudeSubscriptionQuotaFetcherDescriptor } from '@/backends/claude/connectedServices/quotaFetcher'; import { claudeDaemonSpawnHooks } from '@/backends/claude/daemon/spawnHooks'; import { buildClaudeRuntimeLocalHandoffMetadata } from '@/backends/claude/sessionHandoff/runtimeLocalMetadata'; +import { resolveClaudeProbeBindingIdentity } from '@/backends/claude/models/resolveClaudeModelCatalog'; import type { AgentCatalogEntry } from '../types'; import type { ConnectedServiceCredentialLifecycleDescriptor } from '@/daemon/connectedServices/credentials/lifecycleTypes'; + const claudeConnectedServiceCredentialLifecycleDescriptor: ConnectedServiceCredentialLifecycleDescriptor = { providerId: 'claude', serviceIds: AGENTS_CORE.claude.connectedServices.supportedServiceIds, @@ -122,6 +124,10 @@ export const agent = { .hasClaudeEndpointDescriptorForSession(params), vendorResumeSupport: AGENTS_CORE.claude.resume.vendorResume, buildRuntimeLocalHandoffMetadata: buildClaudeRuntimeLocalHandoffMetadata, + resolveModelsProbeVariant: ({ connectedServices }) => + // The models probe authenticates as the bound account, so its result is account-specific; + // sharing one cache entry would serve one account's model list to another. + `claude:${resolveClaudeProbeBindingIdentity(connectedServices)}`, getPreflightSessionControlsProbeAdapter: async () => (await import('@/backends/claude/preflight/claudePreflightModelsProbeAdapter')).claudePreflightModelsProbeAdapter, getHeadlessTmuxArgvTransform: async () => (await import('@/backends/claude/startup/headlessTmuxArgs')).ensureClaudeHeadlessTmuxStartingModeArgs, } satisfies AgentCatalogEntry; diff --git a/apps/cli/src/backends/claude/loop.ts b/apps/cli/src/backends/claude/loop.ts index 983ace7d3d..27ca84b5ef 100644 --- a/apps/cli/src/backends/claude/loop.ts +++ b/apps/cli/src/backends/claude/loop.ts @@ -54,6 +54,15 @@ export interface EnhancedMode { * never `--effort` or the SDK `effort` option. Only honored on xhigh-capable models. */ ultracode?: boolean; + /** + * Effort tiers the selected model reports (Anthropic Models API), resolved once when the mode + * is built. + * + * Curated models carry their own static effort table; this supplies the same evidence for a + * discovered model. It is part of the mode — not read from a cache at spawn time — so + * launch-option hashing stays a pure function of the mode. + */ + modelEffortLevels?: readonly string[]; // Claude remote-mode (provider-scoped) settings forwarded via message meta. claudeRemoteAgentSdkEnabled?: boolean; diff --git a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts new file mode 100644 index 0000000000..f74dbed686 --- /dev/null +++ b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createEnvKeyScope } from '@/testkit/env/envScope'; + +import type { AnthropicModelEntry } from '@/backends/claude/preflight/anthropicModelsFetch'; + +const { fetchAnthropicModelsMock, readClaudeCodeNativeCredentialMock } = vi.hoisted(() => ({ + fetchAnthropicModelsMock: vi.fn<(...args: unknown[]) => Promise>(), + readClaudeCodeNativeCredentialMock: vi.fn<(...args: unknown[]) => Promise>(), +})); + +vi.mock('@/backends/claude/preflight/anthropicModelsFetch', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchAnthropicModels: fetchAnthropicModelsMock }; +}); + +vi.mock('@/backends/claude/connectedServices/nativeAuth/claudeCodeCredentialFile', () => ({ + readClaudeCodeNativeCredential: readClaudeCodeNativeCredentialMock, +})); + +import { buildClaudeEffortCliArgs } from '@/backends/claude/utils/claudeEffort'; +import { + resolveClaudeEffortLevelsFromModelDescriptor, + resolveClaudeModelCatalog, + resetClaudeModelCatalogCacheForTests, +} from './resolveClaudeModelCatalog'; + +const envKeys = [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_OAUTH_TOKEN', + 'CLAUDE_CODE_OAUTH_TOKEN', + 'ANTHROPIC_BASE_URL', +] as const; +let envScope = createEnvKeyScope(envKeys); + +function fullEffort(): AnthropicModelEntry['capabilities'] { + return { + effort: { + supported: true, + low: { supported: true }, + high: { supported: true }, + xhigh: { supported: true }, + }, + }; +} + +beforeEach(() => { + fetchAnthropicModelsMock.mockReset(); + readClaudeCodeNativeCredentialMock.mockReset(); + readClaudeCodeNativeCredentialMock.mockResolvedValue(null); + resetClaudeModelCatalogCacheForTests(); + envScope.restore(); + envScope = createEnvKeyScope(envKeys); +}); + +afterEach(() => { + envScope.restore(); + envScope = createEnvKeyScope(envKeys); +}); + +describe('resolveClaudeModelCatalog', () => { + it('merges discovered models into the curated catalog', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; + fetchAnthropicModelsMock.mockResolvedValue([ + { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: fullEffort() }, + ]); + + const models = await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + + expect(models.some((m) => m.id === 'claude-opus-9')).toBe(true); + expect(models.some((m) => m.id === 'claude-fable-5')).toBe(true); + }); + + it('serves a cached catalog instead of refetching, and partitions by connected account', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; + fetchAnthropicModelsMock.mockResolvedValue([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); + + await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(1); + + // A bound session ignores the ambient key (the spawn strips it) and reads the account's own + // on-disk credential — and must not read the unbound account's cached catalog. + readClaudeCodeNativeCredentialMock.mockResolvedValue({ + payload: { claudeAiOauth: { accessToken: 'sk-ant-oat01-profile-a', scopes: [] } }, + updatedAtMs: 0, + source: 'file', + }); + await resolveClaudeModelCatalog({ + timeoutMs: 1_000, + connectedServices: { + v: 1, + bindingsByServiceId: { + 'claude-subscription': { source: 'connected', selection: 'profile', profileId: 'profile-a' }, + }, + }, + }); + expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(2); + }); + + it('refetches when the ambient credential changes', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-key-one'; + fetchAnthropicModelsMock.mockResolvedValue([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); + await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(1); + + // Swapping the key without changing the config dir must not serve the previous key's list. + process.env.ANTHROPIC_API_KEY = 'sk-ant-key-two'; + await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(2); + }); + + it('carries the extended-context model id through to the picker rows', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; + fetchAnthropicModelsMock.mockResolvedValue(null); + + const models = await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + const sonnet = models.find((m) => m.id === 'claude-sonnet-4-6') ?? null; + + expect(typeof sonnet?.extendedContextModelId).toBe('string'); + }); + + it('falls back to the curated catalog when the fetch fails', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; + fetchAnthropicModelsMock.mockResolvedValue(null); + + const models = await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + + expect(models.length).toBeGreaterThan(0); + expect(models.some((m) => m.id === 'claude-fable-5')).toBe(true); + expect(models.some((m) => m.id === 'claude-opus-9')).toBe(false); + }); +}); + +describe('resolveClaudeEffortLevelsFromModelDescriptor', () => { + it('reads the reported tiers off a discovered model descriptor', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; + fetchAnthropicModelsMock.mockResolvedValue([ + { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: fullEffort() }, + ]); + + const models = await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + const discovered = models.find((m) => m.id === 'claude-opus-9') ?? null; + + expect(resolveClaudeEffortLevelsFromModelDescriptor(discovered)).toEqual(['low', 'high', 'xhigh']); + }); + + it('returns no tiers for a model without an effort control', () => { + expect(resolveClaudeEffortLevelsFromModelDescriptor(null)).toEqual([]); + expect(resolveClaudeEffortLevelsFromModelDescriptor({ id: 'x', name: 'X' })).toEqual([]); + }); +}); + +describe('effort tiers carried on the mode', () => { + it('clamps a carried effort to the tiers the resolved catalog reports', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; + fetchAnthropicModelsMock.mockResolvedValue([ + { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: fullEffort() }, + ]); + + const models = await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + const levels = resolveClaudeEffortLevelsFromModelDescriptor( + models.find((m) => m.id === 'claude-opus-9') ?? null, + ); + + expect(buildClaudeEffortCliArgs({ modelId: 'claude-opus-9', effort: 'xhigh', supportedLevels: levels })) + .toEqual(['--effort', 'xhigh']); + // Carried level above what the model reports clamps down instead of passing through. + expect(buildClaudeEffortCliArgs({ modelId: 'claude-opus-9', effort: 'max', supportedLevels: levels })) + .toEqual(['--effort', 'xhigh']); + // A mode built before the catalog resolved carries no tiers: nothing is sent. + expect(buildClaudeEffortCliArgs({ modelId: 'claude-opus-9', effort: 'max' })).toEqual([]); + }); +}); diff --git a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts new file mode 100644 index 0000000000..edd5af3ebf --- /dev/null +++ b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts @@ -0,0 +1,337 @@ +import { createHash } from 'node:crypto'; + +import { AGENT_MODEL_CONFIG, type AgentModelDescriptor } from '@happier-dev/agents'; +import type { ConnectedServiceBindingsV1 } from '@happier-dev/protocol'; + +import { configuration } from '@/configuration'; +import { readClaudeCodeNativeCredential } from '@/backends/claude/connectedServices/nativeAuth/claudeCodeCredentialFile'; +import { + resolveClaudeConnectedServiceStableConfigDir, + type ClaudeConnectedServiceId, +} from '@/backends/claude/connectedServices/resolveClaudeConnectedServiceStableAuthDir'; +import { fetchAnthropicModels, type AnthropicModelEntry } from '@/backends/claude/preflight/anthropicModelsFetch'; +import { buildDiscoveredClaudeModelDescriptor } from '@/backends/claude/preflight/deriveDiscoveredClaudeModel'; +import { resolveConfiguredClaudeConfigDir } from '@/backends/claude/utils/resolveConfiguredClaudeConfigDir'; + +/** + * Single owner of "which Claude models can this account run". + * + * Both the new-session preflight probe and the in-session `sessionModelsV1` publisher read from + * here, so the two surfaces cannot disagree about which models exist or which effort tiers they + * support. The result is cached per account binding + endpoint so a session start does not pay a + * network round trip. + */ + +const CATALOG_SUCCESS_TTL_MS = 24 * 60 * 60 * 1_000; +const CATALOG_FAILURE_TTL_MS = 60 * 1_000; + +type ClaudeProbeCredential = + | Readonly<{ apiKey: string }> + | Readonly<{ accessToken: string }>; + +type ClaudeProbeTarget = Readonly<{ + baseUrl: string | null; + credential: ClaudeProbeCredential; +}>; + +export type ClaudeProbeBinding = Readonly<{ + serviceId: ClaudeConnectedServiceId; + selection: + | Readonly<{ kind: 'group'; groupId: string }> + | Readonly<{ kind: 'profile'; profileId: string }>; +}>; + +const CLAUDE_PROBE_SERVICE_IDS: readonly ClaudeConnectedServiceId[] = ['claude-subscription', 'anthropic']; + +function readEnvValue(name: string): string | null { + const value = process.env[name]; + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function readNonBlankString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +export function resolveClaudeProbeBinding( + connectedServices?: ConnectedServiceBindingsV1 | null, +): ClaudeProbeBinding | null { + for (const serviceId of CLAUDE_PROBE_SERVICE_IDS) { + const binding = connectedServices?.bindingsByServiceId[serviceId] ?? null; + if (!binding || binding.source === 'native') continue; + + if (binding.selection === 'group') { + const groupId = readNonBlankString(binding.groupId); + if (groupId) return { serviceId, selection: { kind: 'group', groupId } }; + continue; + } + const profileId = readNonBlankString(binding.profileId); + if (profileId) return { serviceId, selection: { kind: 'profile', profileId } }; + } + return null; +} + +/** Stable identity for a binding, used for both the probe cache variant and this module's cache. */ +export function resolveClaudeProbeBindingIdentity( + connectedServices?: ConnectedServiceBindingsV1 | null, +): string { + const bound = resolveClaudeProbeBinding(connectedServices); + if (!bound) return 'native'; + return bound.selection.kind === 'group' + ? `${bound.serviceId}:group:${bound.selection.groupId}` + : `${bound.serviceId}:profile:${bound.selection.profileId}`; +} + +/** + * Read a configured Anthropic-compatible endpoint root, or `null` when unset/unusable. + * + * `ANTHROPIC_BASE_URL` is how Happier's built-in Claude backend profiles point the CLI at + * third-party gateways (Z.AI, DeepSeek, MiniMax), always paired with a gateway-issued + * `ANTHROPIC_AUTH_TOKEN`. + */ +function readConfiguredBaseUrl(): string | null { + const raw = readEnvValue('ANTHROPIC_BASE_URL'); + if (!raw) return null; + try { + new URL(raw); + return raw; + } catch { + return null; + } +} + +function isAnthropicFirstPartyBaseUrl(baseUrl: string | null): boolean { + if (!baseUrl) return true; + try { + const host = new URL(baseUrl).hostname.toLowerCase(); + return host === 'anthropic.com' || host.endsWith('.anthropic.com'); + } catch { + return true; + } +} + +/** + * Ambient auth env keys the catalog fetch may use for a bound session. + * + * Mirrors `isolateClaudeRuntimeAuthEnv`, which strips every `CLAUDE_AUTH_ENV_KEYS` entry from the + * spawned child for a bound session and keeps only `ANTHROPIC_API_KEY` for the `anthropic` + * service. Reading a key the spawn deletes would describe one account for a session that runs as + * another. + */ +function resolveAllowedEnvKeys(bound: ClaudeProbeBinding | null): readonly string[] { + if (!bound) return ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_OAUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN']; + return bound.serviceId === 'anthropic' ? ['ANTHROPIC_API_KEY'] : []; +} + +/** + * Config dir holding the credentials of the account this catalog describes. + * + * This doubles as the cache identity. The preflight probe resolves it from the binding it is + * handed; an in-session consumer resolves the same materialized dir from `CLAUDE_CONFIG_DIR`, + * which the daemon sets for a bound session. Both therefore land on the same cache entry without + * the in-session side having to know the binding. + */ +function resolveClaudeCatalogConfigDir(bound: ClaudeProbeBinding | null): string { + const ownConfigDir = resolveConfiguredClaudeConfigDir({ env: process.env }); + if (!bound) return ownConfigDir; + + const { serviceId, selection } = bound; + const boundDir = resolveClaudeConnectedServiceStableConfigDir({ + activeServerDir: configuration.activeServerDir, + serviceId, + fallbackProfileId: selection.kind === 'group' ? selection.groupId : selection.profileId, + selection, + }); + return boundDir ?? ownConfigDir; +} + +/** + * Resolve the endpoint + credential pair for the catalog fetch. + * + * The credential must belong to the endpoint it is sent to. Environment credentials are configured + * alongside `ANTHROPIC_BASE_URL` and travel with it; the on-disk Claude Code subscription token is + * Anthropic-only and is never sent to a third-party gateway. Returns `null` when no usable pair + * exists so callers fall back to the curated catalog. Never throws. + */ +async function resolveClaudeCatalogTarget( + connectedServices?: ConnectedServiceBindingsV1 | null, +): Promise { + const baseUrl = readConfiguredBaseUrl(); + const bound = resolveClaudeProbeBinding(connectedServices); + const allowedEnvKeys = resolveAllowedEnvKeys(bound); + const readAllowedEnvValue = (name: string): string | null => + allowedEnvKeys.includes(name) ? readEnvValue(name) : null; + + const apiKey = readAllowedEnvValue('ANTHROPIC_API_KEY'); + if (apiKey) return { baseUrl, credential: { apiKey } }; + + const envToken = readAllowedEnvValue('ANTHROPIC_AUTH_TOKEN') + ?? readAllowedEnvValue('ANTHROPIC_OAUTH_TOKEN') + ?? readAllowedEnvValue('CLAUDE_CODE_OAUTH_TOKEN'); + if (envToken) return { baseUrl, credential: { accessToken: envToken } }; + + if (!isAnthropicFirstPartyBaseUrl(baseUrl)) return null; + + try { + const claudeConfigDir = resolveClaudeCatalogConfigDir(bound); + const credential = await readClaudeCodeNativeCredential({ claudeConfigDir }); + const accessToken = credential?.payload.claudeAiOauth.accessToken; + if (typeof accessToken === 'string' && accessToken.trim().length > 0) { + return { baseUrl, credential: { accessToken: accessToken.trim() } }; + } + } catch { + // best-effort — fall through to the curated catalog + } + return null; +} + +/** Lowercase + strip a trailing dated snapshot suffix (`-YYYYMMDD`) for dedup comparison only. */ +function normalizeDatedId(rawId: string): string { + return rawId.trim().toLowerCase().replace(/-\d{8}$/u, ''); +} + +/** + * Major generation of a Claude model id, or `null` for ids that are not Claude models. + * + * Both Claude naming schemes put the major generation in the first numeric segment — + * `claude-3-5-sonnet` (legacy) and `claude-opus-4-8` (current) — so the first number wins. Ids from + * an Anthropic-compatible gateway (`glm-4.6`, `deepseek-reasoner`) are not Claude models and are + * never generation-filtered. + */ +function resolveClaudeModelGeneration(normalizedId: string): number | null { + if (!normalizedId.startsWith('claude')) return null; + const match = normalizedId.match(/(\d+)/u); + return match ? Number.parseInt(match[1]!, 10) : null; +} + +function resolveStaticClaudeModels(): readonly AgentModelDescriptor[] { + return AGENT_MODEL_CONFIG.claude.staticModels ?? []; +} + +/** + * Oldest Claude generation Happier still curates. The Models API lists every model the account may + * call, including generations Claude Code can no longer run, so anything below the curated floor is + * dropped rather than offered as a selectable row. + */ +function resolveMinimumCuratedGeneration(): number | null { + const generations = resolveStaticClaudeModels() + .map((model) => resolveClaudeModelGeneration(normalizeDatedId(model.id))) + .filter((generation): generation is number => generation !== null); + return generations.length > 0 ? Math.min(...generations) : null; +} + +function isRunnableDiscoveredModel(normalizedId: string): boolean { + const generation = resolveClaudeModelGeneration(normalizedId); + if (generation === null) return true; + const floor = resolveMinimumCuratedGeneration(); + return floor === null || generation >= floor; +} + +/** + * Augment the curated static catalog with any Claude models the account can run that are NOT + * already curated. Static models win (full curation preserved); discovered models are appended + * with API-derived options. Dated snapshot ids collapse onto their static alias. + */ +function mergeStaticWithDiscovered(entries: readonly AnthropicModelEntry[]): AgentModelDescriptor[] { + const staticModels = resolveStaticClaudeModels(); + const staticNormalizedIds = new Set(staticModels.map((model) => normalizeDatedId(model.id))); + + const discovered = entries + .filter((entry) => { + const normalized = normalizeDatedId(entry.id); + if (normalized.length === 0 || normalized === 'default') return false; + if (staticNormalizedIds.has(normalized)) return false; + return isRunnableDiscoveredModel(normalized); + }) + .map((entry) => buildDiscoveredClaudeModelDescriptor(entry)); + + return [...staticModels, ...discovered]; +} + +/** + * Non-reversible fingerprint of an ambient env credential, or `''` when none is set. + * + * The config dir identifies a bound or on-disk account, but an ambient `ANTHROPIC_API_KEY` or + * token can be swapped without it changing — which would otherwise serve the previous key's model + * list for the whole TTL. Only env-derived credentials need this: rotating an on-disk credential + * does not change which models the same account can run. The secret itself never enters the key. + */ +function resolveAmbientCredentialFingerprint(bound: ClaudeProbeBinding | null): string { + const value = resolveAllowedEnvKeys(bound) + .map((key) => readEnvValue(key)) + .find((candidate): candidate is string => candidate !== null); + if (!value) return ''; + return createHash('sha256').update(value).digest('hex').slice(0, 12); +} + +function resolveCatalogCacheKey(bound: ClaudeProbeBinding | null): string { + return [ + resolveClaudeCatalogConfigDir(bound), + readConfiguredBaseUrl() ?? 'default', + resolveAmbientCredentialFingerprint(bound), + ].join('|'); +} + +type CatalogCacheEntry = Readonly<{ models: readonly AgentModelDescriptor[]; expiresAtMs: number }>; +const catalogCache = new Map(); + +export function resetClaudeModelCatalogCacheForTests(): void { + catalogCache.clear(); +} + +export type ResolveClaudeModelCatalogParams = Readonly<{ + timeoutMs: number; + connectedServices?: ConnectedServiceBindingsV1 | null; + nowMs?: () => number; +}>; + +/** + * The models this account can run: curated catalog, augmented with anything the Anthropic Models + * API reports. Falls back to the curated catalog on any failure. Never throws. + */ +export async function resolveClaudeModelCatalog( + params: ResolveClaudeModelCatalogParams, +): Promise { + const nowMs = params.nowMs ?? (() => Date.now()); + const cacheKey = resolveCatalogCacheKey(resolveClaudeProbeBinding(params.connectedServices)); + + const cached = catalogCache.get(cacheKey); + if (cached && cached.expiresAtMs > nowMs()) return cached.models; + + const target = await resolveClaudeCatalogTarget(params.connectedServices); + const entries = target + ? await fetchAnthropicModels({ + ...('apiKey' in target.credential ? { apiKey: target.credential.apiKey } : {}), + ...('accessToken' in target.credential ? { accessToken: target.credential.accessToken } : {}), + ...(target.baseUrl ? { baseUrl: target.baseUrl } : {}), + timeoutMs: params.timeoutMs, + }) + : null; + + const models = entries ? mergeStaticWithDiscovered(entries) : resolveStaticClaudeModels(); + catalogCache.set(cacheKey, { + models, + expiresAtMs: nowMs() + (entries ? CATALOG_SUCCESS_TTL_MS : CATALOG_FAILURE_TTL_MS), + }); + return models; +} + +/** Whether the catalog fetch found anything beyond the curated list (used to report probe source). */ +export function hasDiscoveredClaudeModels(models: readonly AgentModelDescriptor[]): boolean { + return models.length > resolveStaticClaudeModels().length; +} + +/** + * Effort tiers a model descriptor reports, read off its `reasoning_effort` control. + * + * This is the evidence `resolveClaudeEffortForModel` needs for a discovered model, whose tiers are + * not in the static effort table. + */ +export function resolveClaudeEffortLevelsFromModelDescriptor( + model: AgentModelDescriptor | null | undefined, +): readonly string[] { + const control = model?.modelOptions?.find((option) => option.id === 'reasoning_effort'); + if (!control || !Array.isArray(control.options)) return []; + return control.options + .map((option) => readNonBlankString(option?.value)) + .filter((value): value is string => value !== null); +} diff --git a/apps/cli/src/backends/claude/preflight/anthropicModelsFetch.test.ts b/apps/cli/src/backends/claude/preflight/anthropicModelsFetch.test.ts new file mode 100644 index 0000000000..843a12fc84 --- /dev/null +++ b/apps/cli/src/backends/claude/preflight/anthropicModelsFetch.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { fetchAnthropicModels, parseAnthropicModelsResponse } from './anthropicModelsFetch'; + +describe('parseAnthropicModelsResponse', () => { + it('parses entries and maps snake_case fields', () => { + const parsed = parseAnthropicModelsResponse({ + data: [ + { + id: 'claude-opus-5', + display_name: 'Claude Opus 5', + max_input_tokens: 1_000_000, + capabilities: { effort: { supported: true, high: { supported: true } } }, + }, + ], + }); + expect(parsed).toEqual([ + { + id: 'claude-opus-5', + displayName: 'Claude Opus 5', + maxInputTokens: 1_000_000, + capabilities: { effort: { supported: true, high: { supported: true } } }, + }, + ]); + }); + + it('drops entries without a string id and returns null for non-objects / empty data', () => { + expect(parseAnthropicModelsResponse({ data: [{ display_name: 'no id' }, { id: 'ok' }] })) + .toEqual([{ id: 'ok' }]); + expect(parseAnthropicModelsResponse({ data: [] })).toBeNull(); + expect(parseAnthropicModelsResponse('nope')).toBeNull(); + expect(parseAnthropicModelsResponse({})).toBeNull(); + }); +}); + +function okResponse(): Response { + return new Response(JSON.stringify({ data: [{ id: 'claude-opus-5' }] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('fetchAnthropicModels', () => { + it('returns null when neither api key nor access token is provided', async () => { + const fetchImpl = vi.fn(); + const result = await fetchAnthropicModels({ timeoutMs: 1_000, fetchImpl: fetchImpl as unknown as typeof fetch }); + expect(result).toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('sends x-api-key when an api key is provided', async () => { + const fetchImpl = vi.fn<(input: unknown, init: { headers: Record }) => Promise>(async () => okResponse()); + const result = await fetchAnthropicModels({ + apiKey: 'sk-ant-key', + timeoutMs: 1_000, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(result).toEqual([{ id: 'claude-opus-5' }]); + const headers = fetchImpl.mock.calls[0]![1].headers; + expect(headers['x-api-key']).toBe('sk-ant-key'); + expect(headers['Authorization']).toBeUndefined(); + }); + + it('sends Bearer + oauth beta header when an access token is provided', async () => { + const fetchImpl = vi.fn<(input: unknown, init: { headers: Record }) => Promise>(async () => okResponse()); + await fetchAnthropicModels({ + accessToken: 'sk-ant-oat01-token', + timeoutMs: 1_000, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + const headers = fetchImpl.mock.calls[0]![1].headers; + expect(headers['Authorization']).toBe('Bearer sk-ant-oat01-token'); + expect(headers['anthropic-beta']).toBe('oauth-2025-04-20'); + expect(headers['x-api-key']).toBeUndefined(); + }); + + it('requests the default Anthropic host when no base url is given', async () => { + const fetchImpl = vi.fn<(input: unknown, init: { headers: Record }) => Promise>(async () => okResponse()); + await fetchAnthropicModels({ + apiKey: 'sk-ant-key', + timeoutMs: 1_000, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(String(fetchImpl.mock.calls[0]![0])).toBe('https://api.anthropic.com/v1/models?limit=1000'); + }); + + it('requests the configured base url instead of the Anthropic host', async () => { + const fetchImpl = vi.fn<(input: unknown, init: { headers: Record }) => Promise>(async () => okResponse()); + await fetchAnthropicModels({ + apiKey: 'gateway-key', + baseUrl: 'https://api.z.ai/api/anthropic/', + timeoutMs: 1_000, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(String(fetchImpl.mock.calls[0]![0])).toBe('https://api.z.ai/api/anthropic/v1/models?limit=1000'); + }); + + it('falls back to the default host when the base url is unusable', async () => { + const fetchImpl = vi.fn<(input: unknown, init: { headers: Record }) => Promise>(async () => okResponse()); + await fetchAnthropicModels({ + apiKey: 'sk-ant-key', + baseUrl: 'not a url', + timeoutMs: 1_000, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(String(fetchImpl.mock.calls[0]![0])).toBe('https://api.anthropic.com/v1/models?limit=1000'); + }); + + it('returns null on a non-2xx response', async () => { + const fetchImpl = vi.fn(async () => new Response('nope', { status: 401 })); + const result = await fetchAnthropicModels({ + apiKey: 'sk-ant-key', + timeoutMs: 1_000, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(result).toBeNull(); + }); + + it('returns null when the fetch throws', async () => { + const fetchImpl = vi.fn(async () => { throw new Error('network'); }); + const result = await fetchAnthropicModels({ + apiKey: 'sk-ant-key', + timeoutMs: 1_000, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(result).toBeNull(); + }); +}); diff --git a/apps/cli/src/backends/claude/preflight/anthropicModelsFetch.ts b/apps/cli/src/backends/claude/preflight/anthropicModelsFetch.ts new file mode 100644 index 0000000000..4709af1bef --- /dev/null +++ b/apps/cli/src/backends/claude/preflight/anthropicModelsFetch.ts @@ -0,0 +1,169 @@ +import { resolveClaudeCodeUserAgent } from '@/backends/claude/utils/claudeCodeUserAgent'; + +export const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com'; +const MODELS_PATH = 'v1/models?limit=1000'; +const ANTHROPIC_VERSION = '2023-06-01'; +const OAUTH_BETA_HEADER_VALUE = 'oauth-2025-04-20'; + +/** + * Build the models endpoint for a base URL. + * + * Anthropic-compatible gateways are configured through `ANTHROPIC_BASE_URL` and may live under a + * path prefix (`https://api.z.ai/api/anthropic`), so the path is appended rather than replacing it. + * An unusable value falls back to the default host instead of throwing. + */ +export function resolveAnthropicModelsUrl(baseUrl?: string | null): string { + const raw = typeof baseUrl === 'string' ? baseUrl.trim() : ''; + const candidate = raw.length > 0 ? raw : DEFAULT_ANTHROPIC_BASE_URL; + try { + return new URL(MODELS_PATH, candidate.endsWith('/') ? candidate : `${candidate}/`).toString(); + } catch { + return `${DEFAULT_ANTHROPIC_BASE_URL}/${MODELS_PATH}`; + } +} + +/** + * Subset of a `capabilities.effort` node from the Anthropic Models API. + * + * Each tier reports `{ supported: boolean }`; the top-level `supported` gates the + * whole effort axis (some models — e.g. Haiku — expose no effort control). + */ +export type AnthropicModelEffortCapability = Readonly<{ + supported?: boolean; + low?: Readonly<{ supported?: boolean }>; + medium?: Readonly<{ supported?: boolean }>; + high?: Readonly<{ supported?: boolean }>; + xhigh?: Readonly<{ supported?: boolean }>; + max?: Readonly<{ supported?: boolean }>; +}>; + +export type AnthropicModelCapabilities = Readonly<{ + effort?: AnthropicModelEffortCapability; +}>; + +/** A single `data[]` entry from `GET /v1/models`, narrowed to fields we consume. */ +export type AnthropicModelEntry = Readonly<{ + id: string; + displayName?: string; + maxInputTokens?: number; + capabilities?: AnthropicModelCapabilities; +}>; + +function readObject(value: unknown): Record | null { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readEffortTier(value: unknown): Readonly<{ supported?: boolean }> | undefined { + const node = readObject(value); + if (!node) return undefined; + return typeof node.supported === 'boolean' ? { supported: node.supported } : {}; +} + +function readCapabilities(value: unknown): AnthropicModelCapabilities | undefined { + const caps = readObject(value); + const effort = readObject(caps?.effort); + if (!effort) return undefined; + return { + effort: { + ...(typeof effort.supported === 'boolean' ? { supported: effort.supported } : {}), + ...(readEffortTier(effort.low) ? { low: readEffortTier(effort.low) } : {}), + ...(readEffortTier(effort.medium) ? { medium: readEffortTier(effort.medium) } : {}), + ...(readEffortTier(effort.high) ? { high: readEffortTier(effort.high) } : {}), + ...(readEffortTier(effort.xhigh) ? { xhigh: readEffortTier(effort.xhigh) } : {}), + ...(readEffortTier(effort.max) ? { max: readEffortTier(effort.max) } : {}), + }, + }; +} + +/** + * Parse a raw `GET /v1/models` JSON body into typed entries. + * + * Pure and defensive: unknown shapes yield `null`, malformed entries are dropped, + * and only entries with a non-empty string `id` survive. + */ +export function parseAnthropicModelsResponse(body: unknown): AnthropicModelEntry[] | null { + const root = readObject(body); + const data = root?.data; + if (!Array.isArray(data)) return null; + + const entries: AnthropicModelEntry[] = []; + for (const raw of data) { + const entry = readObject(raw); + const id = typeof entry?.id === 'string' ? entry.id.trim() : ''; + if (!id) continue; + const displayName = typeof entry?.display_name === 'string' && entry.display_name.trim().length > 0 + ? entry.display_name.trim() + : undefined; + const maxInputTokens = typeof entry?.max_input_tokens === 'number' && Number.isFinite(entry.max_input_tokens) + ? entry.max_input_tokens + : undefined; + entries.push({ + id, + ...(displayName ? { displayName } : {}), + ...(maxInputTokens !== undefined ? { maxInputTokens } : {}), + ...(readCapabilities(entry?.capabilities) ? { capabilities: readCapabilities(entry?.capabilities) } : {}), + }); + } + return entries.length > 0 ? entries : null; +} + +export type FetchAnthropicModelsParams = Readonly<{ + /** OAuth access token (subscription/Claude Code). Sent as `Authorization: Bearer`. */ + accessToken?: string | null; + /** Anthropic API key. Sent as `x-api-key`. Takes precedence over `accessToken`. */ + apiKey?: string | null; + /** Anthropic-compatible endpoint root (`ANTHROPIC_BASE_URL`). Defaults to the Anthropic host. */ + baseUrl?: string | null; + timeoutMs: number; + userAgent?: string; + /** Injectable for tests; defaults to global fetch. */ + fetchImpl?: typeof fetch; +}>; + +/** + * Fetch the caller's available Claude models from the Anthropic Models API. + * + * Returns `null` on any failure (no credential, network error, non-200, unparseable + * body) so the probe pipeline falls back to the static catalog. Never throws. + */ +export async function fetchAnthropicModels( + params: FetchAnthropicModelsParams, +): Promise { + const apiKey = typeof params.apiKey === 'string' && params.apiKey.trim().length > 0 ? params.apiKey.trim() : null; + const accessToken = typeof params.accessToken === 'string' && params.accessToken.trim().length > 0 + ? params.accessToken.trim() + : null; + if (!apiKey && !accessToken) return null; + + const fetchImpl = params.fetchImpl ?? fetch; + const headers: Record = { + 'Accept': 'application/json', + 'anthropic-version': ANTHROPIC_VERSION, + 'User-Agent': resolveClaudeCodeUserAgent(params.userAgent), + }; + if (apiKey) { + headers['x-api-key'] = apiKey; + } else if (accessToken) { + headers['Authorization'] = `Bearer ${accessToken}`; + headers['anthropic-beta'] = OAUTH_BETA_HEADER_VALUE; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), Math.max(250, params.timeoutMs)); + try { + const response = await fetchImpl(resolveAnthropicModelsUrl(params.baseUrl), { + method: 'GET', + headers, + signal: controller.signal, + }); + if (!response.ok) return null; + const body = await response.json().catch(() => null); + return parseAnthropicModelsResponse(body); + } catch { + return null; + } finally { + clearTimeout(timer); + } +} diff --git a/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts b/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts index be2b6c6ec6..a150377f47 100644 --- a/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts +++ b/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts @@ -1,192 +1,284 @@ -import { afterEach, describe, expect, it } from 'vitest'; - -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createEnvKeyScope } from '@/testkit/env/envScope'; -import { writeExecutableShimSync } from '@/testkit/fs/executableShim'; + +import type { AnthropicModelEntry } from './anthropicModelsFetch'; + +const { fetchAnthropicModelsMock, readClaudeCodeNativeCredentialMock } = vi.hoisted(() => ({ + fetchAnthropicModelsMock: vi.fn<(...args: unknown[]) => Promise>(), + readClaudeCodeNativeCredentialMock: vi.fn<(...args: unknown[]) => Promise>(), +})); + +vi.mock('@/backends/claude/preflight/anthropicModelsFetch', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchAnthropicModels: fetchAnthropicModelsMock }; +}); + +vi.mock('@/backends/claude/connectedServices/nativeAuth/claudeCodeCredentialFile', () => ({ + readClaudeCodeNativeCredential: readClaudeCodeNativeCredentialMock, +})); import { claudePreflightModelsProbeAdapter } from './claudePreflightModelsProbeAdapter'; +import { resetClaudeModelCatalogCacheForTests } from '@/backends/claude/models/resolveClaudeModelCatalog'; + +const envKeys = [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_OAUTH_TOKEN', + 'CLAUDE_CODE_OAUTH_TOKEN', + 'ANTHROPIC_BASE_URL', +] as const; +let envScope = createEnvKeyScope(envKeys); -function makeTempDir(prefix: string): string { - return mkdtempSync(join(tmpdir(), prefix)); +function fullEffort(): AnthropicModelEntry['capabilities'] { + return { + effort: { + supported: true, + low: { supported: true }, + medium: { supported: true }, + high: { supported: true }, + xhigh: { supported: true }, + max: { supported: true }, + }, + }; } -const envKeys = ['HAPPIER_CLAUDE_PATH', 'PATH'] as const; -let envScope = createEnvKeyScope(envKeys); +async function runProbe() { + return claudePreflightModelsProbeAdapter.probeModelsRaw?.({ + cwd: '/tmp', + timeoutMs: 1_500, + backendTarget: undefined, + accountSettings: null, + }) as Promise> | null>; +} -afterEach(() => { +beforeEach(() => { + resetClaudeModelCatalogCacheForTests(); + fetchAnthropicModelsMock.mockReset(); + readClaudeCodeNativeCredentialMock.mockReset(); + readClaudeCodeNativeCredentialMock.mockResolvedValue(null); envScope.restore(); envScope = createEnvKeyScope(envKeys); }); -function writeFakeClaudeBinary(dir: string, helpText: string): string { - const isWindows = process.platform === 'win32'; - const fileName = isWindows ? 'claude.cmd' : 'claude'; - const contents = isWindows - ? [ - '@echo off', - 'set args=%*', - 'echo %args% | findstr /c:"--help" >nul', - 'if %errorlevel%==0 (', - ...helpText.split(/\r?\n/).map((l) => ` echo ${l}`), - ' exit /b 0', - ')', - 'exit /b 0', - ].join('\r\n') - : [ - '#!/bin/sh', - 'for a in "$@"; do', - ' if [ "$a" = "--help" ]; then', - ' cat <<\'EOF\'', - helpText, - 'EOF', - ' exit 0', - ' fi', - 'done', - 'exit 0', - ].join('\n'); - return writeExecutableShimSync({ dir, fileName, contents }); -} +afterEach(() => { + envScope.restore(); + envScope = createEnvKeyScope(envKeys); +}); describe('claudePreflightModelsProbeAdapter', () => { - let tempDir: string | null = null; + it('augments the static catalog with discovered models, preserving curation and collapsing dated dupes', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; + fetchAnthropicModelsMock.mockResolvedValue([ + { id: 'claude-opus-5', displayName: 'Claude Opus 5', maxInputTokens: 1_000_000, capabilities: fullEffort() }, + // Dated snapshot of a curated alias — must collapse onto static `claude-opus-4-5`. + { id: 'claude-opus-4-5-20251101', displayName: 'Claude Opus 4.5', capabilities: fullEffort() }, + // Genuinely new model — must appear with derived options. + { id: 'claude-opus-9', displayName: 'Opus 9', maxInputTokens: 1_000_000, capabilities: fullEffort() }, + ]); - afterEach(() => { - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }); - tempDir = null; - } + const raw = await runProbe(); + if (!raw) throw new Error('expected augmented model list'); + + expect(fetchAnthropicModelsMock).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'sk-ant-key' })); + + // Curated static model keeps its hand-authored effort default. + const opus48 = raw.find((m) => m.id === 'claude-opus-4-8'); + const opus48Effort = (opus48?.modelOptions as Array> | undefined) + ?.find((o) => o.id === 'reasoning_effort'); + expect(opus48Effort?.currentValue).toBe('high'); + + // Discovered model appears with derived options + context window. + const opus9 = raw.find((m) => m.id === 'claude-opus-9'); + expect(opus9?.name).toBe('Opus 9'); + expect(opus9?.contextWindowTokens).toBe(1_000_000); + expect((opus9?.modelOptions as Array> | undefined)?.some((o) => o.id === 'reasoning_effort')).toBe(true); + + // Dated dupe collapsed: the alias stays, the dated id is not added. + expect(raw.some((m) => m.id === 'claude-opus-4-5')).toBe(true); + expect(raw.some((m) => m.id === 'claude-opus-4-5-20251101')).toBe(false); + }); + + it('resolves the on-disk Claude credentials when no env token is set', async () => { + readClaudeCodeNativeCredentialMock.mockResolvedValue({ + payload: { claudeAiOauth: { accessToken: 'sk-ant-oat01-disk', scopes: [] } }, + updatedAtMs: 0, + source: 'file', + }); + fetchAnthropicModelsMock.mockResolvedValue([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); + + const raw = await runProbe(); + if (!raw) throw new Error('expected model list'); + + expect(fetchAnthropicModelsMock).toHaveBeenCalledWith(expect.objectContaining({ accessToken: 'sk-ant-oat01-disk' })); + expect(raw.some((m) => m.id === 'claude-opus-9')).toBe(true); + }); + + it('returns null when no credential is available', async () => { + readClaudeCodeNativeCredentialMock.mockResolvedValue(null); + + const raw = await runProbe(); + + expect(raw).toBeNull(); + expect(fetchAnthropicModelsMock).not.toHaveBeenCalled(); }); - it('adds a model-scoped Thinking option only when the installed Claude CLI supports --effort', async () => { - tempDir = makeTempDir('happier-claude-preflight-'); - const fakeClaude = writeFakeClaudeBinary(tempDir, ' --effort Effort level for the current session (low, medium, high, xhigh, max)'); + it('routes the request at the configured base url instead of the Anthropic host', async () => { + process.env.ANTHROPIC_BASE_URL = 'https://api.z.ai/api/anthropic'; + process.env.ANTHROPIC_AUTH_TOKEN = 'zai-gateway-token'; + fetchAnthropicModelsMock.mockResolvedValue([{ id: 'glm-4.6', displayName: 'GLM 4.6' }]); - process.env.PATH = '/usr/bin:/bin'; - process.env.HAPPIER_CLAUDE_PATH = fakeClaude; + await runProbe(); - const raw = await claudePreflightModelsProbeAdapter.probeModelsRaw?.({ - cwd: tempDir, + expect(fetchAnthropicModelsMock).toHaveBeenCalledWith(expect.objectContaining({ + baseUrl: 'https://api.z.ai/api/anthropic', + accessToken: 'zai-gateway-token', + })); + }); + + it('never sends the on-disk Claude credential to a non-Anthropic base url', async () => { + process.env.ANTHROPIC_BASE_URL = 'https://api.deepseek.com/anthropic'; + readClaudeCodeNativeCredentialMock.mockResolvedValue({ + payload: { claudeAiOauth: { accessToken: 'sk-ant-oat01-disk', scopes: [] } }, + updatedAtMs: 0, + source: 'file', + }); + + const raw = await runProbe(); + + expect(raw).toBeNull(); + expect(fetchAnthropicModelsMock).not.toHaveBeenCalled(); + }); + + it('still uses the on-disk Claude credential when the base url is Anthropic', async () => { + process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com'; + readClaudeCodeNativeCredentialMock.mockResolvedValue({ + payload: { claudeAiOauth: { accessToken: 'sk-ant-oat01-disk', scopes: [] } }, + updatedAtMs: 0, + source: 'file', + }); + fetchAnthropicModelsMock.mockResolvedValue([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); + + const raw = await runProbe(); + + expect(raw).not.toBeNull(); + expect(fetchAnthropicModelsMock).toHaveBeenCalledWith(expect.objectContaining({ accessToken: 'sk-ant-oat01-disk' })); + }); + + it('drops discovered models from generations the curated catalog no longer covers', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; + fetchAnthropicModelsMock.mockResolvedValue([ + { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: fullEffort() }, + { id: 'claude-3-5-sonnet-20241022', displayName: 'Claude 3.5 Sonnet' }, + { id: 'claude-3-5-sonnet-20240620', displayName: 'Claude 3.5 Sonnet' }, + { id: 'claude-3-haiku-20240307', displayName: 'Claude 3 Haiku' }, + { id: 'claude-2.1', displayName: 'Claude 2.1' }, + { id: 'claude-instant-1.2', displayName: 'Claude Instant' }, + ]); + + const raw = await runProbe(); + if (!raw) throw new Error('expected augmented model list'); + + expect(raw.some((m) => m.id === 'claude-opus-9')).toBe(true); + for (const legacyId of [ + 'claude-3-5-sonnet-20241022', + 'claude-3-5-sonnet-20240620', + 'claude-3-haiku-20240307', + 'claude-2.1', + 'claude-instant-1.2', + ]) { + expect(raw.some((m) => m.id === legacyId)).toBe(false); + } + }); + + it('reads the bound connected account credentials instead of the daemon own config dir', async () => { + readClaudeCodeNativeCredentialMock.mockResolvedValue({ + payload: { claudeAiOauth: { accessToken: 'sk-ant-oat01-profile', scopes: [] } }, + updatedAtMs: 0, + source: 'file', + }); + fetchAnthropicModelsMock.mockResolvedValue([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); + + await claudePreflightModelsProbeAdapter.probeModelsRaw?.({ + cwd: '/tmp', timeoutMs: 1_500, backendTarget: undefined, accountSettings: null, + connectedServices: { + v: 1, + bindingsByServiceId: { + 'claude-subscription': { source: 'connected', selection: 'profile', profileId: 'profile-a' }, + }, + }, }); - expect(Array.isArray(raw)).toBe(true); - - // Fable 5 is the newest highest-capability generally available Claude model and supports - // effort, including `xhigh` and `max`, with a `high` default. - expect(raw).toEqual(expect.arrayContaining([ - expect.objectContaining({ - id: 'claude-fable-5', - modelOptions: expect.arrayContaining([expect.objectContaining({ - id: 'reasoning_effort', - currentValue: 'high', - options: expect.arrayContaining([ - expect.objectContaining({ value: 'low' }), - expect.objectContaining({ value: 'medium' }), - expect.objectContaining({ value: 'high' }), - expect.objectContaining({ value: 'xhigh' }), - expect.objectContaining({ value: 'max' }), - ]), - })]), - }), - ])); - - // Opus 4.8 supports effort, including `xhigh` and `max`, and defaults to `high`. - expect(raw).toEqual(expect.arrayContaining([ - expect.objectContaining({ - id: 'claude-opus-4-8', - modelOptions: expect.arrayContaining([expect.objectContaining({ - id: 'reasoning_effort', - currentValue: 'high', - options: expect.arrayContaining([ - expect.objectContaining({ value: 'low' }), - expect.objectContaining({ value: 'medium' }), - expect.objectContaining({ value: 'high' }), - expect.objectContaining({ value: 'xhigh' }), - expect.objectContaining({ value: 'max' }), - ]), - })]), - }), - ])); - - // Opus 4.7 remains available and keeps its `xhigh` default. - expect(raw).toEqual(expect.arrayContaining([ - expect.objectContaining({ - id: 'claude-opus-4-7', - modelOptions: expect.arrayContaining([expect.objectContaining({ - id: 'reasoning_effort', - currentValue: 'xhigh', - options: expect.arrayContaining([ - expect.objectContaining({ value: 'low' }), - expect.objectContaining({ value: 'medium' }), - expect.objectContaining({ value: 'high' }), - expect.objectContaining({ value: 'xhigh' }), - expect.objectContaining({ value: 'max' }), - ]), - })]), - }), - ])); - - // Opus 4.6 supports effort, including the special `max` level. - expect(raw).toEqual(expect.arrayContaining([ - expect.objectContaining({ - id: 'claude-opus-4-6', - modelOptions: expect.arrayContaining([expect.objectContaining({ - id: 'reasoning_effort', - currentValue: 'high', - options: expect.arrayContaining([ - expect.objectContaining({ value: 'low' }), - expect.objectContaining({ value: 'medium' }), - expect.objectContaining({ value: 'high' }), - expect.objectContaining({ value: 'max' }), - ]), - })]), - }), - ])); - - // Sonnet 4.6 supports effort but does not accept `max`. - expect(raw).toEqual(expect.arrayContaining([ - expect.objectContaining({ - id: 'claude-sonnet-4-6', - modelOptions: expect.arrayContaining([expect.objectContaining({ - id: 'reasoning_effort', - currentValue: 'high', - options: expect.arrayContaining([ - expect.objectContaining({ value: 'low' }), - expect.objectContaining({ value: 'medium' }), - expect.objectContaining({ value: 'high' }), - ]), - })]), - }), - ])); - - // Haiku does not support effort. - expect(raw).toEqual(expect.arrayContaining([ - expect.objectContaining({ - id: 'claude-haiku-4-5', - modelOptions: undefined, - }), - ])); + const configDir = readClaudeCodeNativeCredentialMock.mock.calls[0]?.[0] as { claudeConfigDir: string }; + expect(configDir.claudeConfigDir).toContain('connected-services'); + expect(configDir.claudeConfigDir).toContain('profile-a'); }); - it('returns null when the installed Claude CLI does not expose --effort', async () => { - tempDir = makeTempDir('happier-claude-preflight-'); - const fakeClaude = writeFakeClaudeBinary(tempDir, 'Claude Code help output without effort'); + it('ignores ambient env auth when the session is bound to a Claude subscription account', async () => { + // The spawn path strips every CLAUDE_AUTH_ENV_KEY for a bound claude-subscription session + // (isolateClaudeRuntimeAuthEnv), so probing with an ambient token would report one account's + // models while the session runs as another — cached under the bound account's variant key. + process.env.ANTHROPIC_AUTH_TOKEN = 'ambient-token-other-account'; + process.env.ANTHROPIC_API_KEY = 'sk-ant-ambient'; + readClaudeCodeNativeCredentialMock.mockResolvedValue({ + payload: { claudeAiOauth: { accessToken: 'sk-ant-oat01-bound', scopes: [] } }, + updatedAtMs: 0, + source: 'file', + }); + fetchAnthropicModelsMock.mockResolvedValue([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); - process.env.PATH = '/usr/bin:/bin'; - process.env.HAPPIER_CLAUDE_PATH = fakeClaude; + await claudePreflightModelsProbeAdapter.probeModelsRaw?.({ + cwd: '/tmp', + timeoutMs: 1_500, + backendTarget: undefined, + accountSettings: null, + connectedServices: { + v: 1, + bindingsByServiceId: { + 'claude-subscription': { source: 'connected', selection: 'profile', profileId: 'profile-a' }, + }, + }, + }); - const raw = await claudePreflightModelsProbeAdapter.probeModelsRaw?.({ - cwd: tempDir, + expect(fetchAnthropicModelsMock).toHaveBeenCalledWith(expect.objectContaining({ + accessToken: 'sk-ant-oat01-bound', + })); + const sent = fetchAnthropicModelsMock.mock.calls[0]?.[0] as Record; + expect(sent.apiKey).toBeUndefined(); + }); + + it('keeps ANTHROPIC_API_KEY for a bound anthropic account, matching the spawn allow-list', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-bound-key'; + process.env.ANTHROPIC_AUTH_TOKEN = 'ambient-token-other-account'; + fetchAnthropicModelsMock.mockResolvedValue([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); + + await claudePreflightModelsProbeAdapter.probeModelsRaw?.({ + cwd: '/tmp', timeoutMs: 1_500, backendTarget: undefined, accountSettings: null, + connectedServices: { + v: 1, + bindingsByServiceId: { + anthropic: { source: 'connected', selection: 'profile', profileId: 'profile-a' }, + }, + }, }); + expect(fetchAnthropicModelsMock).toHaveBeenCalledWith(expect.objectContaining({ + apiKey: 'sk-ant-bound-key', + })); + }); + + it('returns null when the models fetch fails', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; + fetchAnthropicModelsMock.mockResolvedValue(null); + + const raw = await runProbe(); + expect(raw).toBeNull(); }); }); diff --git a/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.ts b/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.ts index 655c308e61..e76bf04bcf 100644 --- a/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.ts +++ b/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.ts @@ -1,25 +1,33 @@ -import { AGENT_MODEL_CONFIG } from '@happier-dev/agents'; +import type { AgentModelDescriptor } from '@happier-dev/agents'; -import type { PreflightModelsProbeAdapter } from '@/capabilities/probes/preflightModelsProbeAdapterTypes'; -import { probeClaudeHelpText } from '@/backends/claude/sessionControls/probeClaudeHelpText'; +import type { PreflightSessionControlsProbeAdapter } from '@/capabilities/probes/preflightSessionControlsProbeAdapterTypes'; +import { hasDiscoveredClaudeModels, resolveClaudeModelCatalog } from '@/backends/claude/models/resolveClaudeModelCatalog'; -export const claudePreflightModelsProbeAdapter: PreflightModelsProbeAdapter = { - failureCacheStrategy: 'cooldown', - probeModelsRaw: async ({ cwd, timeoutMs }) => { - const helpText = await probeClaudeHelpText({ cwd, timeoutMs }); - if (!helpText) return null; - - const supportsEffort = /\B--effort\b/i.test(helpText); - if (!supportsEffort) return null; +function toProbeRawModel(model: AgentModelDescriptor): Record { + return { + id: model.id, + name: model.name, + ...(typeof model.description === 'string' ? { description: model.description } : {}), + ...(typeof model.contextWindowTokens === 'number' ? { contextWindowTokens: model.contextWindowTokens } : {}), + ...(typeof model.extendedContextModelId === 'string' ? { extendedContextModelId: model.extendedContextModelId } : {}), + ...(Array.isArray(model.modelOptions) && model.modelOptions.length > 0 ? { modelOptions: model.modelOptions } : {}), + }; +} - const models = AGENT_MODEL_CONFIG.claude.staticModels ?? []; - return models.map((model) => ({ - id: model.id, - name: model.name, - ...(typeof model.description === 'string' ? { description: model.description } : {}), - ...(Array.isArray(model.modelOptions) && model.modelOptions.length > 0 - ? { modelOptions: model.modelOptions } - : { modelOptions: undefined }), - })); +/** + * New-session model probe for Claude. + * + * The catalog itself is owned by `resolveClaudeModelCatalog`, which the in-session + * `sessionModelsV1` publisher also reads, so both surfaces describe the same models with the same + * effort tiers. + */ +export const claudePreflightModelsProbeAdapter: PreflightSessionControlsProbeAdapter = { + failureCacheStrategy: 'cooldown', + probeModelsRaw: async ({ timeoutMs, connectedServices }) => { + const models = await resolveClaudeModelCatalog({ timeoutMs, connectedServices }); + // Nothing beyond the curated catalog means the probe added nothing: return null so the caller + // reports the list as `static` rather than mislabelling the curated catalog as dynamic. + if (!hasDiscoveredClaudeModels(models)) return null; + return models.map(toProbeRawModel); }, }; diff --git a/apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.test.ts b/apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.test.ts new file mode 100644 index 0000000000..b068d6e3b8 --- /dev/null +++ b/apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; + +import type { AnthropicModelEntry } from './anthropicModelsFetch'; +import { + buildDiscoveredClaudeModelDescriptor, + deriveClaudeModelOptionsFromCapabilities, +} from './deriveDiscoveredClaudeModel'; + +function effort(tiers: readonly string[]): AnthropicModelEntry['capabilities'] { + return { + effort: { + supported: true, + ...Object.fromEntries(tiers.map((tier) => [tier, { supported: true }])), + }, + }; +} + +describe('deriveClaudeModelOptionsFromCapabilities', () => { + it('builds a full 5-tier Thinking select plus Ultracode when xhigh is supported', () => { + const derived = deriveClaudeModelOptionsFromCapabilities({ + id: 'claude-opus-9', + maxInputTokens: 1_000_000, + capabilities: effort(['low', 'medium', 'high', 'xhigh', 'max']), + }); + + expect(derived.contextWindowTokens).toBe(1_000_000); + const reasoning = derived.modelOptions?.find((o) => o.id === 'reasoning_effort'); + expect(reasoning?.currentValue).toBe('high'); + expect(reasoning?.options?.map((o) => o.value)).toEqual(['low', 'medium', 'high', 'xhigh', 'max']); + expect(derived.modelOptions?.some((o) => o.id === 'ultracode' && o.type === 'boolean')).toBe(true); + }); + + it('omits Ultracode when xhigh is not supported', () => { + const derived = deriveClaudeModelOptionsFromCapabilities({ + id: 'claude-sonnet-9', + capabilities: effort(['low', 'medium', 'high', 'max']), + }); + + const reasoning = derived.modelOptions?.find((o) => o.id === 'reasoning_effort'); + expect(reasoning?.options?.map((o) => o.value)).toEqual(['low', 'medium', 'high', 'max']); + expect(derived.modelOptions?.some((o) => o.id === 'ultracode')).toBe(false); + }); + + it('produces no model options when effort is unsupported but still reports the context window', () => { + const derived = deriveClaudeModelOptionsFromCapabilities({ + id: 'claude-haiku-9', + maxInputTokens: 200_000, + capabilities: { effort: { supported: false } }, + }); + + expect(derived.modelOptions).toBeUndefined(); + expect(derived.contextWindowTokens).toBe(200_000); + }); + + it('defaults to the nearest tier at or below high when high is not offered', () => { + // Never silently preselect a tier stronger than the API's own `high` default: a model + // offering only low/max would otherwise open at max and burn budget the user never chose. + expect( + deriveClaudeModelOptionsFromCapabilities({ id: 'claude-mini-9', capabilities: effort(['low', 'medium']) }) + .modelOptions?.find((o) => o.id === 'reasoning_effort')?.currentValue, + ).toBe('medium'); + expect( + deriveClaudeModelOptionsFromCapabilities({ id: 'claude-mini-9', capabilities: effort(['low', 'max']) }) + .modelOptions?.find((o) => o.id === 'reasoning_effort')?.currentValue, + ).toBe('low'); + expect( + deriveClaudeModelOptionsFromCapabilities({ id: 'claude-mini-9', capabilities: effort(['xhigh', 'max']) }) + .modelOptions?.find((o) => o.id === 'reasoning_effort')?.currentValue, + ).toBe('xhigh'); + }); +}); + +describe('buildDiscoveredClaudeModelDescriptor', () => { + it('uses display_name as the name and derives options', () => { + const descriptor = buildDiscoveredClaudeModelDescriptor({ + id: 'claude-opus-9', + displayName: 'Opus 9', + maxInputTokens: 1_000_000, + capabilities: effort(['low', 'medium', 'high', 'xhigh', 'max']), + }); + + expect(descriptor.id).toBe('claude-opus-9'); + expect(descriptor.name).toBe('Opus 9'); + expect(descriptor.description).toBeUndefined(); + expect(descriptor.contextWindowTokens).toBe(1_000_000); + expect(descriptor.modelOptions?.some((o) => o.id === 'reasoning_effort')).toBe(true); + }); + + it('falls back to the id when display_name is missing', () => { + const descriptor = buildDiscoveredClaudeModelDescriptor({ id: 'claude-opus-9' }); + expect(descriptor.name).toBe('claude-opus-9'); + }); +}); diff --git a/apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.ts b/apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.ts new file mode 100644 index 0000000000..ee35b57aea --- /dev/null +++ b/apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.ts @@ -0,0 +1,84 @@ +import { providers, type AgentModelDescriptor, type AgentModelOption } from '@happier-dev/agents'; + +import type { AnthropicModelEntry } from './anthropicModelsFetch'; + +const EFFORT_TIER_ORDER = ['low', 'medium', 'high', 'xhigh', 'max'] as const; +type EffortTier = (typeof EFFORT_TIER_ORDER)[number]; + +// Ultracode copy mirrors the static catalog's `withClaudeEffortModelOptions` (models.ts) so a +// discovered xhigh-capable model surfaces the same session-only toggle as a curated one. +const ULTRACODE_DESCRIPTION = + 'Maximum reasoning with dynamic workflows (forces XHigh effort). Applies to the current session only.'; + +function resolveSupportedEffortTiers(entry: AnthropicModelEntry): readonly EffortTier[] { + const effort = entry.capabilities?.effort; + if (!effort || effort.supported === false) return []; + return EFFORT_TIER_ORDER.filter((tier) => effort[tier]?.supported === true); +} + +// Mirror the API default (`high`) when offered, then step down before stepping up. Preselecting a +// tier above `high` would spend more than the user asked for on a model we know nothing else about. +const DISCOVERED_DEFAULT_EFFORT_PREFERENCE: readonly EffortTier[] = ['high', 'medium', 'low', 'xhigh', 'max']; + +function resolveDiscoveredDefaultEffort(tiers: readonly EffortTier[]): EffortTier | null { + if (tiers.length === 0) return null; + return DISCOVERED_DEFAULT_EFFORT_PREFERENCE.find((tier) => tiers.includes(tier)) ?? null; +} + +/** + * Derive the picker options + context window for a Claude model NOT in the static catalog, + * straight from Anthropic Models API capabilities. + * + * Produces the same `modelOptions` shape as the static catalog's `withClaudeEffortModelOptions` + * (a `reasoning_effort` select + an `ultracode` boolean on xhigh-capable models), so the UI + * renders a discovered model identically to a curated one. + */ +export function deriveClaudeModelOptionsFromCapabilities( + entry: AnthropicModelEntry, +): Readonly<{ contextWindowTokens?: number; modelOptions?: readonly AgentModelOption[] }> { + const tiers = resolveSupportedEffortTiers(entry); + const currentValue = resolveDiscoveredDefaultEffort(tiers); + + const modelOptions: AgentModelOption[] = []; + if (tiers.length > 0 && currentValue) { + modelOptions.push({ + id: 'reasoning_effort', + name: 'Thinking', + type: 'select', + currentValue, + options: tiers.map((tier) => ({ value: tier, name: providers.claude.formatClaudeEffortLevelLabel(tier) })), + }); + if (tiers.includes('xhigh')) { + modelOptions.push({ + id: 'ultracode', + name: 'Ultracode', + description: ULTRACODE_DESCRIPTION, + type: 'boolean', + currentValue: 'false', + }); + } + } + + return { + ...(typeof entry.maxInputTokens === 'number' ? { contextWindowTokens: entry.maxInputTokens } : {}), + ...(modelOptions.length > 0 ? { modelOptions } : {}), + }; +} + +/** + * Build a full descriptor for a discovered (non-static) Claude model. + * + * `name` comes from the API `display_name` (falling back to the id); `description` is left + * undefined — the API carries none, and the field is optional/cosmetic. Add the model to + * `CLAUDE_STATIC_MODELS` later to give it a curated blurb and ordering. + */ +export function buildDiscoveredClaudeModelDescriptor(entry: AnthropicModelEntry): AgentModelDescriptor { + const name = entry.displayName && entry.displayName.length > 0 ? entry.displayName : entry.id; + const derived = deriveClaudeModelOptionsFromCapabilities(entry); + return { + id: entry.id, + name, + ...(derived.contextWindowTokens !== undefined ? { contextWindowTokens: derived.contextWindowTokens } : {}), + ...(derived.modelOptions ? { modelOptions: derived.modelOptions } : {}), + }; +} diff --git a/apps/cli/src/backends/claude/remote/claudeRemoteAgentSdk.ts b/apps/cli/src/backends/claude/remote/claudeRemoteAgentSdk.ts index 6bf18dcd52..5cf4912226 100644 --- a/apps/cli/src/backends/claude/remote/claudeRemoteAgentSdk.ts +++ b/apps/cli/src/backends/claude/remote/claudeRemoteAgentSdk.ts @@ -693,16 +693,20 @@ export async function claudeRemoteAgentSdk(opts: { typeof opts.resumeSessionAt === 'string' && opts.resumeSessionAt.trim().length > 0 ? opts.resumeSessionAt.trim() : null; + const effortModelId = argOverrides.model ?? mode.model; + const effortSupportedLevels = mode.modelEffortLevels; const resolvedEffort = resolveClaudeEffortForModel({ - modelId: argOverrides.model ?? mode.model, + modelId: effortModelId, effort: argOverrides.effort ?? mode.reasoningEffort, + supportedLevels: effortSupportedLevels, }); // Ultracode is a session-only SETTING, not an effort level. The vendored Agent SDK // (0.2.123) has no typed `ultracode` option yet, so it rides the spawned CLI's // `--settings` overlay via extraArgs. Revisit on SDK bump (typed control request). const resolvedUltracode = resolveClaudeUltracodeForModel({ - modelId: argOverrides.model ?? mode.model, + modelId: effortModelId, ultracode: mode.ultracode, + supportedLevels: effortSupportedLevels, }); const extraArgs = (() => { const out: Record = Object.create(null); diff --git a/apps/cli/src/backends/claude/remote/modeHash.ts b/apps/cli/src/backends/claude/remote/modeHash.ts index e9d6f0f36c..68d2b225de 100644 --- a/apps/cli/src/backends/claude/remote/modeHash.ts +++ b/apps/cli/src/backends/claude/remote/modeHash.ts @@ -59,13 +59,16 @@ function buildClaudeUnifiedTerminalLaunchOptionsHashInput(mode: EnhancedMode): R permissionMode: mode.permissionMode, agentModeId: effectiveAgentModeId, }); + const supportedLevels = mode.modelEffortLevels; const resolvedEffort = resolveClaudeEffortForModel({ modelId: mode.model, effort: mode.reasoningEffort, + supportedLevels, }); const resolvedUltracode = resolveClaudeUltracodeForModel({ modelId: mode.model, ultracode: mode.ultracode, + supportedLevels, }); const debugCategories = normalizeClaudeRemoteDebugCategories(mode); @@ -105,13 +108,16 @@ export function hashClaudeEnhancedModeForQueue(mode: EnhancedMode): string { // Spawn-only config for Claude: effort is a query-start option in the Agent SDK and has no dynamic setter. // We normalize effort to the effective value the provider would actually apply (treating "high" as default). + const supportedLevels = mode.modelEffortLevels; const resolvedEffort = resolveClaudeEffortForModel({ modelId: mode.model, effort: mode.reasoningEffort, + supportedLevels, }); const resolvedUltracode = resolveClaudeUltracodeForModel({ modelId: mode.model, ultracode: mode.ultracode, + supportedLevels, }); const debugCategories = normalizeClaudeRemoteDebugCategories(mode); diff --git a/apps/cli/src/backends/claude/runClaude.ts b/apps/cli/src/backends/claude/runClaude.ts index 9723412b93..a8ae5369de 100644 --- a/apps/cli/src/backends/claude/runClaude.ts +++ b/apps/cli/src/backends/claude/runClaude.ts @@ -96,6 +96,11 @@ import { archiveAndCloseRuntimeSession } from '@/session/services/archiveAndClos import { createSessionMetadataShutdownDeadline } from '@/session/services/sessionMetadataShutdownDeadline'; import { resolveRequestedSessionDirectory } from '@/agent/runtime/resolveRequestedSessionDirectory'; import { publishClaudeSessionModelsMetadataBestEffort } from '@/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort'; +import { + resolveClaudeEffortLevelsFromModelDescriptor, + resolveClaudeModelCatalog, +} from '@/backends/claude/models/resolveClaudeModelCatalog'; +import { isCuratedClaudeModelId } from '@/backends/claude/utils/claudeEffort'; import { resolveTerminationArchiveDecision } from '@/agent/runtime/terminationArchivePolicy'; import { buildClaudeAgentState } from '@/backends/claude/localControl/buildClaudeAgentState'; import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; @@ -825,6 +830,28 @@ export async function runClaude(credentials: Credentials, options: StartOptions let currentAgentModeUpdatedAt = typeof options.agentModeUpdatedAt === 'number' ? options.agentModeUpdatedAt : 0; let currentReasoningEffort: string | undefined = undefined; let currentReasoningEffortUpdatedAt = 0; + // Effort tiers the selected model reports. Resolved from the shared Claude model catalog + // (cached, best-effort) and carried on the mode so spawn-time resolution and launch-option + // hashing both see the same value instead of reading a cache at hash time. + let currentModelEffortLevels: readonly string[] = []; + let currentModelEffortLevelsModelId: string | null = null; + const refreshCurrentModelEffortLevels = async (modelId: unknown): Promise => { + const normalized = typeof modelId === 'string' ? modelId.trim() : ''; + if (!normalized || normalized === currentModelEffortLevelsModelId) return; + currentModelEffortLevelsModelId = normalized; + if (isCuratedClaudeModelId(normalized)) { + // Curated models resolve effort from the static table; no catalog lookup needed. + currentModelEffortLevels = []; + return; + } + try { + const models = await resolveClaudeModelCatalog({ timeoutMs: resolveClaudeHelpProbeTimeoutMs() }); + const model = models.find((candidate) => candidate.id === normalized) ?? null; + currentModelEffortLevels = resolveClaudeEffortLevelsFromModelDescriptor(model); + } catch { + currentModelEffortLevels = []; + } + }; let currentUltracode: boolean | undefined = undefined; let currentUltracodeUpdatedAt = 0; let currentFallbackModel: string | undefined = undefined; // Track current fallback model @@ -841,6 +868,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions }); if (adoptedModel.didChange) { currentModel = adoptedModel.modelId; + void refreshCurrentModelEffortLevels(currentModel); currentModelUpdatedAt = adoptedModel.updatedAt; logger.debug(`[loop] Model updated from session metadata: ${adoptedModel.modelId || 'reset to default'}`); } @@ -922,6 +950,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions if (message.meta?.hasOwnProperty('model')) { messageModel = message.meta.model || undefined; // null becomes undefined currentModel = messageModel; + void refreshCurrentModelEffortLevels(currentModel); currentModelUpdatedAt = typeof message.createdAt === 'number' && Number.isFinite(message.createdAt) && message.createdAt > 0 ? message.createdAt @@ -1010,6 +1039,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions customSystemPrompt: messageCustomSystemPrompt, appendSystemPrompt: messageAppendSystemPrompt, reasoningEffort: currentReasoningEffort, + modelEffortLevels: currentModelEffortLevels, ultracode: currentUltracode, ...currentClaudeRemoteMetaState, }; @@ -1219,6 +1249,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions customSystemPrompt: currentCustomSystemPrompt, appendSystemPrompt: currentAppendSystemPrompt, reasoningEffort: currentReasoningEffort, + modelEffortLevels: currentModelEffortLevels, ultracode: currentUltracode, ...currentClaudeRemoteMetaState, }, sessionRuntimeModeKind), @@ -1275,6 +1306,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions typeof options.modelId === 'string' ? options.modelId.trim() : (typeof options.model === 'string' ? options.model.trim() : ''); + void refreshCurrentModelEffortLevels(currentModelId); void publishClaudeSessionModelsMetadataBestEffort({ cwd: workingDirectory, timeoutMs: resolveClaudeHelpProbeTimeoutMs(), @@ -1464,6 +1496,25 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO let currentModelUpdatedAt = typeof options.modelUpdatedAt === 'number' ? options.modelUpdatedAt : 0; let currentReasoningEffort: string | undefined = undefined; let currentReasoningEffortUpdatedAt = 0; + // See the sibling runtime path above: tiers travel on the mode so hashing stays pure. + let currentModelEffortLevels: readonly string[] = []; + let currentModelEffortLevelsModelId: string | null = null; + const refreshCurrentModelEffortLevels = async (modelId: unknown): Promise => { + const normalized = typeof modelId === 'string' ? modelId.trim() : ''; + if (!normalized || normalized === currentModelEffortLevelsModelId) return; + currentModelEffortLevelsModelId = normalized; + if (isCuratedClaudeModelId(normalized)) { + currentModelEffortLevels = []; + return; + } + try { + const models = await resolveClaudeModelCatalog({ timeoutMs: resolveClaudeHelpProbeTimeoutMs() }); + const model = models.find((candidate) => candidate.id === normalized) ?? null; + currentModelEffortLevels = resolveClaudeEffortLevelsFromModelDescriptor(model); + } catch { + currentModelEffortLevels = []; + } + }; let currentUltracode: boolean | undefined = undefined; let currentUltracodeUpdatedAt = 0; let currentFallbackModel: string | undefined = undefined; @@ -1743,6 +1794,7 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO }); if (adoptedModel.didChange) { currentModel = adoptedModel.modelId; + void refreshCurrentModelEffortLevels(currentModel); currentModelUpdatedAt = adoptedModel.updatedAt; } @@ -1814,6 +1866,7 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO if (message.meta?.hasOwnProperty('model')) { messageModel = message.meta.model || undefined; currentModel = messageModel; + void refreshCurrentModelEffortLevels(currentModel); currentModelUpdatedAt = typeof message.createdAt === 'number' && Number.isFinite(message.createdAt) && message.createdAt > 0 ? message.createdAt @@ -1885,6 +1938,7 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO customSystemPrompt: messageCustomSystemPrompt, appendSystemPrompt: messageAppendSystemPrompt, reasoningEffort: currentReasoningEffort, + modelEffortLevels: currentModelEffortLevels, ultracode: currentUltracode, ...currentClaudeRemoteMetaState, }; @@ -2026,6 +2080,7 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO customSystemPrompt: currentCustomSystemPrompt, appendSystemPrompt: currentAppendSystemPrompt, reasoningEffort: currentReasoningEffort, + modelEffortLevels: currentModelEffortLevels, ultracode: currentUltracode, ...currentClaudeRemoteMetaState, }, sessionRuntimeModeKind), @@ -2068,6 +2123,7 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO typeof options.modelId === 'string' ? options.modelId.trim() : (typeof options.model === 'string' ? options.model.trim() : ''); + void refreshCurrentModelEffortLevels(currentModelId); void publishClaudeSessionModelsMetadataBestEffort({ cwd: workingDirectory, timeoutMs: resolveClaudeHelpProbeTimeoutMs(), diff --git a/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts b/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts index 37ff1d1d6c..8020e856a2 100644 --- a/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts +++ b/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts @@ -1,6 +1,5 @@ -import { AGENT_MODEL_CONFIG } from '@happier-dev/agents'; - import type { Metadata } from '@/api/types'; +import { resolveClaudeModelCatalog } from '@/backends/claude/models/resolveClaudeModelCatalog'; type ClaudeSessionModelsState = NonNullable; @@ -18,7 +17,12 @@ export async function resolveClaudeSessionModelsState(params: Readonly<{ if (!supportsEffort) return null; const updatedAt = params.nowMs(); - const models = AGENT_MODEL_CONFIG.claude.staticModels ?? []; + // Same owner as the new-session preflight probe, so the in-session picker cannot disagree about + // which models exist or which effort tiers they support. Cached per account; falls back to the + // curated catalog when the Models API is unavailable. + // No binding needed here: an in-session process already has CLAUDE_CONFIG_DIR pointed at the + // materialized account, and the catalog keys its cache on that resolved dir. + const models = await resolveClaudeModelCatalog({ timeoutMs: params.timeoutMs }); return { v: 1, @@ -32,6 +36,7 @@ export async function resolveClaudeSessionModelsState(params: Readonly<{ name: model.name, ...(description ? { description } : {}), ...(typeof model.contextWindowTokens === 'number' ? { contextWindowTokens: model.contextWindowTokens } : {}), + ...(typeof model.extendedContextModelId === 'string' ? { extendedContextModelId: model.extendedContextModelId } : {}), ...(Array.isArray(model.modelOptions) && model.modelOptions.length > 0 ? { modelOptions: model.modelOptions } : {}), diff --git a/apps/cli/src/backends/claude/unifiedTerminal/runtimeControlIntegration.ts b/apps/cli/src/backends/claude/unifiedTerminal/runtimeControlIntegration.ts index 0841bc6065..00107ca9dc 100644 --- a/apps/cli/src/backends/claude/unifiedTerminal/runtimeControlIntegration.ts +++ b/apps/cli/src/backends/claude/unifiedTerminal/runtimeControlIntegration.ts @@ -178,7 +178,11 @@ export function mapEnhancedModeToDesiredRuntimeConfig(mode: EnhancedMode): Claud if (typeof mode.ultracode === 'boolean') { // Capability-gate here so the controller never types `/effort ultracode` at a model // that does not offer it (conservative: an unhonorable request resolves to off). - desired.ultracode = resolveClaudeUltracodeForModel({ modelId: mode.model, ultracode: mode.ultracode }); + desired.ultracode = resolveClaudeUltracodeForModel({ + modelId: mode.model, + ultracode: mode.ultracode, + supportedLevels: mode.modelEffortLevels, + }); } return desired; } diff --git a/apps/cli/src/backends/claude/utils/claudeEffort.test.ts b/apps/cli/src/backends/claude/utils/claudeEffort.test.ts index 03247baaa5..fff2631e59 100644 --- a/apps/cli/src/backends/claude/utils/claudeEffort.test.ts +++ b/apps/cli/src/backends/claude/utils/claudeEffort.test.ts @@ -36,6 +36,56 @@ describe('buildClaudeEffortCliArgs', () => { expect(buildClaudeEffortCliArgs({ modelId: 'claude-fable-5[1m]', effort: 'high' })).toEqual([]); expect(buildClaudeEffortCliArgs({ modelId: 'claude-sonnet-4-6[1m]', effort: 'low' })).toEqual(['--effort', 'low']); }); + + it('forwards effort for a discovered model only against its reported tiers', () => { + // `reasoningEffort` is session-scoped and is not cleared when the model changes, so an + // unknown model id alone is not evidence that the carried level is supported. Forward only + // when the caller supplies the tiers the Models API actually reported, clamped to them. + expect(buildClaudeEffortCliArgs({ + modelId: 'claude-opus-9', + effort: 'xhigh', + supportedLevels: ['low', 'medium', 'high', 'xhigh', 'max'], + })).toEqual(['--effort', 'xhigh']); + + // Carried level exceeds what the model reports: clamp down rather than pass it through. + expect(buildClaudeEffortCliArgs({ + modelId: 'claude-opus-9', + effort: 'max', + supportedLevels: ['low', 'medium'], + })).toEqual(['--effort', 'medium']); + + // No reported tiers means no evidence — never send a stale session effort. + expect(buildClaudeEffortCliArgs({ modelId: 'claude-opus-9', effort: 'max' })).toEqual([]); + expect(buildClaudeEffortCliArgs({ modelId: 'glm-4.6', effort: 'max' })).toEqual([]); + }); + + it('prefers a discovered model reported tiers over a substring alias match', () => { + // `claude-opus-5-preview` contains the curated `opus-5` alias but is a different model; its own + // reported tiers must win so the picker and the spawned flag agree. + expect(buildClaudeEffortCliArgs({ + modelId: 'claude-opus-5-preview', + effort: 'max', + supportedLevels: ['low', 'medium'], + })).toEqual(['--effort', 'medium']); + // A curated id keeps its static table even when tiers are supplied. + expect(buildClaudeEffortCliArgs({ + modelId: 'claude-haiku-4-5', + effort: 'high', + supportedLevels: ['low', 'medium', 'high'], + })).toEqual([]); + }); + + it('never sends --effort when no model is selected', () => { + expect(buildClaudeEffortCliArgs({ modelId: undefined, effort: 'max' })).toEqual([]); + expect(buildClaudeEffortCliArgs({ modelId: '', effort: 'max' })).toEqual([]); + expect(buildClaudeEffortCliArgs({ modelId: ' ', effort: 'max' })).toEqual([]); + expect(buildClaudeEffortCliArgs({ modelId: 'default', effort: 'max' })).toEqual([]); + }); + + it('never sends --effort for known models that do not support it', () => { + expect(buildClaudeEffortCliArgs({ modelId: 'claude-haiku-4-5', effort: 'high' })).toEqual([]); + expect(buildClaudeEffortCliArgs({ modelId: 'claude-sonnet-4-5', effort: 'high' })).toEqual([]); + }); }); describe('resolveClaudeUltracodeForModel', () => { @@ -54,6 +104,22 @@ describe('resolveClaudeUltracodeForModel', () => { expect(resolveClaudeUltracodeForModel({ modelId: 'claude-fable-5', ultracode: false })).toBe(false); expect(resolveClaudeUltracodeForModel({ modelId: 'claude-fable-5', ultracode: undefined })).toBe(false); expect(resolveClaudeUltracodeForModel({ modelId: undefined, ultracode: true })).toBe(false); + expect(resolveClaudeUltracodeForModel({ modelId: 'default', ultracode: true })).toBe(false); + }); + + it('honors ultracode for a discovered model only against its reported tiers', () => { + expect(resolveClaudeUltracodeForModel({ + modelId: 'claude-opus-9', + ultracode: true, + supportedLevels: ['low', 'high', 'xhigh'], + })).toBe(true); + expect(resolveClaudeUltracodeForModel({ + modelId: 'claude-opus-9', + ultracode: true, + supportedLevels: ['low', 'medium', 'high'], + })).toBe(false); + // Same rule as effort: a stale session `ultracode` override is not evidence of support. + expect(resolveClaudeUltracodeForModel({ modelId: 'claude-opus-9', ultracode: true })).toBe(false); }); }); diff --git a/apps/cli/src/backends/claude/utils/claudeEffort.ts b/apps/cli/src/backends/claude/utils/claudeEffort.ts index b8707d157d..fa37bf7004 100644 --- a/apps/cli/src/backends/claude/utils/claudeEffort.ts +++ b/apps/cli/src/backends/claude/utils/claudeEffort.ts @@ -1,9 +1,67 @@ -import { providers as agentProviders } from '@happier-dev/agents'; +import { AGENT_MODEL_CONFIG, providers as agentProviders } from '@happier-dev/agents'; export type ClaudeEffortLevel = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; const CLAUDE_EFFORT_LEVEL_PRIORITY: readonly ClaudeEffortLevel[] = ['low', 'medium', 'high', 'xhigh', 'max']; +/** Normalize a Claude model id for the "is this a curated model?" check (strip `[1m]` + dated suffix). */ +function normalizeClaudeModelIdForKnownCheck(raw: unknown): string { + const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + return value.replace(/\[[^\]]*\]$/u, '').replace(/-\d{8}$/u, ''); +} + +const KNOWN_STATIC_CLAUDE_MODEL_IDS: ReadonlySet = new Set( + (AGENT_MODEL_CONFIG.claude.staticModels ?? []).map((model) => normalizeClaudeModelIdForKnownCheck(model.id)), +); + +/** + * True when the id is a model Happier curates in the static catalog (or a known bare alias). + * + * Used to distinguish a curated model that intentionally has no effort control (e.g. Haiku — + * must never receive `--effort`) from a dynamically-discovered model, where a user-selected + * effort is trusted and passed through. + */ +export function isCuratedClaudeModelId(modelIdRaw: unknown): boolean { + const id = normalizeClaudeModelIdForKnownCheck(modelIdRaw); + if (!id) return false; + if (KNOWN_STATIC_CLAUDE_MODEL_IDS.has(id)) return true; + return id === 'opus' || id === 'sonnet' || id === 'haiku' || id === 'fable'; +} + +/** Narrow caller-supplied tiers (e.g. from the Anthropic Models API) to known effort levels. */ +function normalizeReportedClaudeEffortLevels(raw: unknown): readonly ClaudeEffortLevel[] { + if (!Array.isArray(raw)) return []; + const levels = raw + .map((value) => normalizeClaudeEffortLevel(value)) + .filter((level): level is ClaudeEffortLevel => level !== null); + return CLAUDE_EFFORT_LEVEL_PRIORITY.filter((level) => levels.includes(level)); +} + +/** + * Effort levels we have evidence the model supports. + * + * The curated table wins for curated models — including curated models with NO effort support + * (Haiku), which must never be overridden by reported tiers. For a discovered model the only + * evidence is what the caller passes in; absent that, there is none. `reasoningEffort` is + * session-scoped and is not cleared when the model changes, so an unrecognised id is not by + * itself a reason to forward a carried level. + */ +function resolveEvidencedClaudeEffortLevels( + modelIdRaw: unknown, + reportedRaw: unknown, +): readonly ClaudeEffortLevel[] { + // A discovered id that merely CONTAINS a curated alias (`claude-opus-5-preview` matching the + // `opus-5` substring rule) is not that model. Its own reported tiers win, so the picker and the + // spawned flag cannot disagree. + const reported = normalizeReportedClaudeEffortLevels(reportedRaw); + if (!isCuratedClaudeModelId(modelIdRaw) && reported.length > 0) return reported; + + const staticLevels = resolveClaudeEffortLevelsForKnownAliasOrModel(modelIdRaw); + if (staticLevels.length > 0) return staticLevels; + if (isCuratedClaudeModelId(modelIdRaw)) return []; + return reported; +} + function normalizeClaudeEffortLevel(raw: unknown): ClaudeEffortLevel | null { const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; if (!value) return null; @@ -96,10 +154,17 @@ function resolveBestSupportedClaudeEffort( export function resolveClaudeEffortForModel(params: Readonly<{ modelId: unknown; effort: unknown; + /** Effort tiers the model reported (Anthropic Models API). Required for discovered models. */ + supportedLevels?: readonly unknown[]; }>): ClaudeEffortLevel | null { const effort = normalizeClaudeEffortLevel(params.effort); if (!effort) return null; - const supportedLevels = resolveClaudeEffortLevelsForKnownAliasOrModel(params.modelId); + // No explicit model means the CLI picks its own default; forwarding `--effort` would apply a + // level the user never chose for a model we cannot check support against. + const normalizedModelId = normalizeClaudeModelIdForKnownCheck(params.modelId); + if (!normalizedModelId || normalizedModelId === 'default') return null; + + const supportedLevels = resolveEvidencedClaudeEffortLevels(params.modelId, params.supportedLevels); if (supportedLevels.length === 0) return null; const normalized = resolveBestSupportedClaudeEffort(effort, supportedLevels); @@ -112,6 +177,7 @@ export function resolveClaudeEffortForModel(params: Readonly<{ export function buildClaudeEffortCliArgs(params: Readonly<{ modelId: unknown; effort: unknown; + supportedLevels?: readonly unknown[]; }>): string[] { const resolved = resolveClaudeEffortForModel(params); return resolved ? ['--effort', resolved] : []; @@ -137,9 +203,17 @@ function normalizeUltracodeRequest(raw: unknown): boolean { export function resolveClaudeUltracodeForModel(params: Readonly<{ modelId: unknown; ultracode: unknown; + /** Effort tiers the model reported (Anthropic Models API). Required for discovered models. */ + supportedLevels?: readonly unknown[]; }>): boolean { if (!normalizeUltracodeRequest(params.ultracode)) return false; - return resolveClaudeEffortLevelsForKnownAliasOrModel(params.modelId).includes('xhigh'); + + const normalizedModelId = normalizeClaudeModelIdForKnownCheck(params.modelId); + if (!normalizedModelId || normalizedModelId === 'default') return false; + + // Ultracode forces xhigh, so it needs the same evidence as an xhigh effort selection. A + // discovered model qualifies only when the caller passes the tiers the API reported. + return resolveEvidencedClaudeEffortLevels(params.modelId, params.supportedLevels).includes('xhigh'); } /** The `--settings` JSON overlay value that turns ultracode on for a spawned Claude CLI. */ diff --git a/apps/cli/src/capabilities/probes/resolveAgentProbeVariant.test.ts b/apps/cli/src/capabilities/probes/resolveAgentProbeVariant.test.ts new file mode 100644 index 0000000000..4dee74913b --- /dev/null +++ b/apps/cli/src/capabilities/probes/resolveAgentProbeVariant.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveAgentProbeVariant } from './resolveAgentProbeVariant'; + +describe('resolveAgentProbeVariant', () => { + it('partitions the Claude models probe cache by connected account', () => { + const profileA = resolveAgentProbeVariant({ + agentId: 'claude', + connectedServices: { + v: 1, + bindingsByServiceId: { + 'claude-subscription': { source: 'connected', selection: 'profile', profileId: 'profile-a' }, + }, + }, + }); + const profileB = resolveAgentProbeVariant({ + agentId: 'claude', + connectedServices: { + v: 1, + bindingsByServiceId: { + 'claude-subscription': { source: 'connected', selection: 'profile', profileId: 'profile-b' }, + }, + }, + }); + const native = resolveAgentProbeVariant({ agentId: 'claude', connectedServices: null }); + + // Each account probes its own model list, so they must never share a cache entry. + expect(new Set([profileA, profileB, native]).size).toBe(3); + }); + + it('partitions the Claude models probe cache by group binding', () => { + const group = resolveAgentProbeVariant({ + agentId: 'claude', + connectedServices: { + v: 1, + bindingsByServiceId: { + 'claude-subscription': { source: 'connected', selection: 'group', groupId: 'group-a' }, + }, + }, + }); + const profile = resolveAgentProbeVariant({ + agentId: 'claude', + connectedServices: { + v: 1, + bindingsByServiceId: { + 'claude-subscription': { source: 'connected', selection: 'profile', profileId: 'group-a' }, + }, + }, + }); + + expect(group).not.toBe(profile); + }); +}); diff --git a/docs/agents-catalog.md b/docs/agents-catalog.md index f9019b81c5..66a28c9e15 100644 --- a/docs/agents-catalog.md +++ b/docs/agents-catalog.md @@ -143,6 +143,44 @@ Instead: - explicit “resume inactive session” is **fail-closed**: if `loadSession` fails, we surface the error instead of silently starting a fresh vendor session - any ACP capability probing (e.g. `includeAcpCapabilities`) is reserved for opt-in diagnostics / e2e probes, not day-to-day UX +### Dynamic model lists + +Whether a provider's model list is resolved at runtime is one catalog fact: +`AGENT_MODEL_CONFIG..dynamicProbe` in `@happier-dev/agents`. + +- `'static-only'` — the curated `staticModels` list is the whole truth. The app does not run the + preflight models probe and ignores any `sessionModelsV1` the session publishes. +- `'auto'` (default when omitted) — the app runs the preflight models probe on the new-session + screen **and** consumes the in-session `sessionModelsV1` list. Both readers share this one flag, + so flipping it turns on both. + +A provider that publishes `sessionModelsV1` from its runtime and is left on `'static-only'` has an +active producer with its consumer gated off — the published list is silently discarded. Flipping +that flag is a user-visible change: the app switches to the dynamic row builder, which carries less +per-model metadata than the static one, so audit what the dynamic path drops before flipping. + +A provider with both surfaces needs **one owner** for the model list. Claude's is +`apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts`: the preflight probe adapter and +the in-session `sessionModelsV1` publisher both read it, so the two pickers cannot disagree about +which models exist or which effort tiers they report. It caches per resolved account config dir + +endpoint + ambient-credential fingerprint, so a session start does not pay a network round trip. +Effort tiers are resolved once when the session mode is built and travel on the mode, so spawn-time +resolution and launch-option hashing see the same value and hashing stays pure. + +Provider-owned probing: +- Implement the probe in `apps/cli/src/backends//preflight/**` and register it through + `getPreflightSessionControlsProbeAdapter`. Type it as `PreflightSessionControlsProbeAdapter` — + that is the shape the caller invokes, and its params carry `connectedServices` and + `accountSettings`. Typing it as the narrower `PreflightModelsProbeAdapter` compiles but silently + drops those inputs. +- Set `resolveModelsProbeVariant` on the catalog entry whenever the probe result depends on + something other than the agent id — runtime flavor, auth method, or the bound connected account. + The returned string partitions the probe cache; without it, results computed for one account or + runtime mode are served to another. Codex and Claude both key theirs on the connected-service + binding. +- Fail closed to the static catalog. A probe that cannot authenticate returns `null` rather than + probing with whatever credential happens to be in the daemon's environment. + --- ## Adding a new agent/provider (end-to-end) diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index 2b1f3e04f3..52ad42c0bd 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -118,6 +118,8 @@ export { type AgentModelConfig, type AgentModelDescriptor, type AgentModelNonAcpApplyScope, + type AgentModelOption, + type AgentModelOptionValueId, } from './models.js'; export { AGENT_LOCAL_CLI_CONFIG, From ba5645d0dd1c25f6fa9861748d6875fa3cc7a92d Mon Sep 17 00:00:00 2001 From: danljungstrom Date: Sun, 9 Aug 2026 13:12:03 +0000 Subject: [PATCH 02/11] feat(claude-models)!: consume the dynamic Claude model list Flip Claude to dynamicProbe: 'auto' so the app runs the preflight models probe and consumes the sessionModelsV1 list the CLI already publishes. The flip switches the app from the static row builder to the dynamic one, which carried less per-model metadata: `extendedContextModelId` was dropped by the persisted session-model schema, the preflight parse, the probe cache, and both dynamic row builders. AgentInput gates the 1M-context toggle on that field, so without this the toggle would disappear for Claude and a session already on claude-sonnet-4-6[1m] would lose its model controls. Carry the field through every hop, and restore it from the catalog in mergeDynamicModelOptionWithCatalog so a curated model arriving through the dynamic path keeps its extended-context variant even when the dynamic source has no reason to know about it. A dynamic source that declares its own variant is honored too. `extendedContextModelId` is the only ModelOption field the dynamic path was missing; the rest (label, description, modelOptions) already flowed or were backfilled from the catalog. Still open, tracked separately and not user-blocking for the list itself: - modelEffortLevels is resolved once against options.modelId, so switching model mid-session keeps the previous model's tiers; - the first turn builds its mode before the catalog resolves, so the first spawn of a discovered-model session carries no tiers. --- apps/cli/src/backends/claude/claudeRemote.ts | 4 +- .../backends/claude/cli/terminalOptions.ts | 10 +- apps/cli/src/backends/claude/loop.ts | 7 + .../claudeModelEffortLevelsTracker.test.ts | 181 ++++++++++++++++++ .../models/claudeModelEffortLevelsTracker.ts | 98 ++++++++++ .../models/resolveClaudeModelCatalog.test.ts | 58 +++++- .../models/resolveClaudeModelCatalog.ts | 90 ++++++--- .../claude/preflight/anthropicModelsFetch.ts | 22 ++- .../claudePreflightModelsProbeAdapter.test.ts | 7 +- .../preflight/deriveDiscoveredClaudeModel.ts | 23 +-- .../claude/remote/claudeRemoteAgentSdk.ts | 3 +- .../src/backends/claude/remote/modeHash.ts | 10 +- apps/cli/src/backends/claude/runClaude.ts | 81 +++----- ...udeSessionModelsMetadataBestEffort.test.ts | 67 +++++++ ...shClaudeSessionModelsMetadataBestEffort.ts | 37 +++- .../resolveClaudeSessionModelsState.test.ts | 10 +- .../resolveClaudeSessionModelsState.ts | 14 +- .../runtimeControlIntegration.ts | 4 +- .../claude/utils/claudeEffort.test.ts | 28 +++ .../src/backends/claude/utils/claudeEffort.ts | 34 +++- .../agentModelsProbe.staticOnly.test.ts | 1 - .../probes/resolveAgentProbeVariant.test.ts | 14 ++ ...ssionPreflightModelsState.refresh.test.tsx | 50 ++++- .../domains/models/dynamicModelProbeCache.ts | 4 + .../sync/domains/models/modelOptions.test.ts | 133 ++++++++++--- .../sync/domains/models/modelOptions.ts | 27 +++ ...PreflightModelListFromProbeModelsResult.ts | 4 + .../sync/domains/state/storageTypes.ts | 2 + packages/agents/src/index.ts | 1 + packages/agents/src/models.ts | 28 ++- 30 files changed, 878 insertions(+), 174 deletions(-) create mode 100644 apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.test.ts create mode 100644 apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.ts diff --git a/apps/cli/src/backends/claude/claudeRemote.ts b/apps/cli/src/backends/claude/claudeRemote.ts index 1e3a8306a9..07e56a7a59 100644 --- a/apps/cli/src/backends/claude/claudeRemote.ts +++ b/apps/cli/src/backends/claude/claudeRemote.ts @@ -16,7 +16,7 @@ import { logClaudeRuntimeAuthEnvDiagnostic } from "./spawn/logClaudeRuntimeAuthE import { ensureClaudeJsRuntimeExecutable } from "./utils/ensureClaudeJsRuntimeExecutable"; import { resolveClaudeCliPath } from "./utils/resolveClaudeCliPath"; import { resolveCliRuntimeAssetPath } from '@/runtime/assets/resolveCliRuntimeAssetPath'; -import { buildClaudeEffortCliArgs } from "./utils/claudeEffort"; +import { buildClaudeEffortCliArgs, resolveModeEffortLevelsForModel } from "./utils/claudeEffort"; import { buildClaudeCompactionCompletedEvent, buildClaudeCompactionLifecycleId, @@ -228,7 +228,7 @@ export async function claudeRemote(opts: { const effortArgs = buildClaudeEffortArgs({ modelId: argOverrides.model ?? initial.mode.model, effort: argOverrides.effort ?? initial.mode.reasoningEffort, - supportedLevels: initial.mode.modelEffortLevels, + supportedLevels: resolveModeEffortLevelsForModel(initial.mode, argOverrides.model ?? initial.mode.model), }); const extraArgs = [ ...(opts.hookPluginDir ? ['--plugin-dir', opts.hookPluginDir] : []), diff --git a/apps/cli/src/backends/claude/cli/terminalOptions.ts b/apps/cli/src/backends/claude/cli/terminalOptions.ts index dec591e416..9e4e3f5194 100644 --- a/apps/cli/src/backends/claude/cli/terminalOptions.ts +++ b/apps/cli/src/backends/claude/cli/terminalOptions.ts @@ -1,5 +1,9 @@ import type { EnhancedMode } from '@/backends/claude/loop'; -import { buildClaudeEffortCliArgs, resolveClaudeUltracodeForModel } from '@/backends/claude/utils/claudeEffort'; +import { + buildClaudeEffortCliArgs, + resolveClaudeUltracodeForModel, + resolveModeEffortLevelsForModel, +} from '@/backends/claude/utils/claudeEffort'; import { getClaudeRemoteSystemPrompt } from '@/backends/claude/utils/remoteSystemPrompt'; import { parseClaudeSdkFlagOverridesFromArgs } from '@/backends/claude/remote/sdkFlagOverrides'; @@ -192,7 +196,7 @@ export function resolveClaudeTerminalCliOptions(params: Readonly<{ extraArgs.push(...buildClaudeEffortCliArgs({ modelId: effectiveModel, effort: argOverrides.effort ?? params.mode.reasoningEffort, - supportedLevels: params.mode.modelEffortLevels, + supportedLevels: resolveModeEffortLevelsForModel(params.mode, effectiveModel), })); if (effectiveModel) { extraArgs.push('--model', effectiveModel); @@ -234,7 +238,7 @@ export function resolveClaudeTerminalCliOptions(params: Readonly<{ ultracodeEnabled: resolveClaudeUltracodeForModel({ modelId: effectiveModel, ultracode: params.mode.ultracode, - supportedLevels: params.mode.modelEffortLevels, + supportedLevels: resolveModeEffortLevelsForModel(params.mode, effectiveModel), }), diagnostics: Object.freeze([...diagnostics]), }); diff --git a/apps/cli/src/backends/claude/loop.ts b/apps/cli/src/backends/claude/loop.ts index 27ca84b5ef..4d48ef5979 100644 --- a/apps/cli/src/backends/claude/loop.ts +++ b/apps/cli/src/backends/claude/loop.ts @@ -63,6 +63,13 @@ export interface EnhancedMode { * launch-option hashing stays a pure function of the mode. */ modelEffortLevels?: readonly string[]; + /** + * The model `modelEffortLevels` was resolved for. + * + * Call sites can override the model (e.g. `--model` inside `claudeArgs`), so tiers are only + * evidence when they belong to the model actually being launched. + */ + modelEffortLevelsModelId?: string | null; // Claude remote-mode (provider-scoped) settings forwarded via message meta. claudeRemoteAgentSdkEnabled?: boolean; diff --git a/apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.test.ts b/apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.test.ts new file mode 100644 index 0000000000..c0813e08c3 --- /dev/null +++ b/apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createEnvKeyScope } from '@/testkit/env/envScope'; + +import type { AnthropicModelEntry } from '@/backends/claude/preflight/anthropicModelsFetch'; + +const { fetchAnthropicModelsMock, readClaudeCodeNativeCredentialMock } = vi.hoisted(() => ({ + fetchAnthropicModelsMock: vi.fn<(...args: unknown[]) => Promise>(), + readClaudeCodeNativeCredentialMock: vi.fn<(...args: unknown[]) => Promise>(), +})); + +vi.mock('@/backends/claude/preflight/anthropicModelsFetch', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchAnthropicModels: fetchAnthropicModelsMock }; +}); + +vi.mock('@/backends/claude/connectedServices/nativeAuth/claudeCodeCredentialFile', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readClaudeCodeNativeCredential: readClaudeCodeNativeCredentialMock }; +}); + +import { createClaudeModelEffortLevelsTracker } from './claudeModelEffortLevelsTracker'; +import { resetClaudeModelCatalogCacheForTests } from './resolveClaudeModelCatalog'; + +const envKeys = [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_OAUTH_TOKEN', + 'CLAUDE_CODE_OAUTH_TOKEN', + 'ANTHROPIC_BASE_URL', +] as const; +let envScope = createEnvKeyScope(envKeys); + +const effortCapabilities = (tiers: readonly string[]): AnthropicModelEntry['capabilities'] => ({ + effort: { + supported: true, + ...Object.fromEntries(tiers.map((tier) => [tier, { supported: true }])), + }, +}); + +const createTracker = (): ReturnType => + createClaudeModelEffortLevelsTracker({ resolveTimeoutMs: () => 1_000 }); + +beforeEach(() => { + fetchAnthropicModelsMock.mockReset(); + readClaudeCodeNativeCredentialMock.mockReset(); + readClaudeCodeNativeCredentialMock.mockResolvedValue(null); + resetClaudeModelCatalogCacheForTests(); + envScope.restore(); + envScope = createEnvKeyScope(envKeys); + process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; +}); + +afterEach(() => { + // Leave no cached catalog behind: the cache is module state, so a later suite sharing this + // module context would otherwise read entries this one populated. + resetClaudeModelCatalogCacheForTests(); + envScope.restore(); + envScope = createEnvKeyScope(envKeys); +}); + +describe('createClaudeModelEffortLevelsTracker', () => { + it('reports the tiers a discovered model declares', async () => { + fetchAnthropicModelsMock.mockResolvedValue([ + { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'high', 'xhigh']) }, + ]); + const tracker = createTracker(); + + await tracker.refresh('claude-opus-9'); + + expect(tracker.getModelId()).toBe('claude-opus-9'); + expect(tracker.getLevels()).toEqual(['low', 'high', 'xhigh']); + }); + + it('forgets the previous model on reset to the CLI default', async () => { + fetchAnthropicModelsMock.mockResolvedValue([ + { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'xhigh']) }, + ]); + const tracker = createTracker(); + await tracker.refresh('claude-opus-9'); + expect(tracker.getLevels()).not.toEqual([]); + + await tracker.refresh(undefined); + + // Leaving the tiers live would clamp whatever is selected next against this model. + expect(tracker.getModelId()).toBeNull(); + expect(tracker.getLevels()).toEqual([]); + }); + + it('does not consult the catalog for a curated model', async () => { + const tracker = createTracker(); + + await tracker.refresh('claude-haiku-4-5'); + + // Curated models resolve effort from the static table, so a network round trip is wasted work. + expect(fetchAnthropicModelsMock).not.toHaveBeenCalled(); + expect(tracker.getLevels()).toEqual([]); + expect(tracker.getModelId()).toBe('claude-haiku-4-5'); + }); + + it('reports no tiers when the catalog lookup fails', async () => { + fetchAnthropicModelsMock.mockRejectedValue(new Error('network')); + const tracker = createTracker(); + + await tracker.refresh('claude-opus-9'); + + // Fails closed: no evidence means no `--effort`, never a guessed level. + expect(tracker.getLevels()).toEqual([]); + }); + + it('does not let a late lookup publish a superseded model tiers', async () => { + // Concurrent resolutions for one account share a single fetch, so both refreshes await the same + // promise. The guard has to be the model id, not which lookup happened to start first. + let releaseCatalog: ((entries: AnthropicModelEntry[]) => void) | null = null; + fetchAnthropicModelsMock.mockImplementation(() => new Promise((resolve) => { + releaseCatalog = resolve; + })); + + const tracker = createTracker(); + const first = tracker.refresh('claude-opus-9'); + const second = tracker.refresh('claude-sonnet-9'); + expect(tracker.getModelId()).toBe('claude-sonnet-9'); + + // Credential resolution is async, so the fetch starts a tick after refresh is called. + await new Promise((resolve) => { setTimeout(resolve, 0); }); + + const release = releaseCatalog as ((entries: AnthropicModelEntry[]) => void) | null; + if (!release) { + throw new Error('expected the catalog lookup to be in flight'); + } + release([ + { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'xhigh']) }, + { id: 'claude-sonnet-9', displayName: 'Sonnet 9', capabilities: effortCapabilities(['low']) }, + ]); + await Promise.all([first, second]); + + // The superseded lookup must not publish Opus 9 tiers under Sonnet 9. + expect(tracker.getModelId()).toBe('claude-sonnet-9'); + expect(tracker.getLevels()).toEqual(['low']); + }); + + it('does not block the caller past its budget on a cold catalog', async () => { + // SessionClient awaits the user-message callback as part of the pending-queue handoff, so an + // unbounded wait here would hold the queue behind this fetch. + fetchAnthropicModelsMock.mockImplementation(() => new Promise(() => {})); + const tracker = createTracker(); + + const startedAt = Date.now(); + await tracker.refreshWithin('claude-opus-9', 30); + + expect(Date.now() - startedAt).toBeLessThan(2_000); + // Past the budget the turn proceeds with no evidence, exactly as it would have before. + expect(tracker.getLevels()).toEqual([]); + }); + + it('returns immediately once the catalog is cached', async () => { + fetchAnthropicModelsMock.mockResolvedValue([ + { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'xhigh']) }, + ]); + const tracker = createTracker(); + await tracker.refresh('claude-opus-9'); + + // A different model on a warm catalog must still resolve within the budget, not fall back to []. + const second = createTracker(); + await second.refreshWithin('claude-opus-9', 30); + expect(second.getLevels()).toEqual(['low', 'xhigh']); + }); + + it('shares one catalog fetch across concurrent refreshes for the same account', async () => { + fetchAnthropicModelsMock.mockResolvedValue([ + { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low']) }, + ]); + const trackerA = createTracker(); + const trackerB = createTracker(); + + await Promise.all([trackerA.refresh('claude-opus-9'), trackerB.refresh('claude-opus-9')]); + + // The preflight probe and the session publisher both resolve at session start; one fetch is enough. + expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.ts b/apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.ts new file mode 100644 index 0000000000..cdff5c10fc --- /dev/null +++ b/apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.ts @@ -0,0 +1,98 @@ +import { isCuratedClaudeModelId } from '@/backends/claude/utils/claudeEffort'; + +import { + resolveClaudeEffortLevelsFromModelDescriptor, + resolveClaudeModelCatalog, +} from './resolveClaudeModelCatalog'; + +/** + * How long a caller on the message path will wait for a cold catalog. + * + * `SessionClient` awaits the user-message callback as part of the pending-queue handoff, so an + * unbounded wait here would hold the queue behind a network fetch on the first prompt after + * switching to an uncached model. Past the budget the turn proceeds with no tiers — the same + * fail-closed state as before — and the resolution continues so the next turn has them. + */ +export const CLAUDE_MODEL_EFFORT_TIER_WAIT_MS = 400; + +export type ClaudeModelEffortLevelsTracker = Readonly<{ + /** Resolve the tiers for a newly selected model. Safe to call repeatedly; never throws. */ + refresh: (modelId: unknown) => Promise; + /** + * Resolve the tiers, but never block the caller for longer than `waitMs`. + * + * Use this on any path that holds up message delivery. + */ + refreshWithin: (modelId: unknown, waitMs?: number) => Promise; + /** Tiers for the model `getModelId()` reports, or `[]` when there is no evidence yet. */ + getLevels: () => readonly string[]; + /** The model the current tiers belong to, so callers never apply them to a different model. */ + getModelId: () => string | null; +}>; + +/** + * Tracks the effort tiers reported for the session's current Claude model. + * + * The tiers travel on the session mode rather than being read from a cache at spawn or hash time, + * so launch-option hashing stays a pure function of the mode. Single owner: the two runtime paths + * in `runClaude` each create one, and previously each carried its own copy of this logic, which + * let their staleness semantics drift. + */ +export function createClaudeModelEffortLevelsTracker(params: Readonly<{ + resolveTimeoutMs: () => number; +}>): ClaudeModelEffortLevelsTracker { + let levels: readonly string[] = []; + let modelId: string | null = null; + + const refresh = async (nextModelId: unknown): Promise => { + const normalized = typeof nextModelId === 'string' ? nextModelId.trim() : ''; + if (!normalized) { + // Reset to the CLI default: forget the previous model's tiers rather than leaving them live + // for whatever is selected next. + modelId = null; + levels = []; + return; + } + if (normalized === modelId) return; + + // Drop the previous model's tiers at the transition, not when the lookup returns: a spawn + // between here and the resolve would otherwise clamp the new model against them. + modelId = normalized; + levels = []; + + // Curated models resolve effort from the static table; no catalog lookup needed. + if (isCuratedClaudeModelId(normalized)) return; + + try { + const models = await resolveClaudeModelCatalog({ timeoutMs: params.resolveTimeoutMs() }); + // A newer model may have been selected while this lookup was in flight; a late resolve must + // not publish the previous model's tiers under the current model. + if (modelId !== normalized) return; + const model = models.find((candidate) => candidate.id === normalized) ?? null; + levels = resolveClaudeEffortLevelsFromModelDescriptor(model); + } catch { + if (modelId !== normalized) return; + levels = []; + } + }; + + const refreshWithin = async (nextModelId: unknown, waitMs = CLAUDE_MODEL_EFFORT_TIER_WAIT_MS): Promise => { + const resolution = refresh(nextModelId); + let timer: ReturnType | null = null; + try { + await Promise.race([ + resolution, + new Promise((resolve) => { timer = setTimeout(resolve, Math.max(0, waitMs)); }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + }; + + return { + refresh, + refreshWithin, + getLevels: () => levels, + getModelId: () => modelId, + }; +} diff --git a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts index f74dbed686..2778ab786b 100644 --- a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts +++ b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts @@ -14,9 +14,10 @@ vi.mock('@/backends/claude/preflight/anthropicModelsFetch', async (importOrigina return { ...actual, fetchAnthropicModels: fetchAnthropicModelsMock }; }); -vi.mock('@/backends/claude/connectedServices/nativeAuth/claudeCodeCredentialFile', () => ({ - readClaudeCodeNativeCredential: readClaudeCodeNativeCredentialMock, -})); +vi.mock('@/backends/claude/connectedServices/nativeAuth/claudeCodeCredentialFile', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readClaudeCodeNativeCredential: readClaudeCodeNativeCredentialMock }; +}); import { buildClaudeEffortCliArgs } from '@/backends/claude/utils/claudeEffort'; import { @@ -55,6 +56,9 @@ beforeEach(() => { }); afterEach(() => { + // Leave no cached catalog behind: the cache is module state, so a later suite sharing this + // module context would otherwise read entries this one populated. + resetClaudeModelCatalogCacheForTests(); envScope.restore(); envScope = createEnvKeyScope(envKeys); }); @@ -111,6 +115,54 @@ describe('resolveClaudeModelCatalog', () => { expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(2); }); + it('refetches when the on-disk credential is replaced in the same config dir', async () => { + readClaudeCodeNativeCredentialMock.mockResolvedValue({ + payload: { claudeAiOauth: { accessToken: 'sk-ant-oat01-account-one', scopes: [] } }, + updatedAtMs: 0, + source: 'file', + }); + fetchAnthropicModelsMock.mockResolvedValue([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); + await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(1); + + // Re-authing the same slot to a different account must not inherit the previous list for the + // rest of the TTL — the config dir is unchanged, so the credential itself has to key the cache. + readClaudeCodeNativeCredentialMock.mockResolvedValue({ + payload: { claudeAiOauth: { accessToken: 'sk-ant-oat01-account-two', scopes: [] } }, + updatedAtMs: 1, + source: 'file', + }); + await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(2); + }); + + it('does not let an unreadable credential evict a valid cached catalog', async () => { + readClaudeCodeNativeCredentialMock.mockResolvedValue({ + payload: { claudeAiOauth: { accessToken: 'sk-ant-oat01-account', scopes: [] } }, + updatedAtMs: 0, + source: 'file', + }); + fetchAnthropicModelsMock.mockResolvedValue([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); + const warm = await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + expect(warm.some((m) => m.id === 'claude-opus-9')).toBe(true); + + // A transient credential read failure degrades this call to the curated catalog... + readClaudeCodeNativeCredentialMock.mockRejectedValue(new Error('EIO')); + const degraded = await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + expect(degraded.some((m) => m.id === 'claude-opus-9')).toBe(false); + + // ...but must not have evicted or shadowed the still-valid entry: recovery is immediate and + // does not require a refetch. + readClaudeCodeNativeCredentialMock.mockResolvedValue({ + payload: { claudeAiOauth: { accessToken: 'sk-ant-oat01-account', scopes: [] } }, + updatedAtMs: 0, + source: 'file', + }); + const recovered = await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + expect(recovered.some((m) => m.id === 'claude-opus-9')).toBe(true); + expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(1); + }); + it('carries the extended-context model id through to the picker rows', async () => { process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; fetchAnthropicModelsMock.mockResolvedValue(null); diff --git a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts index edd5af3ebf..69e266d208 100644 --- a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts +++ b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts @@ -102,7 +102,11 @@ function readConfiguredBaseUrl(): string | null { function isAnthropicFirstPartyBaseUrl(baseUrl: string | null): boolean { if (!baseUrl) return true; try { - const host = new URL(baseUrl).hostname.toLowerCase(); + const url = new URL(baseUrl); + // Host alone is not enough: `http://api.anthropic.com` would otherwise be treated as + // first-party and the on-disk subscription token sent in plaintext. + if (url.protocol !== 'https:') return false; + const host = url.hostname.toLowerCase(); return host === 'anthropic.com' || host.endsWith('.anthropic.com'); } catch { return true; @@ -248,34 +252,47 @@ function mergeStaticWithDiscovered(entries: readonly AnthropicModelEntry[]): Age } /** - * Non-reversible fingerprint of an ambient env credential, or `''` when none is set. + * Non-reversible fingerprint of the credential this catalog was fetched with. * - * The config dir identifies a bound or on-disk account, but an ambient `ANTHROPIC_API_KEY` or - * token can be swapped without it changing — which would otherwise serve the previous key's model - * list for the whole TTL. Only env-derived credentials need this: rotating an on-disk credential - * does not change which models the same account can run. The secret itself never enters the key. + * The config dir identifies the account slot, but the credential inside it can be replaced — + * re-authing a bound profile to a different account, or swapping an ambient key — without the dir + * changing. Fingerprinting the resolved credential means a new one always gets its own entry + * instead of inheriting the previous account's list for the rest of the TTL. The secret itself + * never enters the key. */ -function resolveAmbientCredentialFingerprint(bound: ClaudeProbeBinding | null): string { - const value = resolveAllowedEnvKeys(bound) - .map((key) => readEnvValue(key)) - .find((candidate): candidate is string => candidate !== null); - if (!value) return ''; +function resolveCredentialFingerprint(target: ClaudeProbeTarget): string { + const value = 'apiKey' in target.credential ? target.credential.apiKey : target.credential.accessToken; return createHash('sha256').update(value).digest('hex').slice(0, 12); } -function resolveCatalogCacheKey(bound: ClaudeProbeBinding | null): string { +function resolveCatalogCacheKey(bound: ClaudeProbeBinding | null, target: ClaudeProbeTarget): string { return [ resolveClaudeCatalogConfigDir(bound), readConfiguredBaseUrl() ?? 'default', - resolveAmbientCredentialFingerprint(bound), + resolveCredentialFingerprint(target), ].join('|'); } type CatalogCacheEntry = Readonly<{ models: readonly AgentModelDescriptor[]; expiresAtMs: number }>; const catalogCache = new Map(); +/** + * Resolutions currently in flight, keyed the same as the cache. + * + * The preflight probe and the `sessionModelsV1` publisher both resolve at session start; without + * this they miss the cache in parallel and each issue their own fetch for the same account. + */ +const inFlightCatalogResolutions = new Map>(); export function resetClaudeModelCatalogCacheForTests(): void { catalogCache.clear(); + inFlightCatalogResolutions.clear(); +} + +/** Drop expired entries so a long-lived daemon does not retain one per rotated credential. */ +function pruneExpiredCatalogEntries(nowMs: number): void { + for (const [key, entry] of catalogCache) { + if (entry.expiresAtMs <= nowMs) catalogCache.delete(key); + } } export type ResolveClaudeModelCatalogParams = Readonly<{ @@ -292,27 +309,48 @@ export async function resolveClaudeModelCatalog( params: ResolveClaudeModelCatalogParams, ): Promise { const nowMs = params.nowMs ?? (() => Date.now()); - const cacheKey = resolveCatalogCacheKey(resolveClaudeProbeBinding(params.connectedServices)); + // Resolving the target first is what lets the cache key carry the credential identity. It is env + // reads plus at most one local credential-file read — cheap next to the network fetch it guards, + // and this runs at session start and on model change, not on a hot path. + const bound = resolveClaudeProbeBinding(params.connectedServices); + const target = await resolveClaudeCatalogTarget(params.connectedServices); + + // No resolvable credential is an absence of identity, not an identity of its own. Caching under a + // placeholder key would let one unreadable credential file evict a valid catalog for the whole + // failure TTL, so degrade to the curated catalog for this call only and leave the cache untouched. + if (!target) return resolveStaticClaudeModels(); + const cacheKey = resolveCatalogCacheKey(bound, target); const cached = catalogCache.get(cacheKey); if (cached && cached.expiresAtMs > nowMs()) return cached.models; - const target = await resolveClaudeCatalogTarget(params.connectedServices); - const entries = target - ? await fetchAnthropicModels({ + const inFlight = inFlightCatalogResolutions.get(cacheKey); + if (inFlight) return await inFlight; + + const resolution = (async () => { + const entries = await fetchAnthropicModels({ ...('apiKey' in target.credential ? { apiKey: target.credential.apiKey } : {}), ...('accessToken' in target.credential ? { accessToken: target.credential.accessToken } : {}), ...(target.baseUrl ? { baseUrl: target.baseUrl } : {}), timeoutMs: params.timeoutMs, - }) - : null; - - const models = entries ? mergeStaticWithDiscovered(entries) : resolveStaticClaudeModels(); - catalogCache.set(cacheKey, { - models, - expiresAtMs: nowMs() + (entries ? CATALOG_SUCCESS_TTL_MS : CATALOG_FAILURE_TTL_MS), - }); - return models; + }); + + const models = entries ? mergeStaticWithDiscovered(entries) : resolveStaticClaudeModels(); + const resolvedAtMs = nowMs(); + pruneExpiredCatalogEntries(resolvedAtMs); + catalogCache.set(cacheKey, { + models, + expiresAtMs: resolvedAtMs + (entries ? CATALOG_SUCCESS_TTL_MS : CATALOG_FAILURE_TTL_MS), + }); + return models; + })(); + + inFlightCatalogResolutions.set(cacheKey, resolution); + try { + return await resolution; + } finally { + inFlightCatalogResolutions.delete(cacheKey); + } } /** Whether the catalog fetch found anything beyond the curated list (used to report probe source). */ diff --git a/apps/cli/src/backends/claude/preflight/anthropicModelsFetch.ts b/apps/cli/src/backends/claude/preflight/anthropicModelsFetch.ts index 4709af1bef..7143f47304 100644 --- a/apps/cli/src/backends/claude/preflight/anthropicModelsFetch.ts +++ b/apps/cli/src/backends/claude/preflight/anthropicModelsFetch.ts @@ -65,14 +65,23 @@ function readCapabilities(value: unknown): AnthropicModelCapabilities | undefine const caps = readObject(value); const effort = readObject(caps?.effort); if (!effort) return undefined; + + const tiers = { + low: readEffortTier(effort.low), + medium: readEffortTier(effort.medium), + high: readEffortTier(effort.high), + xhigh: readEffortTier(effort.xhigh), + max: readEffortTier(effort.max), + } as const; + return { effort: { ...(typeof effort.supported === 'boolean' ? { supported: effort.supported } : {}), - ...(readEffortTier(effort.low) ? { low: readEffortTier(effort.low) } : {}), - ...(readEffortTier(effort.medium) ? { medium: readEffortTier(effort.medium) } : {}), - ...(readEffortTier(effort.high) ? { high: readEffortTier(effort.high) } : {}), - ...(readEffortTier(effort.xhigh) ? { xhigh: readEffortTier(effort.xhigh) } : {}), - ...(readEffortTier(effort.max) ? { max: readEffortTier(effort.max) } : {}), + ...(tiers.low ? { low: tiers.low } : {}), + ...(tiers.medium ? { medium: tiers.medium } : {}), + ...(tiers.high ? { high: tiers.high } : {}), + ...(tiers.xhigh ? { xhigh: tiers.xhigh } : {}), + ...(tiers.max ? { max: tiers.max } : {}), }, }; } @@ -99,11 +108,12 @@ export function parseAnthropicModelsResponse(body: unknown): AnthropicModelEntry const maxInputTokens = typeof entry?.max_input_tokens === 'number' && Number.isFinite(entry.max_input_tokens) ? entry.max_input_tokens : undefined; + const capabilities = readCapabilities(entry?.capabilities); entries.push({ id, ...(displayName ? { displayName } : {}), ...(maxInputTokens !== undefined ? { maxInputTokens } : {}), - ...(readCapabilities(entry?.capabilities) ? { capabilities: readCapabilities(entry?.capabilities) } : {}), + ...(capabilities ? { capabilities } : {}), }); } return entries.length > 0 ? entries : null; diff --git a/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts b/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts index a150377f47..8a8feb2bba 100644 --- a/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts +++ b/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts @@ -14,9 +14,10 @@ vi.mock('@/backends/claude/preflight/anthropicModelsFetch', async (importOrigina return { ...actual, fetchAnthropicModels: fetchAnthropicModelsMock }; }); -vi.mock('@/backends/claude/connectedServices/nativeAuth/claudeCodeCredentialFile', () => ({ - readClaudeCodeNativeCredential: readClaudeCodeNativeCredentialMock, -})); +vi.mock('@/backends/claude/connectedServices/nativeAuth/claudeCodeCredentialFile', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, readClaudeCodeNativeCredential: readClaudeCodeNativeCredentialMock }; +}); import { claudePreflightModelsProbeAdapter } from './claudePreflightModelsProbeAdapter'; import { resetClaudeModelCatalogCacheForTests } from '@/backends/claude/models/resolveClaudeModelCatalog'; diff --git a/apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.ts b/apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.ts index ee35b57aea..0388e630c1 100644 --- a/apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.ts +++ b/apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.ts @@ -1,15 +1,15 @@ -import { providers, type AgentModelDescriptor, type AgentModelOption } from '@happier-dev/agents'; +import { + buildClaudeUltracodeModelOption, + providers, + type AgentModelDescriptor, + type AgentModelOption, +} from '@happier-dev/agents'; import type { AnthropicModelEntry } from './anthropicModelsFetch'; const EFFORT_TIER_ORDER = ['low', 'medium', 'high', 'xhigh', 'max'] as const; type EffortTier = (typeof EFFORT_TIER_ORDER)[number]; -// Ultracode copy mirrors the static catalog's `withClaudeEffortModelOptions` (models.ts) so a -// discovered xhigh-capable model surfaces the same session-only toggle as a curated one. -const ULTRACODE_DESCRIPTION = - 'Maximum reasoning with dynamic workflows (forces XHigh effort). Applies to the current session only.'; - function resolveSupportedEffortTiers(entry: AnthropicModelEntry): readonly EffortTier[] { const effort = entry.capabilities?.effort; if (!effort || effort.supported === false) return []; @@ -48,15 +48,8 @@ export function deriveClaudeModelOptionsFromCapabilities( currentValue, options: tiers.map((tier) => ({ value: tier, name: providers.claude.formatClaudeEffortLevelLabel(tier) })), }); - if (tiers.includes('xhigh')) { - modelOptions.push({ - id: 'ultracode', - name: 'Ultracode', - description: ULTRACODE_DESCRIPTION, - type: 'boolean', - currentValue: 'false', - }); - } + // Same option the curated catalog builds, from one owner. + if (tiers.includes('xhigh')) modelOptions.push(buildClaudeUltracodeModelOption()); } return { diff --git a/apps/cli/src/backends/claude/remote/claudeRemoteAgentSdk.ts b/apps/cli/src/backends/claude/remote/claudeRemoteAgentSdk.ts index 5cf4912226..a0b4c65f03 100644 --- a/apps/cli/src/backends/claude/remote/claudeRemoteAgentSdk.ts +++ b/apps/cli/src/backends/claude/remote/claudeRemoteAgentSdk.ts @@ -33,6 +33,7 @@ import { normalizeClaudeToolUseNamesInSdkMessage } from '@/backends/claude/utils import { tryMergeUserMcpConfigArgsIntoHappierMcp } from '@/backends/claude/utils/mcpConfigMerge'; import { ensureClaudeJsRuntimeExecutable } from '@/backends/claude/utils/ensureClaudeJsRuntimeExecutable'; import { + resolveModeEffortLevelsForModel, buildClaudeUltracodeSettingsJson, resolveClaudeEffortForModel, resolveClaudeUltracodeForModel, @@ -694,7 +695,7 @@ export async function claudeRemoteAgentSdk(opts: { ? opts.resumeSessionAt.trim() : null; const effortModelId = argOverrides.model ?? mode.model; - const effortSupportedLevels = mode.modelEffortLevels; + const effortSupportedLevels = resolveModeEffortLevelsForModel(mode, effortModelId); const resolvedEffort = resolveClaudeEffortForModel({ modelId: effortModelId, effort: argOverrides.effort ?? mode.reasoningEffort, diff --git a/apps/cli/src/backends/claude/remote/modeHash.ts b/apps/cli/src/backends/claude/remote/modeHash.ts index 68d2b225de..869d255a80 100644 --- a/apps/cli/src/backends/claude/remote/modeHash.ts +++ b/apps/cli/src/backends/claude/remote/modeHash.ts @@ -2,7 +2,11 @@ import { hashObject } from '@/utils/deterministicJson'; import type { EnhancedMode } from '@/backends/claude/loop'; import { resolveClaudeSdkPermissionModeFromEnhancedMode } from '@/backends/claude/utils/permissionMode'; -import { resolveClaudeEffortForModel, resolveClaudeUltracodeForModel } from '@/backends/claude/utils/claudeEffort'; +import { + resolveClaudeEffortForModel, + resolveClaudeUltracodeForModel, + resolveModeEffortLevelsForModel, +} from '@/backends/claude/utils/claudeEffort'; import { normalizeClaudeRemoteMode } from './normalizeClaudeRemoteMode'; function resolveClaudeRemoteSettingSourcesOverrideForAgentSdk(mode: EnhancedMode): readonly ('user' | 'project' | 'local')[] | null { @@ -59,7 +63,7 @@ function buildClaudeUnifiedTerminalLaunchOptionsHashInput(mode: EnhancedMode): R permissionMode: mode.permissionMode, agentModeId: effectiveAgentModeId, }); - const supportedLevels = mode.modelEffortLevels; + const supportedLevels = resolveModeEffortLevelsForModel(mode, mode.model); const resolvedEffort = resolveClaudeEffortForModel({ modelId: mode.model, effort: mode.reasoningEffort, @@ -108,7 +112,7 @@ export function hashClaudeEnhancedModeForQueue(mode: EnhancedMode): string { // Spawn-only config for Claude: effort is a query-start option in the Agent SDK and has no dynamic setter. // We normalize effort to the effective value the provider would actually apply (treating "high" as default). - const supportedLevels = mode.modelEffortLevels; + const supportedLevels = resolveModeEffortLevelsForModel(mode, mode.model); const resolvedEffort = resolveClaudeEffortForModel({ modelId: mode.model, effort: mode.reasoningEffort, diff --git a/apps/cli/src/backends/claude/runClaude.ts b/apps/cli/src/backends/claude/runClaude.ts index a8ae5369de..ba3bc79fd2 100644 --- a/apps/cli/src/backends/claude/runClaude.ts +++ b/apps/cli/src/backends/claude/runClaude.ts @@ -96,11 +96,7 @@ import { archiveAndCloseRuntimeSession } from '@/session/services/archiveAndClos import { createSessionMetadataShutdownDeadline } from '@/session/services/sessionMetadataShutdownDeadline'; import { resolveRequestedSessionDirectory } from '@/agent/runtime/resolveRequestedSessionDirectory'; import { publishClaudeSessionModelsMetadataBestEffort } from '@/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort'; -import { - resolveClaudeEffortLevelsFromModelDescriptor, - resolveClaudeModelCatalog, -} from '@/backends/claude/models/resolveClaudeModelCatalog'; -import { isCuratedClaudeModelId } from '@/backends/claude/utils/claudeEffort'; +import { createClaudeModelEffortLevelsTracker } from '@/backends/claude/models/claudeModelEffortLevelsTracker'; import { resolveTerminationArchiveDecision } from '@/agent/runtime/terminationArchivePolicy'; import { buildClaudeAgentState } from '@/backends/claude/localControl/buildClaudeAgentState'; import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; @@ -833,25 +829,9 @@ export async function runClaude(credentials: Credentials, options: StartOptions // Effort tiers the selected model reports. Resolved from the shared Claude model catalog // (cached, best-effort) and carried on the mode so spawn-time resolution and launch-option // hashing both see the same value instead of reading a cache at hash time. - let currentModelEffortLevels: readonly string[] = []; - let currentModelEffortLevelsModelId: string | null = null; - const refreshCurrentModelEffortLevels = async (modelId: unknown): Promise => { - const normalized = typeof modelId === 'string' ? modelId.trim() : ''; - if (!normalized || normalized === currentModelEffortLevelsModelId) return; - currentModelEffortLevelsModelId = normalized; - if (isCuratedClaudeModelId(normalized)) { - // Curated models resolve effort from the static table; no catalog lookup needed. - currentModelEffortLevels = []; - return; - } - try { - const models = await resolveClaudeModelCatalog({ timeoutMs: resolveClaudeHelpProbeTimeoutMs() }); - const model = models.find((candidate) => candidate.id === normalized) ?? null; - currentModelEffortLevels = resolveClaudeEffortLevelsFromModelDescriptor(model); - } catch { - currentModelEffortLevels = []; - } - }; + const modelEffortTracker = createClaudeModelEffortLevelsTracker({ + resolveTimeoutMs: () => resolveClaudeHelpProbeTimeoutMs(), + }); let currentUltracode: boolean | undefined = undefined; let currentUltracodeUpdatedAt = 0; let currentFallbackModel: string | undefined = undefined; // Track current fallback model @@ -860,7 +840,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions existingSessionId, defaultSystemPromptText, }); // Track current append system prompt - session.onUserMessage((message, deliveryInfo) => { + session.onUserMessage(async (message, deliveryInfo) => { const adoptedModel = adoptModelOverrideFromMetadata({ currentModelId: currentModel, currentUpdatedAt: currentModelUpdatedAt, @@ -868,7 +848,6 @@ export async function runClaude(credentials: Credentials, options: StartOptions }); if (adoptedModel.didChange) { currentModel = adoptedModel.modelId; - void refreshCurrentModelEffortLevels(currentModel); currentModelUpdatedAt = adoptedModel.updatedAt; logger.debug(`[loop] Model updated from session metadata: ${adoptedModel.modelId || 'reset to default'}`); } @@ -950,7 +929,6 @@ export async function runClaude(credentials: Credentials, options: StartOptions if (message.meta?.hasOwnProperty('model')) { messageModel = message.meta.model || undefined; // null becomes undefined currentModel = messageModel; - void refreshCurrentModelEffortLevels(currentModel); currentModelUpdatedAt = typeof message.createdAt === 'number' && Number.isFinite(message.createdAt) && message.createdAt > 0 ? message.createdAt @@ -1028,6 +1006,12 @@ export async function runClaude(credentials: Credentials, options: StartOptions meta: message.meta, }); + // Resolve the selected model's effort tiers before the mode is built. Leaving this to a + // fire-and-forget refresh dropped `--effort` and ultracode for the first turn after any + // model change. Bounded, because SessionClient awaits this callback as part of the pending + // queue handoff: a cold catalog must not hold the queue behind a network fetch. + await modelEffortTracker.refreshWithin(currentModel); + // Push with resolved permission mode, model, system prompts, and tools const enhancedMode: EnhancedMode = { permissionMode: messagePermissionMode || 'default', @@ -1039,7 +1023,8 @@ export async function runClaude(credentials: Credentials, options: StartOptions customSystemPrompt: messageCustomSystemPrompt, appendSystemPrompt: messageAppendSystemPrompt, reasoningEffort: currentReasoningEffort, - modelEffortLevels: currentModelEffortLevels, + modelEffortLevels: modelEffortTracker.getLevels(), + modelEffortLevelsModelId: modelEffortTracker.getModelId(), ultracode: currentUltracode, ...currentClaudeRemoteMetaState, }; @@ -1249,7 +1234,8 @@ export async function runClaude(credentials: Credentials, options: StartOptions customSystemPrompt: currentCustomSystemPrompt, appendSystemPrompt: currentAppendSystemPrompt, reasoningEffort: currentReasoningEffort, - modelEffortLevels: currentModelEffortLevels, + modelEffortLevels: modelEffortTracker.getLevels(), + modelEffortLevelsModelId: modelEffortTracker.getModelId(), ultracode: currentUltracode, ...currentClaudeRemoteMetaState, }, sessionRuntimeModeKind), @@ -1306,7 +1292,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions typeof options.modelId === 'string' ? options.modelId.trim() : (typeof options.model === 'string' ? options.model.trim() : ''); - void refreshCurrentModelEffortLevels(currentModelId); + void modelEffortTracker.refresh(currentModelId); void publishClaudeSessionModelsMetadataBestEffort({ cwd: workingDirectory, timeoutMs: resolveClaudeHelpProbeTimeoutMs(), @@ -1497,24 +1483,9 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO let currentReasoningEffort: string | undefined = undefined; let currentReasoningEffortUpdatedAt = 0; // See the sibling runtime path above: tiers travel on the mode so hashing stays pure. - let currentModelEffortLevels: readonly string[] = []; - let currentModelEffortLevelsModelId: string | null = null; - const refreshCurrentModelEffortLevels = async (modelId: unknown): Promise => { - const normalized = typeof modelId === 'string' ? modelId.trim() : ''; - if (!normalized || normalized === currentModelEffortLevelsModelId) return; - currentModelEffortLevelsModelId = normalized; - if (isCuratedClaudeModelId(normalized)) { - currentModelEffortLevels = []; - return; - } - try { - const models = await resolveClaudeModelCatalog({ timeoutMs: resolveClaudeHelpProbeTimeoutMs() }); - const model = models.find((candidate) => candidate.id === normalized) ?? null; - currentModelEffortLevels = resolveClaudeEffortLevelsFromModelDescriptor(model); - } catch { - currentModelEffortLevels = []; - } - }; + const modelEffortTracker = createClaudeModelEffortLevelsTracker({ + resolveTimeoutMs: () => resolveClaudeHelpProbeTimeoutMs(), + }); let currentUltracode: boolean | undefined = undefined; let currentUltracodeUpdatedAt = 0; let currentFallbackModel: string | undefined = undefined; @@ -1786,7 +1757,7 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO seedInitialAppendSystemPrompt(defaultSystemPromptText); // Forward messages from server to the local queue. - session.onUserMessage((message, deliveryInfo) => { + session.onUserMessage(async (message, deliveryInfo) => { const adoptedModel = adoptModelOverrideFromMetadata({ currentModelId: currentModel, currentUpdatedAt: currentModelUpdatedAt, @@ -1794,7 +1765,6 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO }); if (adoptedModel.didChange) { currentModel = adoptedModel.modelId; - void refreshCurrentModelEffortLevels(currentModel); currentModelUpdatedAt = adoptedModel.updatedAt; } @@ -1866,7 +1836,6 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO if (message.meta?.hasOwnProperty('model')) { messageModel = message.meta.model || undefined; currentModel = messageModel; - void refreshCurrentModelEffortLevels(currentModel); currentModelUpdatedAt = typeof message.createdAt === 'number' && Number.isFinite(message.createdAt) && message.createdAt > 0 ? message.createdAt @@ -1928,6 +1897,8 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO text: message.content.text, meta: message.meta, }); + // See the sibling path: bounded resolve before the mode is built, not after. + await modelEffortTracker.refreshWithin(currentModel); const enhancedMode: EnhancedMode = { permissionMode: messagePermissionMode || 'default', agentModeId: currentAgentModeId, @@ -1938,7 +1909,8 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO customSystemPrompt: messageCustomSystemPrompt, appendSystemPrompt: messageAppendSystemPrompt, reasoningEffort: currentReasoningEffort, - modelEffortLevels: currentModelEffortLevels, + modelEffortLevels: modelEffortTracker.getLevels(), + modelEffortLevelsModelId: modelEffortTracker.getModelId(), ultracode: currentUltracode, ...currentClaudeRemoteMetaState, }; @@ -2080,7 +2052,8 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO customSystemPrompt: currentCustomSystemPrompt, appendSystemPrompt: currentAppendSystemPrompt, reasoningEffort: currentReasoningEffort, - modelEffortLevels: currentModelEffortLevels, + modelEffortLevels: modelEffortTracker.getLevels(), + modelEffortLevelsModelId: modelEffortTracker.getModelId(), ultracode: currentUltracode, ...currentClaudeRemoteMetaState, }, sessionRuntimeModeKind), @@ -2123,7 +2096,7 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO typeof options.modelId === 'string' ? options.modelId.trim() : (typeof options.model === 'string' ? options.model.trim() : ''); - void refreshCurrentModelEffortLevels(currentModelId); + void modelEffortTracker.refresh(currentModelId); void publishClaudeSessionModelsMetadataBestEffort({ cwd: workingDirectory, timeoutMs: resolveClaudeHelpProbeTimeoutMs(), diff --git a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts index 0dd10549f6..3da7077692 100644 --- a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts +++ b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts @@ -5,6 +5,73 @@ import type { Metadata } from '@/api/types'; import { publishClaudeSessionModelsMetadataBestEffort } from './publishClaudeSessionModelsMetadataBestEffort'; describe('publishClaudeSessionModelsMetadataBestEffort', () => { + it('retires effort overrides the runtime can no longer apply', async () => { + const state: { metadata: Metadata } = { + metadata: { + sessionConfigOptionOverridesV1: { + v: 1, + updatedAt: 1, + overrides: { + reasoning_effort: { updatedAt: 1, value: 'max' }, + ultracode: { updatedAt: 1, value: 'true' }, + some_other_option: { updatedAt: 1, value: 'keep-me' }, + }, + }, + } as unknown as Metadata, + }; + + await publishClaudeSessionModelsMetadataBestEffort({ + cwd: '/', + timeoutMs: 250, + currentModelId: 'claude-sonnet-4-6', + nowMs: () => 999, + probeHelpText: async () => 'Claude Code help output without effort', + session: { + ensureMetadataSnapshot: async () => state.metadata, + updateMetadata: async (updater) => { + state.metadata = updater(state.metadata); + }, + }, + }); + + // Hiding the controls is not enough: the composer emits whatever overrides remain in metadata, + // so a stale ultracode would keep riding later prompts as an unsupported option. + const overrides = state.metadata.sessionConfigOptionOverridesV1?.overrides ?? {}; + expect(overrides.reasoning_effort).toBeUndefined(); + expect(overrides.ultracode).toBeUndefined(); + expect(overrides.some_other_option).toEqual({ updatedAt: 1, value: 'keep-me' }); + expect(state.metadata.sessionModelsV1?.availableModels.length).toBeGreaterThan(0); + }); + + it('keeps effort overrides when the runtime still supports --effort', async () => { + const state: { metadata: Metadata } = { + metadata: { + sessionConfigOptionOverridesV1: { + v: 1, + updatedAt: 1, + overrides: { reasoning_effort: { updatedAt: 1, value: 'high' } }, + }, + } as unknown as Metadata, + }; + + await publishClaudeSessionModelsMetadataBestEffort({ + cwd: '/', + timeoutMs: 250, + currentModelId: 'claude-sonnet-4-6', + nowMs: () => 999, + probeHelpText: async () => ' --effort (low, medium, high, max)', + session: { + ensureMetadataSnapshot: async () => state.metadata, + updateMetadata: async (updater) => { + state.metadata = updater(state.metadata); + }, + }, + }); + + expect(state.metadata.sessionConfigOptionOverridesV1?.overrides.reasoning_effort) + .toEqual({ updatedAt: 1, value: 'high' }); + }); + it('publishes sessionModelsV1/acpSessionModelsV1 when --effort is supported', async () => { const state: { metadata: Metadata } = { metadata: {} as Metadata }; diff --git a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts index 53444fcbc9..dfc356f76a 100644 --- a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts +++ b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts @@ -4,6 +4,37 @@ import { logger } from '@/ui/logger'; import { probeClaudeHelpText } from './probeClaudeHelpText'; import { resolveClaudeSessionModelsState } from './resolveClaudeSessionModelsState'; +/** Options that only exist while the installed CLI can apply `--effort`. */ +const EFFORT_DEPENDENT_OPTION_IDS = ['reasoning_effort', 'ultracode'] as const; + +/** + * Drop effort-dependent overrides a session set earlier when the runtime can no longer apply them. + * + * Hiding the controls from `sessionModelsV1` is not enough: the composer emits whatever overrides + * are in metadata regardless of which options are advertised, and treats them as non-steerable — so + * a stale `ultracode` would keep riding later prompts and could push a busy send down the + * "provider config change refused" path. + */ +function withoutEffortDependentOverrides(prev: Metadata): Metadata { + const next = { ...prev }; + let changed = false; + + for (const key of ['sessionConfigOptionOverridesV1', 'acpConfigOptionOverridesV1'] as const) { + const state = prev[key]; + const overrides = state?.overrides; + if (!overrides) continue; + if (!EFFORT_DEPENDENT_OPTION_IDS.some((id) => id in overrides)) continue; + + const retained = Object.fromEntries( + Object.entries(overrides).filter(([id]) => !EFFORT_DEPENDENT_OPTION_IDS.includes(id as typeof EFFORT_DEPENDENT_OPTION_IDS[number])), + ); + next[key] = { ...state, overrides: retained }; + changed = true; + } + + return changed ? next : prev; +} + export async function publishClaudeSessionModelsMetadataBestEffort(params: Readonly<{ cwd: string; timeoutMs: number; @@ -30,9 +61,13 @@ export async function publishClaudeSessionModelsMetadataBestEffort(params: Reado }).catch(() => null); if (!state) return; + const supportsEffort = state.availableModels.some( + (model) => (model.modelOptions ?? []).some((option) => option.id === 'reasoning_effort'), + ); + try { await params.session.updateMetadata((prev) => ({ - ...prev, + ...(supportsEffort ? prev : withoutEffortDependentOverrides(prev)), sessionModelsV1: state, acpSessionModelsV1: state, })); diff --git a/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.test.ts b/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.test.ts index 1667671b91..04b7ddf9e0 100644 --- a/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.test.ts +++ b/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { resolveClaudeSessionModelsState } from './resolveClaudeSessionModelsState'; describe('resolveClaudeSessionModelsState', () => { - it('returns null when the installed Claude CLI does not expose --effort', async () => { + it('still publishes the model list when the installed Claude CLI does not expose --effort', async () => { const res = await resolveClaudeSessionModelsState({ cwd: '/', timeoutMs: 250, @@ -12,7 +12,13 @@ describe('resolveClaudeSessionModelsState', () => { probeHelpText: async () => 'Claude Code help output without effort', }); - expect(res).toBeNull(); + // Missing `--effort` disables the effort CONTROL, not the list itself; suppressing the list + // would leave the new-session picker and the running session disagreeing about which models + // exist. + expect(res?.availableModels.length).toBeGreaterThan(0); + const optionIds = (res?.availableModels ?? []).flatMap((m) => (m.modelOptions ?? []).map((o) => o.id)); + expect(optionIds).not.toContain('reasoning_effort'); + expect(optionIds).not.toContain('ultracode'); }); it('publishes a dynamic session model list with a Thinking option when --effort is supported', async () => { diff --git a/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts b/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts index 8020e856a2..1b5fbf6339 100644 --- a/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts +++ b/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts @@ -13,8 +13,10 @@ export async function resolveClaudeSessionModelsState(params: Readonly<{ const helpText = await params.probeHelpText({ cwd: params.cwd, timeoutMs: params.timeoutMs }); if (!helpText) return null; + // `--effort` support gates the effort CONTROL, not the model list. Returning null here would + // leave the new-session picker showing an account-specific list while the running session + // published none. const supportsEffort = /\B--effort\b/i.test(helpText); - if (!supportsEffort) return null; const updatedAt = params.nowMs(); // Same owner as the new-session preflight probe, so the in-session picker cannot disagree about @@ -37,9 +39,13 @@ export async function resolveClaudeSessionModelsState(params: Readonly<{ ...(description ? { description } : {}), ...(typeof model.contextWindowTokens === 'number' ? { contextWindowTokens: model.contextWindowTokens } : {}), ...(typeof model.extendedContextModelId === 'string' ? { extendedContextModelId: model.extendedContextModelId } : {}), - ...(Array.isArray(model.modelOptions) && model.modelOptions.length > 0 - ? { modelOptions: model.modelOptions } - : {}), + ...(() => { + const modelOptions = Array.isArray(model.modelOptions) + ? model.modelOptions.filter((option) => supportsEffort + || (option.id !== 'reasoning_effort' && option.id !== 'ultracode')) + : []; + return modelOptions.length > 0 ? { modelOptions } : {}; + })(), }; }), } satisfies ClaudeSessionModelsState; diff --git a/apps/cli/src/backends/claude/unifiedTerminal/runtimeControlIntegration.ts b/apps/cli/src/backends/claude/unifiedTerminal/runtimeControlIntegration.ts index 00107ca9dc..d99c66afd9 100644 --- a/apps/cli/src/backends/claude/unifiedTerminal/runtimeControlIntegration.ts +++ b/apps/cli/src/backends/claude/unifiedTerminal/runtimeControlIntegration.ts @@ -5,7 +5,7 @@ import type { } from '@happier-dev/protocol'; import type { InFlightConfigApplyOutcome } from '@/agent/runtime/permission/bindPermissionModeQueue'; -import { resolveClaudeUltracodeForModel } from '@/backends/claude/utils/claudeEffort'; +import { resolveClaudeUltracodeForModel, resolveModeEffortLevelsForModel } from '@/backends/claude/utils/claudeEffort'; import type { EnhancedMode } from '../loop'; import { controlResultToChangeOutcome } from './tuiControls/outcome'; @@ -181,7 +181,7 @@ export function mapEnhancedModeToDesiredRuntimeConfig(mode: EnhancedMode): Claud desired.ultracode = resolveClaudeUltracodeForModel({ modelId: mode.model, ultracode: mode.ultracode, - supportedLevels: mode.modelEffortLevels, + supportedLevels: resolveModeEffortLevelsForModel(mode, mode.model), }); } return desired; diff --git a/apps/cli/src/backends/claude/utils/claudeEffort.test.ts b/apps/cli/src/backends/claude/utils/claudeEffort.test.ts index fff2631e59..43e1b04e18 100644 --- a/apps/cli/src/backends/claude/utils/claudeEffort.test.ts +++ b/apps/cli/src/backends/claude/utils/claudeEffort.test.ts @@ -4,6 +4,7 @@ import { buildClaudeEffortCliArgs, resolveClaudeDefaultEffortForModel, resolveClaudeUltracodeForModel, + resolveModeEffortLevelsForModel, } from './claudeEffort'; describe('buildClaudeEffortCliArgs', () => { @@ -75,6 +76,20 @@ describe('buildClaudeEffortCliArgs', () => { })).toEqual([]); }); + it('does not let a discovered id inherit curated tiers through a substring alias', () => { + // `claude-opus-5-preview` contains the `opus-5` substring the alias table matches on. Without + // reported tiers there is no evidence for it, so nothing may be forwarded — clamping against + // the curated Opus 5 table would apply another model's capabilities. + expect(buildClaudeEffortCliArgs({ modelId: 'claude-opus-5-preview', effort: 'max' })).toEqual([]); + expect(resolveClaudeUltracodeForModel({ modelId: 'claude-opus-5-preview', ultracode: true })).toBe(false); + // With its own reported tiers it behaves normally. + expect(buildClaudeEffortCliArgs({ + modelId: 'claude-opus-5-preview', + effort: 'max', + supportedLevels: ['low', 'medium'], + })).toEqual(['--effort', 'medium']); + }); + it('never sends --effort when no model is selected', () => { expect(buildClaudeEffortCliArgs({ modelId: undefined, effort: 'max' })).toEqual([]); expect(buildClaudeEffortCliArgs({ modelId: '', effort: 'max' })).toEqual([]); @@ -123,6 +138,19 @@ describe('resolveClaudeUltracodeForModel', () => { }); }); +describe('resolveModeEffortLevelsForModel', () => { + it('only supplies tiers when they belong to the model being launched', () => { + const mode = { modelEffortLevels: ['low', 'xhigh'], modelEffortLevelsModelId: 'claude-opus-9' }; + + expect(resolveModeEffortLevelsForModel(mode, 'claude-opus-9')).toEqual(['low', 'xhigh']); + // A launch-time override (e.g. `--model` inside claudeArgs) must not reuse another model's + // tiers as evidence. + expect(resolveModeEffortLevelsForModel(mode, 'claude-opus-8')).toBeUndefined(); + expect(resolveModeEffortLevelsForModel(mode, '')).toBeUndefined(); + expect(resolveModeEffortLevelsForModel({ modelEffortLevels: ['low'] }, 'claude-opus-9')).toBeUndefined(); + }); +}); + describe('resolveClaudeDefaultEffortForModel', () => { it('resolves the model default effort with alias and [1m] tolerance', () => { expect(resolveClaudeDefaultEffortForModel('claude-fable-5')).toBe('high'); diff --git a/apps/cli/src/backends/claude/utils/claudeEffort.ts b/apps/cli/src/backends/claude/utils/claudeEffort.ts index fa37bf7004..e1b8dfcbff 100644 --- a/apps/cli/src/backends/claude/utils/claudeEffort.ts +++ b/apps/cli/src/backends/claude/utils/claudeEffort.ts @@ -50,16 +50,15 @@ function resolveEvidencedClaudeEffortLevels( modelIdRaw: unknown, reportedRaw: unknown, ): readonly ClaudeEffortLevel[] { - // A discovered id that merely CONTAINS a curated alias (`claude-opus-5-preview` matching the - // `opus-5` substring rule) is not that model. Its own reported tiers win, so the picker and the - // spawned flag cannot disagree. - const reported = normalizeReportedClaudeEffortLevels(reportedRaw); - if (!isCuratedClaudeModelId(modelIdRaw) && reported.length > 0) return reported; - - const staticLevels = resolveClaudeEffortLevelsForKnownAliasOrModel(modelIdRaw); - if (staticLevels.length > 0) return staticLevels; - if (isCuratedClaudeModelId(modelIdRaw)) return []; - return reported; + // A discovered id is only ever evidenced by its own reported tiers. It may CONTAIN a curated + // alias (`claude-opus-5-preview` matches the `opus-5` substring rule) without being that model, + // so the curated table must not be consulted for it at all — otherwise a stale session effort + // would be clamped against another model's tiers and forwarded. + if (!isCuratedClaudeModelId(modelIdRaw)) return normalizeReportedClaudeEffortLevels(reportedRaw); + + // Curated models own their table, including curated models with no effort support (Haiku), + // which reported tiers must never override. + return resolveClaudeEffortLevelsForKnownAliasOrModel(modelIdRaw); } function normalizeClaudeEffortLevel(raw: unknown): ClaudeEffortLevel | null { @@ -151,6 +150,21 @@ function resolveBestSupportedClaudeEffort( return null; } +/** + * Tiers carried on the session mode, but only when they belong to `modelId`. + * + * A launch can override the model (`--model` in `claudeArgs`), and one model's reported tiers must + * never gate another model's effort or ultracode. + */ +export function resolveModeEffortLevelsForModel( + mode: Readonly<{ modelEffortLevels?: readonly string[]; modelEffortLevelsModelId?: string | null }>, + modelId: unknown, +): readonly string[] | undefined { + const normalized = typeof modelId === 'string' ? modelId.trim() : ''; + if (!normalized) return undefined; + return mode.modelEffortLevelsModelId === normalized ? mode.modelEffortLevels : undefined; +} + export function resolveClaudeEffortForModel(params: Readonly<{ modelId: unknown; effort: unknown; diff --git a/apps/cli/src/capabilities/probes/agentModelsProbe.staticOnly.test.ts b/apps/cli/src/capabilities/probes/agentModelsProbe.staticOnly.test.ts index 78f4a49d8d..55f4ca8525 100644 --- a/apps/cli/src/capabilities/probes/agentModelsProbe.staticOnly.test.ts +++ b/apps/cli/src/capabilities/probes/agentModelsProbe.staticOnly.test.ts @@ -151,7 +151,6 @@ describe('probeAgentModelsBestEffort (static-only providers)', () => { expect(res.provider).toBe('claude'); expect(res.source).toBe('static'); - expect(createConfiguredAcpProbeBackendMock).not.toHaveBeenCalled(); expect(res.availableModels).toEqual(expect.arrayContaining([ expect.objectContaining({ id: 'default', name: 'Default' }), diff --git a/apps/cli/src/capabilities/probes/resolveAgentProbeVariant.test.ts b/apps/cli/src/capabilities/probes/resolveAgentProbeVariant.test.ts index 4dee74913b..37b99bbce5 100644 --- a/apps/cli/src/capabilities/probes/resolveAgentProbeVariant.test.ts +++ b/apps/cli/src/capabilities/probes/resolveAgentProbeVariant.test.ts @@ -28,6 +28,20 @@ describe('resolveAgentProbeVariant', () => { expect(new Set([profileA, profileB, native]).size).toBe(3); }); + it('returns a stable variant for an equivalent binding', () => { + const binding = { + v: 1 as const, + bindingsByServiceId: { + 'claude-subscription': { source: 'connected' as const, selection: 'profile' as const, profileId: 'profile-a' }, + }, + }; + + // A variant that changed per call would pass the separation assertions above while silently + // disabling cache reuse. + expect(resolveAgentProbeVariant({ agentId: 'claude', connectedServices: binding })) + .toBe(resolveAgentProbeVariant({ agentId: 'claude', connectedServices: binding })); + }); + it('partitions the Claude models probe cache by group binding', () => { const group = resolveAgentProbeVariant({ agentId: 'claude', diff --git a/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionPreflightModelsState.refresh.test.tsx b/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionPreflightModelsState.refresh.test.tsx index 287ed29173..4ce6286035 100644 --- a/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionPreflightModelsState.refresh.test.tsx +++ b/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionPreflightModelsState.refresh.test.tsx @@ -9,6 +9,7 @@ import { DYNAMIC_MODEL_PROBE_SUCCESS_TTL_MS, } from '@/sync/domains/models/dynamicModelProbeCache'; import { installCapabilitiesOpsModuleMock } from '@/dev/testkit/mocks/capabilities'; +import { getModelOptionsForAgentType } from '@/sync/domains/models/modelOptions'; const machineCapabilitiesInvokeMock = vi.fn(); type DeferredModelProbeResult = { @@ -36,7 +37,7 @@ describe('useNewSessionPreflightModelsState (refresh)', () => { const { useNewSessionPreflightModelsState } = await import('./useNewSessionPreflightModelsState'); const hook = await renderHook( () => useNewSessionPreflightModelsState({ - backendTarget: { kind: 'builtInAgent', agentId: 'claude' }, + backendTarget: { kind: 'builtInAgent', agentId: 'qwen' }, selectedMachineId: 'machine-1', capabilityServerId: 'server-1', cwd: '/repo', @@ -44,13 +45,50 @@ describe('useNewSessionPreflightModelsState (refresh)', () => { ); expect(machineCapabilitiesInvokeMock).not.toHaveBeenCalled(); - expect(hook.getCurrent().modelOptions.some((o) => o.value === 'claude-opus-4-6')).toBe(true); + expect(hook.getCurrent().modelOptions.map((o) => o.value)).toEqual( + getModelOptionsForAgentType('qwen').map((o) => o.value), + ); expect(hook.getCurrent().probe.phase).toBe('idle'); expect(hook.getCurrent().probe.onRefresh).toBeUndefined(); await hook.unmount(); }); + it('probes models for Claude, which resolves its list at runtime', async () => { + vi.resetModules(); + machineCapabilitiesInvokeMock.mockReset(); + resetDynamicModelProbeCacheForTests(); + vi.doMock('@/sync/ops/capabilities', installCapabilitiesOpsModuleMock({ + machineCapabilitiesInvoke: machineCapabilitiesInvokeMock, + })); + + machineCapabilitiesInvokeMock.mockImplementation(async () => ({ + supported: true as const, + response: { + ok: true as const, + result: { availableModels: [{ id: 'claude-opus-9', name: 'Opus 9' }], supportsFreeform: true }, + }, + })); + + const { useNewSessionPreflightModelsState } = await import('./useNewSessionPreflightModelsState'); + const hook = await renderHook( + () => useNewSessionPreflightModelsState({ + backendTarget: { kind: 'builtInAgent', agentId: 'claude' }, + selectedMachineId: 'machine-1', + capabilityServerId: 'server-1', + cwd: '/repo', + }), + ); + + await flushHookEffects(); + + expect(machineCapabilitiesInvokeMock).toHaveBeenCalledTimes(1); + expect(hook.getCurrent().modelOptions.some((o) => o.value === 'claude-opus-9')).toBe(true); + expect(hook.getCurrent().probe.onRefresh).toBeDefined(); + + await hook.unmount(); + }); + it('forces a refresh probe without clearing existing options', async () => { vi.resetModules(); machineCapabilitiesInvokeMock.mockReset(); @@ -247,7 +285,7 @@ describe('useNewSessionPreflightModelsState (refresh)', () => { const { useNewSessionPreflightModelsState } = await import('./useNewSessionPreflightModelsState'); const hook = await renderHook( - (props: { backendTarget: { kind: 'builtInAgent'; agentId: 'codex' | 'claude' } }) => + (props: { backendTarget: { kind: 'builtInAgent'; agentId: 'codex' | 'qwen' } }) => useNewSessionPreflightModelsState({ backendTarget: props.backendTarget, selectedMachineId: 'machine-1', @@ -261,12 +299,14 @@ describe('useNewSessionPreflightModelsState (refresh)', () => { expect(hook.getCurrent().preflightModelsTargetKey).toBe('agent:codex'); expect(hook.getCurrent().modelOptions.some((option) => option.value === 'gpt-5.5')).toBe(true); - await hook.rerender({ backendTarget: { kind: 'builtInAgent', agentId: 'claude' } }); + await hook.rerender({ backendTarget: { kind: 'builtInAgent', agentId: 'qwen' } }); expect(hook.getCurrent().preflightModels).toBeNull(); expect(hook.getCurrent().preflightModelsTargetKey).toBeNull(); expect(hook.getCurrent().modelOptions.some((option) => option.value === 'gpt-5.5')).toBe(false); - expect(hook.getCurrent().modelOptions.some((option) => option.value === 'claude-opus-4-6')).toBe(true); + expect(hook.getCurrent().modelOptions.map((option) => option.value)).toEqual( + getModelOptionsForAgentType('qwen').map((option) => option.value), + ); expect(machineCapabilitiesInvokeMock).toHaveBeenCalledTimes(1); await hook.unmount(); diff --git a/apps/ui/sources/sync/domains/models/dynamicModelProbeCache.ts b/apps/ui/sources/sync/domains/models/dynamicModelProbeCache.ts index cb226ce753..5eceab97a3 100644 --- a/apps/ui/sources/sync/domains/models/dynamicModelProbeCache.ts +++ b/apps/ui/sources/sync/domains/models/dynamicModelProbeCache.ts @@ -1,3 +1,4 @@ +import { readExtendedContextModelId } from './modelOptions'; import { normalizeSessionConfigOptionsArray, type SessionConfigOption } from '@/sync/domains/sessionControl/configOptionsControl'; import type { PreflightModelList } from '@/sync/domains/models/modelOptions'; import type { ProbedResourceSnapshot } from '@happier-dev/protocol'; @@ -47,6 +48,9 @@ function normalizePersistedModelList(input: unknown): PreflightModelList | null id: modelRecord.id, name: modelRecord.name, ...(typeof modelRecord.description === 'string' ? { description: modelRecord.description } : {}), + ...(readExtendedContextModelId(modelRecord.extendedContextModelId) + ? { extendedContextModelId: readExtendedContextModelId(modelRecord.extendedContextModelId) } + : {}), ...(typeof modelRecord.contextWindowTokens === 'number' && Number.isFinite(modelRecord.contextWindowTokens) && modelRecord.contextWindowTokens > 0 ? { contextWindowTokens: Math.trunc(modelRecord.contextWindowTokens) } : {}), diff --git a/apps/ui/sources/sync/domains/models/modelOptions.test.ts b/apps/ui/sources/sync/domains/models/modelOptions.test.ts index 6032219f29..a24cdfd869 100644 --- a/apps/ui/sources/sync/domains/models/modelOptions.test.ts +++ b/apps/ui/sources/sync/domains/models/modelOptions.test.ts @@ -4,6 +4,7 @@ import { findModelOptionForEffectiveModelId, getModelOptionsForAgentType, getModelOptionsForModes, + getModelOptionsForPreflightModelList, getModelOptionsForSession, getSelectableModelIdsForSession, hasDynamicModelListForSession, @@ -160,8 +161,64 @@ describe('modelOptions', () => { }); it('ignores stale dynamic session model rows for static-only providers and uses the static catalog', () => { - const staticClaudeValues = getModelOptionsForAgentType('claude').map((option) => option.value); + const staticKiroValues = getModelOptionsForAgentType('kiro').map((option) => option.value); + const out = getModelOptionsForSession( + 'kiro', + withMetadata({ + sessionModelsV1: { + v: 1, + provider: 'kiro', + updatedAt: 1, + currentModelId: 'kiro-from-session', + availableModels: [ + { id: 'kiro-from-session', name: 'Kiro (From Session)' }, + ], + }, + }), + ); + + expect(out.map((option) => option.value)).toEqual(staticKiroValues); + expect(out.some((option) => option.value === 'kiro-from-session')).toBe(false); + }); + + it('keeps the extended-context variant for a curated model that arrives via the dynamic path', () => { + // AgentInput gates the 1M-context toggle on extendedContextModelId. The dynamic row builder + // has no reason to know about it, so the catalog merge must restore it — otherwise turning + // a provider dynamic silently removes the toggle. const out = getModelOptionsForSession( + 'claude', + withMetadata({ + sessionModelsV1: { + v: 1, + provider: 'claude', + updatedAt: 1, + currentModelId: 'claude-sonnet-4-6', + availableModels: [{ id: 'claude-sonnet-4-6', name: 'Sonnet 4.6 (From Session)' }], + }, + }), + ); + + const staticSonnet = getModelOptionsForAgentType('claude') + .find((option) => option.value === 'claude-sonnet-4-6') ?? null; + expect(staticSonnet?.extendedContextModelId).toBeTruthy(); + expect(out.find((option) => option.value === 'claude-sonnet-4-6')?.extendedContextModelId) + .toBe(staticSonnet?.extendedContextModelId); + }); + + it('carries an extended-context variant declared by the dynamic source itself', () => { + const out = getModelOptionsForPreflightModelList({ + availableModels: [ + { id: 'claude-opus-9', name: 'Opus 9', extendedContextModelId: 'claude-opus-9[1m]' }, + ], + supportsFreeform: true, + }); + + expect(out.find((option) => option.value === 'claude-opus-9')?.extendedContextModelId) + .toBe('claude-opus-9[1m]'); + }); + + it('uses the published session model list for Claude and keeps the static catalog as the fallback', () => { + const withSession = getModelOptionsForSession( 'claude', withMetadata({ sessionModelsV1: { @@ -171,22 +228,31 @@ describe('modelOptions', () => { currentModelId: 'claude-opus-4-6', availableModels: [ { id: 'claude-opus-4-6', name: 'Opus 4.6 (From Session)' }, - { id: 'claude-sonnet-4-6', name: 'Sonnet 4.6 (From Session)' }, + { id: 'claude-opus-9', name: 'Opus 9 (Discovered)' }, ], }, }), ); - expect(out.map((option) => option.value)).toEqual(staticClaudeValues); - expect(out.find((option) => option.value === 'claude-opus-4-6')).toMatchObject({ + // Claude publishes sessionModelsV1 from the CLI, so a discovered model reaches the picker, + // published rows lead, and the curated catalog is still appended behind them. + const values = withSession.map((option) => option.value); + expect(values.slice(0, 3)).toEqual(['default', 'claude-opus-4-6', 'claude-opus-9']); + for (const staticValue of getModelOptionsForAgentType('claude').map((option) => option.value)) { + expect(values).toContain(staticValue); + } + expect(withSession.find((option) => option.value === 'claude-opus-9')?.label) + .toBe('Opus 9 (Discovered)'); + + const staticOnly = getModelOptionsForSession('claude', withMetadata({})); + expect(staticOnly.map((option) => option.value)) + .toEqual(getModelOptionsForAgentType('claude').map((option) => option.value)); + expect(staticOnly.find((option) => option.value === 'claude-opus-4-6')).toMatchObject({ label: 'Opus 4.6', modelOptions: expect.arrayContaining([ expect.objectContaining({ id: 'reasoning_effort' }), ]), }); - expect(out.find((option) => option.value === 'claude-sonnet-4-6')).toMatchObject({ - label: 'Sonnet 4.6', - }); }); it('treats ACP session models as selectable', () => { @@ -231,47 +297,47 @@ describe('modelOptions', () => { }); it('appends custom metadata override models after the static catalog for static-only providers', () => { - const staticClaudeValues = getModelOptionsForAgentType('claude').map((option) => option.value); + const staticKiroValues = getModelOptionsForAgentType('kiro').map((option) => option.value); const out = getModelOptionsForSession( - 'claude', + 'kiro', withMetadata({ sessionModelsV1: { v: 1, - provider: 'claude', + provider: 'kiro', updatedAt: 1, - currentModelId: 'claude-sonnet-4-6', + currentModelId: 'kiro-from-session', availableModels: [ - { id: 'claude-sonnet-4-6', name: 'Sonnet 4.6 (From Session)' }, + { id: 'kiro-from-session', name: 'Kiro (From Session)' }, ], }, - modelOverrideV1: { v: 1, updatedAt: 100, modelId: 'claude-custom-model' }, + modelOverrideV1: { v: 1, updatedAt: 100, modelId: 'kiro-custom-model' }, }), ); expect(out.map((option) => option.value)).toEqual([ - ...staticClaudeValues, - 'claude-custom-model', + ...staticKiroValues, + 'kiro-custom-model', ]); }); it('derives selectable ids from the same static-only session model policy for freeform providers', () => { - const staticClaudeValues = getModelOptionsForAgentType('claude').map((option) => option.value); + const staticKiroValues = getModelOptionsForAgentType('kiro').map((option) => option.value); const metadata = withMetadata({ sessionModelsV1: { v: 1, - provider: 'claude', + provider: 'kiro', updatedAt: 1, - currentModelId: 'claude-sonnet-4-6', + currentModelId: 'kiro-from-session', availableModels: [ - { id: 'claude-sonnet-4-6', name: 'Sonnet 4.6 (From Session)' }, + { id: 'kiro-from-session', name: 'Kiro (From Session)' }, ], }, - modelOverrideV1: { v: 1, updatedAt: 100, modelId: 'claude-custom-model' }, + modelOverrideV1: { v: 1, updatedAt: 100, modelId: 'kiro-custom-model' }, }); - expect(getSelectableModelIdsForSession('claude', metadata)).toEqual([ - ...staticClaudeValues, - 'claude-custom-model', + expect(getSelectableModelIdsForSession('kiro', metadata)).toEqual([ + ...staticKiroValues, + 'kiro-custom-model', ]); }); @@ -336,6 +402,23 @@ describe('modelOptions', () => { }); it('does not treat static-only provider metadata as dynamic list support', () => { + expect( + hasDynamicModelListForSession( + 'kiro', + withMetadata({ + sessionModelsV1: { + v: 1, + provider: 'kiro', + updatedAt: 1, + currentModelId: 'kiro-from-session', + availableModels: [{ id: 'kiro-from-session', name: 'Kiro (From Session)' }], + }, + }), + ), + ).toBe(false); + }); + + it('treats a published Claude session model list as dynamic list support', () => { expect( hasDynamicModelListForSession( 'claude', @@ -345,11 +428,11 @@ describe('modelOptions', () => { provider: 'claude', updatedAt: 1, currentModelId: 'claude-haiku-4-5', - availableModels: [{ id: 'haiku', name: 'Haiku' }], + availableModels: [{ id: 'claude-haiku-4-5', name: 'Haiku' }], }, }), ), - ).toBe(false); + ).toBe(true); }); it('falls back to legacy ACP session models when canonical key is absent', () => { diff --git a/apps/ui/sources/sync/domains/models/modelOptions.ts b/apps/ui/sources/sync/domains/models/modelOptions.ts index c87c185fc1..b5195a4f45 100644 --- a/apps/ui/sources/sync/domains/models/modelOptions.ts +++ b/apps/ui/sources/sync/domains/models/modelOptions.ts @@ -47,6 +47,7 @@ export type PreflightModelList = Readonly<{ name: string; description?: string; contextWindowTokens?: number; + extendedContextModelId?: string; modelOptions?: readonly SessionConfigOption[]; }>>; supportsFreeform: boolean; @@ -58,10 +59,23 @@ type SessionModelListState = Readonly<{ id?: unknown; name?: unknown; description?: unknown; + extendedContextModelId?: unknown; modelOptions?: unknown; }>; }>; +/** + * Normalize a catalog- or session-declared extended-context variant id. + * + * One owner for the rule, because the value now flows through the preflight parse, the probe cache, + * and both dynamic row builders — four places that must agree on what counts as present. + */ +export function readExtendedContextModelId(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + function dedupeModelOptionsByValue(options: readonly ModelOption[]): readonly ModelOption[] { const seen = new Set(); return options.filter((option) => { @@ -79,10 +93,17 @@ function mergeDynamicModelOptionWithCatalog( if (!catalog) return option; const hasModelOptions = Array.isArray(option.modelOptions) && option.modelOptions.length > 0; const hasDescription = typeof option.description === 'string' && option.description.trim().length > 0; + const hasExtendedContext = typeof option.extendedContextModelId === 'string' + && option.extendedContextModelId.trim().length > 0; return { ...option, ...(!hasDescription && catalog.description ? { description: catalog.description } : {}), ...(!hasModelOptions && catalog.modelOptions ? { modelOptions: catalog.modelOptions } : {}), + // Without this a curated model arriving through the dynamic path loses its extended-context + // variant, and the 1M toggle disappears for a model that still supports it. + ...(!hasExtendedContext && catalog.extendedContextModelId + ? { extendedContextModelId: catalog.extendedContextModelId } + : {}), }; } @@ -141,6 +162,9 @@ export function getModelOptionsForPreflightModelList(list: PreflightModelList): value: String(m.id), label: String(m.name), description: typeof m.description === 'string' ? m.description : '', + ...(readExtendedContextModelId(m.extendedContextModelId) + ? { extendedContextModelId: readExtendedContextModelId(m.extendedContextModelId) } + : {}), ...(Array.isArray(m.modelOptions) && m.modelOptions.length > 0 ? { modelOptions: m.modelOptions } : {}), })); @@ -272,6 +296,9 @@ function resolveModelOptionsForSession(agentType: AgentType, metadata: Metadata value, label: String(m.name), description, + ...(readExtendedContextModelId(m.extendedContextModelId) + ? { extendedContextModelId: readExtendedContextModelId(m.extendedContextModelId) } + : {}), ...(modelOptionsRaw ? { modelOptions: modelOptionsRaw } : {}), }; }); diff --git a/apps/ui/sources/sync/domains/models/parsePreflightModelListFromProbeModelsResult.ts b/apps/ui/sources/sync/domains/models/parsePreflightModelListFromProbeModelsResult.ts index 8860a33d09..fd45e4c7f7 100644 --- a/apps/ui/sources/sync/domains/models/parsePreflightModelListFromProbeModelsResult.ts +++ b/apps/ui/sources/sync/domains/models/parsePreflightModelListFromProbeModelsResult.ts @@ -1,3 +1,4 @@ +import { readExtendedContextModelId } from './modelOptions'; import type { SessionConfigOption } from '@/sync/domains/sessionControl/configOptionsControl'; import type { PreflightModelList } from '@/sync/domains/models/modelOptions'; @@ -15,6 +16,9 @@ export function parsePreflightModelListFromProbeModelsResult(raw: unknown): Pref id: String(m.id), name: String(m.name), ...(typeof m.description === 'string' ? { description: m.description } : {}), + ...(readExtendedContextModelId(m.extendedContextModelId) + ? { extendedContextModelId: readExtendedContextModelId(m.extendedContextModelId) } + : {}), ...(typeof m.contextWindowTokens === 'number' && Number.isFinite(m.contextWindowTokens) && m.contextWindowTokens > 0 ? { contextWindowTokens: Math.trunc(m.contextWindowTokens) } : {}), diff --git a/apps/ui/sources/sync/domains/state/storageTypes.ts b/apps/ui/sources/sync/domains/state/storageTypes.ts index e4aca5fd52..312ec14e68 100644 --- a/apps/ui/sources/sync/domains/state/storageTypes.ts +++ b/apps/ui/sources/sync/domains/state/storageTypes.ts @@ -120,6 +120,7 @@ const MetadataObjectSchema = z.object({ name: z.string(), description: z.string().optional(), contextWindowTokens: z.number().int().positive().optional(), + extendedContextModelId: z.string().optional(), modelOptions: z.array(z.object({ id: z.string(), name: z.string(), @@ -145,6 +146,7 @@ const MetadataObjectSchema = z.object({ name: z.string(), description: z.string().optional(), contextWindowTokens: z.number().int().positive().optional(), + extendedContextModelId: z.string().optional(), modelOptions: z.array(z.object({ id: z.string(), name: z.string(), diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index 52ad42c0bd..df836cb02f 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -118,6 +118,7 @@ export { type AgentModelConfig, type AgentModelDescriptor, type AgentModelNonAcpApplyScope, + buildClaudeUltracodeModelOption, type AgentModelOption, type AgentModelOptionValueId, } from './models.js'; diff --git a/packages/agents/src/models.ts b/packages/agents/src/models.ts index e6f69d962e..ff1aa066c3 100644 --- a/packages/agents/src/models.ts +++ b/packages/agents/src/models.ts @@ -91,6 +91,22 @@ export type AgentModelConfig = Readonly<{ staticModels?: readonly AgentModelDescriptor[]; }>; +/** + * The Ultracode toggle as the picker renders it. + * + * Shared so a model discovered at runtime surfaces the identical control to a curated one; two + * copies of this copy would drift. + */ +export function buildClaudeUltracodeModelOption(): AgentModelOption { + return { + id: 'ultracode', + name: 'Ultracode', + description: 'Maximum reasoning with dynamic workflows (forces XHigh effort). Applies to the current session only.', + type: 'boolean', + currentValue: 'false', + }; +} + function withClaudeEffortModelOptions(model: AgentModelDescriptor): AgentModelDescriptor { const levels = resolveClaudeEffortLevelsForModelId(model.id); const currentValue = resolveClaudeDefaultEffortLevelForModelId(model.id); @@ -108,13 +124,7 @@ function withClaudeEffortModelOptions(model: AgentModelDescriptor): AgentModelDe // Ultracode is a session-only Claude Code setting (forces xhigh + Dynamic Workflows), // orthogonal to the effort axis — surfaced as a boolean toggle, never a 6th effort pill. if (isClaudeUltracodeSupportedModelId(model.id)) { - modelOptions.push({ - id: 'ultracode', - name: 'Ultracode', - description: 'Maximum reasoning with dynamic workflows (forces XHigh effort). Applies to the current session only.', - type: 'boolean', - currentValue: 'false', - }); + modelOptions.push(buildClaudeUltracodeModelOption()); } return { ...model, modelOptions }; @@ -220,7 +230,9 @@ export const AGENT_MODEL_CONFIG: Readonly> = O supportsSelection: true, supportsFreeform: true, nonAcpApplyScope: 'next_prompt', - dynamicProbe: 'static-only', + // Augment the curated static catalog with any models the account can run (fetched from the + // Anthropic Models API in the Claude preflight adapter). Falls back to static on any failure. + dynamicProbe: 'auto', defaultMode: 'default', allowedModes: [ ...CLAUDE_STATIC_MODELS.map((model) => model.id), From 32031381395ab25618aae3625c9c4871a419479e Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 12 Aug 2026 01:10:38 +0200 Subject: [PATCH 03/11] fix(claude-models): probe the selected runtime identity --- apps/cli/src/api/types.ts | 2 + apps/cli/src/backends/catalog.test.ts | 2 + apps/cli/src/backends/claude/index.ts | 6 +- .../deriveDiscoveredClaudeModel.test.ts | 2 +- .../deriveDiscoveredClaudeModel.ts | 2 +- .../fetchAnthropicModels.test.ts} | 30 ++- .../fetchAnthropicModels.ts} | 21 +- .../models/resolveClaudeModelCatalog.test.ts | 26 +- .../models/resolveClaudeModelCatalog.ts | 248 +++--------------- .../resolveClaudeModelProbeTarget.test.ts | 102 +++++++ .../models/resolveClaudeModelProbeTarget.ts | 247 +++++++++++++++++ .../claudePreflightModelsProbeAdapter.test.ts | 131 +++++++-- .../claudePreflightModelsProbeAdapter.ts | 19 +- .../probes/agentModelsProbe.cache.test.ts | 66 ++++- .../agentModelsProbe.staticOnly.test.ts | 31 ++- .../capabilities/probes/agentModelsProbe.ts | 87 ++++-- ...eflightSessionControlsProbeAdapterTypes.ts | 5 + .../capabilities.probeModels.cwd.test.ts | 5 +- apps/cli/src/rpc/handlers/capabilities.ts | 7 + .../NewSessionEngineOptionDetail.tsx | 2 + .../NewSessionFavoriteModelsDetail.tsx | 4 + .../useNewSessionAgentPickerControls.tsx | 6 + ...ewSessionPreflightModelsState.cwd.test.tsx | 40 ++- ...nPreflightModelsState.persistence.test.tsx | 101 +++++-- .../useNewSessionPreflightModelsState.ts | 30 ++- .../new/hooks/useNewSessionScreenModel.tsx | 1 + .../sync/domains/models/modelOptions.ts | 66 ++--- .../readSessionControlMetadata.test.ts | 10 +- .../sync/domains/sessionControl/schema.ts | 1 + .../src/sessionControls/metadata.spec.ts | 21 ++ .../agents/src/sessionControls/metadata.ts | 5 + 31 files changed, 974 insertions(+), 352 deletions(-) rename apps/cli/src/backends/claude/{preflight => models}/deriveDiscoveredClaudeModel.test.ts (98%) rename apps/cli/src/backends/claude/{preflight => models}/deriveDiscoveredClaudeModel.ts (98%) rename apps/cli/src/backends/claude/{preflight/anthropicModelsFetch.test.ts => models/fetchAnthropicModels.test.ts} (80%) rename apps/cli/src/backends/claude/{preflight/anthropicModelsFetch.ts => models/fetchAnthropicModels.ts} (92%) create mode 100644 apps/cli/src/backends/claude/models/resolveClaudeModelProbeTarget.test.ts create mode 100644 apps/cli/src/backends/claude/models/resolveClaudeModelProbeTarget.ts diff --git a/apps/cli/src/api/types.ts b/apps/cli/src/api/types.ts index 0d37cd8ade..d0f1f4068e 100644 --- a/apps/cli/src/api/types.ts +++ b/apps/cli/src/api/types.ts @@ -645,6 +645,7 @@ export type Metadata = { name: string, description?: string, contextWindowTokens?: number, + extendedContextModelId?: string, modelOptions?: Array<{ id: string, name: string, @@ -670,6 +671,7 @@ export type Metadata = { name: string, description?: string, contextWindowTokens?: number, + extendedContextModelId?: string, modelOptions?: Array<{ id: string, name: string, diff --git a/apps/cli/src/backends/catalog.test.ts b/apps/cli/src/backends/catalog.test.ts index 1d7f30ea1e..cbf0af87a1 100644 --- a/apps/cli/src/backends/catalog.test.ts +++ b/apps/cli/src/backends/catalog.test.ts @@ -152,9 +152,11 @@ describe('AGENTS', () => { it('exposes a preflight session-controls probe adapter for claude so model-scoped options can be surfaced without ACP', async () => { const entry = requireCatalogEntry('claude'); + expect(entry.needsAccountSettingsForProbes).toBe(true); expect(entry.getPreflightSessionControlsProbeAdapter).toBeTypeOf('function'); const adapter = await entry.getPreflightSessionControlsProbeAdapter!(); expect(adapter).toMatchObject({ + modelProbeCachePolicy: 'provider-owned', probeModelsRaw: expect.any(Function), }); }); diff --git a/apps/cli/src/backends/claude/index.ts b/apps/cli/src/backends/claude/index.ts index 9e8af056db..035819083b 100644 --- a/apps/cli/src/backends/claude/index.ts +++ b/apps/cli/src/backends/claude/index.ts @@ -14,7 +14,6 @@ import { applyClaudeSharedGroupGenerationApplication } from '@/backends/claude/c import { claudeSubscriptionQuotaFetcherDescriptor } from '@/backends/claude/connectedServices/quotaFetcher'; import { claudeDaemonSpawnHooks } from '@/backends/claude/daemon/spawnHooks'; import { buildClaudeRuntimeLocalHandoffMetadata } from '@/backends/claude/sessionHandoff/runtimeLocalMetadata'; -import { resolveClaudeProbeBindingIdentity } from '@/backends/claude/models/resolveClaudeModelCatalog'; import type { AgentCatalogEntry } from '../types'; import type { ConnectedServiceCredentialLifecycleDescriptor } from '@/daemon/connectedServices/credentials/lifecycleTypes'; @@ -124,10 +123,7 @@ export const agent = { .hasClaudeEndpointDescriptorForSession(params), vendorResumeSupport: AGENTS_CORE.claude.resume.vendorResume, buildRuntimeLocalHandoffMetadata: buildClaudeRuntimeLocalHandoffMetadata, - resolveModelsProbeVariant: ({ connectedServices }) => - // The models probe authenticates as the bound account, so its result is account-specific; - // sharing one cache entry would serve one account's model list to another. - `claude:${resolveClaudeProbeBindingIdentity(connectedServices)}`, + needsAccountSettingsForProbes: true, getPreflightSessionControlsProbeAdapter: async () => (await import('@/backends/claude/preflight/claudePreflightModelsProbeAdapter')).claudePreflightModelsProbeAdapter, getHeadlessTmuxArgvTransform: async () => (await import('@/backends/claude/startup/headlessTmuxArgs')).ensureClaudeHeadlessTmuxStartingModeArgs, } satisfies AgentCatalogEntry; diff --git a/apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.test.ts b/apps/cli/src/backends/claude/models/deriveDiscoveredClaudeModel.test.ts similarity index 98% rename from apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.test.ts rename to apps/cli/src/backends/claude/models/deriveDiscoveredClaudeModel.test.ts index b068d6e3b8..396fdd7daa 100644 --- a/apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.test.ts +++ b/apps/cli/src/backends/claude/models/deriveDiscoveredClaudeModel.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { AnthropicModelEntry } from './anthropicModelsFetch'; +import type { AnthropicModelEntry } from './fetchAnthropicModels'; import { buildDiscoveredClaudeModelDescriptor, deriveClaudeModelOptionsFromCapabilities, diff --git a/apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.ts b/apps/cli/src/backends/claude/models/deriveDiscoveredClaudeModel.ts similarity index 98% rename from apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.ts rename to apps/cli/src/backends/claude/models/deriveDiscoveredClaudeModel.ts index 0388e630c1..598bf0dff7 100644 --- a/apps/cli/src/backends/claude/preflight/deriveDiscoveredClaudeModel.ts +++ b/apps/cli/src/backends/claude/models/deriveDiscoveredClaudeModel.ts @@ -5,7 +5,7 @@ import { type AgentModelOption, } from '@happier-dev/agents'; -import type { AnthropicModelEntry } from './anthropicModelsFetch'; +import type { AnthropicModelEntry } from './fetchAnthropicModels'; const EFFORT_TIER_ORDER = ['low', 'medium', 'high', 'xhigh', 'max'] as const; type EffortTier = (typeof EFFORT_TIER_ORDER)[number]; diff --git a/apps/cli/src/backends/claude/preflight/anthropicModelsFetch.test.ts b/apps/cli/src/backends/claude/models/fetchAnthropicModels.test.ts similarity index 80% rename from apps/cli/src/backends/claude/preflight/anthropicModelsFetch.test.ts rename to apps/cli/src/backends/claude/models/fetchAnthropicModels.test.ts index 843a12fc84..049f2c20b9 100644 --- a/apps/cli/src/backends/claude/preflight/anthropicModelsFetch.test.ts +++ b/apps/cli/src/backends/claude/models/fetchAnthropicModels.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { fetchAnthropicModels, parseAnthropicModelsResponse } from './anthropicModelsFetch'; +import { fetchAnthropicModels, parseAnthropicModelsResponse } from './fetchAnthropicModels'; describe('parseAnthropicModelsResponse', () => { it('parses entries and maps snake_case fields', () => { @@ -95,7 +95,7 @@ describe('fetchAnthropicModels', () => { expect(String(fetchImpl.mock.calls[0]![0])).toBe('https://api.z.ai/api/anthropic/v1/models?limit=1000'); }); - it('falls back to the default host when the base url is unusable', async () => { + it('does not send credentials anywhere when an explicit base url is unusable', async () => { const fetchImpl = vi.fn<(input: unknown, init: { headers: Record }) => Promise>(async () => okResponse()); await fetchAnthropicModels({ apiKey: 'sk-ant-key', @@ -103,7 +103,31 @@ describe('fetchAnthropicModels', () => { timeoutMs: 1_000, fetchImpl: fetchImpl as unknown as typeof fetch, }); - expect(String(fetchImpl.mock.calls[0]![0])).toBe('https://api.anthropic.com/v1/models?limit=1000'); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('prefers the bearer credential when both legacy inputs are present', async () => { + const fetchImpl = vi.fn<(input: unknown, init: { headers: Record }) => Promise>(async () => okResponse()); + await fetchAnthropicModels({ + apiKey: 'must-not-leave-process', + accessToken: 'gateway-token', + baseUrl: 'https://gateway.example/anthropic', + timeoutMs: 1_000, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + const headers = fetchImpl.mock.calls[0]![1].headers; + expect(headers.Authorization).toBe('Bearer gateway-token'); + expect(headers['x-api-key']).toBeUndefined(); + }); + + it('rejects redirects instead of forwarding credential headers', async () => { + const fetchImpl = vi.fn<(input: unknown, init: RequestInit) => Promise>(async () => okResponse()); + await fetchAnthropicModels({ + apiKey: 'sk-ant-key', + timeoutMs: 1_000, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + expect(fetchImpl.mock.calls[0]![1].redirect).toBe('error'); }); it('returns null on a non-2xx response', async () => { diff --git a/apps/cli/src/backends/claude/preflight/anthropicModelsFetch.ts b/apps/cli/src/backends/claude/models/fetchAnthropicModels.ts similarity index 92% rename from apps/cli/src/backends/claude/preflight/anthropicModelsFetch.ts rename to apps/cli/src/backends/claude/models/fetchAnthropicModels.ts index 7143f47304..8ae78a208f 100644 --- a/apps/cli/src/backends/claude/preflight/anthropicModelsFetch.ts +++ b/apps/cli/src/backends/claude/models/fetchAnthropicModels.ts @@ -10,15 +10,16 @@ const OAUTH_BETA_HEADER_VALUE = 'oauth-2025-04-20'; * * Anthropic-compatible gateways are configured through `ANTHROPIC_BASE_URL` and may live under a * path prefix (`https://api.z.ai/api/anthropic`), so the path is appended rather than replacing it. - * An unusable value falls back to the default host instead of throwing. + * An unusable explicit value returns `null`: credentials must never be silently redirected to + * Anthropic when the caller intended to use another origin. */ -export function resolveAnthropicModelsUrl(baseUrl?: string | null): string { +export function resolveAnthropicModelsUrl(baseUrl?: string | null): string | null { const raw = typeof baseUrl === 'string' ? baseUrl.trim() : ''; const candidate = raw.length > 0 ? raw : DEFAULT_ANTHROPIC_BASE_URL; try { return new URL(MODELS_PATH, candidate.endsWith('/') ? candidate : `${candidate}/`).toString(); } catch { - return `${DEFAULT_ANTHROPIC_BASE_URL}/${MODELS_PATH}`; + return null; } } @@ -122,7 +123,7 @@ export function parseAnthropicModelsResponse(body: unknown): AnthropicModelEntry export type FetchAnthropicModelsParams = Readonly<{ /** OAuth access token (subscription/Claude Code). Sent as `Authorization: Bearer`. */ accessToken?: string | null; - /** Anthropic API key. Sent as `x-api-key`. Takes precedence over `accessToken`. */ + /** Anthropic API key. Sent as `x-api-key` only when no bearer token is available. */ apiKey?: string | null; /** Anthropic-compatible endpoint root (`ANTHROPIC_BASE_URL`). Defaults to the Anthropic host. */ baseUrl?: string | null; @@ -153,20 +154,24 @@ export async function fetchAnthropicModels( 'anthropic-version': ANTHROPIC_VERSION, 'User-Agent': resolveClaudeCodeUserAgent(params.userAgent), }; - if (apiKey) { - headers['x-api-key'] = apiKey; - } else if (accessToken) { + if (accessToken) { headers['Authorization'] = `Bearer ${accessToken}`; headers['anthropic-beta'] = OAUTH_BETA_HEADER_VALUE; + } else if (apiKey) { + headers['x-api-key'] = apiKey; } + const modelsUrl = resolveAnthropicModelsUrl(params.baseUrl); + if (!modelsUrl) return null; + const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), Math.max(250, params.timeoutMs)); try { - const response = await fetchImpl(resolveAnthropicModelsUrl(params.baseUrl), { + const response = await fetchImpl(modelsUrl, { method: 'GET', headers, signal: controller.signal, + redirect: 'error', }); if (!response.ok) return null; const body = await response.json().catch(() => null); diff --git a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts index 2778ab786b..291e53964d 100644 --- a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts +++ b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts @@ -2,15 +2,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createEnvKeyScope } from '@/testkit/env/envScope'; -import type { AnthropicModelEntry } from '@/backends/claude/preflight/anthropicModelsFetch'; +import type { AnthropicModelEntry } from './fetchAnthropicModels'; const { fetchAnthropicModelsMock, readClaudeCodeNativeCredentialMock } = vi.hoisted(() => ({ fetchAnthropicModelsMock: vi.fn<(...args: unknown[]) => Promise>(), readClaudeCodeNativeCredentialMock: vi.fn<(...args: unknown[]) => Promise>(), })); -vi.mock('@/backends/claude/preflight/anthropicModelsFetch', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('./fetchAnthropicModels', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, fetchAnthropicModels: fetchAnthropicModelsMock }; }); @@ -23,6 +23,7 @@ import { buildClaudeEffortCliArgs } from '@/backends/claude/utils/claudeEffort'; import { resolveClaudeEffortLevelsFromModelDescriptor, resolveClaudeModelCatalog, + resolveClaudeModelCatalogResolution, resetClaudeModelCatalogCacheForTests, } from './resolveClaudeModelCatalog'; @@ -76,7 +77,19 @@ describe('resolveClaudeModelCatalog', () => { expect(models.some((m) => m.id === 'claude-fable-5')).toBe(true); }); - it('serves a cached catalog instead of refetching, and partitions by connected account', async () => { + it('records a successful endpoint response as dynamic even when every id is already curated', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; + fetchAnthropicModelsMock.mockResolvedValue([ + { id: 'claude-fable-5', displayName: 'Claude Fable 5' }, + ]); + + const resolution = await resolveClaudeModelCatalogResolution({ timeoutMs: 1_000 }); + + expect(resolution.source).toBe('dynamic'); + expect(resolution.models.some((model) => model.id === 'claude-fable-5')).toBe(true); + }); + + it('serves a cached catalog and never substitutes ambient auth for a selected account', async () => { process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; fetchAnthropicModelsMock.mockResolvedValue([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); @@ -91,7 +104,7 @@ describe('resolveClaudeModelCatalog', () => { updatedAtMs: 0, source: 'file', }); - await resolveClaudeModelCatalog({ + const boundResult = await resolveClaudeModelCatalog({ timeoutMs: 1_000, connectedServices: { v: 1, @@ -100,7 +113,8 @@ describe('resolveClaudeModelCatalog', () => { }, }, }); - expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(2); + expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(1); + expect(boundResult.some((model) => model.id === 'claude-opus-9')).toBe(false); }); it('refetches when the ambient credential changes', async () => { diff --git a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts index 69e266d208..56f9f148eb 100644 --- a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts +++ b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts @@ -1,17 +1,17 @@ -import { createHash } from 'node:crypto'; - import { AGENT_MODEL_CONFIG, type AgentModelDescriptor } from '@happier-dev/agents'; import type { ConnectedServiceBindingsV1 } from '@happier-dev/protocol'; -import { configuration } from '@/configuration'; -import { readClaudeCodeNativeCredential } from '@/backends/claude/connectedServices/nativeAuth/claudeCodeCredentialFile'; +import { buildDiscoveredClaudeModelDescriptor } from './deriveDiscoveredClaudeModel'; +import { fetchAnthropicModels, type AnthropicModelEntry } from './fetchAnthropicModels'; +import type { Credentials } from '@/persistence'; import { - resolveClaudeConnectedServiceStableConfigDir, - type ClaudeConnectedServiceId, -} from '@/backends/claude/connectedServices/resolveClaudeConnectedServiceStableAuthDir'; -import { fetchAnthropicModels, type AnthropicModelEntry } from '@/backends/claude/preflight/anthropicModelsFetch'; -import { buildDiscoveredClaudeModelDescriptor } from '@/backends/claude/preflight/deriveDiscoveredClaudeModel'; -import { resolveConfiguredClaudeConfigDir } from '@/backends/claude/utils/resolveConfiguredClaudeConfigDir'; + resolveClaudeModelProbeTarget, +} from './resolveClaudeModelProbeTarget'; + +export { + resolveClaudeProbeBinding, + type ClaudeProbeBinding, +} from './resolveClaudeModelProbeTarget'; /** * Single owner of "which Claude models can this account run". @@ -25,169 +25,10 @@ import { resolveConfiguredClaudeConfigDir } from '@/backends/claude/utils/resolv const CATALOG_SUCCESS_TTL_MS = 24 * 60 * 60 * 1_000; const CATALOG_FAILURE_TTL_MS = 60 * 1_000; -type ClaudeProbeCredential = - | Readonly<{ apiKey: string }> - | Readonly<{ accessToken: string }>; - -type ClaudeProbeTarget = Readonly<{ - baseUrl: string | null; - credential: ClaudeProbeCredential; -}>; - -export type ClaudeProbeBinding = Readonly<{ - serviceId: ClaudeConnectedServiceId; - selection: - | Readonly<{ kind: 'group'; groupId: string }> - | Readonly<{ kind: 'profile'; profileId: string }>; -}>; - -const CLAUDE_PROBE_SERVICE_IDS: readonly ClaudeConnectedServiceId[] = ['claude-subscription', 'anthropic']; - -function readEnvValue(name: string): string | null { - const value = process.env[name]; - return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; -} - function readNonBlankString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; } -export function resolveClaudeProbeBinding( - connectedServices?: ConnectedServiceBindingsV1 | null, -): ClaudeProbeBinding | null { - for (const serviceId of CLAUDE_PROBE_SERVICE_IDS) { - const binding = connectedServices?.bindingsByServiceId[serviceId] ?? null; - if (!binding || binding.source === 'native') continue; - - if (binding.selection === 'group') { - const groupId = readNonBlankString(binding.groupId); - if (groupId) return { serviceId, selection: { kind: 'group', groupId } }; - continue; - } - const profileId = readNonBlankString(binding.profileId); - if (profileId) return { serviceId, selection: { kind: 'profile', profileId } }; - } - return null; -} - -/** Stable identity for a binding, used for both the probe cache variant and this module's cache. */ -export function resolveClaudeProbeBindingIdentity( - connectedServices?: ConnectedServiceBindingsV1 | null, -): string { - const bound = resolveClaudeProbeBinding(connectedServices); - if (!bound) return 'native'; - return bound.selection.kind === 'group' - ? `${bound.serviceId}:group:${bound.selection.groupId}` - : `${bound.serviceId}:profile:${bound.selection.profileId}`; -} - -/** - * Read a configured Anthropic-compatible endpoint root, or `null` when unset/unusable. - * - * `ANTHROPIC_BASE_URL` is how Happier's built-in Claude backend profiles point the CLI at - * third-party gateways (Z.AI, DeepSeek, MiniMax), always paired with a gateway-issued - * `ANTHROPIC_AUTH_TOKEN`. - */ -function readConfiguredBaseUrl(): string | null { - const raw = readEnvValue('ANTHROPIC_BASE_URL'); - if (!raw) return null; - try { - new URL(raw); - return raw; - } catch { - return null; - } -} - -function isAnthropicFirstPartyBaseUrl(baseUrl: string | null): boolean { - if (!baseUrl) return true; - try { - const url = new URL(baseUrl); - // Host alone is not enough: `http://api.anthropic.com` would otherwise be treated as - // first-party and the on-disk subscription token sent in plaintext. - if (url.protocol !== 'https:') return false; - const host = url.hostname.toLowerCase(); - return host === 'anthropic.com' || host.endsWith('.anthropic.com'); - } catch { - return true; - } -} - -/** - * Ambient auth env keys the catalog fetch may use for a bound session. - * - * Mirrors `isolateClaudeRuntimeAuthEnv`, which strips every `CLAUDE_AUTH_ENV_KEYS` entry from the - * spawned child for a bound session and keeps only `ANTHROPIC_API_KEY` for the `anthropic` - * service. Reading a key the spawn deletes would describe one account for a session that runs as - * another. - */ -function resolveAllowedEnvKeys(bound: ClaudeProbeBinding | null): readonly string[] { - if (!bound) return ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_OAUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN']; - return bound.serviceId === 'anthropic' ? ['ANTHROPIC_API_KEY'] : []; -} - -/** - * Config dir holding the credentials of the account this catalog describes. - * - * This doubles as the cache identity. The preflight probe resolves it from the binding it is - * handed; an in-session consumer resolves the same materialized dir from `CLAUDE_CONFIG_DIR`, - * which the daemon sets for a bound session. Both therefore land on the same cache entry without - * the in-session side having to know the binding. - */ -function resolveClaudeCatalogConfigDir(bound: ClaudeProbeBinding | null): string { - const ownConfigDir = resolveConfiguredClaudeConfigDir({ env: process.env }); - if (!bound) return ownConfigDir; - - const { serviceId, selection } = bound; - const boundDir = resolveClaudeConnectedServiceStableConfigDir({ - activeServerDir: configuration.activeServerDir, - serviceId, - fallbackProfileId: selection.kind === 'group' ? selection.groupId : selection.profileId, - selection, - }); - return boundDir ?? ownConfigDir; -} - -/** - * Resolve the endpoint + credential pair for the catalog fetch. - * - * The credential must belong to the endpoint it is sent to. Environment credentials are configured - * alongside `ANTHROPIC_BASE_URL` and travel with it; the on-disk Claude Code subscription token is - * Anthropic-only and is never sent to a third-party gateway. Returns `null` when no usable pair - * exists so callers fall back to the curated catalog. Never throws. - */ -async function resolveClaudeCatalogTarget( - connectedServices?: ConnectedServiceBindingsV1 | null, -): Promise { - const baseUrl = readConfiguredBaseUrl(); - const bound = resolveClaudeProbeBinding(connectedServices); - const allowedEnvKeys = resolveAllowedEnvKeys(bound); - const readAllowedEnvValue = (name: string): string | null => - allowedEnvKeys.includes(name) ? readEnvValue(name) : null; - - const apiKey = readAllowedEnvValue('ANTHROPIC_API_KEY'); - if (apiKey) return { baseUrl, credential: { apiKey } }; - - const envToken = readAllowedEnvValue('ANTHROPIC_AUTH_TOKEN') - ?? readAllowedEnvValue('ANTHROPIC_OAUTH_TOKEN') - ?? readAllowedEnvValue('CLAUDE_CODE_OAUTH_TOKEN'); - if (envToken) return { baseUrl, credential: { accessToken: envToken } }; - - if (!isAnthropicFirstPartyBaseUrl(baseUrl)) return null; - - try { - const claudeConfigDir = resolveClaudeCatalogConfigDir(bound); - const credential = await readClaudeCodeNativeCredential({ claudeConfigDir }); - const accessToken = credential?.payload.claudeAiOauth.accessToken; - if (typeof accessToken === 'string' && accessToken.trim().length > 0) { - return { baseUrl, credential: { accessToken: accessToken.trim() } }; - } - } catch { - // best-effort — fall through to the curated catalog - } - return null; -} - /** Lowercase + strip a trailing dated snapshot suffix (`-YYYYMMDD`) for dedup comparison only. */ function normalizeDatedId(rawId: string): string { return rawId.trim().toLowerCase().replace(/-\d{8}$/u, ''); @@ -251,29 +92,12 @@ function mergeStaticWithDiscovered(entries: readonly AnthropicModelEntry[]): Age return [...staticModels, ...discovered]; } -/** - * Non-reversible fingerprint of the credential this catalog was fetched with. - * - * The config dir identifies the account slot, but the credential inside it can be replaced — - * re-authing a bound profile to a different account, or swapping an ambient key — without the dir - * changing. Fingerprinting the resolved credential means a new one always gets its own entry - * instead of inheriting the previous account's list for the rest of the TTL. The secret itself - * never enters the key. - */ -function resolveCredentialFingerprint(target: ClaudeProbeTarget): string { - const value = 'apiKey' in target.credential ? target.credential.apiKey : target.credential.accessToken; - return createHash('sha256').update(value).digest('hex').slice(0, 12); -} - -function resolveCatalogCacheKey(bound: ClaudeProbeBinding | null, target: ClaudeProbeTarget): string { - return [ - resolveClaudeCatalogConfigDir(bound), - readConfiguredBaseUrl() ?? 'default', - resolveCredentialFingerprint(target), - ].join('|'); -} +export type ClaudeModelCatalogResolution = Readonly<{ + models: readonly AgentModelDescriptor[]; + source: 'dynamic' | 'static'; +}>; -type CatalogCacheEntry = Readonly<{ models: readonly AgentModelDescriptor[]; expiresAtMs: number }>; +type CatalogCacheEntry = Readonly<{ resolution: ClaudeModelCatalogResolution; expiresAtMs: number }>; const catalogCache = new Map(); /** * Resolutions currently in flight, keyed the same as the cache. @@ -281,7 +105,7 @@ const catalogCache = new Map(); * The preflight probe and the `sessionModelsV1` publisher both resolve at session start; without * this they miss the cache in parallel and each issue their own fetch for the same account. */ -const inFlightCatalogResolutions = new Map>(); +const inFlightCatalogResolutions = new Map>(); export function resetClaudeModelCatalogCacheForTests(): void { catalogCache.clear(); @@ -298,6 +122,9 @@ function pruneExpiredCatalogEntries(nowMs: number): void { export type ResolveClaudeModelCatalogParams = Readonly<{ timeoutMs: number; connectedServices?: ConnectedServiceBindingsV1 | null; + credentials?: Credentials | null; + accountSettings?: Readonly> | null; + profileId?: string | null; nowMs?: () => number; }>; @@ -305,44 +132,50 @@ export type ResolveClaudeModelCatalogParams = Readonly<{ * The models this account can run: curated catalog, augmented with anything the Anthropic Models * API reports. Falls back to the curated catalog on any failure. Never throws. */ -export async function resolveClaudeModelCatalog( +export async function resolveClaudeModelCatalogResolution( params: ResolveClaudeModelCatalogParams, -): Promise { +): Promise { const nowMs = params.nowMs ?? (() => Date.now()); // Resolving the target first is what lets the cache key carry the credential identity. It is env // reads plus at most one local credential-file read — cheap next to the network fetch it guards, // and this runs at session start and on model change, not on a hot path. - const bound = resolveClaudeProbeBinding(params.connectedServices); - const target = await resolveClaudeCatalogTarget(params.connectedServices); + const target = await resolveClaudeModelProbeTarget({ + connectedServices: params.connectedServices, + credentials: params.credentials, + accountSettings: params.accountSettings, + profileId: params.profileId, + }); // No resolvable credential is an absence of identity, not an identity of its own. Caching under a // placeholder key would let one unreadable credential file evict a valid catalog for the whole // failure TTL, so degrade to the curated catalog for this call only and leave the cache untouched. - if (!target) return resolveStaticClaudeModels(); + if (!target) return { models: resolveStaticClaudeModels(), source: 'static' }; - const cacheKey = resolveCatalogCacheKey(bound, target); + const cacheKey = target.cacheIdentity; const cached = catalogCache.get(cacheKey); - if (cached && cached.expiresAtMs > nowMs()) return cached.models; + if (cached && cached.expiresAtMs > nowMs()) return cached.resolution; const inFlight = inFlightCatalogResolutions.get(cacheKey); if (inFlight) return await inFlight; const resolution = (async () => { const entries = await fetchAnthropicModels({ - ...('apiKey' in target.credential ? { apiKey: target.credential.apiKey } : {}), - ...('accessToken' in target.credential ? { accessToken: target.credential.accessToken } : {}), + ...(target.credential.kind === 'api_key' ? { apiKey: target.credential.value } : {}), + ...(target.credential.kind === 'bearer' ? { accessToken: target.credential.value } : {}), ...(target.baseUrl ? { baseUrl: target.baseUrl } : {}), timeoutMs: params.timeoutMs, }); - const models = entries ? mergeStaticWithDiscovered(entries) : resolveStaticClaudeModels(); + const resolution: ClaudeModelCatalogResolution = entries + ? { models: mergeStaticWithDiscovered(entries), source: 'dynamic' } + : { models: resolveStaticClaudeModels(), source: 'static' }; const resolvedAtMs = nowMs(); pruneExpiredCatalogEntries(resolvedAtMs); catalogCache.set(cacheKey, { - models, + resolution, expiresAtMs: resolvedAtMs + (entries ? CATALOG_SUCCESS_TTL_MS : CATALOG_FAILURE_TTL_MS), }); - return models; + return resolution; })(); inFlightCatalogResolutions.set(cacheKey, resolution); @@ -353,9 +186,10 @@ export async function resolveClaudeModelCatalog( } } -/** Whether the catalog fetch found anything beyond the curated list (used to report probe source). */ -export function hasDiscoveredClaudeModels(models: readonly AgentModelDescriptor[]): boolean { - return models.length > resolveStaticClaudeModels().length; +export async function resolveClaudeModelCatalog( + params: ResolveClaudeModelCatalogParams, +): Promise { + return (await resolveClaudeModelCatalogResolution(params)).models; } /** diff --git a/apps/cli/src/backends/claude/models/resolveClaudeModelProbeTarget.test.ts b/apps/cli/src/backends/claude/models/resolveClaudeModelProbeTarget.test.ts new file mode 100644 index 0000000000..505f76b3d0 --- /dev/null +++ b/apps/cli/src/backends/claude/models/resolveClaudeModelProbeTarget.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; + +import type { ConnectedServiceCredentialRecordV1 } from '@happier-dev/protocol'; + +import type { ConnectedServiceAuthGroupApi, ConnectedServiceCredentialApi } from '@/api/connectedServices/connectedServiceCredentialApi'; +import type { Credentials } from '@/persistence'; +import { resolveClaudeModelProbeTarget } from './resolveClaudeModelProbeTarget'; + +const credentials: Credentials = { + token: 'account-token', + encryption: { type: 'legacy', secret: new Uint8Array(32) }, +}; + +function anthropicRecord(token: string): ConnectedServiceCredentialRecordV1 { + return { + v: 1, + serviceId: 'anthropic', + profileId: 'selected-account', + kind: 'token', + token: { token, providerAccountId: null, providerEmail: null, raw: null }, + oauth: null, + createdAt: 1, + updatedAt: 1, + expiresAt: null, + }; +} + +describe('resolveClaudeModelProbeTarget', () => { + it('uses bearer auth before an API key from the same effective environment', async () => { + const target = await resolveClaudeModelProbeTarget({ + processEnv: { + ANTHROPIC_BASE_URL: 'https://gateway.example/anthropic', + ANTHROPIC_AUTH_TOKEN: 'gateway-token', + ANTHROPIC_API_KEY: 'ambient-api-key', + }, + }); + + expect(target).toMatchObject({ + baseUrl: 'https://gateway.example/anthropic', + credential: { kind: 'bearer', value: 'gateway-token' }, + }); + }); + + it('fails closed on an invalid explicit endpoint', async () => { + await expect(resolveClaudeModelProbeTarget({ + processEnv: { + ANTHROPIC_BASE_URL: 'not a url', + ANTHROPIC_AUTH_TOKEN: 'must-not-be-rehomed', + }, + })).resolves.toBeNull(); + }); + + it('uses the selected connected Anthropic account instead of ambient auth', async () => { + const record = anthropicRecord('selected-api-key'); + const api: ConnectedServiceCredentialApi & ConnectedServiceAuthGroupApi = { + getAccountEncryptionMode: async () => 'plain', + getConnectedServiceCredentialSealed: async () => null, + getConnectedServiceCredentialPlain: async () => ({ + content: { t: 'plain', v: record }, + revisionSemantics: 'revisioned', + credentialRevision: 7, + }), + listConnectedServiceAuthGroups: async () => [], + getConnectedServiceAuthGroup: async () => null, + }; + + const target = await resolveClaudeModelProbeTarget({ + credentials, + connectedServices: { + v: 1, + bindingsByServiceId: { + anthropic: { + source: 'connected', + selection: 'profile', + profileId: 'selected-account', + }, + }, + }, + processEnv: { ANTHROPIC_API_KEY: 'wrong-ambient-key' }, + createCredentialApi: () => api, + }); + + expect(target?.credential).toEqual({ kind: 'api_key', value: 'selected-api-key' }); + }); + + it('projects a selected built-in gateway profile without mutating ambient auth', async () => { + const target = await resolveClaudeModelProbeTarget({ + credentials, + profileId: 'deepseek', + accountSettings: {}, + processEnv: { + DEEPSEEK_AUTH_TOKEN: 'deepseek-token', + ANTHROPIC_API_KEY: 'unrelated-key', + }, + }); + + expect(target).toMatchObject({ + baseUrl: 'https://api.deepseek.com/anthropic', + credential: { kind: 'bearer', value: 'deepseek-token' }, + }); + }); +}); diff --git a/apps/cli/src/backends/claude/models/resolveClaudeModelProbeTarget.ts b/apps/cli/src/backends/claude/models/resolveClaudeModelProbeTarget.ts new file mode 100644 index 0000000000..94f54f5ce1 --- /dev/null +++ b/apps/cli/src/backends/claude/models/resolveClaudeModelProbeTarget.ts @@ -0,0 +1,247 @@ +import { createHash } from 'node:crypto'; + +import type { ConnectedServiceBindingsV1, ConnectedServiceCredentialRecordV1 } from '@happier-dev/protocol'; + +import { + createConnectedServiceCredentialApi, + type ConnectedServiceCredentialApi, + type ConnectedServiceAuthGroupApi, +} from '@/api/connectedServices/connectedServiceCredentialApi'; +import { readClaudeCodeNativeCredential } from '@/backends/claude/connectedServices/nativeAuth/claudeCodeCredentialFile'; +import { resolveConnectedServiceCredentialsWithRevisions } from '@/cloud/connectedServices/resolveConnectedServiceCredentials'; +import type { Credentials } from '@/persistence'; +import { buildProfileEnvOverlay } from '@/settings/profiles/buildProfileEnvOverlay'; +import { readProfilesFromAccountSettings } from '@/settings/profiles/readProfilesFromAccountSettings'; +import { resolveProfileForAgent } from '@/settings/profiles/resolveProfileForAgent'; +import { resolveConfiguredClaudeConfigDir } from '@/backends/claude/utils/resolveConfiguredClaudeConfigDir'; +import { DEFAULT_ANTHROPIC_BASE_URL } from './fetchAnthropicModels'; + +export type ClaudeModelProbeCredential = Readonly<{ + kind: 'bearer' | 'api_key'; + value: string; +}>; + +export type ClaudeModelProbeTarget = Readonly<{ + baseUrl: string | null; + credential: ClaudeModelProbeCredential; + cacheIdentity: string; +}>; + +export type ClaudeProbeBinding = Readonly<{ + serviceId: 'claude-subscription' | 'anthropic'; + selection: + | Readonly<{ kind: 'group'; groupId: string }> + | Readonly<{ kind: 'profile'; profileId: string }>; +}>; + +const CLAUDE_PROBE_SERVICE_IDS = ['claude-subscription', 'anthropic'] as const; +const CLAUDE_AUTH_ENV_KEYS = [ + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_OAUTH_TOKEN', + 'CLAUDE_CODE_OAUTH_TOKEN', + 'ANTHROPIC_API_KEY', +] as const; + +function readNonBlankString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +export function resolveClaudeProbeBinding( + connectedServices?: ConnectedServiceBindingsV1 | null, +): ClaudeProbeBinding | null { + for (const serviceId of CLAUDE_PROBE_SERVICE_IDS) { + const binding = connectedServices?.bindingsByServiceId[serviceId] ?? null; + if (!binding || binding.source === 'native') continue; + if (binding.selection === 'group') { + const groupId = readNonBlankString(binding.groupId); + if (groupId) return { serviceId, selection: { kind: 'group', groupId } }; + continue; + } + const profileId = readNonBlankString(binding.profileId); + if (profileId) return { serviceId, selection: { kind: 'profile', profileId } }; + } + return null; +} + +function readAuthFromEnv(env: NodeJS.ProcessEnv | Readonly>): ClaudeModelProbeCredential | null { + for (const key of CLAUDE_AUTH_ENV_KEYS) { + const value = readNonBlankString(env[key]); + if (!value) continue; + return key === 'ANTHROPIC_API_KEY' + ? { kind: 'api_key', value } + : { kind: 'bearer', value }; + } + return null; +} + +function normalizeExplicitBaseUrl(env: NodeJS.ProcessEnv | Readonly>): string | null | 'invalid' { + const raw = readNonBlankString(env.ANTHROPIC_BASE_URL); + if (!raw) return null; + try { + const parsed = new URL(raw); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return 'invalid'; + const defaultOrigin = new URL(DEFAULT_ANTHROPIC_BASE_URL); + if ( + parsed.origin === defaultOrigin.origin + && (parsed.pathname === '' || parsed.pathname === '/') + && parsed.search === '' + && parsed.hash === '' + ) { + return null; + } + return parsed.toString(); + } catch { + return 'invalid'; + } +} + +function targetCacheIdentity(baseUrl: string | null, credential: ClaudeModelProbeCredential): string { + return [ + baseUrl ?? 'https://api.anthropic.com', + credential.kind, + createHash('sha256').update(credential.value).digest('hex'), + ].join('|'); +} + +function projectConnectedCredential( + serviceId: ClaudeProbeBinding['serviceId'], + record: ConnectedServiceCredentialRecordV1, + nowMs: number, +): ClaudeModelProbeCredential | null { + if (record.expiresAt !== null && record.expiresAt <= nowMs) return null; + if (serviceId === 'anthropic') { + return record.kind === 'token' ? { kind: 'api_key', value: record.token.token } : null; + } + return record.kind === 'oauth' ? { kind: 'bearer', value: record.oauth.accessToken } : null; +} + +type ConnectedServiceReadApi = ConnectedServiceCredentialApi & ConnectedServiceAuthGroupApi; + +async function resolveConnectedCredential(params: Readonly<{ + binding: ClaudeProbeBinding; + credentials: Credentials; + api: ConnectedServiceReadApi; + nowMs: number; +}>): Promise { + const readProfile = async (profileId: string): Promise => { + const resolved = await resolveConnectedServiceCredentialsWithRevisions({ + credentials: params.credentials, + api: params.api, + bindings: [{ serviceId: params.binding.serviceId, profileId }], + }); + const record = resolved.get(params.binding.serviceId)?.record ?? null; + return record ? projectConnectedCredential(params.binding.serviceId, record, params.nowMs) : null; + }; + + if (params.binding.selection.kind === 'profile') { + return await readProfile(params.binding.selection.profileId); + } + + // A passive probe may observe a concurrent group switch. Re-read once after credential + // resolution and accept only a stable active member/generation; never mutate group state. + for (let attempt = 0; attempt < 2; attempt += 1) { + const before = await params.api.getConnectedServiceAuthGroup({ + serviceId: params.binding.serviceId, + groupId: params.binding.selection.groupId, + }); + const activeProfileId = readNonBlankString(before?.activeProfileId); + if (!before || !activeProfileId) return null; + const projected = await readProfile(activeProfileId); + const after = await params.api.getConnectedServiceAuthGroup({ + serviceId: params.binding.serviceId, + groupId: params.binding.selection.groupId, + }); + if ( + after + && after.generation === before.generation + && after.activeProfileId === activeProfileId + ) { + return projected; + } + } + return null; +} + +export type ResolveClaudeModelProbeTargetParams = Readonly<{ + connectedServices?: ConnectedServiceBindingsV1 | null; + credentials?: Credentials | null; + accountSettings?: Readonly> | null; + profileId?: string | null; + processEnv?: NodeJS.ProcessEnv; + nowMs?: () => number; + createCredentialApi?: (credentials: Credentials) => ConnectedServiceReadApi; +}>; + +/** + * Resolve the exact endpoint/credential pair used for model discovery. + * + * This is deliberately passive: it reads profiles and selected connected credentials but never + * mutates process env, materializes auth homes, refreshes OAuth, or switches account groups. + */ +export async function resolveClaudeModelProbeTarget( + params: ResolveClaudeModelProbeTargetParams, +): Promise { + const processEnv = params.processEnv ?? process.env; + let effectiveEnv: NodeJS.ProcessEnv = { ...processEnv }; + const profileId = readNonBlankString(params.profileId); + if (profileId) { + if (!params.credentials || !params.accountSettings) return null; + try { + const { customProfiles } = readProfilesFromAccountSettings(params.accountSettings); + const profile = resolveProfileForAgent({ agentId: 'claude', query: profileId, customProfiles }); + const overlay = await buildProfileEnvOverlay({ + agentId: 'claude', + profile, + accountSettings: params.accountSettings, + credentials: params.credentials, + processEnv, + promptSecretFn: null, + startedBy: 'daemon', + }); + effectiveEnv = { ...processEnv, ...overlay.envOverlayExpanded }; + } catch { + return null; + } + } + + const baseUrl = normalizeExplicitBaseUrl(effectiveEnv); + if (baseUrl === 'invalid') return null; + + const binding = resolveClaudeProbeBinding(params.connectedServices); + if (binding) { + if (!params.credentials) return null; + try { + const api = (params.createCredentialApi ?? createConnectedServiceCredentialApi)(params.credentials); + const credential = await resolveConnectedCredential({ + binding, + credentials: params.credentials, + api, + nowMs: (params.nowMs ?? Date.now)(), + }); + if (!credential) return null; + return { baseUrl, credential, cacheIdentity: targetCacheIdentity(baseUrl, credential) }; + } catch { + return null; + } + } + + const envCredential = readAuthFromEnv(effectiveEnv); + if (envCredential) { + return { baseUrl, credential: envCredential, cacheIdentity: targetCacheIdentity(baseUrl, envCredential) }; + } + + // Never pair a native saved subscription token with an explicitly configured third-party + // endpoint. With the default endpoint, reading the native credential mirrors Claude Code login. + if (baseUrl !== null) return null; + try { + const native = await readClaudeCodeNativeCredential({ + claudeConfigDir: resolveConfiguredClaudeConfigDir({ env: effectiveEnv }), + }); + const accessToken = readNonBlankString(native?.payload.claudeAiOauth.accessToken); + if (!accessToken) return null; + const credential = { kind: 'bearer' as const, value: accessToken }; + return { baseUrl: null, credential, cacheIdentity: targetCacheIdentity(null, credential) }; + } catch { + return null; + } +} diff --git a/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts b/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts index 8a8feb2bba..38640736c0 100644 --- a/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts +++ b/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts @@ -1,16 +1,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ConnectedServiceCredentialRecordV1 } from '@happier-dev/protocol'; import { createEnvKeyScope } from '@/testkit/env/envScope'; +import type { Credentials } from '@/persistence'; -import type { AnthropicModelEntry } from './anthropicModelsFetch'; +import type { AnthropicModelEntry } from '@/backends/claude/models/fetchAnthropicModels'; -const { fetchAnthropicModelsMock, readClaudeCodeNativeCredentialMock } = vi.hoisted(() => ({ +const { + createConnectedServiceCredentialApiMock, + fetchAnthropicModelsMock, + getConnectedServiceCredentialPlainMock, + readClaudeCodeNativeCredentialMock, +} = vi.hoisted(() => ({ + createConnectedServiceCredentialApiMock: vi.fn(), fetchAnthropicModelsMock: vi.fn<(...args: unknown[]) => Promise>(), + getConnectedServiceCredentialPlainMock: vi.fn(), readClaudeCodeNativeCredentialMock: vi.fn<(...args: unknown[]) => Promise>(), })); -vi.mock('@/backends/claude/preflight/anthropicModelsFetch', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('@/api/connectedServices/connectedServiceCredentialApi', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, createConnectedServiceCredentialApi: createConnectedServiceCredentialApiMock }; +}); + +vi.mock('@/backends/claude/models/fetchAnthropicModels', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, fetchAnthropicModels: fetchAnthropicModelsMock }; }); @@ -30,6 +44,68 @@ const envKeys = [ 'ANTHROPIC_BASE_URL', ] as const; let envScope = createEnvKeyScope(envKeys); +const probeCredentials: Credentials = { + token: 'account-token', + encryption: { type: 'legacy', secret: new Uint8Array(32).fill(5) }, +}; + +function setConnectedCredentialRecord(record: ConnectedServiceCredentialRecordV1): void { + getConnectedServiceCredentialPlainMock.mockResolvedValue({ + content: { t: 'plain', v: record }, + revisionSemantics: 'revisioned', + credentialRevision: 1, + }); +} + +function buildConnectedOauthRecord(params: Readonly<{ + serviceId: 'claude-subscription'; + profileId: string; + accessToken: string; +}>): ConnectedServiceCredentialRecordV1 { + return { + v: 1, + serviceId: params.serviceId, + profileId: params.profileId, + kind: 'oauth', + oauth: { + accessToken: params.accessToken, + refreshToken: 'refresh-token', + idToken: null, + scope: null, + tokenType: null, + providerAccountId: null, + providerEmail: null, + raw: null, + }, + token: null, + createdAt: 1, + updatedAt: 1, + expiresAt: Date.now() + 60_000, + }; +} + +function buildConnectedTokenRecord(params: Readonly<{ + serviceId: 'anthropic'; + profileId: string; + token: string; +}>): ConnectedServiceCredentialRecordV1 { + return { + v: 1, + serviceId: params.serviceId, + profileId: params.profileId, + kind: 'token', + oauth: null, + token: { + token: params.token, + providerAccountId: null, + providerEmail: null, + raw: null, + }, + createdAt: 1, + updatedAt: 1, + expiresAt: null, + }; +} function fullEffort(): AnthropicModelEntry['capabilities'] { return { @@ -56,6 +132,16 @@ async function runProbe() { beforeEach(() => { resetClaudeModelCatalogCacheForTests(); fetchAnthropicModelsMock.mockReset(); + getConnectedServiceCredentialPlainMock.mockReset(); + getConnectedServiceCredentialPlainMock.mockResolvedValue(null); + createConnectedServiceCredentialApiMock.mockReset(); + createConnectedServiceCredentialApiMock.mockReturnValue({ + getAccountEncryptionMode: async () => 'plain', + getConnectedServiceCredentialSealed: async () => null, + getConnectedServiceCredentialPlain: getConnectedServiceCredentialPlainMock, + listConnectedServiceAuthGroups: async () => [], + getConnectedServiceAuthGroup: async () => null, + }); readClaudeCodeNativeCredentialMock.mockReset(); readClaudeCodeNativeCredentialMock.mockResolvedValue(null); envScope.restore(); @@ -192,12 +278,12 @@ describe('claudePreflightModelsProbeAdapter', () => { } }); - it('reads the bound connected account credentials instead of the daemon own config dir', async () => { - readClaudeCodeNativeCredentialMock.mockResolvedValue({ - payload: { claudeAiOauth: { accessToken: 'sk-ant-oat01-profile', scopes: [] } }, - updatedAtMs: 0, - source: 'file', - }); + it('reads the selected connected account credential instead of the daemon own config dir', async () => { + setConnectedCredentialRecord(buildConnectedOauthRecord({ + serviceId: 'claude-subscription', + profileId: 'profile-a', + accessToken: 'sk-ant-oat01-profile', + })); fetchAnthropicModelsMock.mockResolvedValue([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); await claudePreflightModelsProbeAdapter.probeModelsRaw?.({ @@ -205,6 +291,7 @@ describe('claudePreflightModelsProbeAdapter', () => { timeoutMs: 1_500, backendTarget: undefined, accountSettings: null, + credentials: probeCredentials, connectedServices: { v: 1, bindingsByServiceId: { @@ -213,9 +300,10 @@ describe('claudePreflightModelsProbeAdapter', () => { }, }); - const configDir = readClaudeCodeNativeCredentialMock.mock.calls[0]?.[0] as { claudeConfigDir: string }; - expect(configDir.claudeConfigDir).toContain('connected-services'); - expect(configDir.claudeConfigDir).toContain('profile-a'); + expect(fetchAnthropicModelsMock).toHaveBeenCalledWith(expect.objectContaining({ + accessToken: 'sk-ant-oat01-profile', + })); + expect(readClaudeCodeNativeCredentialMock).not.toHaveBeenCalled(); }); it('ignores ambient env auth when the session is bound to a Claude subscription account', async () => { @@ -224,11 +312,11 @@ describe('claudePreflightModelsProbeAdapter', () => { // models while the session runs as another — cached under the bound account's variant key. process.env.ANTHROPIC_AUTH_TOKEN = 'ambient-token-other-account'; process.env.ANTHROPIC_API_KEY = 'sk-ant-ambient'; - readClaudeCodeNativeCredentialMock.mockResolvedValue({ - payload: { claudeAiOauth: { accessToken: 'sk-ant-oat01-bound', scopes: [] } }, - updatedAtMs: 0, - source: 'file', - }); + setConnectedCredentialRecord(buildConnectedOauthRecord({ + serviceId: 'claude-subscription', + profileId: 'profile-a', + accessToken: 'sk-ant-oat01-bound', + })); fetchAnthropicModelsMock.mockResolvedValue([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); await claudePreflightModelsProbeAdapter.probeModelsRaw?.({ @@ -236,6 +324,7 @@ describe('claudePreflightModelsProbeAdapter', () => { timeoutMs: 1_500, backendTarget: undefined, accountSettings: null, + credentials: probeCredentials, connectedServices: { v: 1, bindingsByServiceId: { @@ -254,6 +343,11 @@ describe('claudePreflightModelsProbeAdapter', () => { it('keeps ANTHROPIC_API_KEY for a bound anthropic account, matching the spawn allow-list', async () => { process.env.ANTHROPIC_API_KEY = 'sk-ant-bound-key'; process.env.ANTHROPIC_AUTH_TOKEN = 'ambient-token-other-account'; + setConnectedCredentialRecord(buildConnectedTokenRecord({ + serviceId: 'anthropic', + profileId: 'profile-a', + token: 'sk-ant-bound-key', + })); fetchAnthropicModelsMock.mockResolvedValue([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); await claudePreflightModelsProbeAdapter.probeModelsRaw?.({ @@ -261,6 +355,7 @@ describe('claudePreflightModelsProbeAdapter', () => { timeoutMs: 1_500, backendTarget: undefined, accountSettings: null, + credentials: probeCredentials, connectedServices: { v: 1, bindingsByServiceId: { diff --git a/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.ts b/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.ts index e76bf04bcf..0f7bbd1258 100644 --- a/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.ts +++ b/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.ts @@ -1,7 +1,7 @@ import type { AgentModelDescriptor } from '@happier-dev/agents'; import type { PreflightSessionControlsProbeAdapter } from '@/capabilities/probes/preflightSessionControlsProbeAdapterTypes'; -import { hasDiscoveredClaudeModels, resolveClaudeModelCatalog } from '@/backends/claude/models/resolveClaudeModelCatalog'; +import { resolveClaudeModelCatalogResolution } from '@/backends/claude/models/resolveClaudeModelCatalog'; function toProbeRawModel(model: AgentModelDescriptor): Record { return { @@ -22,12 +22,17 @@ function toProbeRawModel(model: AgentModelDescriptor): Record { * effort tiers. */ export const claudePreflightModelsProbeAdapter: PreflightSessionControlsProbeAdapter = { + modelProbeCachePolicy: 'provider-owned', failureCacheStrategy: 'cooldown', - probeModelsRaw: async ({ timeoutMs, connectedServices }) => { - const models = await resolveClaudeModelCatalog({ timeoutMs, connectedServices }); - // Nothing beyond the curated catalog means the probe added nothing: return null so the caller - // reports the list as `static` rather than mislabelling the curated catalog as dynamic. - if (!hasDiscoveredClaudeModels(models)) return null; - return models.map(toProbeRawModel); + probeModelsRaw: async ({ timeoutMs, connectedServices, credentials, accountSettings, profileId }) => { + const resolution = await resolveClaudeModelCatalogResolution({ + timeoutMs, + connectedServices, + credentials, + accountSettings, + profileId, + }); + if (resolution.source === 'static') return null; + return resolution.models.map(toProbeRawModel); }, }; diff --git a/apps/cli/src/capabilities/probes/agentModelsProbe.cache.test.ts b/apps/cli/src/capabilities/probes/agentModelsProbe.cache.test.ts index c58f0c6dbc..73543fb151 100644 --- a/apps/cli/src/capabilities/probes/agentModelsProbe.cache.test.ts +++ b/apps/cli/src/capabilities/probes/agentModelsProbe.cache.test.ts @@ -3,8 +3,10 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { delimiter, join, resolve } from 'node:path'; import { createProbeTempDir, writeExecutableScript } from './agentModelsProbe.testkit'; +import type { Credentials } from '@/persistence'; -const { createConfiguredAcpProbeBackendMock } = vi.hoisted(() => ({ +const { claudeProbeModelsRawMock, createConfiguredAcpProbeBackendMock } = vi.hoisted(() => ({ + claudeProbeModelsRawMock: vi.fn(async () => [{ id: 'claude-account-model', name: 'Claude Account Model' }]), createConfiguredAcpProbeBackendMock: vi.fn(async () => null), })); @@ -32,6 +34,13 @@ vi.mock('@/backends/catalog', () => ({ }, }), }, + claude: { + getPreflightSessionControlsProbeAdapter: async () => ({ + modelProbeCachePolicy: 'provider-owned', + failureCacheStrategy: 'cooldown', + probeModelsRaw: claudeProbeModelsRawMock, + }), + }, }, })); @@ -140,4 +149,59 @@ describe('probeAgentModelsBestEffort (cache)', () => { await fixture.cleanup(); } }); + + it('leaves provider-owned results transient and forwards auth context without generic caching', async () => { + vi.resetModules(); + claudeProbeModelsRawMock.mockClear(); + + const fixture = await createProbeTempDir('happier-cli-model-probe-provider-cache'); + try { + const { probeAgentModelsBestEffort, resetAgentModelsProbeCacheForTests } = await import('./agentModelsProbe'); + resetAgentModelsProbeCacheForTests(); + + const credentials: Credentials = { + token: 'account-token', + encryption: { type: 'legacy', secret: new Uint8Array(32).fill(7) }, + }; + const accountSettings = { connectedServicesSettingsV1: { version: 1 } }; + const connectedServices = { + v: 1, + bindingsByServiceId: { + 'claude-subscription': { + source: 'connected', + selection: 'profile', + profileId: 'connected-profile', + }, + }, + } as const; + const params = { + agentId: 'claude' as const, + cwd: fixture.dir, + timeoutMs: 2_000, + profileId: 'session-profile', + credentials, + accountSettings, + connectedServices, + }; + + const [first, concurrent] = await Promise.all([ + probeAgentModelsBestEffort(params), + probeAgentModelsBestEffort(params), + ]); + const later = await probeAgentModelsBestEffort(params); + + expect(first).toMatchObject({ source: 'dynamic', cacheable: false }); + expect(concurrent).toEqual(first); + expect(later).toMatchObject({ source: 'dynamic', cacheable: false }); + expect(claudeProbeModelsRawMock).toHaveBeenCalledTimes(3); + expect(claudeProbeModelsRawMock).toHaveBeenCalledWith(expect.objectContaining({ + profileId: 'session-profile', + credentials, + accountSettings, + connectedServices, + })); + } finally { + await fixture.cleanup(); + } + }); }); diff --git a/apps/cli/src/capabilities/probes/agentModelsProbe.staticOnly.test.ts b/apps/cli/src/capabilities/probes/agentModelsProbe.staticOnly.test.ts index 55f4ca8525..73f81dba31 100644 --- a/apps/cli/src/capabilities/probes/agentModelsProbe.staticOnly.test.ts +++ b/apps/cli/src/capabilities/probes/agentModelsProbe.staticOnly.test.ts @@ -20,13 +20,21 @@ const { createConfiguredAcpProbeBackendMock } = vi.hoisted(() => ({ createConfiguredAcpProbeBackendMock: vi.fn(async () => null), })); +const { claudePreflightModelsProbeMock } = vi.hoisted(() => ({ + claudePreflightModelsProbeMock: vi.fn(async () => null), +})); + vi.mock('./createConfiguredAcpProbeBackend', () => ({ createConfiguredAcpProbeBackend: createConfiguredAcpProbeBackendMock, })); vi.mock('@/backends/catalog', () => ({ AGENTS: { - claude: {}, + claude: { + getPreflightSessionControlsProbeAdapter: async () => ({ + probeModelsRaw: claudePreflightModelsProbeMock, + }), + }, kimi: { getAcpBackendFactory: vi.fn(), resolveModelsProbeVariant: ({ accountSettings }: { accountSettings?: Record | null }) => @@ -45,6 +53,8 @@ describe('probeAgentModelsBestEffort (static-only providers)', () => { createCatalogAcpBackendMock.mockReset(); validateCatalogAcpProbeSpawnMock.mockClear(); createConfiguredAcpProbeBackendMock.mockClear(); + claudePreflightModelsProbeMock.mockReset(); + claudePreflightModelsProbeMock.mockResolvedValue(null); }); it('does not start ACP backend for qwen model probing', async () => { @@ -181,6 +191,7 @@ describe('probeAgentModelsBestEffort (static-only providers)', () => { id: 'claude-sonnet-4-6', name: 'Sonnet 4.6', description: expect.any(String), + extendedContextModelId: 'claude-sonnet-4-6[1m]', }), ])); @@ -192,6 +203,24 @@ describe('probeAgentModelsBestEffort (static-only providers)', () => { expect(createCatalogAcpBackendMock).not.toHaveBeenCalled(); }); + it('preserves an extended-context model id returned by a dynamic probe', async () => { + claudePreflightModelsProbeMock.mockResolvedValue([{ + id: 'claude-opus-9', + name: 'Opus 9', + extendedContextModelId: 'claude-opus-9[1m]', + }]); + + const res = await probeAgentModelsBestEffort({ + agentId: 'claude', + cwd: process.cwd(), + timeoutMs: 100, + }); + + expect(res.source).toBe('dynamic'); + expect(res.availableModels.find((model) => model.id === 'claude-opus-9')) + .toMatchObject({ extendedContextModelId: 'claude-opus-9[1m]' }); + }); + it('falls back only to the default Codex model when dynamic probing is unavailable', async () => { const res = await probeAgentModelsBestEffort({ agentId: 'codex', diff --git a/apps/cli/src/capabilities/probes/agentModelsProbe.ts b/apps/cli/src/capabilities/probes/agentModelsProbe.ts index 8ebbde4e1c..6c1be91106 100644 --- a/apps/cli/src/capabilities/probes/agentModelsProbe.ts +++ b/apps/cli/src/capabilities/probes/agentModelsProbe.ts @@ -40,6 +40,7 @@ export type ProbedAgentModel = Readonly<{ name: string; description?: string; contextWindowTokens?: number; + extendedContextModelId?: string; modelOptions?: ReadonlyArray; }>; @@ -48,6 +49,7 @@ export type ProbedAgentModelsResult = Readonly<{ availableModels: ReadonlyArray; supportsFreeform: boolean; source: 'dynamic' | 'static'; + cacheable?: boolean; }>; const DEFAULT_PROBE_MODELS_TIMEOUT_MS = 15_000; @@ -79,6 +81,7 @@ const ProbeDynamicModelInputSchema = z.object({ name: ProbeNonEmptyStringSchema, description: ProbeDescriptionSchema.optional(), contextWindowTokens: z.unknown().optional(), + extendedContextModelId: ProbeNonEmptyStringSchema.optional(), modelOptions: z.array(z.unknown()).optional(), }); const ProbeConfigOptionCandidateSchema = z.object({ @@ -107,6 +110,9 @@ function buildStatic(agentId: CatalogAgentId): ProbedAgentModelsResult { name: model.name, ...(typeof model.description === 'string' ? { description: model.description } : {}), ...(typeof model.contextWindowTokens === 'number' ? { contextWindowTokens: model.contextWindowTokens } : {}), + ...(typeof model.extendedContextModelId === 'string' + ? { extendedContextModelId: model.extendedContextModelId } + : {}), ...(Array.isArray(model.modelOptions) && model.modelOptions.length > 0 ? { modelOptions: model.modelOptions } : {}), })), ] @@ -171,6 +177,9 @@ function normalizeProbeModel(modelRaw: unknown): ProbedAgentModel | null { id: parsed.data.id, name: parsed.data.name, ...(parsed.data.description ? { description: parsed.data.description } : {}), + ...(parsed.data.extendedContextModelId + ? { extendedContextModelId: parsed.data.extendedContextModelId } + : {}), ...(normalizeContextWindowTokens(parsed.data.contextWindowTokens) !== undefined ? { contextWindowTokens: normalizeContextWindowTokens(parsed.data.contextWindowTokens) } : {}), @@ -413,12 +422,16 @@ export async function probeAgentModelsBestEffort(params: { backendTarget?: BackendTargetRefV1; cwd: string; timeoutMs?: number; + profileId?: string | null; accountSettings?: Readonly> | null; credentials?: Credentials | null; connectedServices?: ConnectedServiceBindingsV1 | null; }): Promise { const nowMs = Date.now(); const cwd = typeof params.cwd === 'string' && params.cwd.trim().length > 0 ? params.cwd.trim() : process.cwd(); + const profileId = typeof params.profileId === 'string' && params.profileId.trim().length > 0 + ? params.profileId.trim() + : null; const probeVariant = resolveAgentProbeVariant({ agentId: params.agentId, backendTarget: params.backendTarget, @@ -429,24 +442,30 @@ export async function probeAgentModelsBestEffort(params: { agentId: params.agentId, cwd, backendTarget: params.backendTarget, - variant: probeVariant, + variant: profileId ? `${probeVariant}|profile:${profileId}` : probeVariant, }); + const entry = AGENTS[params.agentId]; + const preflightModelsAdapter = entry?.getPreflightSessionControlsProbeAdapter + ? await entry.getPreflightSessionControlsProbeAdapter().catch(() => null) + : null; + const usesProviderOwnedCache = preflightModelsAdapter?.modelProbeCachePolicy === 'provider-owned'; const cached = agentModelsProbeCache.get(cacheKey); - if (cached?.kind === 'success' && agentModelsProbeCache.isFresh(cached, nowMs)) return cached.value; + if (!usesProviderOwnedCache && cached?.kind === 'success' && agentModelsProbeCache.isFresh(cached, nowMs)) return cached.value; - return await agentModelsProbeCache.runDedupe(cacheKey, async () => { + const runProbe = async (): Promise => { const cached2 = agentModelsProbeCache.get(cacheKey); const nowMs2 = Date.now(); - if (cached2?.kind === 'success' && agentModelsProbeCache.isFresh(cached2, nowMs2)) return cached2.value; + if (!usesProviderOwnedCache && cached2?.kind === 'success' && agentModelsProbeCache.isFresh(cached2, nowMs2)) return cached2.value; const fallback = buildStatic(params.agentId); const modelConfig = getAgentModelConfig(params.agentId); if (modelConfig.dynamicProbe === 'static-only') { - agentModelsProbeCache.setSuccess(cacheKey, fallback, { nowMs: nowMs2, ttlMs: PROBE_MODELS_SUCCESS_TTL_MS }); + if (!usesProviderOwnedCache) { + agentModelsProbeCache.setSuccess(cacheKey, fallback, { nowMs: nowMs2, ttlMs: PROBE_MODELS_SUCCESS_TTL_MS }); + } return fallback; } - const entry = AGENTS[params.agentId]; const timeoutMs = typeof params.timeoutMs === 'number' ? params.timeoutMs : DEFAULT_PROBE_MODELS_TIMEOUT_MS; @@ -463,14 +482,20 @@ export async function probeAgentModelsBestEffort(params: { const models = await probeModelsFromAcpBackend({ backend: configuredBackend, timeoutMs }).catch(() => null); if (models) { const res: ProbedAgentModelsResult = { ...fallback, availableModels: models, source: 'dynamic' }; - agentModelsProbeCache.setSuccess(cacheKey, res, { nowMs: nowMs2, ttlMs: PROBE_MODELS_SUCCESS_TTL_MS }); + if (!usesProviderOwnedCache) { + agentModelsProbeCache.setSuccess(cacheKey, res, { nowMs: nowMs2, ttlMs: PROBE_MODELS_SUCCESS_TTL_MS }); + } return res; } - agentModelsProbeCache.setSuccess(cacheKey, fallback, { nowMs: nowMs2, ttlMs: PROBE_MODELS_FAILURE_TTL_MS }); + if (!usesProviderOwnedCache) { + agentModelsProbeCache.setSuccess(cacheKey, fallback, { nowMs: nowMs2, ttlMs: PROBE_MODELS_FAILURE_TTL_MS }); + } return fallback; } } catch { - agentModelsProbeCache.setSuccess(cacheKey, fallback, { nowMs: nowMs2, ttlMs: PROBE_MODELS_FAILURE_TTL_MS }); + if (!usesProviderOwnedCache) { + agentModelsProbeCache.setSuccess(cacheKey, fallback, { nowMs: nowMs2, ttlMs: PROBE_MODELS_FAILURE_TTL_MS }); + } return fallback; } finally { if (configuredBackend) { @@ -478,16 +503,15 @@ export async function probeAgentModelsBestEffort(params: { } } - const preflightModelsAdapter = entry?.getPreflightSessionControlsProbeAdapter - ? await entry.getPreflightSessionControlsProbeAdapter().catch(() => null) - : null; if (preflightModelsAdapter?.probeModelsRaw) { const probePreflightModelsOnce = async (): Promise => { const modelsRaw = await preflightModelsAdapter.probeModelsRaw!({ backendTarget: params.backendTarget, cwd, timeoutMs, + profileId, accountSettings: params.accountSettings ?? null, + credentials: params.credentials ?? null, connectedServices: params.connectedServices ?? null, }).catch(() => null); return normalizeDynamicModels(modelsRaw); @@ -501,13 +525,17 @@ export async function probeAgentModelsBestEffort(params: { } if (models) { const res: ProbedAgentModelsResult = { ...fallback, availableModels: models, source: 'dynamic' }; - agentModelsProbeCache.setSuccess(cacheKey, res, { nowMs: nowMs2, ttlMs: PROBE_MODELS_SUCCESS_TTL_MS }); + if (!usesProviderOwnedCache) { + agentModelsProbeCache.setSuccess(cacheKey, res, { nowMs: nowMs2, ttlMs: PROBE_MODELS_SUCCESS_TTL_MS }); + } return res; } if (preflightModelsAdapter.failureCacheStrategy === 'retry') { // For providers where this probe is the primary/authoritative source (e.g. Codex app-server), // cache an error so subsequent calls retry instead of freezing the static fallback. - agentModelsProbeCache.setError(cacheKey, { nowMs: nowMs2, ttlMs: PROBE_MODELS_FAILURE_TTL_MS }); + if (!usesProviderOwnedCache) { + agentModelsProbeCache.setError(cacheKey, { nowMs: nowMs2, ttlMs: PROBE_MODELS_FAILURE_TTL_MS }); + } return fallback; } } @@ -523,19 +551,25 @@ export async function probeAgentModelsBestEffort(params: { const models = await probeModelsFromCliModelsCommand({ command, args: cliProbeArgs, cwd, timeoutMs }).catch(() => null); if (models) { const res: ProbedAgentModelsResult = { ...fallback, availableModels: models, source: 'dynamic' }; - agentModelsProbeCache.setSuccess(cacheKey, res, { nowMs: nowMs2, ttlMs: PROBE_MODELS_SUCCESS_TTL_MS }); + if (!usesProviderOwnedCache) { + agentModelsProbeCache.setSuccess(cacheKey, res, { nowMs: nowMs2, ttlMs: PROBE_MODELS_SUCCESS_TTL_MS }); + } return res; } } if (!entry?.getAcpBackendFactory) { - agentModelsProbeCache.setSuccess(cacheKey, fallback, { nowMs: nowMs2, ttlMs: PROBE_MODELS_FAILURE_TTL_MS }); + if (!usesProviderOwnedCache) { + agentModelsProbeCache.setSuccess(cacheKey, fallback, { nowMs: nowMs2, ttlMs: PROBE_MODELS_FAILURE_TTL_MS }); + } return fallback; } const spawnValidation = await validateCatalogAcpProbeSpawn(params.agentId); if (!spawnValidation.ok) { - agentModelsProbeCache.setSuccess(cacheKey, fallback, { nowMs: nowMs2, ttlMs: PROBE_MODELS_FAILURE_TTL_MS }); + if (!usesProviderOwnedCache) { + agentModelsProbeCache.setSuccess(cacheKey, fallback, { nowMs: nowMs2, ttlMs: PROBE_MODELS_FAILURE_TTL_MS }); + } return fallback; } @@ -561,20 +595,31 @@ export async function probeAgentModelsBestEffort(params: { const models = await probeModelsFromAcpBackend({ backend, timeoutMs }).catch(() => null); if (!models) { - agentModelsProbeCache.setSuccess(cacheKey, fallback, { nowMs: nowMs2, ttlMs: PROBE_MODELS_FAILURE_TTL_MS }); + if (!usesProviderOwnedCache) { + agentModelsProbeCache.setSuccess(cacheKey, fallback, { nowMs: nowMs2, ttlMs: PROBE_MODELS_FAILURE_TTL_MS }); + } return fallback; } const res: ProbedAgentModelsResult = { ...fallback, availableModels: models, source: 'dynamic' }; - agentModelsProbeCache.setSuccess(cacheKey, res, { nowMs: nowMs2, ttlMs: PROBE_MODELS_SUCCESS_TTL_MS }); + if (!usesProviderOwnedCache) { + agentModelsProbeCache.setSuccess(cacheKey, res, { nowMs: nowMs2, ttlMs: PROBE_MODELS_SUCCESS_TTL_MS }); + } return res; } catch { - agentModelsProbeCache.setSuccess(cacheKey, fallback, { nowMs: nowMs2, ttlMs: PROBE_MODELS_FAILURE_TTL_MS }); + if (!usesProviderOwnedCache) { + agentModelsProbeCache.setSuccess(cacheKey, fallback, { nowMs: nowMs2, ttlMs: PROBE_MODELS_FAILURE_TTL_MS }); + } return fallback; } finally { if (backend) { await backend.dispose().catch(() => {}); } } - }); + }; + + const result = usesProviderOwnedCache + ? await runProbe() + : await agentModelsProbeCache.runDedupe(cacheKey, runProbe); + return usesProviderOwnedCache ? { ...result, cacheable: false } : result; } diff --git a/apps/cli/src/capabilities/probes/preflightSessionControlsProbeAdapterTypes.ts b/apps/cli/src/capabilities/probes/preflightSessionControlsProbeAdapterTypes.ts index 527d7ac682..f46c6e5e72 100644 --- a/apps/cli/src/capabilities/probes/preflightSessionControlsProbeAdapterTypes.ts +++ b/apps/cli/src/capabilities/probes/preflightSessionControlsProbeAdapterTypes.ts @@ -1,12 +1,16 @@ import type { BackendTargetRefV1, ConnectedServiceBindingsV1 } from '@happier-dev/protocol'; +import type { Credentials } from '@/persistence'; export type PreflightSessionControlsProbeFailureCacheStrategy = 'cooldown' | 'retry'; +export type PreflightModelsProbeCachePolicy = 'generic' | 'provider-owned'; export type PreflightSessionControlsProbeParams = Readonly<{ backendTarget?: BackendTargetRefV1; cwd: string; timeoutMs: number; + profileId?: string | null; accountSettings?: Readonly> | null; + credentials?: Credentials | null; connectedServices?: ConnectedServiceBindingsV1 | null; }>; @@ -17,6 +21,7 @@ export type PreflightSessionControlsProbeParams = Readonly<{ * The probe functions return raw payloads (best-effort). Callers must normalize/validate. */ export type PreflightSessionControlsProbeAdapter = Readonly<{ + modelProbeCachePolicy?: PreflightModelsProbeCachePolicy; failureCacheStrategy?: PreflightSessionControlsProbeFailureCacheStrategy; probeModelsRaw?: (params: PreflightSessionControlsProbeParams) => Promise; cliModelsCommandArgs?: ReadonlyArray; diff --git a/apps/cli/src/rpc/handlers/capabilities.probeModels.cwd.test.ts b/apps/cli/src/rpc/handlers/capabilities.probeModels.cwd.test.ts index 6d2114e381..38cdc3b774 100644 --- a/apps/cli/src/rpc/handlers/capabilities.probeModels.cwd.test.ts +++ b/apps/cli/src/rpc/handlers/capabilities.probeModels.cwd.test.ts @@ -359,7 +359,7 @@ describe('capabilities.invoke(cli.* probeModels)', () => { })); }); - it('forwards valid connectedServices bindings to probeAgentModelsBestEffort', async () => { + it('forwards valid profile and connectedServices context to probeAgentModelsBestEffort', async () => { vi.resetModules(); const probeSpy = vi.fn(async (_params: unknown) => ({ @@ -403,12 +403,13 @@ describe('capabilities.invoke(cli.* probeModels)', () => { await call(RPC_METHODS.CAPABILITIES_INVOKE, { id: 'cli.codex', method: 'probeModels', - params: { cwd: '/tmp/happier-probe-cwd', connectedServices }, + params: { cwd: '/tmp/happier-probe-cwd', profileId: ' session-profile ', connectedServices }, }); expect(probeSpy).toHaveBeenCalledTimes(1); expect(probeSpy).toHaveBeenCalledWith(expect.objectContaining({ agentId: 'codex', + profileId: 'session-profile', connectedServices, })); }); diff --git a/apps/cli/src/rpc/handlers/capabilities.ts b/apps/cli/src/rpc/handlers/capabilities.ts index 8553eed3a5..b047c33de4 100644 --- a/apps/cli/src/rpc/handlers/capabilities.ts +++ b/apps/cli/src/rpc/handlers/capabilities.ts @@ -86,6 +86,11 @@ function parseProbeConnectedServices(params?: Record): Connecte return parsed.success ? parsed.data : null; } +function parseProbeProfileId(params?: Record): string | null { + const profileId = typeof params?.profileId === 'string' ? params.profileId.trim() : ''; + return profileId || null; +} + async function resolveProbeBackendContext(params?: Record): Promise<{ backendTarget: BackendTargetRefV1 | undefined; credentials: Awaited> | null; @@ -200,11 +205,13 @@ async function invokeCliProbeMethod( const timeoutMs = typeof timeoutMsRaw === 'number' ? timeoutMsRaw : DEFAULT_PROBE_MODELS_TIMEOUT_MS; const cwd = resolveProbeCwd((params ?? {}).cwd); const connectedServices = parseProbeConnectedServices(params); + const profileId = parseProbeProfileId(params); const commonParams = { agentId, backendTarget: probeContext.backendTarget, cwd, timeoutMs, + profileId, accountSettings: probeContext.accountSettings, credentials: probeContext.credentials, connectedServices, diff --git a/apps/ui/sources/components/sessions/new/components/NewSessionEngineOptionDetail.tsx b/apps/ui/sources/components/sessions/new/components/NewSessionEngineOptionDetail.tsx index ae28778221..900ff770ad 100644 --- a/apps/ui/sources/components/sessions/new/components/NewSessionEngineOptionDetail.tsx +++ b/apps/ui/sources/components/sessions/new/components/NewSessionEngineOptionDetail.tsx @@ -32,6 +32,7 @@ export type NewSessionEngineOptionDetailProps = Readonly<{ selectedMachineId: string | null; capabilityServerId: string; cwd?: string | null; + profileId?: string | null; capabilityProbeContext?: NewSessionCapabilityProbeContext | null; connectedServices?: ConnectedServiceBindingsV1 | null; /** @@ -139,6 +140,7 @@ export function NewSessionEngineOptionDetail(props: NewSessionEngineOptionDetail selectedMachineId: props.selectedMachineId, capabilityServerId: props.capabilityServerId, cwd: props.cwd ?? null, + profileId: props.profileId ?? null, probeContext: props.capabilityProbeContext ?? null, connectedServices: props.connectedServices ?? null, }); diff --git a/apps/ui/sources/components/sessions/new/components/NewSessionFavoriteModelsDetail.tsx b/apps/ui/sources/components/sessions/new/components/NewSessionFavoriteModelsDetail.tsx index 1fcce6ec71..6f98cb13f7 100644 --- a/apps/ui/sources/components/sessions/new/components/NewSessionFavoriteModelsDetail.tsx +++ b/apps/ui/sources/components/sessions/new/components/NewSessionFavoriteModelsDetail.tsx @@ -67,6 +67,7 @@ export type NewSessionFavoriteModelsDetailProps = Readonly<{ selectedMachineId: string | null; capabilityServerId: string; cwd?: string | null; + profileId?: string | null; settings: Settings; connectedServicesByTargetKey?: Readonly>; refreshProbe?: OptionPickerProbeState | null; @@ -177,6 +178,7 @@ function FavoriteBackendModelsCollector(props: Readonly<{ selectedMachineId: string | null; capabilityServerId: string; cwd?: string | null; + profileId?: string | null; settings: Settings; connectedServices?: ConnectedServiceBindingsV1 | null; refreshProbe?: OptionPickerProbeState | null; @@ -194,6 +196,7 @@ function FavoriteBackendModelsCollector(props: Readonly<{ selectedMachineId: props.selectedMachineId, capabilityServerId: props.capabilityServerId, cwd: props.cwd ?? null, + profileId: props.profileId ?? null, probeContext: capabilityProbeContext, connectedServices: props.connectedServices ?? null, }); @@ -433,6 +436,7 @@ export function NewSessionFavoriteModelsDetail(props: NewSessionFavoriteModelsDe selectedMachineId={props.selectedMachineId} capabilityServerId={props.capabilityServerId} cwd={props.cwd} + profileId={props.profileId} settings={props.settings} connectedServices={props.connectedServicesByTargetKey?.[entry.targetKey] ?? null} refreshProbe={props.refreshProbe} diff --git a/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionAgentPickerControls.tsx b/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionAgentPickerControls.tsx index a3276b548a..35ec53771f 100644 --- a/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionAgentPickerControls.tsx +++ b/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionAgentPickerControls.tsx @@ -549,6 +549,7 @@ export function useNewSessionAgentPickerControls(rawParams: Readonly<{ selectedBackendTargetKey: params.selectedBackendEntry?.targetKey ?? params.selectedBackendTargetKey, selectedModelId: String(params.modelMode), }); + const effectiveProfileId = params.useProfiles ? params.selectedProfileId : null; const agentPickerOptions = React.useMemo | undefined>(() => { if (params.resolvedBackendEntries.length <= 1) { @@ -602,6 +603,7 @@ export function useNewSessionAgentPickerControls(rawParams: Readonly<{ params.selectedMachineId ?? '', entry.targetKey, params.selectedPath ?? '', + effectiveProfileId ?? '', connectedServicesCacheKeyPart, ].join(':'), onSelectImmediate: () => { @@ -626,6 +628,7 @@ export function useNewSessionAgentPickerControls(rawParams: Readonly<{ selectedMachineId={params.selectedMachineId} capabilityServerId={params.capabilityServerId} cwd={params.selectedPath} + profileId={effectiveProfileId} capabilityProbeContext={capabilityProbeContext} connectedServices={detailConnectedServices} refreshProbe={params.refreshProbe} @@ -691,6 +694,7 @@ export function useNewSessionAgentPickerControls(rawParams: Readonly<{ params.capabilityServerId, params.selectedMachineId ?? '', params.selectedPath ?? '', + effectiveProfileId ?? '', favoriteConnectedServicesCacheKeyPart, ].join(':'), preserveFocusOnExternalSelectionChange: true, @@ -709,6 +713,7 @@ export function useNewSessionAgentPickerControls(rawParams: Readonly<{ selectedMachineId={params.selectedMachineId} capabilityServerId={params.capabilityServerId} cwd={params.selectedPath} + profileId={effectiveProfileId} settings={params.settings} connectedServicesByTargetKey={favoriteConnectedServicesByTargetKey} refreshProbe={params.refreshProbe ?? null} @@ -725,6 +730,7 @@ export function useNewSessionAgentPickerControls(rawParams: Readonly<{ }, [ applyEngineSelection, compatibleBackendTargetKeys, + effectiveProfileId, handleSelectFavoriteModel, handleSelectFavoriteModelOptionValue, handleToggleFavoriteBackendTarget, diff --git a/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionPreflightModelsState.cwd.test.tsx b/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionPreflightModelsState.cwd.test.tsx index 9c72003cc7..0a1966f389 100644 --- a/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionPreflightModelsState.cwd.test.tsx +++ b/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionPreflightModelsState.cwd.test.tsx @@ -8,9 +8,11 @@ import { NEW_SESSION_CAPABILITY_PROBE_TIMEOUT_MS } from '@/components/sessions/n (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -const machineCapabilitiesInvokeMock = vi.fn(async (_machineId: any, _request: any, _options: any) => ({ - supported: true as const, - response: { ok: true as const, result: { availableModels: [{ id: 'model-a', name: 'Model A' }], supportsFreeform: false } }, +const { machineCapabilitiesInvokeMock } = vi.hoisted(() => ({ + machineCapabilitiesInvokeMock: vi.fn(async (_machineId: any, _request: any, _options: any) => ({ + supported: true as const, + response: { ok: true as const, result: { availableModels: [{ id: 'model-a', name: 'Model A' }], supportsFreeform: false } }, + })), })); vi.mock('@/sync/ops/capabilities', () => ({ @@ -160,6 +162,38 @@ describe('useNewSessionPreflightModelsState', () => { }); }); + it('passes the selected profile to probeModels and re-probes when it changes', async () => { + const { useNewSessionPreflightModelsState } = await import('./useNewSessionPreflightModelsState'); + + machineCapabilitiesInvokeMock.mockClear(); + resetDynamicModelProbeCacheForTests(); + + function Harness(props: { profileId: string }) { + useNewSessionPreflightModelsState({ + backendTarget: { kind: 'builtInAgent', agentId: 'claude' }, + selectedMachineId: 'machine-1', + capabilityServerId: 'server-1', + cwd: '/repo', + profileId: props.profileId, + }); + return null; + } + + let root!: renderer.ReactTestRenderer; + root = (await renderScreen(React.createElement(Harness, { profileId: 'profile-a' }))).tree; + await act(async () => { + root.update(React.createElement(Harness, { profileId: 'profile-b' })); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + root.unmount(); + }); + + expect(machineCapabilitiesInvokeMock).toHaveBeenCalledTimes(2); + expect(machineCapabilitiesInvokeMock.mock.calls[0]?.[1]?.params).toEqual(expect.objectContaining({ profileId: 'profile-a' })); + expect(machineCapabilitiesInvokeMock.mock.calls[1]?.[1]?.params).toEqual(expect.objectContaining({ profileId: 'profile-b' })); + }); + it('uses a long enough timeout for slow ACP providers', async () => { const { useNewSessionPreflightModelsState } = await import('./useNewSessionPreflightModelsState'); diff --git a/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionPreflightModelsState.persistence.test.tsx b/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionPreflightModelsState.persistence.test.tsx index 9ea85a8093..00858b9356 100644 --- a/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionPreflightModelsState.persistence.test.tsx +++ b/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionPreflightModelsState.persistence.test.tsx @@ -12,6 +12,7 @@ import { renderScreen } from '@/dev/testkit'; type ProbeModelsResult = Readonly<{ provider?: string; source?: 'dynamic' | 'static'; + cacheable?: boolean; availableModels: Array<{ id: string; name: string; @@ -34,28 +35,30 @@ type ProbeResponse = Readonly<{ }>; }>; -const machineCapabilitiesInvokeMock = vi.fn(async (_machineId: any, _request: any, _options: any): Promise => ({ - supported: true as const, - response: { - ok: true as const, - result: { - availableModels: [{ - id: 'm1', - name: 'Model 1', - modelOptions: [{ - id: 'reasoning_effort', - name: 'Thinking', - type: 'select', - currentValue: 'medium', - options: [ - { value: 'low', name: 'Low' }, - { value: 'medium', name: 'Medium' }, - ], +const { machineCapabilitiesInvokeMock } = vi.hoisted(() => ({ + machineCapabilitiesInvokeMock: vi.fn(async (_machineId: any, _request: any, _options: any): Promise => ({ + supported: true as const, + response: { + ok: true as const, + result: { + availableModels: [{ + id: 'm1', + name: 'Model 1', + modelOptions: [{ + id: 'reasoning_effort', + name: 'Thinking', + type: 'select', + currentValue: 'medium', + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + ], + }], }], - }], - supportsFreeform: false, + supportsFreeform: false, + }, }, - }, + })), })); vi.mock('@/sync/ops/capabilities', () => ({ @@ -198,6 +201,64 @@ describe('useNewSessionPreflightModelsState (persistence)', () => { expect(machineCapabilitiesInvokeMock).toHaveBeenCalledTimes(2); }); + it('does not persist dynamic probe results explicitly marked non-cacheable', async () => { + vi.resetModules(); + resetDynamicModelProbeCacheForTests(); + machineCapabilitiesInvokeMock.mockClear(); + + machineCapabilitiesInvokeMock.mockResolvedValue({ + supported: true as const, + response: { + ok: true as const, + result: { + provider: 'claude', + source: 'dynamic', + cacheable: false, + availableModels: [{ id: 'claude-account-model', name: 'Claude Account Model' }], + supportsFreeform: false, + }, + }, + }); + + const { useNewSessionPreflightModelsState } = await import('./useNewSessionPreflightModelsState'); + + function Harness() { + useNewSessionPreflightModelsState({ + backendTarget: { kind: 'builtInAgent', agentId: 'claude' }, + selectedMachineId: 'machine-1', + capabilityServerId: 'server-1', + cwd: '/repo', + }); + return null; + } + + let root1!: renderer.ReactTestRenderer; + root1 = (await renderScreen(React.createElement(Harness))).tree; + await act(async () => { + root1.unmount(); + }); + + vi.resetModules(); + const { useNewSessionPreflightModelsState: useNewSessionPreflightModelsState2 } = await import('./useNewSessionPreflightModelsState'); + function Harness2() { + useNewSessionPreflightModelsState2({ + backendTarget: { kind: 'builtInAgent', agentId: 'claude' }, + selectedMachineId: 'machine-1', + capabilityServerId: 'server-1', + cwd: '/repo', + }); + return null; + } + + let root2!: renderer.ReactTestRenderer; + root2 = (await renderScreen(React.createElement(Harness2))).tree; + await act(async () => { + root2.unmount(); + }); + + expect(machineCapabilitiesInvokeMock).toHaveBeenCalledTimes(2); + }); + it('ignores legacy persisted model-option cache entries after the model-option contract changes', async () => { vi.resetModules(); machineCapabilitiesInvokeMock.mockClear(); diff --git a/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionPreflightModelsState.ts b/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionPreflightModelsState.ts index 623dbebb75..851f8c3069 100644 --- a/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionPreflightModelsState.ts +++ b/apps/ui/sources/components/sessions/new/hooks/screenModel/useNewSessionPreflightModelsState.ts @@ -36,6 +36,7 @@ export function useNewSessionPreflightModelsState(params: Readonly<{ selectedMachineId: string | null; capabilityServerId: string; cwd?: string | null; + profileId?: string | null; probeContext?: NewSessionCapabilityProbeContext | null; connectedServices?: ConnectedServiceBindingsV1 | null; }>): Readonly<{ @@ -98,12 +99,21 @@ export function useNewSessionPreflightModelsState(params: Readonly<{ () => stableJsonStringify(params.connectedServices ?? null), [params.connectedServices], ); + const profileId = typeof params.profileId === 'string' && params.profileId.trim().length > 0 + ? params.profileId.trim() + : null; + const probeCacheKeySuffixParts = React.useMemo( + () => [ + ...(probeContextCacheKeySuffixParts ?? []), + ...(profileId ? [`profile:${profileId}`] : []), + ], + [probeContextCacheKeySuffixParts, profileId], + ); const probeScopeKey = React.useMemo(() => { const machineId = String(params.selectedMachineId ?? '').trim(); if (!machineId) return null; const serverId = String(params.capabilityServerId ?? '').trim() || 'active'; - const extraKeySuffixParts = probeContextCacheKeySuffixParts ?? []; // Scope key excludes cwd so switching worktrees doesn't flash the dynamic model list. return JSON.stringify([ 'dynamicModelProbeScope', @@ -111,9 +121,9 @@ export function useNewSessionPreflightModelsState(params: Readonly<{ machineId, backendTargetKey, connectedServicesKey, - ...extraKeySuffixParts, + ...probeCacheKeySuffixParts, ]); - }, [backendTargetKey, params.capabilityServerId, params.selectedMachineId, probeContextKey, probeContextCacheKeySuffixParts, connectedServicesKey]); + }, [backendTargetKey, params.capabilityServerId, params.selectedMachineId, probeContextKey, probeCacheKeySuffixParts, connectedServicesKey]); const preflightModelsKey = React.useMemo(() => { return buildDynamicModelProbeCacheKey({ @@ -121,10 +131,10 @@ export function useNewSessionPreflightModelsState(params: Readonly<{ targetKey: backendTargetKey, serverId: params.capabilityServerId, cwd: params.cwd ?? null, - extraKeySuffixParts: probeContextCacheKeySuffixParts, + extraKeySuffixParts: probeCacheKeySuffixParts, connectedServices: params.connectedServices ?? null, }); - }, [backendTargetKey, params.capabilityServerId, params.cwd, params.selectedMachineId, probeContextCacheKeySuffixParts, connectedServicesKey]); + }, [backendTargetKey, params.capabilityServerId, params.cwd, params.selectedMachineId, probeCacheKeySuffixParts, connectedServicesKey]); React.useEffect(() => { preflightModelsRef.current = preflightModels; @@ -227,6 +237,7 @@ export function useNewSessionPreflightModelsState(params: Readonly<{ params: { timeoutMs: NEW_SESSION_CAPABILITY_PROBE_TIMEOUT_MS, backendTarget, + ...(profileId ? { profileId } : {}), ...(probeContextCapabilityParams ? probeContextCapabilityParams : {}), ...(params.connectedServices ? { connectedServices: params.connectedServices } : {}), ...(cwd ? { cwd } : {}), @@ -242,12 +253,13 @@ export function useNewSessionPreflightModelsState(params: Readonly<{ if (!list) return null; const result = res.response.result; - const source = result && typeof result === 'object' && !Array.isArray(result) - ? (typeof (result as Record).source === 'string' ? (result as Record).source : null) + const resultRecord = result && typeof result === 'object' && !Array.isArray(result) + ? result as Record : null; + const source = typeof resultRecord?.source === 'string' ? resultRecord.source : null; // When the CLI probe returns a static fallback (dynamic probe failed), do not persist it // for a full day. Persisting it long-lived is what causes “Thinking/Speed only appear after refresh”. - const cacheable = source !== 'static'; + const cacheable = resultRecord?.cacheable !== false && source !== 'static'; return { list, cacheable }; }); @@ -329,7 +341,7 @@ export function useNewSessionPreflightModelsState(params: Readonly<{ cancelled = true; if (retryTimeout) clearTimeout(retryTimeout); }; - }, [agentType, backendTarget, backendTargetKey, preflightModelsKey, probeScopeKey, params.capabilityServerId, params.cwd, params.selectedMachineId, probeContextKey, refreshNonce, probeContextCapabilityParams, params.connectedServices]); + }, [agentType, backendTarget, backendTargetKey, preflightModelsKey, probeScopeKey, params.capabilityServerId, params.cwd, params.selectedMachineId, profileId, probeContextKey, refreshNonce, probeContextCapabilityParams, params.connectedServices]); const modelOptions = React.useMemo( () => getModelOptionsForAgentTypeOrPreflight({ agentType, preflight: preflightModels }), diff --git a/apps/ui/sources/components/sessions/new/hooks/useNewSessionScreenModel.tsx b/apps/ui/sources/components/sessions/new/hooks/useNewSessionScreenModel.tsx index 7253209a24..f78d5b12ba 100644 --- a/apps/ui/sources/components/sessions/new/hooks/useNewSessionScreenModel.tsx +++ b/apps/ui/sources/components/sessions/new/hooks/useNewSessionScreenModel.tsx @@ -850,6 +850,7 @@ export function useNewSessionScreenModel(): NewSessionScreenModel { selectedMachineId, capabilityServerId, cwd: selectedPath, + profileId: useProfiles ? selectedProfileId : null, probeContext: resolveNewSessionCapabilityProbeContext({ backendTarget, settings }), connectedServices: connectedServicesBindingsPayload, }); diff --git a/apps/ui/sources/sync/domains/models/modelOptions.ts b/apps/ui/sources/sync/domains/models/modelOptions.ts index b5195a4f45..d584e75a18 100644 --- a/apps/ui/sources/sync/domains/models/modelOptions.ts +++ b/apps/ui/sources/sync/domains/models/modelOptions.ts @@ -53,15 +53,18 @@ export type PreflightModelList = Readonly<{ supportsFreeform: boolean; }>; +type DynamicModelRowInput = Readonly<{ + id: unknown; + name: unknown; + description?: unknown; + contextWindowTokens?: unknown; + extendedContextModelId?: unknown; + modelOptions?: unknown; +}>; + type SessionModelListState = Readonly<{ provider?: string; - availableModels?: Array<{ - id?: unknown; - name?: unknown; - description?: unknown; - extendedContextModelId?: unknown; - modelOptions?: unknown; - }>; + availableModels?: DynamicModelRowInput[]; }>; /** @@ -85,6 +88,23 @@ function dedupeModelOptionsByValue(options: readonly ModelOption[]): readonly Mo }); } +function projectDynamicModelRows(rows: readonly DynamicModelRowInput[]): ModelOption[] { + return rows.flatMap((row) => { + if (!row || typeof row.id !== 'string' || typeof row.name !== 'string') return []; + const extendedContextModelId = readExtendedContextModelId(row.extendedContextModelId); + const modelOptions = Array.isArray(row.modelOptions) && row.modelOptions.length > 0 + ? row.modelOptions as readonly SessionConfigOption[] + : null; + return [{ + value: String(row.id), + label: String(row.name), + description: typeof row.description === 'string' ? row.description : '', + ...(extendedContextModelId ? { extendedContextModelId } : {}), + ...(modelOptions ? { modelOptions } : {}), + }]; + }); +} + function mergeDynamicModelOptionWithCatalog( option: ModelOption, catalogByValue: ReadonlyMap, @@ -156,17 +176,7 @@ function supportsDynamicSessionModelList(agentType: AgentType): boolean { } export function getModelOptionsForPreflightModelList(list: PreflightModelList): readonly ModelOption[] { - const dynamic = (list.availableModels ?? []) - .filter((m) => m && typeof m.id === 'string' && typeof m.name === 'string') - .map((m) => ({ - value: String(m.id), - label: String(m.name), - description: typeof m.description === 'string' ? m.description : '', - ...(readExtendedContextModelId(m.extendedContextModelId) - ? { extendedContextModelId: readExtendedContextModelId(m.extendedContextModelId) } - : {}), - ...(Array.isArray(m.modelOptions) && m.modelOptions.length > 0 ? { modelOptions: m.modelOptions } : {}), - })); + const dynamic = projectDynamicModelRows(list.availableModels ?? []); const withDefault: ModelOption[] = [ { value: 'default', label: getModelLabel('default'), description: '' }, @@ -283,25 +293,7 @@ function resolveModelOptionsForSession(agentType: AgentType, metadata: Metadata if (state && state.provider === agentType && Array.isArray(state.availableModels) && state.availableModels.length > 0) { const catalogOptions = getModelOptionsForAgentType(agentType); - const dynamic = state.availableModels - .filter((m) => m && typeof m.id === 'string' && typeof m.name === 'string') - .map((m) => { - const value = String(m.id); - const description = typeof m.description === 'string' ? m.description : ''; - const modelOptionsRaw = Array.isArray(m.modelOptions) && m.modelOptions.length > 0 - ? (m.modelOptions as readonly SessionConfigOption[]) - : null; - - return { - value, - label: String(m.name), - description, - ...(readExtendedContextModelId(m.extendedContextModelId) - ? { extendedContextModelId: readExtendedContextModelId(m.extendedContextModelId) } - : {}), - ...(modelOptionsRaw ? { modelOptions: modelOptionsRaw } : {}), - }; - }); + const dynamic = projectDynamicModelRows(state.availableModels); return appendSelectedFreeformModelOption({ options: mergeModelOptionsWithCatalog({ diff --git a/apps/ui/sources/sync/domains/sessionControl/readSessionControlMetadata.test.ts b/apps/ui/sources/sync/domains/sessionControl/readSessionControlMetadata.test.ts index affc4a1cc8..796383fa35 100644 --- a/apps/ui/sources/sync/domains/sessionControl/readSessionControlMetadata.test.ts +++ b/apps/ui/sources/sync/domains/sessionControl/readSessionControlMetadata.test.ts @@ -22,7 +22,11 @@ describe('readSessionControlMetadata', () => { }, acpSessionModelsV1: { v: 1, provider: 'grok', updatedAt: 20, currentModelId: 'legacy-model', - availableModels: [{ id: 'legacy-model', name: 'Legacy model' }], + availableModels: [{ + id: 'legacy-model', + name: 'Legacy model', + extendedContextModelId: 'legacy-model[1m]', + }], }, sessionConfigOptionsV1: { v: 1, provider: 'grok', updatedAt: 30, @@ -34,7 +38,9 @@ describe('readSessionControlMetadata', () => { }, }); - expect(readSessionModelsState(value)?.currentModelId).toBe('legacy-model'); + const models = readSessionModelsState(value); + expect(models?.currentModelId).toBe('legacy-model'); + expect(models?.availableModels[0]?.extendedContextModelId).toBe('legacy-model[1m]'); expect(readSessionConfigOptionsState(value)?.configOptions[0]?.currentValue).toBe('high'); }); diff --git a/apps/ui/sources/sync/domains/sessionControl/schema.ts b/apps/ui/sources/sync/domains/sessionControl/schema.ts index 679e988eb5..a9626045f0 100644 --- a/apps/ui/sources/sync/domains/sessionControl/schema.ts +++ b/apps/ui/sources/sync/domains/sessionControl/schema.ts @@ -36,6 +36,7 @@ const SessionModelSchema = z.object({ name: z.string().trim().min(1), description: z.string().trim().min(1).optional(), contextWindowTokens: z.number().int().positive().optional(), + extendedContextModelId: opaqueSessionControlIdentifierSchema.optional(), modelOptions: z.array(SessionModelOptionSchema).default([]), }); diff --git a/packages/agents/src/sessionControls/metadata.spec.ts b/packages/agents/src/sessionControls/metadata.spec.ts index 4a7c0da7f3..9d51f8e2d8 100644 --- a/packages/agents/src/sessionControls/metadata.spec.ts +++ b/packages/agents/src/sessionControls/metadata.spec.ts @@ -83,6 +83,27 @@ describe('parseSessionModelsMetadataStateV1', () => { ], })).not.toBeNull(); }); + + it('preserves an extended-context model id and rejects malformed values', () => { + const parsed = parseSessionModelsMetadataStateV1({ + ...base, + availableModels: [{ + id: 'model-a', + name: 'Model A', + extendedContextModelId: 'model-a[1m]', + }], + }); + + expect(parsed?.availableModels[0]?.extendedContextModelId).toBe('model-a[1m]'); + expect(parseSessionModelsMetadataStateV1({ + ...base, + availableModels: [{ + id: 'model-a', + name: 'Model A', + extendedContextModelId: 1_000_000, + }], + })).toBeNull(); + }); }); describe('readNewestMetadataAliasValue', () => { diff --git a/packages/agents/src/sessionControls/metadata.ts b/packages/agents/src/sessionControls/metadata.ts index d006fdeab3..1bf209f611 100644 --- a/packages/agents/src/sessionControls/metadata.ts +++ b/packages/agents/src/sessionControls/metadata.ts @@ -49,6 +49,7 @@ export type SessionModelsMetadataStateV1 = Readonly<{ id: string; name: string; description?: string; + extendedContextModelId?: string; modelOptions?: readonly SessionModelOptionMetadataV1[]; [key: string]: unknown; }>[]; @@ -129,6 +130,10 @@ export function parseSessionModelsMetadataStateV1(raw: unknown): SessionModelsMe if (typeof model.id !== 'string' || !model.id.trim()) return null; if (typeof model.name !== 'string' || !model.name.trim()) return null; if (model.description !== undefined && typeof model.description !== 'string') return null; + if ( + model.extendedContextModelId !== undefined + && (typeof model.extendedContextModelId !== 'string' || !model.extendedContextModelId.trim()) + ) return null; if (model.modelOptions !== undefined) { if (!Array.isArray(model.modelOptions)) return null; for (const rawOption of model.modelOptions) { From 86f1b51b544d91b8e4f8c51b77a6b3590f4957f2 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 12 Aug 2026 01:10:57 +0200 Subject: [PATCH 04/11] fix(claude-models): unify runtime capability state --- .../claudeModelEffortLevelsTracker.test.ts | 58 ++++++++++++++- .../models/claudeModelEffortLevelsTracker.ts | 62 +++++++++++----- ...essionModelsMetadataFromSupportedModels.ts | 10 ++- .../runClaude.fastStart.integration.test.ts | 69 +++++++++++++++++ apps/cli/src/backends/claude/runClaude.ts | 54 +++++++------- ...udeSessionModelsMetadataBestEffort.test.ts | 51 +++++++++++++ ...shClaudeSessionModelsMetadataBestEffort.ts | 19 +++-- .../reconcileClaudeSessionModelsState.ts | 74 +++++++++++++++++++ .../injectionDialogRouting.test.ts | 4 +- .../runtimeControlIntegration.test.ts | 42 +++++++---- .../runtimeControlIntegration.ts | 14 +++- .../src/backends/claude/utils/claudeEffort.ts | 12 ++- 12 files changed, 392 insertions(+), 77 deletions(-) create mode 100644 apps/cli/src/backends/claude/sessionModels/reconcileClaudeSessionModelsState.ts diff --git a/apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.test.ts b/apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.test.ts index c0813e08c3..e1fb1a7f85 100644 --- a/apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.test.ts +++ b/apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.test.ts @@ -2,15 +2,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createEnvKeyScope } from '@/testkit/env/envScope'; -import type { AnthropicModelEntry } from '@/backends/claude/preflight/anthropicModelsFetch'; +import type { AnthropicModelEntry } from './fetchAnthropicModels'; const { fetchAnthropicModelsMock, readClaudeCodeNativeCredentialMock } = vi.hoisted(() => ({ fetchAnthropicModelsMock: vi.fn<(...args: unknown[]) => Promise>(), readClaudeCodeNativeCredentialMock: vi.fn<(...args: unknown[]) => Promise>(), })); -vi.mock('@/backends/claude/preflight/anthropicModelsFetch', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('./fetchAnthropicModels', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, fetchAnthropicModels: fetchAnthropicModelsMock }; }); @@ -178,4 +178,56 @@ describe('createClaudeModelEffortLevelsTracker', () => { // The preflight probe and the session publisher both resolve at session start; one fetch is enough. expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(1); }); + + it('joins a startup prewarm for the same model inside the first-turn budget', async () => { + let releaseCatalog: ((entries: AnthropicModelEntry[]) => void) | null = null; + fetchAnthropicModelsMock.mockImplementation(() => new Promise((resolve) => { + releaseCatalog = resolve; + })); + const tracker = createTracker(); + + void tracker.refresh('claude-opus-9'); + const firstTurn = tracker.refreshWithin('claude-opus-9', 1_000); + await new Promise((resolve) => { setTimeout(resolve, 0); }); + + const release = releaseCatalog as ((entries: AnthropicModelEntry[]) => void) | null; + if (!release) throw new Error('expected the startup catalog lookup to be in flight'); + release([ + { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'medium']) }, + ]); + await firstTurn; + + expect(tracker.getLevels()).toEqual(['low', 'medium']); + expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(1); + }); + + it('retries the same discovered model after an unavailable catalog recovers', async () => { + fetchAnthropicModelsMock + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValueOnce([ + { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities(['low', 'high']) }, + ]); + const tracker = createTracker(); + + await tracker.refresh('claude-opus-9'); + await tracker.refresh('claude-opus-9'); + + expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(2); + expect(tracker.getLevels()).toEqual(['low', 'high']); + }); + + it('does not resolve the catalog again after the same model settles successfully', async () => { + fetchAnthropicModelsMock.mockResolvedValue([ + { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: effortCapabilities([]) }, + ]); + const resolveTimeoutMs = vi.fn(() => 1_000); + const tracker = createClaudeModelEffortLevelsTracker({ resolveTimeoutMs }); + + await tracker.refresh('claude-opus-9'); + await tracker.refresh('claude-opus-9'); + + // An empty supported-tier list is still a successfully settled catalog answer, not a retry signal. + expect(resolveTimeoutMs).toHaveBeenCalledTimes(1); + expect(tracker.getLevels()).toEqual([]); + }); }); diff --git a/apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.ts b/apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.ts index cdff5c10fc..01326449b9 100644 --- a/apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.ts +++ b/apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.ts @@ -43,37 +43,61 @@ export function createClaudeModelEffortLevelsTracker(params: Readonly<{ }>): ClaudeModelEffortLevelsTracker { let levels: readonly string[] = []; let modelId: string | null = null; + let settledModelId: string | null = null; + let inFlight: { modelId: string; promise: Promise } | null = null; - const refresh = async (nextModelId: unknown): Promise => { + const refresh = (nextModelId: unknown): Promise => { const normalized = typeof nextModelId === 'string' ? nextModelId.trim() : ''; if (!normalized) { // Reset to the CLI default: forget the previous model's tiers rather than leaving them live // for whatever is selected next. modelId = null; levels = []; - return; + settledModelId = null; + inFlight = null; + return Promise.resolve(); } - if (normalized === modelId) return; - // Drop the previous model's tiers at the transition, not when the lookup returns: a spawn - // between here and the resolve would otherwise clamp the new model against them. - modelId = normalized; - levels = []; + if (normalized !== modelId) { + // Drop the previous model's tiers at the transition, not when the lookup returns: a spawn + // between here and the resolve would otherwise clamp the new model against them. + modelId = normalized; + levels = []; + settledModelId = null; + } // Curated models resolve effort from the static table; no catalog lookup needed. - if (isCuratedClaudeModelId(normalized)) return; - - try { - const models = await resolveClaudeModelCatalog({ timeoutMs: params.resolveTimeoutMs() }); - // A newer model may have been selected while this lookup was in flight; a late resolve must - // not publish the previous model's tiers under the current model. - if (modelId !== normalized) return; - const model = models.find((candidate) => candidate.id === normalized) ?? null; - levels = resolveClaudeEffortLevelsFromModelDescriptor(model); - } catch { - if (modelId !== normalized) return; - levels = []; + if (isCuratedClaudeModelId(normalized)) { + settledModelId = normalized; + inFlight = null; + return Promise.resolve(); } + + // Concurrent same-model callers join the exact resolution rather than treating the selected + // model id as proof that its tiers already settled. + if (inFlight?.modelId === normalized) return inFlight.promise; + if (settledModelId === normalized) return Promise.resolve(); + + const resolution = { modelId: normalized, promise: Promise.resolve() }; + resolution.promise = (async () => { + try { + const models = await resolveClaudeModelCatalog({ timeoutMs: params.resolveTimeoutMs() }); + // A newer model may have been selected while this lookup was in flight; a late resolve must + // not publish the previous model's tiers under the current model. + if (modelId !== normalized) return; + const model = models.find((candidate) => candidate.id === normalized) ?? null; + levels = resolveClaudeEffortLevelsFromModelDescriptor(model); + settledModelId = normalized; + } catch { + if (modelId !== normalized) return; + levels = []; + settledModelId = null; + } finally { + if (inFlight === resolution) inFlight = null; + } + })(); + inFlight = resolution; + return resolution.promise; }; const refreshWithin = async (nextModelId: unknown, waitMs = CLAUDE_MODEL_EFFORT_TIER_WAIT_MS): Promise => { diff --git a/apps/cli/src/backends/claude/remote/buildClaudeSessionModelsMetadataFromSupportedModels.ts b/apps/cli/src/backends/claude/remote/buildClaudeSessionModelsMetadataFromSupportedModels.ts index 5561ce6d09..042dc5be9a 100644 --- a/apps/cli/src/backends/claude/remote/buildClaudeSessionModelsMetadataFromSupportedModels.ts +++ b/apps/cli/src/backends/claude/remote/buildClaudeSessionModelsMetadataFromSupportedModels.ts @@ -2,6 +2,8 @@ import type { Metadata } from '@/api/types'; import { normalizeContextWindowTokens } from '@/backends/modelCapabilities/contextWindowTokens'; import { readNewestSessionModelsMetadataStateV1 } from '@happier-dev/agents'; +import { reconcileClaudeSessionModelsState } from '../sessionModels/reconcileClaudeSessionModelsState'; + type SessionModelsState = NonNullable; type SessionModelEntry = SessionModelsState['availableModels'][number]; type SessionModelOption = NonNullable[number]; @@ -247,13 +249,17 @@ export function buildClaudeSessionModelsMetadataFromSupportedModels(params: Read const updatedAt = params.nowMs ? params.nowMs() : Date.now(); const currentModelId = resolveCurrentModelId(params.metadata); - const state: SessionModelsState = { + const state = reconcileClaudeSessionModelsState({ + metadata: params.metadata, + source: 'agent_sdk', + incomingState: { v: 1, provider: 'claude', updatedAt, currentModelId, availableModels, - }; + }, + }); return { sessionModelsV1: state, diff --git a/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts b/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts index f4c122ef5c..4e41dba85a 100644 --- a/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts +++ b/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts @@ -9,6 +9,7 @@ import type { Credentials } from '@/persistence'; import { configuration } from '@/configuration'; import type { registerRunnerTerminationHandlers as registerRunnerTerminationHandlersFn } from '@/agent/runtime/runnerTerminationHandlers'; import type { RunnerTerminationEvent, RunnerTerminationOutcome } from '@/agent/runtime/runnerTerminationOutcome'; +import type { AgentModelDescriptor } from '@happier-dev/agents'; type Deferred = { promise: Promise; resolve: (value: T) => void; reject: (error: unknown) => void }; @@ -532,6 +533,74 @@ describe('runClaude fast-start', () => { } }); + it('does not complete session readiness before the selected model effort catalog settles', async () => { + vi.resetModules(); + const catalogRequested = createDeferred(); + const catalogResult = createDeferred(); + vi.doMock('@/backends/claude/models/resolveClaudeModelCatalog', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveClaudeModelCatalog: vi.fn(() => { + catalogRequested.resolve(); + return catalogResult.promise; + }), + }; + }); + + loopEntered = createDeferred(); + loopStarted = createDeferred(); + loopExit = createDeferred(); + lastLoopOpts = null; + autoSessionReady = true; + awaitAutoSessionReadyCallback = true; + initResolved = false; + backendInitDelayMs = 0; + getOrCreateSessionSpy.mockImplementation(async () => ({ id: 'sess_effort_ready', metadataVersion: 1 })); + reportSessionToDaemonIfRunningSpy.mockClear(); + + const { runClaude } = await import('./runClaude'); + let testError: unknown = null; + const runPromise = runClaude(createLegacyCredentials(), { + startedBy: 'terminal', + startingMode: 'remote', + model: 'claude-opus-9', + }).catch((error) => { + testError = error; + loopStarted.resolve(); + }); + + try { + await waitFor(catalogRequested.promise, loopStartWaitMs); + let readinessCompleted = false; + void loopStarted.promise.then(() => { readinessCompleted = true; }); + await Promise.resolve(); + expect(readinessCompleted).toBe(false); + expect(reportSessionToDaemonIfRunningSpy.mock.calls.filter(([params]) => ( + params.sessionId === 'sess_effort_ready' && params.requireDaemonAck === false + ))) + .toHaveLength(0); + + catalogResult.resolve([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); + await waitFor(loopStarted.promise, loopStartWaitMs); + if (testError) throw testError; + expect(reportSessionToDaemonIfRunningSpy).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: 'sess_effort_ready', + requireDaemonAck: false, + })); + } finally { + catalogResult.resolve([]); + loopExit.resolve(0); + await runPromise; + vi.doUnmock('@/backends/claude/models/resolveClaudeModelCatalog'); + autoSessionReady = true; + awaitAutoSessionReadyCallback = false; + getOrCreateSessionSpy.mockImplementation(async () => ({ id: 'sess_1', metadataVersion: 1 })); + } + + if (testError) throw testError; + }); + it('installs unavailable group truth before a daemon-started remote Claude producer can dequeue', async () => { vi.resetModules(); reportSessionToDaemonIfRunningSpy.mockClear(); diff --git a/apps/cli/src/backends/claude/runClaude.ts b/apps/cli/src/backends/claude/runClaude.ts index ba3bc79fd2..167834bf46 100644 --- a/apps/cli/src/backends/claude/runClaude.ts +++ b/apps/cli/src/backends/claude/runClaude.ts @@ -1274,6 +1274,20 @@ export async function runClaude(credentials: Credentials, options: StartOptions onSessionReady: async (sessionInstance) => { // Store reference for hook server callback currentSession = sessionInstance; + const currentModelId = + typeof options.modelId === 'string' + ? options.modelId.trim() + : (typeof options.model === 'string' ? options.model.trim() : ''); + await modelEffortTracker.refresh(currentModelId); + if (!didPublishSessionModelsMetadata) { + didPublishSessionModelsMetadata = true; + void publishClaudeSessionModelsMetadataBestEffort({ + cwd: workingDirectory, + timeoutMs: resolveClaudeHelpProbeTimeoutMs(), + currentModelId, + session, + }); + } const readinessReport = reportSessionToDaemonIfRunning({ sessionId: baseSession.id, metadata, @@ -1286,20 +1300,6 @@ export async function runClaude(credentials: Credentials, options: StartOptions logger.debug('[claude] Daemon session readiness report failed (non-fatal)', error); }); } - if (!didPublishSessionModelsMetadata) { - didPublishSessionModelsMetadata = true; - const currentModelId = - typeof options.modelId === 'string' - ? options.modelId.trim() - : (typeof options.model === 'string' ? options.model.trim() : ''); - void modelEffortTracker.refresh(currentModelId); - void publishClaudeSessionModelsMetadataBestEffort({ - cwd: workingDirectory, - timeoutMs: resolveClaudeHelpProbeTimeoutMs(), - currentModelId, - session, - }); - } if (!localPermissionBridge) { localPermissionBridge = new ClaudeLocalPermissionBridge(sessionInstance, { responseTimeoutMs: localPermissionBridgeTimeoutMs }); localPermissionBridge.activate(); @@ -2082,21 +2082,13 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO }, onSessionReady: async (sessionInstance) => { currentSession = sessionInstance; - const readySessionId = artifacts.deferredSession.sessionId; - const readyMetadata = artifacts.deferredSession.getMetadataSnapshot?.() as Metadata | null | undefined; - if (readySessionId && readyMetadata) { - await reportSessionToDaemonIfRunning({ - sessionId: readySessionId, - metadata: readyMetadata, - }); - } + const currentModelId = + typeof options.modelId === 'string' + ? options.modelId.trim() + : (typeof options.model === 'string' ? options.model.trim() : ''); + await modelEffortTracker.refresh(currentModelId); if (!didPublishSessionModelsMetadata) { didPublishSessionModelsMetadata = true; - const currentModelId = - typeof options.modelId === 'string' - ? options.modelId.trim() - : (typeof options.model === 'string' ? options.model.trim() : ''); - void modelEffortTracker.refresh(currentModelId); void publishClaudeSessionModelsMetadataBestEffort({ cwd: workingDirectory, timeoutMs: resolveClaudeHelpProbeTimeoutMs(), @@ -2107,6 +2099,14 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO }, }); } + const readySessionId = artifacts.deferredSession.sessionId; + const readyMetadata = artifacts.deferredSession.getMetadataSnapshot?.() as Metadata | null | undefined; + if (readySessionId && readyMetadata) { + await reportSessionToDaemonIfRunning({ + sessionId: readySessionId, + metadata: readyMetadata, + }); + } if (!localPermissionBridge) { localPermissionBridge = new ClaudeLocalPermissionBridge(sessionInstance, { responseTimeoutMs: localPermissionBridgeTimeoutMs }); if (localPermissionBridgeEnabled) { diff --git a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts index 3da7077692..1e34289303 100644 --- a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts +++ b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { Metadata } from '@/api/types'; +import { buildClaudeSessionModelsMetadataFromSupportedModels } from '@/backends/claude/remote/buildClaudeSessionModelsMetadataFromSupportedModels'; import { publishClaudeSessionModelsMetadataBestEffort } from './publishClaudeSessionModelsMetadataBestEffort'; @@ -137,4 +138,54 @@ describe('publishClaudeSessionModelsMetadataBestEffort', () => { }, })).resolves.toBeUndefined(); }); + + it('converges catalog and Agent SDK model publications in either order', async () => { + const publishSdkModels = (metadata: Metadata, nowMs: number): Metadata => { + const update = buildClaudeSessionModelsMetadataFromSupportedModels({ + modelsRaw: [ + { value: 'claude-fable-5', displayName: 'Sparse SDK Fable' }, + { value: 'claude-sdk-only', displayName: 'SDK Only' }, + ], + metadata, + nowMs: () => nowMs, + }); + if (!update) throw new Error('expected Agent SDK model metadata'); + return { ...metadata, ...update }; + }; + const publishCatalogModels = async (metadata: Metadata, nowMs: number): Promise => { + const state: { metadata: Metadata } = { metadata }; + await publishClaudeSessionModelsMetadataBestEffort({ + cwd: '/', + timeoutMs: 250, + currentModelId: 'claude-fable-5', + nowMs: () => nowMs, + probeHelpText: async () => ' --effort (low, medium, high, max)', + session: { + ensureMetadataSnapshot: async () => state.metadata, + updateMetadata: async (updater) => { + state.metadata = updater(state.metadata); + }, + }, + }); + return state.metadata; + }; + + const catalogThenSdk = publishSdkModels( + await publishCatalogModels({} as Metadata, 100), + 200, + ); + const sdkThenCatalog = await publishCatalogModels( + publishSdkModels({} as Metadata, 200), + 100, + ); + + expect(catalogThenSdk.sessionModelsV1).toEqual(sdkThenCatalog.sessionModelsV1); + expect(catalogThenSdk.sessionModelsV1).toEqual(catalogThenSdk.acpSessionModelsV1); + expect(sdkThenCatalog.sessionModelsV1).toEqual(sdkThenCatalog.acpSessionModelsV1); + expect(catalogThenSdk.sessionModelsV1?.currentModelId).toBe('claude-fable-5'); + expect(catalogThenSdk.sessionModelsV1?.availableModels.map((model) => model.id)) + .toContain('claude-sdk-only'); + expect(catalogThenSdk.sessionModelsV1?.availableModels.find((model) => model.id === 'claude-fable-5')?.modelOptions) + .toEqual(expect.arrayContaining([expect.objectContaining({ id: 'reasoning_effort' })])); + }); }); diff --git a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts index dfc356f76a..76333dc4f2 100644 --- a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts +++ b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts @@ -1,6 +1,7 @@ import type { Metadata } from '@/api/types'; import { logger } from '@/ui/logger'; +import { reconcileClaudeSessionModelsState } from '../sessionModels/reconcileClaudeSessionModelsState'; import { probeClaudeHelpText } from './probeClaudeHelpText'; import { resolveClaudeSessionModelsState } from './resolveClaudeSessionModelsState'; @@ -66,11 +67,19 @@ export async function publishClaudeSessionModelsMetadataBestEffort(params: Reado ); try { - await params.session.updateMetadata((prev) => ({ - ...(supportsEffort ? prev : withoutEffortDependentOverrides(prev)), - sessionModelsV1: state, - acpSessionModelsV1: state, - })); + await params.session.updateMetadata((prev) => { + const base = supportsEffort ? prev : withoutEffortDependentOverrides(prev); + const reconciled = reconcileClaudeSessionModelsState({ + metadata: base, + incomingState: state, + source: 'catalog', + }); + return { + ...base, + sessionModelsV1: reconciled, + acpSessionModelsV1: reconciled, + }; + }); } catch (error) { logger.debug('[claude] Failed to publish session models metadata (non-fatal)', error); } diff --git a/apps/cli/src/backends/claude/sessionModels/reconcileClaudeSessionModelsState.ts b/apps/cli/src/backends/claude/sessionModels/reconcileClaudeSessionModelsState.ts new file mode 100644 index 0000000000..05d4a83277 --- /dev/null +++ b/apps/cli/src/backends/claude/sessionModels/reconcileClaudeSessionModelsState.ts @@ -0,0 +1,74 @@ +import type { Metadata } from '@/api/types'; +import { readNewestSessionModelsMetadataStateV1 } from '@happier-dev/agents'; + +type SessionModelsState = NonNullable; +type SessionModelEntry = SessionModelsState['availableModels'][number]; + +export type ClaudeSessionModelsPublicationSource = 'catalog' | 'agent_sdk'; + +function normalizeNonEmptyString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function readNewestClaudeState(metadata: Metadata | null | undefined): SessionModelsState | null { + const state = readNewestSessionModelsMetadataStateV1( + metadata as unknown as Record | null | undefined, + ) as SessionModelsState | null; + return state?.provider === 'claude' ? state : null; +} + +function mergeAvailableModels(params: Readonly<{ + existing: readonly SessionModelEntry[]; + incoming: readonly SessionModelEntry[]; + source: ClaudeSessionModelsPublicationSource; +}>): SessionModelEntry[] { + const existingById = new Map(params.existing.map((model) => [model.id, model])); + const incomingById = new Map(params.incoming.map((model) => [model.id, model])); + + if (params.source === 'catalog') { + return [ + ...params.incoming.map((model) => ({ ...existingById.get(model.id), ...model })), + ...params.existing.filter((model) => !incomingById.has(model.id)), + ]; + } + + return [ + ...params.existing.map((model) => ({ ...incomingById.get(model.id), ...model })), + ...params.incoming.filter((model) => !existingById.has(model.id)), + ]; +} + +/** + * Reconcile the two Claude model-list producers without making either publication order observable. + * + * The catalog owns the canonical baseline, ordering, and fields for matching ids. Agent SDK facts + * enrich that baseline and may add SDK-only ids, but a sparse SDK response cannot erase catalog + * options. Callers publish the returned object to both metadata aliases in one update. + */ +export function reconcileClaudeSessionModelsState(params: Readonly<{ + metadata: Metadata | null | undefined; + incomingState: SessionModelsState; + source: ClaudeSessionModelsPublicationSource; +}>): SessionModelsState { + const existing = readNewestClaudeState(params.metadata); + if (!existing) return params.incomingState; + + const existingCurrentModelId = normalizeNonEmptyString(existing.currentModelId); + const incomingCurrentModelId = normalizeNonEmptyString(params.incomingState.currentModelId); + const currentModelId = params.source === 'catalog' + ? incomingCurrentModelId || existingCurrentModelId || 'default' + : existingCurrentModelId && existingCurrentModelId !== 'default' + ? existingCurrentModelId + : incomingCurrentModelId || existingCurrentModelId || 'default'; + + return { + ...params.incomingState, + updatedAt: Math.max(existing.updatedAt, params.incomingState.updatedAt), + currentModelId, + availableModels: mergeAvailableModels({ + existing: Array.isArray(existing.availableModels) ? existing.availableModels : [], + incoming: Array.isArray(params.incomingState.availableModels) ? params.incomingState.availableModels : [], + source: params.source, + }), + }; +} diff --git a/apps/cli/src/backends/claude/unifiedTerminal/dialogChoice/injectionDialogRouting.test.ts b/apps/cli/src/backends/claude/unifiedTerminal/dialogChoice/injectionDialogRouting.test.ts index 73d527a9e6..45532bd76c 100644 --- a/apps/cli/src/backends/claude/unifiedTerminal/dialogChoice/injectionDialogRouting.test.ts +++ b/apps/cli/src/backends/claude/unifiedTerminal/dialogChoice/injectionDialogRouting.test.ts @@ -96,13 +96,13 @@ describe('Claude unified pending-injection dialog routing', () => { const bridge = createClaudeUnifiedRuntimeControlBridge({ controller, emitRuntimeConfigOutcome: () => undefined, - startupMode: mode({ reasoningEffort: 'high' }), + startupMode: mode({ model: 'sonnet', reasoningEffort: 'high' }), }); const injectUserPrompt = vi.fn(async () => ({ status: 'injected', at: 1, bytesWritten: 11 } as const)); const injector = createClaudeUnifiedPromptInjector({ inputInjection: { hostKind: 'tmux', injectUserPrompt }, beforeComposerDraftGuard: async () => { - const apply = await bridge.applyBeforePrompt(mode({ reasoningEffort: 'medium' })); + const apply = await bridge.applyBeforePrompt(mode({ model: 'sonnet', reasoningEffort: 'medium' })); return apply.promptMayProceed ? null : { status: 'deferred', reason: 'terminal_busy', retryAfterMs: 2_000 } as const; diff --git a/apps/cli/src/backends/claude/unifiedTerminal/runtimeControlIntegration.test.ts b/apps/cli/src/backends/claude/unifiedTerminal/runtimeControlIntegration.test.ts index 6bd5f4c53c..c0a2342ccd 100644 --- a/apps/cli/src/backends/claude/unifiedTerminal/runtimeControlIntegration.test.ts +++ b/apps/cli/src/backends/claude/unifiedTerminal/runtimeControlIntegration.test.ts @@ -72,6 +72,17 @@ describe('mapEnhancedModeToDesiredRuntimeConfig', () => { expect(desired.reasoningEffort).toBeUndefined(); }); + it('clamps a discovered model effort to the highest evidenced supported tier', () => { + const desired = mapEnhancedModeToDesiredRuntimeConfig(mode({ + model: 'claude-opus-9', + reasoningEffort: 'max', + modelEffortLevels: ['low', 'medium'], + modelEffortLevelsModelId: 'claude-opus-9', + })); + + expect(desired.reasoningEffort).toBe('medium'); + }); + it('maps ultracode gated by xhigh capability of the mode model', () => { expect(mapEnhancedModeToDesiredRuntimeConfig(mode({ model: 'claude-fable-5', ultracode: true })).ultracode).toBe(true); // Requested but not honorable on this model → resolved off. @@ -416,10 +427,10 @@ describe('createClaudeUnifiedRuntimeControlBridge', () => { const bridge = createClaudeUnifiedRuntimeControlBridge({ controller, emitRuntimeConfigOutcome: (event) => events.push(event), - startupMode: mode({ reasoningEffort: 'high' }), + startupMode: mode({ model: 'sonnet', reasoningEffort: 'high' }), }); - const deferred = await bridge.applyBeforePrompt(mode({ reasoningEffort: 'medium' })); + const deferred = await bridge.applyBeforePrompt(mode({ model: 'sonnet', reasoningEffort: 'medium' })); expect(deferred).toEqual({ promptMayProceed: true, attempted: true }); expect(port.sentLiteral).toHaveLength(0); expect(events).toHaveLength(1); @@ -429,7 +440,7 @@ describe('createClaudeUnifiedRuntimeControlBridge', () => { changes: [expect.objectContaining({ key: 'reasoningEffort', requested: 'medium', reason: 'generating' })], }); - const retried = await bridge.applyBeforePrompt(mode({ reasoningEffort: 'medium' })); + const retried = await bridge.applyBeforePrompt(mode({ model: 'sonnet', reasoningEffort: 'medium' })); expect(retried.promptMayProceed).toBe(true); expect(port.sentLiteral).toContain('/effort medium'); }); @@ -441,10 +452,10 @@ describe('createClaudeUnifiedRuntimeControlBridge', () => { const bridge = createClaudeUnifiedRuntimeControlBridge({ controller, emitRuntimeConfigOutcome: (event) => events.push(event), - startupMode: mode({ reasoningEffort: 'high' }), + startupMode: mode({ model: 'sonnet', reasoningEffort: 'high' }), }); - const result = await bridge.applyBeforePrompt(mode({ reasoningEffort: 'medium' })); + const result = await bridge.applyBeforePrompt(mode({ model: 'sonnet', reasoningEffort: 'medium' })); expect(result).toEqual({ promptMayProceed: true, @@ -529,14 +540,15 @@ describe('createClaudeUnifiedRuntimeControlBridge', () => { const bridge = createClaudeUnifiedRuntimeControlBridge({ controller, emitRuntimeConfigOutcome: () => undefined, - startupMode: mode({ permissionMode: 'default', reasoningEffort: 'high' }), + startupMode: mode({ model: 'sonnet', permissionMode: 'default', reasoningEffort: 'high' }), }); - const deferredAmbient = await bridge.applyBeforePrompt(mode({ reasoningEffort: 'medium' })); + const deferredAmbient = await bridge.applyBeforePrompt(mode({ model: 'sonnet', reasoningEffort: 'medium' })); expect(deferredAmbient).toEqual({ promptMayProceed: true, attempted: true }); const dependent = await bridge.applyBeforePrompt(mode({ permissionMode: 'acceptEdits', + model: 'sonnet', reasoningEffort: 'medium', })); @@ -687,21 +699,21 @@ describe('createClaudeUnifiedRuntimeControlBridge', () => { const bridge = createClaudeUnifiedRuntimeControlBridge({ controller, emitRuntimeConfigOutcome: (event) => events.push(event), - startupMode: mode({ reasoningEffort: 'high' }), + startupMode: mode({ model: 'sonnet', reasoningEffort: 'high' }), }); - const first = await bridge.applyBeforePrompt(mode({ reasoningEffort: 'medium' })); + const first = await bridge.applyBeforePrompt(mode({ model: 'sonnet', reasoningEffort: 'medium' })); expect(first.promptMayProceed).toBe(true); expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ status: 'applied', timing: 'queued_until_safe_window' }); // Identical blocked outcomes: no new transcript events. - await bridge.applyBeforePrompt(mode({ reasoningEffort: 'medium' })); - await bridge.applyBeforePrompt(mode({ reasoningEffort: 'medium' })); + await bridge.applyBeforePrompt(mode({ model: 'sonnet', reasoningEffort: 'medium' })); + await bridge.applyBeforePrompt(mode({ model: 'sonnet', reasoningEffort: 'medium' })); expect(events).toHaveLength(1); // Transition (applied in the current window) emits exactly one new event. - const resolved = await bridge.applyBeforePrompt(mode({ reasoningEffort: 'medium' })); + const resolved = await bridge.applyBeforePrompt(mode({ model: 'sonnet', reasoningEffort: 'medium' })); expect(resolved.promptMayProceed).toBe(true); expect(events).toHaveLength(2); expect(events[1]).toMatchObject({ @@ -719,13 +731,13 @@ describe('createClaudeUnifiedRuntimeControlBridge', () => { const bridge = createClaudeUnifiedRuntimeControlBridge({ controller, emitRuntimeConfigOutcome: (event) => events.push(event), - startupMode: mode({ reasoningEffort: 'high' }), + startupMode: mode({ model: 'sonnet', reasoningEffort: 'high' }), }); // Later evidence (UserPromptSubmit metadata) proves the desired effort is already active. bridge.reconcileFromPromptSubmitMetadata({ reasoningEffort: 'medium' }); - const result = await bridge.applyBeforePrompt(mode({ reasoningEffort: 'medium' })); + const result = await bridge.applyBeforePrompt(mode({ model: 'sonnet', reasoningEffort: 'medium' })); expect(result.promptMayProceed).toBe(true); expect(port.sentLiteral).toHaveLength(0); expect(events).toHaveLength(1); @@ -736,7 +748,7 @@ describe('createClaudeUnifiedRuntimeControlBridge', () => { }); // Converged: the next prompt attempts nothing and emits nothing. - const again = await bridge.applyBeforePrompt(mode({ reasoningEffort: 'medium' })); + const again = await bridge.applyBeforePrompt(mode({ model: 'sonnet', reasoningEffort: 'medium' })); expect(again).toEqual({ promptMayProceed: true, attempted: false }); expect(events).toHaveLength(1); }); diff --git a/apps/cli/src/backends/claude/unifiedTerminal/runtimeControlIntegration.ts b/apps/cli/src/backends/claude/unifiedTerminal/runtimeControlIntegration.ts index d99c66afd9..430ce4d046 100644 --- a/apps/cli/src/backends/claude/unifiedTerminal/runtimeControlIntegration.ts +++ b/apps/cli/src/backends/claude/unifiedTerminal/runtimeControlIntegration.ts @@ -5,7 +5,11 @@ import type { } from '@happier-dev/protocol'; import type { InFlightConfigApplyOutcome } from '@/agent/runtime/permission/bindPermissionModeQueue'; -import { resolveClaudeUltracodeForModel, resolveModeEffortLevelsForModel } from '@/backends/claude/utils/claudeEffort'; +import { + resolveClaudeEffectiveEffortForModel, + resolveClaudeUltracodeForModel, + resolveModeEffortLevelsForModel, +} from '@/backends/claude/utils/claudeEffort'; import type { EnhancedMode } from '../loop'; import { controlResultToChangeOutcome } from './tuiControls/outcome'; @@ -168,8 +172,12 @@ export function mapEnhancedModeToDesiredRuntimeConfig(mode: EnhancedMode): Claud } = {}; const model = normalizeNonEmptyString(mode.model); if (model !== undefined) desired.model = model; - const reasoningEffort = normalizeNonEmptyString(mode.reasoningEffort); - if (reasoningEffort !== undefined) desired.reasoningEffort = reasoningEffort; + const reasoningEffort = resolveClaudeEffectiveEffortForModel({ + modelId: mode.model, + effort: mode.reasoningEffort, + supportedLevels: resolveModeEffortLevelsForModel(mode, mode.model), + }); + if (reasoningEffort !== null) desired.reasoningEffort = reasoningEffort; if (typeof mode.permissionMode === 'string') desired.permissionMode = mode.permissionMode; if (mode.agentModeId !== undefined) desired.agentModeId = mode.agentModeId ?? null; if (typeof mode.claudeRemoteMaxThinkingTokens === 'number') { diff --git a/apps/cli/src/backends/claude/utils/claudeEffort.ts b/apps/cli/src/backends/claude/utils/claudeEffort.ts index e1b8dfcbff..577d178874 100644 --- a/apps/cli/src/backends/claude/utils/claudeEffort.ts +++ b/apps/cli/src/backends/claude/utils/claudeEffort.ts @@ -165,7 +165,7 @@ export function resolveModeEffortLevelsForModel( return mode.modelEffortLevelsModelId === normalized ? mode.modelEffortLevels : undefined; } -export function resolveClaudeEffortForModel(params: Readonly<{ +export function resolveClaudeEffectiveEffortForModel(params: Readonly<{ modelId: unknown; effort: unknown; /** Effort tiers the model reported (Anthropic Models API). Required for discovered models. */ @@ -182,6 +182,16 @@ export function resolveClaudeEffortForModel(params: Readonly<{ if (supportedLevels.length === 0) return null; const normalized = resolveBestSupportedClaudeEffort(effort, supportedLevels); + return normalized; +} + +export function resolveClaudeEffortForModel(params: Readonly<{ + modelId: unknown; + effort: unknown; + /** Effort tiers the model reported (Anthropic Models API). Required for discovered models. */ + supportedLevels?: readonly unknown[]; +}>): ClaudeEffortLevel | null { + const normalized = resolveClaudeEffectiveEffortForModel(params); if (!normalized) return null; const defaultEffort = resolveClaudeDefaultEffortForKnownAliasOrModel(params.modelId); From be2184cb6cdf037ad823c6993c1915233204352f Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 12 Aug 2026 01:38:12 +0200 Subject: [PATCH 05/11] fix(claude-models): address current-head review findings --- .../models/resolveClaudeModelCatalog.test.ts | 15 +++ .../models/resolveClaudeModelCatalog.ts | 6 +- .../runClaude.fastStart.integration.test.ts | 113 ++++++++++++++++-- apps/cli/src/backends/claude/runClaude.ts | 38 +++--- ...udeSessionModelsMetadataBestEffort.test.ts | 74 ++++++++++++ ...shClaudeSessionModelsMetadataBestEffort.ts | 25 ++-- .../resolveClaudeSessionModelsState.ts | 7 +- docs/agents-catalog.md | 9 +- 8 files changed, 243 insertions(+), 44 deletions(-) diff --git a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts index 291e53964d..230a4fa6a7 100644 --- a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts +++ b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts @@ -77,6 +77,21 @@ describe('resolveClaudeModelCatalog', () => { expect(models.some((m) => m.id === 'claude-fable-5')).toBe(true); }); + it('keeps the first discovered row when an alias and dated snapshot normalize to the same id', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; + fetchAnthropicModelsMock.mockResolvedValue([ + { id: 'claude-opus-9', displayName: 'Opus 9 Alias' }, + { id: 'claude-opus-9-20260812', displayName: 'Opus 9 Snapshot' }, + ]); + + const models = await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + const matching = models.filter((model) => model.id === 'claude-opus-9' || model.id === 'claude-opus-9-20260812'); + + expect(matching).toEqual([ + expect.objectContaining({ id: 'claude-opus-9', name: 'Opus 9 Alias' }), + ]); + }); + it('records a successful endpoint response as dynamic even when every id is already curated', async () => { process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; fetchAnthropicModelsMock.mockResolvedValue([ diff --git a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts index 56f9f148eb..8e03e95b56 100644 --- a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts +++ b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts @@ -79,13 +79,17 @@ function isRunnableDiscoveredModel(normalizedId: string): boolean { function mergeStaticWithDiscovered(entries: readonly AnthropicModelEntry[]): AgentModelDescriptor[] { const staticModels = resolveStaticClaudeModels(); const staticNormalizedIds = new Set(staticModels.map((model) => normalizeDatedId(model.id))); + const discoveredNormalizedIds = new Set(); const discovered = entries .filter((entry) => { const normalized = normalizeDatedId(entry.id); if (normalized.length === 0 || normalized === 'default') return false; if (staticNormalizedIds.has(normalized)) return false; - return isRunnableDiscoveredModel(normalized); + if (!isRunnableDiscoveredModel(normalized)) return false; + if (discoveredNormalizedIds.has(normalized)) return false; + discoveredNormalizedIds.add(normalized); + return true; }) .map((entry) => buildDiscoveredClaudeModelDescriptor(entry)); diff --git a/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts b/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts index 4e41dba85a..0acecf254f 100644 --- a/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts +++ b/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts @@ -85,6 +85,7 @@ let loopExit: Deferred = createDeferred(); let lastLoopOpts: any = null; let autoSessionReady = true; let awaitAutoSessionReadyCallback = false; +let beforeLoopCapture: Promise | null = null; let lastTerminationHandlerParams: Parameters[0] | null = null; let readSettingsCalls = 0; let initializeBackendApiContextCalls = 0; @@ -126,9 +127,11 @@ vi.mock('@/backends/claude/loop', () => ({ const invocationLoopExit = loopExit; const invocationAutoSessionReady = autoSessionReady; const invocationAwaitAutoSessionReadyCallback = awaitAutoSessionReadyCallback; + const invocationBeforeLoopCapture = beforeLoopCapture; loopCalls += 1; lastLoopOpts = opts; invocationLoopEntered.resolve(); + if (invocationBeforeLoopCapture) await invocationBeforeLoopCapture; if (invocationAutoSessionReady) { const sessionReady = opts?.onSessionReady?.({ cleanup: vi.fn(), @@ -533,7 +536,7 @@ describe('runClaude fast-start', () => { } }); - it('does not complete session readiness before the selected model effort catalog settles', async () => { + it('does not complete true fast-start readiness before the selected model effort catalog settles', async () => { vi.resetModules(); const catalogRequested = createDeferred(); const catalogResult = createDeferred(); @@ -547,6 +550,9 @@ describe('runClaude fast-start', () => { }), }; }); + vi.doMock('@/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort', () => ({ + publishClaudeSessionModelsMetadataBestEffort: vi.fn(async () => {}), + })); loopEntered = createDeferred(); loopStarted = createDeferred(); @@ -555,7 +561,7 @@ describe('runClaude fast-start', () => { autoSessionReady = true; awaitAutoSessionReadyCallback = true; initResolved = false; - backendInitDelayMs = 0; + backendInitDelayMs = 200; getOrCreateSessionSpy.mockImplementation(async () => ({ id: 'sess_effort_ready', metadataVersion: 1 })); reportSessionToDaemonIfRunningSpy.mockClear(); @@ -563,7 +569,7 @@ describe('runClaude fast-start', () => { let testError: unknown = null; const runPromise = runClaude(createLegacyCredentials(), { startedBy: 'terminal', - startingMode: 'remote', + startingMode: 'local', model: 'claude-opus-9', }).catch((error) => { testError = error; @@ -576,25 +582,110 @@ describe('runClaude fast-start', () => { void loopStarted.promise.then(() => { readinessCompleted = true; }); await Promise.resolve(); expect(readinessCompleted).toBe(false); - expect(reportSessionToDaemonIfRunningSpy.mock.calls.filter(([params]) => ( - params.sessionId === 'sess_effort_ready' && params.requireDaemonAck === false - ))) - .toHaveLength(0); + expect(initResolved).toBe(false); catalogResult.resolve([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); await waitFor(loopStarted.promise, loopStartWaitMs); if (testError) throw testError; - expect(reportSessionToDaemonIfRunningSpy).toHaveBeenCalledWith(expect.objectContaining({ - sessionId: 'sess_effort_ready', - requireDaemonAck: false, - })); + expect(initResolved).toBe(false); + expect(lastLoopOpts?.initialClaudeUnifiedTerminalMode).toMatchObject({ + model: 'claude-opus-9', + modelEffortLevelsModelId: 'claude-opus-9', + }); } finally { catalogResult.resolve([]); loopExit.resolve(0); await runPromise; vi.doUnmock('@/backends/claude/models/resolveClaudeModelCatalog'); + vi.doUnmock('@/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort'); + autoSessionReady = true; + awaitAutoSessionReadyCallback = false; + getOrCreateSessionSpy.mockImplementation(async () => ({ id: 'sess_1', metadataVersion: 1 })); + } + + if (testError) throw testError; + }); + + it('uses a persisted model override for the initial fast-start mode and tier identity', async () => { + vi.resetModules(); + const overrideSynced = createDeferred(); + vi.doMock('@/agent/runtime/runtimeOverridesSynchronizer', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + initializeRuntimeOverridesSynchronizer: async ( + params: Parameters[0], + ) => { + const synchronizer = await actual.initializeRuntimeOverridesSynchronizer(params); + return { + ...synchronizer, + syncFromMetadata: () => { + synchronizer.syncFromMetadata(); + overrideSynced.resolve(); + }, + }; + }, + }; + }); + vi.doMock('@/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort', () => ({ + publishClaudeSessionModelsMetadataBestEffort: vi.fn(async () => {}), + })); + + loopStarted = createDeferred(); + loopExit = createDeferred(); + lastLoopOpts = null; + autoSessionReady = true; + awaitAutoSessionReadyCallback = true; + initResolved = false; + backendInitDelayMs = 0; + beforeLoopCapture = overrideSynced.promise; + getOrCreateSessionSpy.mockImplementation(async () => ({ id: 'sess_model_override', metadataVersion: 1 })); + + const persistedMetadata = { + modelOverrideV1: { v: 1 as const, updatedAt: 77, modelId: 'claude-fable-5' }, + }; + const previousSessionImpl = sessionSyncClientSpy.getMockImplementation(); + if (!previousSessionImpl) throw new Error('expected the session client test implementation'); + sessionSyncClientSpy.mockImplementation((response: unknown) => { + const client = previousSessionImpl(response); + return { + ...client, + ensureMetadataSnapshot: vi.fn(async () => persistedMetadata), + getMetadataSnapshot: vi.fn(() => persistedMetadata), + }; + }); + + const { runClaude } = await import('./runClaude'); + let testError: unknown = null; + const runPromise = runClaude(createLegacyCredentials(), { + startedBy: 'terminal', + startingMode: 'local', + claudeArgs: ['--dangerously-skip-permissions'], + }).catch((error) => { + testError = error; + loopStarted.resolve(); + }); + + try { + await waitFor(overrideSynced.promise, 10_000).catch((error) => { + throw new Error(`persisted model override did not sync (initResolved=${String(initResolved)}): ${String(error)}`); + }); + await waitFor(loopStarted.promise, loopStartWaitMs); + if (testError) throw testError; + expect(lastLoopOpts?.initialClaudeUnifiedTerminalMode).toMatchObject({ + model: 'claude-fable-5', + modelEffortLevelsModelId: 'claude-fable-5', + }); + } finally { + overrideSynced.resolve(); + loopExit.resolve(0); + await runPromise; + sessionSyncClientSpy.mockImplementation(previousSessionImpl); + vi.doUnmock('@/agent/runtime/runtimeOverridesSynchronizer'); + vi.doUnmock('@/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort'); autoSessionReady = true; awaitAutoSessionReadyCallback = false; + beforeLoopCapture = null; getOrCreateSessionSpy.mockImplementation(async () => ({ id: 'sess_1', metadataVersion: 1 })); } diff --git a/apps/cli/src/backends/claude/runClaude.ts b/apps/cli/src/backends/claude/runClaude.ts index 167834bf46..9c851063ec 100644 --- a/apps/cli/src/backends/claude/runClaude.ts +++ b/apps/cli/src/backends/claude/runClaude.ts @@ -1693,6 +1693,8 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO options.modelId = modelOverrideRef.current ?? undefined; options.model = modelOverrideRef.current ?? undefined; options.modelUpdatedAt = modelOverrideRef.updatedAt; + currentModel = modelOverrideRef.current ?? undefined; + currentModelUpdatedAt = modelOverrideRef.updatedAt; }, }); @@ -2037,6 +2039,20 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO }); seedInitialAppendSystemPrompt(defaultSystemPromptText); + const initialClaudeUnifiedTerminalMode = pinClaudeRemoteModeToActiveRuntime({ + permissionMode: options.permissionMode ?? 'default', + agentModeId: currentAgentModeId, + model: currentModel, + fallbackModel: currentFallbackModel, + customSystemPrompt: currentCustomSystemPrompt, + appendSystemPrompt: currentAppendSystemPrompt, + reasoningEffort: currentReasoningEffort, + modelEffortLevels: modelEffortTracker.getLevels(), + modelEffortLevelsModelId: modelEffortTracker.getModelId(), + ultracode: currentUltracode, + ...currentClaudeRemoteMetaState, + }, sessionRuntimeModeKind); + const exitCode = await loop({ path: workingDirectory, model: options.model, @@ -2044,19 +2060,7 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO permissionModeUpdatedAt: options.permissionModeUpdatedAt, startingMode: options.startingMode, claudeUnifiedTerminalEnabled: unifiedTerminalRuntimeActive, - initialClaudeUnifiedTerminalMode: pinClaudeRemoteModeToActiveRuntime({ - permissionMode: options.permissionMode ?? 'default', - agentModeId: currentAgentModeId, - model: currentModel, - fallbackModel: currentFallbackModel, - customSystemPrompt: currentCustomSystemPrompt, - appendSystemPrompt: currentAppendSystemPrompt, - reasoningEffort: currentReasoningEffort, - modelEffortLevels: modelEffortTracker.getLevels(), - modelEffortLevelsModelId: modelEffortTracker.getModelId(), - ultracode: currentUltracode, - ...currentClaudeRemoteMetaState, - }, sessionRuntimeModeKind), + initialClaudeUnifiedTerminalMode, claudeCodeExperimentalAgentTeamsEnabled: currentClaudeRemoteMetaState.claudeCodeExperimentalAgentTeamsEnabled, startedBy: options.startedBy, terminalRuntime: options.terminalRuntime ?? null, @@ -2082,11 +2086,11 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO }, onSessionReady: async (sessionInstance) => { currentSession = sessionInstance; - const currentModelId = - typeof options.modelId === 'string' - ? options.modelId.trim() - : (typeof options.model === 'string' ? options.model.trim() : ''); + const currentModelId = typeof currentModel === 'string' ? currentModel.trim() : ''; await modelEffortTracker.refresh(currentModelId); + initialClaudeUnifiedTerminalMode.model = currentModelId || undefined; + initialClaudeUnifiedTerminalMode.modelEffortLevels = modelEffortTracker.getLevels(); + initialClaudeUnifiedTerminalMode.modelEffortLevelsModelId = modelEffortTracker.getModelId(); if (!didPublishSessionModelsMetadata) { didPublishSessionModelsMetadata = true; void publishClaudeSessionModelsMetadataBestEffort({ diff --git a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts index 1e34289303..c6579cbaa1 100644 --- a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts +++ b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts @@ -73,6 +73,80 @@ describe('publishClaudeSessionModelsMetadataBestEffort', () => { .toEqual({ updatedAt: 1, value: 'high' }); }); + it('retires stale effort overrides when the selected model does not support reasoning effort', async () => { + const state: { metadata: Metadata } = { + metadata: { + acpConfigOptionOverridesV1: { + v: 1, + updatedAt: 1, + overrides: { + reasoning_effort: { updatedAt: 1, value: 'high' }, + ultracode: { updatedAt: 1, value: 'true' }, + some_other_option: { updatedAt: 1, value: 'keep-me' }, + }, + }, + } as unknown as Metadata, + }; + + await publishClaudeSessionModelsMetadataBestEffort({ + cwd: '/', + timeoutMs: 250, + currentModelId: 'claude-haiku-4-5', + nowMs: () => 999, + probeHelpText: async () => ' --effort (low, medium, high, max)', + session: { + ensureMetadataSnapshot: async () => state.metadata, + updateMetadata: async (updater) => { + state.metadata = updater(state.metadata); + }, + }, + }); + + const overrides = state.metadata.acpConfigOptionOverridesV1?.overrides ?? {}; + expect(overrides.reasoning_effort).toBeUndefined(); + expect(overrides.ultracode).toBeUndefined(); + expect(overrides.some_other_option).toEqual({ updatedAt: 1, value: 'keep-me' }); + expect(state.metadata.sessionModelsV1?.currentModelId).toBe('claude-haiku-4-5'); + expect(state.metadata.sessionModelsV1).toEqual(state.metadata.acpSessionModelsV1); + }); + + it('retires only stale ultracode when the selected model supports reasoning effort without ultracode', async () => { + const state: { metadata: Metadata } = { + metadata: { + sessionConfigOptionOverridesV1: { + v: 1, + updatedAt: 1, + overrides: { + reasoning_effort: { updatedAt: 1, value: 'high' }, + ultracode: { updatedAt: 1, value: 'true' }, + some_other_option: { updatedAt: 1, value: 'keep-me' }, + }, + }, + } as unknown as Metadata, + }; + + await publishClaudeSessionModelsMetadataBestEffort({ + cwd: '/', + timeoutMs: 250, + currentModelId: 'claude-sonnet-4-6[1m]', + nowMs: () => 999, + probeHelpText: async () => ' --effort (low, medium, high, max)', + session: { + ensureMetadataSnapshot: async () => state.metadata, + updateMetadata: async (updater) => { + state.metadata = updater(state.metadata); + }, + }, + }); + + const overrides = state.metadata.sessionConfigOptionOverridesV1?.overrides ?? {}; + expect(overrides.reasoning_effort).toEqual({ updatedAt: 1, value: 'high' }); + expect(overrides.ultracode).toBeUndefined(); + expect(overrides.some_other_option).toEqual({ updatedAt: 1, value: 'keep-me' }); + expect(state.metadata.sessionModelsV1?.currentModelId).toBe('claude-sonnet-4-6[1m]'); + expect(state.metadata.sessionModelsV1).toEqual(state.metadata.acpSessionModelsV1); + }); + it('publishes sessionModelsV1/acpSessionModelsV1 when --effort is supported', async () => { const state: { metadata: Metadata } = { metadata: {} as Metadata }; diff --git a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts index 76333dc4f2..32f703d90e 100644 --- a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts +++ b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts @@ -9,14 +9,20 @@ import { resolveClaudeSessionModelsState } from './resolveClaudeSessionModelsSta const EFFORT_DEPENDENT_OPTION_IDS = ['reasoning_effort', 'ultracode'] as const; /** - * Drop effort-dependent overrides a session set earlier when the runtime can no longer apply them. + * Drop effort-dependent overrides the selected model does not advertise. * * Hiding the controls from `sessionModelsV1` is not enough: the composer emits whatever overrides * are in metadata regardless of which options are advertised, and treats them as non-steerable — so * a stale `ultracode` would keep riding later prompts and could push a busy send down the * "provider config change refused" path. */ -function withoutEffortDependentOverrides(prev: Metadata): Metadata { +function withoutUnsupportedEffortDependentOverrides( + prev: Metadata, + supportedOptionIds: ReadonlySet, +): Metadata { + const unsupportedOptionIds = EFFORT_DEPENDENT_OPTION_IDS.filter((id) => !supportedOptionIds.has(id)); + if (unsupportedOptionIds.length === 0) return prev; + const next = { ...prev }; let changed = false; @@ -24,10 +30,12 @@ function withoutEffortDependentOverrides(prev: Metadata): Metadata { const state = prev[key]; const overrides = state?.overrides; if (!overrides) continue; - if (!EFFORT_DEPENDENT_OPTION_IDS.some((id) => id in overrides)) continue; + if (!unsupportedOptionIds.some((id) => id in overrides)) continue; const retained = Object.fromEntries( - Object.entries(overrides).filter(([id]) => !EFFORT_DEPENDENT_OPTION_IDS.includes(id as typeof EFFORT_DEPENDENT_OPTION_IDS[number])), + Object.entries(overrides).filter( + ([id]) => !unsupportedOptionIds.includes(id as typeof unsupportedOptionIds[number]), + ), ); next[key] = { ...state, overrides: retained }; changed = true; @@ -62,13 +70,16 @@ export async function publishClaudeSessionModelsMetadataBestEffort(params: Reado }).catch(() => null); if (!state) return; - const supportsEffort = state.availableModels.some( - (model) => (model.modelOptions ?? []).some((option) => option.id === 'reasoning_effort'), + const selectedModel = state.availableModels.find( + (model) => model.id === currentModelId || model.extendedContextModelId === currentModelId, + ); + const selectedModelOptionIds = new Set( + (selectedModel?.modelOptions ?? []).map((option) => option.id), ); try { await params.session.updateMetadata((prev) => { - const base = supportsEffort ? prev : withoutEffortDependentOverrides(prev); + const base = withoutUnsupportedEffortDependentOverrides(prev, selectedModelOptionIds); const reconciled = reconcileClaudeSessionModelsState({ metadata: base, incomingState: state, diff --git a/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts b/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts index 1b5fbf6339..07a58711f1 100644 --- a/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts +++ b/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts @@ -20,10 +20,9 @@ export async function resolveClaudeSessionModelsState(params: Readonly<{ const updatedAt = params.nowMs(); // Same owner as the new-session preflight probe, so the in-session picker cannot disagree about - // which models exist or which effort tiers they support. Cached per account; falls back to the - // curated catalog when the Models API is unavailable. - // No binding needed here: an in-session process already has CLAUDE_CONFIG_DIR pointed at the - // materialized account, and the catalog keys its cache on that resolved dir. + // which models exist or which effort tiers they support. The catalog owns credential-aware + // caching and falls back to the curated list when the Models API is unavailable. No binding is + // needed here: the in-session process already runs with the selected account environment. const models = await resolveClaudeModelCatalog({ timeoutMs: params.timeoutMs }); return { diff --git a/docs/agents-catalog.md b/docs/agents-catalog.md index 66a28c9e15..0327e605e5 100644 --- a/docs/agents-catalog.md +++ b/docs/agents-catalog.md @@ -162,8 +162,9 @@ per-model metadata than the static one, so audit what the dynamic path drops bef A provider with both surfaces needs **one owner** for the model list. Claude's is `apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts`: the preflight probe adapter and the in-session `sessionModelsV1` publisher both read it, so the two pickers cannot disagree about -which models exist or which effort tiers they report. It caches per resolved account config dir + -endpoint + ambient-credential fingerprint, so a session start does not pay a network round trip. +which models exist or which effort tiers they report. Its provider-owned cache identity is the +normalized endpoint, credential kind, and full SHA-256 credential hash. A warm cache entry avoids a +network request; a cold session start may fetch the catalog before publishing the resolved models. Effort tiers are resolved once when the session mode is built and travel on the mode, so spawn-time resolution and launch-option hashing see the same value and hashing stays pure. @@ -176,8 +177,8 @@ Provider-owned probing: - Set `resolveModelsProbeVariant` on the catalog entry whenever the probe result depends on something other than the agent id — runtime flavor, auth method, or the bound connected account. The returned string partitions the probe cache; without it, results computed for one account or - runtime mode are served to another. Codex and Claude both key theirs on the connected-service - binding. + runtime mode are served to another. Codex uses this generic cache variant; Claude instead declares + provider-owned caching and keys its catalog by the effective endpoint and credential identity. - Fail closed to the static catalog. A probe that cannot authenticate returns `null` rather than probing with whatever credential happens to be in the daemon's environment. From 599812c4122c6ba3aabb125ee9a0a236a0e75eb0 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 12 Aug 2026 02:00:13 +0200 Subject: [PATCH 06/11] fix(claude): preserve startup effort dialog intent --- .../claudeUnifiedResumeChoiceStartupResolver.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/backends/claude/unifiedTerminal/resumeChoice/claudeUnifiedResumeChoiceStartupResolver.ts b/apps/cli/src/backends/claude/unifiedTerminal/resumeChoice/claudeUnifiedResumeChoiceStartupResolver.ts index 1fffc9e447..db56dcdfeb 100644 --- a/apps/cli/src/backends/claude/unifiedTerminal/resumeChoice/claudeUnifiedResumeChoiceStartupResolver.ts +++ b/apps/cli/src/backends/claude/unifiedTerminal/resumeChoice/claudeUnifiedResumeChoiceStartupResolver.ts @@ -56,9 +56,12 @@ function normalizeNonEmptyString(value: string | null | undefined): string | nul function resolveConfiguredEffortTargets(startupMode: EnhancedMode | undefined): readonly string[] { if (!startupMode) return []; - const desired = mapEnhancedModeToDesiredRuntimeConfig(startupMode); - if (desired.ultracode === true) return ['ultracode', 'xhigh']; - const effort = normalizeNonEmptyString(desired.reasoningEffort); + // This resolver only answers an effort-change dialog Claude has already rendered. The visible + // dialog is provider evidence that the target exists, so compare it with the configured startup + // intent directly. The stricter runtime-control mapper still owns whether Happier may proactively + // request an effort for a model whose capabilities are not evidenced. + if (startupMode.ultracode === true) return ['ultracode', 'xhigh']; + const effort = normalizeNonEmptyString(startupMode.reasoningEffort); return effort ? [effort] : []; } From 2484abc88f3915f916d0fa456c2add21ad117003 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 12 Aug 2026 02:12:48 +0200 Subject: [PATCH 07/11] test(claude): close exact-head review gaps --- .../runClaude.fastStart.integration.test.ts | 4 +- ...UnifiedResumeChoiceStartupResolver.test.ts | 48 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts b/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts index 0acecf254f..8b38408a0e 100644 --- a/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts +++ b/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts @@ -639,6 +639,8 @@ describe('runClaude fast-start', () => { initResolved = false; backendInitDelayMs = 0; beforeLoopCapture = overrideSynced.promise; + const previousGetOrCreateSessionImpl = getOrCreateSessionSpy.getMockImplementation(); + if (!previousGetOrCreateSessionImpl) throw new Error('expected the session creation test implementation'); getOrCreateSessionSpy.mockImplementation(async () => ({ id: 'sess_model_override', metadataVersion: 1 })); const persistedMetadata = { @@ -686,7 +688,7 @@ describe('runClaude fast-start', () => { autoSessionReady = true; awaitAutoSessionReadyCallback = false; beforeLoopCapture = null; - getOrCreateSessionSpy.mockImplementation(async () => ({ id: 'sess_1', metadataVersion: 1 })); + getOrCreateSessionSpy.mockImplementation(previousGetOrCreateSessionImpl); } if (testError) throw testError; diff --git a/apps/cli/src/backends/claude/unifiedTerminal/resumeChoice/claudeUnifiedResumeChoiceStartupResolver.test.ts b/apps/cli/src/backends/claude/unifiedTerminal/resumeChoice/claudeUnifiedResumeChoiceStartupResolver.test.ts index fc97dea4af..0feb9baa68 100644 --- a/apps/cli/src/backends/claude/unifiedTerminal/resumeChoice/claudeUnifiedResumeChoiceStartupResolver.test.ts +++ b/apps/cli/src/backends/claude/unifiedTerminal/resumeChoice/claudeUnifiedResumeChoiceStartupResolver.test.ts @@ -39,6 +39,24 @@ const EFFORT_DIALOG_MEDIUM = [ ' 2. No, go back', ].join('\n'); +const EFFORT_DIALOG_ULTRACODE = [ + 'Change effort level?', + 'This conversation is cached for the current effort level.', + 'Switching to ultracode means the full history gets re-read before Claude can continue.', + '', + '❯ 1. Yes, switch to ultracode', + ' 2. No, go back', +].join('\n'); + +const EFFORT_DIALOG_XHIGH = [ + 'Change effort level?', + 'This conversation is cached for the current effort level.', + 'Switching to xhigh means the full history gets re-read before Claude can continue.', + '', + '❯ 1. Yes, switch to xhigh', + ' 2. No, go back', +].join('\n'); + const SWITCH_MODEL_DIALOG = [ 'Switch model?', 'Reading from cache may produce different results.', @@ -389,6 +407,36 @@ describe('createClaudeUnifiedResumeChoiceStartupResolver', () => { expect(port.sentKeys).toEqual([]); }); + it.each([ + ['ultracode', EFFORT_DIALOG_ULTRACODE], + ['xhigh', EFFORT_DIALOG_XHIGH], + ] as const)( + 'accepts an orphan %s effort target when startup configured Ultracode', + async (_target, capture) => { + const { session } = createPermissionHandlerSessionStub('resume-choice-session'); + const broker = new ClaudeUnifiedDialogChoiceBroker(session); + const port = createFakeControlPort({ captures: [capture, IDLE] }); + const resolver = createClaudeUnifiedResumeChoiceStartupResolver({ + choice: 'ask_every_time', + broker, + port, + wait: async () => undefined, + settleMs: 1, + startupMode: { permissionMode: 'default', ultracode: true }, + isRuntimeControlInFlight: () => false, + }); + + await expect(resolver({ + screenState: parseClaudeScreenState(capture), + observedAtMs: 1, + abortSignal: new AbortController().signal, + })).resolves.toEqual({ status: 'handled' }); + + expect(port.sentLiteral).toEqual(['1']); + expect(port.sentKeys).toEqual([]); + }, + ); + it('leaves an effort-change dialog to the runtime-control apply episode while that driver owns it', async () => { const { session } = createPermissionHandlerSessionStub('resume-choice-session'); const broker = new ClaudeUnifiedDialogChoiceBroker(session); From 46419565ff870d8ed4aacf89138e77bc64b1a6ed Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 12 Aug 2026 02:18:57 +0200 Subject: [PATCH 08/11] test(claude): assert startup dialog control channel --- .../claudeUnifiedResumeChoiceStartupResolver.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cli/src/backends/claude/unifiedTerminal/resumeChoice/claudeUnifiedResumeChoiceStartupResolver.test.ts b/apps/cli/src/backends/claude/unifiedTerminal/resumeChoice/claudeUnifiedResumeChoiceStartupResolver.test.ts index 0feb9baa68..c60ae6f116 100644 --- a/apps/cli/src/backends/claude/unifiedTerminal/resumeChoice/claudeUnifiedResumeChoiceStartupResolver.test.ts +++ b/apps/cli/src/backends/claude/unifiedTerminal/resumeChoice/claudeUnifiedResumeChoiceStartupResolver.test.ts @@ -434,6 +434,7 @@ describe('createClaudeUnifiedResumeChoiceStartupResolver', () => { expect(port.sentLiteral).toEqual(['1']); expect(port.sentKeys).toEqual([]); + expect(port.sentRaw).toEqual([]); }, ); From c4ef502ae1fd89993d2fa0de1e4c2bb936fca800 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 12 Aug 2026 08:34:14 +0200 Subject: [PATCH 09/11] fix(claude-models): trust authenticated catalog capabilities --- .../models/fetchAnthropicModels.test.ts | 17 +- .../claude/models/fetchAnthropicModels.ts | 10 +- .../models/resolveClaudeModelCatalog.test.ts | 155 +++++++++++++- .../models/resolveClaudeModelCatalog.ts | 140 +++++++------ .../claudePreflightModelsProbeAdapter.test.ts | 80 +++++-- .../claudePreflightModelsProbeAdapter.ts | 18 +- .../runClaude.fastStart.integration.test.ts | 78 +++++++ .../runClaude.startupMetadataOrdering.test.ts | 60 ++++++ apps/cli/src/backends/claude/runClaude.ts | 38 ++-- .../sessionControls/probeClaudeHelpText.ts | 104 --------- ...ClaudeInstalledRuntimeCapabilities.test.ts | 92 ++++++++ ...probeClaudeInstalledRuntimeCapabilities.ts | 198 ++++++++++++++++++ ...udeSessionModelsMetadataBestEffort.test.ts | 16 +- ...shClaudeSessionModelsMetadataBestEffort.ts | 10 +- .../resolveClaudeSessionModelsState.test.ts | 10 +- .../resolveClaudeSessionModelsState.ts | 23 +- .../agentModelsProbe.staticOnly.test.ts | 16 ++ .../capabilities/probes/agentModelsProbe.ts | 4 + docs/agents-catalog.md | 4 + packages/agents/src/models.ts | 4 +- 20 files changed, 833 insertions(+), 244 deletions(-) delete mode 100644 apps/cli/src/backends/claude/sessionControls/probeClaudeHelpText.ts create mode 100644 apps/cli/src/backends/claude/sessionControls/probeClaudeInstalledRuntimeCapabilities.test.ts create mode 100644 apps/cli/src/backends/claude/sessionControls/probeClaudeInstalledRuntimeCapabilities.ts diff --git a/apps/cli/src/backends/claude/models/fetchAnthropicModels.test.ts b/apps/cli/src/backends/claude/models/fetchAnthropicModels.test.ts index 049f2c20b9..805e7cd661 100644 --- a/apps/cli/src/backends/claude/models/fetchAnthropicModels.test.ts +++ b/apps/cli/src/backends/claude/models/fetchAnthropicModels.test.ts @@ -24,10 +24,11 @@ describe('parseAnthropicModelsResponse', () => { ]); }); - it('drops entries without a string id and returns null for non-objects / empty data', () => { + it('drops entries without a string id, preserves valid empty success, and rejects malformed envelopes', () => { expect(parseAnthropicModelsResponse({ data: [{ display_name: 'no id' }, { id: 'ok' }] })) .toEqual([{ id: 'ok' }]); - expect(parseAnthropicModelsResponse({ data: [] })).toBeNull(); + expect(parseAnthropicModelsResponse({ data: [] })).toEqual([]); + expect(parseAnthropicModelsResponse({ data: [{ display_name: 'no id' }] })).toBeNull(); expect(parseAnthropicModelsResponse('nope')).toBeNull(); expect(parseAnthropicModelsResponse({})).toBeNull(); }); @@ -41,6 +42,18 @@ function okResponse(): Response { } describe('fetchAnthropicModels', () => { + it('returns an empty array for a successful response with no model rows', async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ data: [] }), { status: 200 })); + + const result = await fetchAnthropicModels({ + apiKey: 'sk-ant-key', + timeoutMs: 1_000, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + + expect(result).toEqual([]); + }); + it('returns null when neither api key nor access token is provided', async () => { const fetchImpl = vi.fn(); const result = await fetchAnthropicModels({ timeoutMs: 1_000, fetchImpl: fetchImpl as unknown as typeof fetch }); diff --git a/apps/cli/src/backends/claude/models/fetchAnthropicModels.ts b/apps/cli/src/backends/claude/models/fetchAnthropicModels.ts index 8ae78a208f..4257100717 100644 --- a/apps/cli/src/backends/claude/models/fetchAnthropicModels.ts +++ b/apps/cli/src/backends/claude/models/fetchAnthropicModels.ts @@ -91,7 +91,8 @@ function readCapabilities(value: unknown): AnthropicModelCapabilities | undefine * Parse a raw `GET /v1/models` JSON body into typed entries. * * Pure and defensive: unknown shapes yield `null`, malformed entries are dropped, - * and only entries with a non-empty string `id` survive. + * and only entries with a non-empty string `id` survive. A valid empty `data` array remains an + * empty successful result so callers can distinguish authoritative empty membership from failure. */ export function parseAnthropicModelsResponse(body: unknown): AnthropicModelEntry[] | null { const root = readObject(body); @@ -99,6 +100,7 @@ export function parseAnthropicModelsResponse(body: unknown): AnthropicModelEntry if (!Array.isArray(data)) return null; const entries: AnthropicModelEntry[] = []; + const wasExplicitlyEmpty = data.length === 0; for (const raw of data) { const entry = readObject(raw); const id = typeof entry?.id === 'string' ? entry.id.trim() : ''; @@ -117,7 +119,7 @@ export function parseAnthropicModelsResponse(body: unknown): AnthropicModelEntry ...(capabilities ? { capabilities } : {}), }); } - return entries.length > 0 ? entries : null; + return entries.length > 0 || wasExplicitlyEmpty ? entries : null; } export type FetchAnthropicModelsParams = Readonly<{ @@ -136,8 +138,8 @@ export type FetchAnthropicModelsParams = Readonly<{ /** * Fetch the caller's available Claude models from the Anthropic Models API. * - * Returns `null` on any failure (no credential, network error, non-200, unparseable - * body) so the probe pipeline falls back to the static catalog. Never throws. + * Returns `null` on any failure (no credential, network error, non-200, unparseable body). A + * successful response may return an empty array. Never throws. */ export async function fetchAnthropicModels( params: FetchAnthropicModelsParams, diff --git a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts index 230a4fa6a7..ad1d962099 100644 --- a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts +++ b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts @@ -65,30 +65,83 @@ afterEach(() => { }); describe('resolveClaudeModelCatalog', () => { - it('merges discovered models into the curated catalog', async () => { + it('uses a successful response as membership authority and enriches matching rows without overriding API facts', async () => { process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; fetchAnthropicModelsMock.mockResolvedValue([ - { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: fullEffort() }, + { + id: 'claude-sonnet-4-6', + displayName: 'Claude Sonnet 4.6', + maxInputTokens: 222_222, + capabilities: { + effort: { + supported: true, + low: { supported: true }, + medium: { supported: true }, + high: { supported: true }, + max: { supported: true }, + }, + }, + }, ]); const models = await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); - expect(models.some((m) => m.id === 'claude-opus-9')).toBe(true); - expect(models.some((m) => m.id === 'claude-fable-5')).toBe(true); + expect(models).toHaveLength(1); + expect(models[0]).toEqual(expect.objectContaining({ + id: 'claude-sonnet-4-6', + name: 'Sonnet 4.6', + description: expect.any(String), + contextWindowTokens: 222_222, + extendedContextModelId: 'claude-sonnet-4-6[1m]', + })); + expect(resolveClaudeEffortLevelsFromModelDescriptor(models[0])).toEqual(['low', 'medium', 'high', 'max']); }); - it('keeps the first discovered row when an alias and dated snapshot normalize to the same id', async () => { + it('retains every exact returned id when an alias and dated snapshot normalize to the same curated row', async () => { process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; fetchAnthropicModelsMock.mockResolvedValue([ - { id: 'claude-opus-9', displayName: 'Opus 9 Alias' }, + { id: 'claude-sonnet-4-6', displayName: 'Sonnet Alias' }, + { id: 'claude-sonnet-4-6-20260812', displayName: 'Sonnet Snapshot' }, + ]); + + const models = await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + + expect(models).toEqual([ + expect.objectContaining({ id: 'claude-sonnet-4-6', name: 'Sonnet 4.6' }), + expect.objectContaining({ + id: 'claude-sonnet-4-6-20260812', + name: 'Sonnet 4.6', + extendedContextModelId: 'claude-sonnet-4-6-20260812[1m]', + }), + ]); + }); + + it('deduplicates only repeated exact returned ids', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; + fetchAnthropicModelsMock.mockResolvedValue([ + { id: 'claude-opus-9', displayName: 'Opus 9 First' }, + { id: 'claude-opus-9', displayName: 'Opus 9 Duplicate' }, { id: 'claude-opus-9-20260812', displayName: 'Opus 9 Snapshot' }, ]); const models = await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); - const matching = models.filter((model) => model.id === 'claude-opus-9' || model.id === 'claude-opus-9-20260812'); - expect(matching).toEqual([ - expect.objectContaining({ id: 'claude-opus-9', name: 'Opus 9 Alias' }), + expect(models.map((model) => ({ id: model.id, name: model.name }))).toEqual([ + { id: 'claude-opus-9', name: 'Opus 9 First' }, + { id: 'claude-opus-9-20260812', name: 'Opus 9 Snapshot' }, + ]); + }); + + it('does not apply a curated-generation floor to account-returned ids', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; + fetchAnthropicModelsMock.mockResolvedValue([ + { id: 'claude-2.1-account-model', displayName: 'Account Legacy Model' }, + ]); + + const models = await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + + expect(models).toEqual([ + expect.objectContaining({ id: 'claude-2.1-account-model', name: 'Account Legacy Model' }), ]); }); @@ -104,6 +157,15 @@ describe('resolveClaudeModelCatalog', () => { expect(resolution.models.some((model) => model.id === 'claude-fable-5')).toBe(true); }); + it('treats a successful empty response as authoritative empty membership', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; + fetchAnthropicModelsMock.mockResolvedValue([]); + + const resolution = await resolveClaudeModelCatalogResolution({ timeoutMs: 1_000 }); + + expect(resolution).toEqual({ models: [], source: 'dynamic' }); + }); + it('serves a cached catalog and never substitutes ambient auth for a selected account', async () => { process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; fetchAnthropicModelsMock.mockResolvedValue([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); @@ -144,6 +206,21 @@ describe('resolveClaudeModelCatalog', () => { expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(2); }); + it('bounds retained snapshots across rotated credential identities', async () => { + fetchAnthropicModelsMock.mockResolvedValue([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); + + for (let index = 0; index < 33; index += 1) { + process.env.ANTHROPIC_API_KEY = `sk-ant-rotated-${index}`; + await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + } + expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(33); + + process.env.ANTHROPIC_API_KEY = 'sk-ant-rotated-0'; + await resolveClaudeModelCatalog({ timeoutMs: 1_000 }); + + expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(34); + }); + it('refetches when the on-disk credential is replaced in the same config dir', async () => { readClaudeCodeNativeCredentialMock.mockResolvedValue({ payload: { claudeAiOauth: { accessToken: 'sk-ant-oat01-account-one', scopes: [] } }, @@ -212,6 +289,66 @@ describe('resolveClaudeModelCatalog', () => { expect(models.some((m) => m.id === 'claude-fable-5')).toBe(true); expect(models.some((m) => m.id === 'claude-opus-9')).toBe(false); }); + + it('retains the last successful dynamic snapshot when a later refresh fails', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; + let currentTimeMs = 10; + fetchAnthropicModelsMock + .mockResolvedValueOnce([{ id: 'claude-opus-9', displayName: 'Opus 9' }]) + .mockResolvedValueOnce(null); + + const first = await resolveClaudeModelCatalogResolution({ + timeoutMs: 1_000, + nowMs: () => currentTimeMs, + }); + currentTimeMs += 24 * 60 * 60 * 1_000 + 1; + const stale = await resolveClaudeModelCatalogResolution({ + timeoutMs: 1_000, + nowMs: () => currentTimeMs, + }); + currentTimeMs += 1; + const staleDuringFailureCooldown = await resolveClaudeModelCatalogResolution({ + timeoutMs: 1_000, + nowMs: () => currentTimeMs, + }); + + expect(first).toEqual(expect.objectContaining({ source: 'dynamic' })); + expect(stale).toEqual(first); + expect(staleDuringFailureCooldown).toEqual(first); + expect(stale.models.map((model) => model.id)).toEqual(['claude-opus-9']); + expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(2); + }); + + it('does not let another account refresh discard an expired dynamic snapshot before its own refresh', async () => { + let currentTimeMs = 10; + fetchAnthropicModelsMock + .mockResolvedValueOnce([{ id: 'claude-account-a', displayName: 'Account A' }]) + .mockResolvedValueOnce([{ id: 'claude-account-b', displayName: 'Account B' }]) + .mockResolvedValueOnce(null); + + process.env.ANTHROPIC_API_KEY = 'sk-ant-account-a'; + const accountA = await resolveClaudeModelCatalogResolution({ + timeoutMs: 1_000, + nowMs: () => currentTimeMs, + }); + + currentTimeMs += 24 * 60 * 60 * 1_000 + 1; + process.env.ANTHROPIC_API_KEY = 'sk-ant-account-b'; + await resolveClaudeModelCatalogResolution({ + timeoutMs: 1_000, + nowMs: () => currentTimeMs, + }); + + process.env.ANTHROPIC_API_KEY = 'sk-ant-account-a'; + const staleAccountA = await resolveClaudeModelCatalogResolution({ + timeoutMs: 1_000, + nowMs: () => currentTimeMs, + }); + + expect(staleAccountA).toEqual(accountA); + expect(staleAccountA.models.map((model) => model.id)).toEqual(['claude-account-a']); + expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(3); + }); }); describe('resolveClaudeEffortLevelsFromModelDescriptor', () => { diff --git a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts index 8e03e95b56..839e09b497 100644 --- a/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts +++ b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts @@ -1,4 +1,8 @@ -import { AGENT_MODEL_CONFIG, type AgentModelDescriptor } from '@happier-dev/agents'; +import { + AGENT_MODEL_CONFIG, + providers, + type AgentModelDescriptor, +} from '@happier-dev/agents'; import type { ConnectedServiceBindingsV1 } from '@happier-dev/protocol'; import { buildDiscoveredClaudeModelDescriptor } from './deriveDiscoveredClaudeModel'; @@ -24,76 +28,50 @@ export { const CATALOG_SUCCESS_TTL_MS = 24 * 60 * 60 * 1_000; const CATALOG_FAILURE_TTL_MS = 60 * 1_000; +const CATALOG_MAX_ENTRIES = 32; function readNonBlankString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; } -/** Lowercase + strip a trailing dated snapshot suffix (`-YYYYMMDD`) for dedup comparison only. */ +/** Lowercase + strip a trailing dated suffix (`-YYYYMMDD`) for curated-row matching only. */ function normalizeDatedId(rawId: string): string { return rawId.trim().toLowerCase().replace(/-\d{8}$/u, ''); } -/** - * Major generation of a Claude model id, or `null` for ids that are not Claude models. - * - * Both Claude naming schemes put the major generation in the first numeric segment — - * `claude-3-5-sonnet` (legacy) and `claude-opus-4-8` (current) — so the first number wins. Ids from - * an Anthropic-compatible gateway (`glm-4.6`, `deepseek-reasoner`) are not Claude models and are - * never generation-filtered. - */ -function resolveClaudeModelGeneration(normalizedId: string): number | null { - if (!normalizedId.startsWith('claude')) return null; - const match = normalizedId.match(/(\d+)/u); - return match ? Number.parseInt(match[1]!, 10) : null; -} - function resolveStaticClaudeModels(): readonly AgentModelDescriptor[] { return AGENT_MODEL_CONFIG.claude.staticModels ?? []; } /** - * Oldest Claude generation Happier still curates. The Models API lists every model the account may - * call, including generations Claude Code can no longer run, so anything below the curated floor is - * dropped rather than offered as a selectable row. - */ -function resolveMinimumCuratedGeneration(): number | null { - const generations = resolveStaticClaudeModels() - .map((model) => resolveClaudeModelGeneration(normalizeDatedId(model.id))) - .filter((generation): generation is number => generation !== null); - return generations.length > 0 ? Math.min(...generations) : null; -} - -function isRunnableDiscoveredModel(normalizedId: string): boolean { - const generation = resolveClaudeModelGeneration(normalizedId); - if (generation === null) return true; - const floor = resolveMinimumCuratedGeneration(); - return floor === null || generation >= floor; -} - -/** - * Augment the curated static catalog with any Claude models the account can run that are NOT - * already curated. Static models win (full curation preserved); discovered models are appended - * with API-derived options. Dated snapshot ids collapse onto their static alias. + * A successful Models API response owns membership and API-provided capability/context facts. + * Curated rows only add presentation and Claude Code-specific metadata to returned ids that match + * either an alias or its dated snapshot form. */ -function mergeStaticWithDiscovered(entries: readonly AnthropicModelEntry[]): AgentModelDescriptor[] { - const staticModels = resolveStaticClaudeModels(); - const staticNormalizedIds = new Set(staticModels.map((model) => normalizeDatedId(model.id))); - const discoveredNormalizedIds = new Set(); - - const discovered = entries - .filter((entry) => { - const normalized = normalizeDatedId(entry.id); - if (normalized.length === 0 || normalized === 'default') return false; - if (staticNormalizedIds.has(normalized)) return false; - if (!isRunnableDiscoveredModel(normalized)) return false; - if (discoveredNormalizedIds.has(normalized)) return false; - discoveredNormalizedIds.add(normalized); - return true; - }) - .map((entry) => buildDiscoveredClaudeModelDescriptor(entry)); - - return [...staticModels, ...discovered]; +function buildAuthoritativeDynamicCatalog(entries: readonly AnthropicModelEntry[]): AgentModelDescriptor[] { + const staticByNormalizedId = new Map( + resolveStaticClaudeModels().map((model) => [normalizeDatedId(model.id), model] as const), + ); + + const seenExactIds = new Set(); + return entries.flatMap((entry) => { + const exactId = entry.id.trim(); + if (seenExactIds.has(exactId)) return []; + seenExactIds.add(exactId); + + const discovered = buildDiscoveredClaudeModelDescriptor(entry); + const curated = staticByNormalizedId.get(normalizeDatedId(entry.id)); + if (!curated) return [discovered]; + + return [{ + ...discovered, + name: curated.name, + ...(typeof curated.description === 'string' ? { description: curated.description } : {}), + ...(typeof curated.extendedContextModelId === 'string' + ? { extendedContextModelId: providers.claude.toClaude1mModelId(exactId) } + : {}), + }]; + }); } export type ClaudeModelCatalogResolution = Readonly<{ @@ -116,10 +94,34 @@ export function resetClaudeModelCatalogCacheForTests(): void { inFlightCatalogResolutions.clear(); } -/** Drop expired entries so a long-lived daemon does not retain one per rotated credential. */ -function pruneExpiredCatalogEntries(nowMs: number): void { +/** + * Drop expired static cold fallbacks. Dynamic snapshots remain eligible as last-good results for + * their own later refresh; the separate size bound removes least-recently-resolved identities so + * retaining them cannot grow without limit. + */ +function pruneCatalogEntries(nowMs: number, protectedKey: string): void { for (const [key, entry] of catalogCache) { - if (entry.expiresAtMs <= nowMs) catalogCache.delete(key); + if ( + entry.expiresAtMs > nowMs + || key === protectedKey + || entry.resolution.source === 'dynamic' + ) continue; + catalogCache.delete(key); + } +} + +/** Bound credential-rotation growth while never evicting the snapshot resolving this call. */ +function trimCatalogEntries(protectedKey: string): void { + while (catalogCache.size > CATALOG_MAX_ENTRIES) { + let oldestUnprotectedKey: string | null = null; + for (const key of catalogCache.keys()) { + if (key !== protectedKey) { + oldestUnprotectedKey = key; + break; + } + } + if (!oldestUnprotectedKey) return; + catalogCache.delete(oldestUnprotectedKey); } } @@ -133,8 +135,9 @@ export type ResolveClaudeModelCatalogParams = Readonly<{ }>; /** - * The models this account can run: curated catalog, augmented with anything the Anthropic Models - * API reports. Falls back to the curated catalog on any failure. Never throws. + * The models this account can run. A successful Models API response owns membership; static rows + * enrich matching returned ids. The curated catalog is used only until the first success, after + * which a failed refresh keeps the last successful dynamic snapshot. Never throws. */ export async function resolveClaudeModelCatalogResolution( params: ResolveClaudeModelCatalogParams, @@ -170,15 +173,20 @@ export async function resolveClaudeModelCatalogResolution( timeoutMs: params.timeoutMs, }); - const resolution: ClaudeModelCatalogResolution = entries - ? { models: mergeStaticWithDiscovered(entries), source: 'dynamic' } - : { models: resolveStaticClaudeModels(), source: 'static' }; + const resolution: ClaudeModelCatalogResolution = entries !== null + ? { models: buildAuthoritativeDynamicCatalog(entries), source: 'dynamic' } + : cached?.resolution.source === 'dynamic' + ? cached.resolution + : { models: resolveStaticClaudeModels(), source: 'static' }; const resolvedAtMs = nowMs(); - pruneExpiredCatalogEntries(resolvedAtMs); + pruneCatalogEntries(resolvedAtMs, cacheKey); + // Refresh insertion order so the bounded cache removes the least recently resolved identity. + catalogCache.delete(cacheKey); catalogCache.set(cacheKey, { resolution, - expiresAtMs: resolvedAtMs + (entries ? CATALOG_SUCCESS_TTL_MS : CATALOG_FAILURE_TTL_MS), + expiresAtMs: resolvedAtMs + (entries !== null ? CATALOG_SUCCESS_TTL_MS : CATALOG_FAILURE_TTL_MS), }); + trimCatalogEntries(cacheKey); return resolution; })(); diff --git a/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts b/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts index 38640736c0..551562cdff 100644 --- a/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts +++ b/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts @@ -10,11 +10,13 @@ const { createConnectedServiceCredentialApiMock, fetchAnthropicModelsMock, getConnectedServiceCredentialPlainMock, + probeClaudeInstalledRuntimeCapabilitiesMock, readClaudeCodeNativeCredentialMock, } = vi.hoisted(() => ({ createConnectedServiceCredentialApiMock: vi.fn(), fetchAnthropicModelsMock: vi.fn<(...args: unknown[]) => Promise>(), getConnectedServiceCredentialPlainMock: vi.fn(), + probeClaudeInstalledRuntimeCapabilitiesMock: vi.fn(), readClaudeCodeNativeCredentialMock: vi.fn<(...args: unknown[]) => Promise>(), })); @@ -33,6 +35,14 @@ vi.mock('@/backends/claude/connectedServices/nativeAuth/claudeCodeCredentialFile return { ...actual, readClaudeCodeNativeCredential: readClaudeCodeNativeCredentialMock }; }); +vi.mock('@/backends/claude/sessionControls/probeClaudeInstalledRuntimeCapabilities', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + probeClaudeInstalledRuntimeCapabilities: probeClaudeInstalledRuntimeCapabilitiesMock, + }; +}); + import { claudePreflightModelsProbeAdapter } from './claudePreflightModelsProbeAdapter'; import { resetClaudeModelCatalogCacheForTests } from '@/backends/claude/models/resolveClaudeModelCatalog'; @@ -144,6 +154,8 @@ beforeEach(() => { }); readClaudeCodeNativeCredentialMock.mockReset(); readClaudeCodeNativeCredentialMock.mockResolvedValue(null); + probeClaudeInstalledRuntimeCapabilitiesMock.mockReset(); + probeClaudeInstalledRuntimeCapabilitiesMock.mockResolvedValue({ supportsEffort: true, supportsUltracode: true }); envScope.restore(); envScope = createEnvKeyScope(envKeys); }); @@ -154,26 +166,36 @@ afterEach(() => { }); describe('claudePreflightModelsProbeAdapter', () => { - it('augments the static catalog with discovered models, preserving curation and collapsing dated dupes', async () => { + it('projects authoritative returned membership with API facts and curated matching-row enrichment', async () => { process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; fetchAnthropicModelsMock.mockResolvedValue([ { id: 'claude-opus-5', displayName: 'Claude Opus 5', maxInputTokens: 1_000_000, capabilities: fullEffort() }, - // Dated snapshot of a curated alias — must collapse onto static `claude-opus-4-5`. + // Dated snapshot of a curated alias — the exact account-returned id must remain selectable. { id: 'claude-opus-4-5-20251101', displayName: 'Claude Opus 4.5', capabilities: fullEffort() }, // Genuinely new model — must appear with derived options. { id: 'claude-opus-9', displayName: 'Opus 9', maxInputTokens: 1_000_000, capabilities: fullEffort() }, ]); const raw = await runProbe(); - if (!raw) throw new Error('expected augmented model list'); + if (!raw) throw new Error('expected authoritative model list'); expect(fetchAnthropicModelsMock).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'sk-ant-key' })); - // Curated static model keeps its hand-authored effort default. - const opus48 = raw.find((m) => m.id === 'claude-opus-4-8'); - const opus48Effort = (opus48?.modelOptions as Array> | undefined) + expect(raw.map((model) => model.id)).toEqual([ + 'claude-opus-5', + 'claude-opus-4-5-20251101', + 'claude-opus-9', + ]); + + // Matching curated rows keep curated presentation while API capabilities own effort facts. + const opus5 = raw.find((m) => m.id === 'claude-opus-5'); + const opus5Effort = (opus5?.modelOptions as Array> | undefined) ?.find((o) => o.id === 'reasoning_effort'); - expect(opus48Effort?.currentValue).toBe('high'); + expect(opus5?.name).toBe('Opus 5'); + expect(opus5?.description).toEqual(expect.any(String)); + expect(opus5Effort?.options).toEqual(expect.arrayContaining([ + expect.objectContaining({ value: 'max' }), + ])); // Discovered model appears with derived options + context window. const opus9 = raw.find((m) => m.id === 'claude-opus-9'); @@ -181,9 +203,12 @@ describe('claudePreflightModelsProbeAdapter', () => { expect(opus9?.contextWindowTokens).toBe(1_000_000); expect((opus9?.modelOptions as Array> | undefined)?.some((o) => o.id === 'reasoning_effort')).toBe(true); - // Dated dupe collapsed: the alias stays, the dated id is not added. - expect(raw.some((m) => m.id === 'claude-opus-4-5')).toBe(true); - expect(raw.some((m) => m.id === 'claude-opus-4-5-20251101')).toBe(false); + const dated = raw.find((m) => m.id === 'claude-opus-4-5-20251101'); + expect(dated).toEqual(expect.objectContaining({ + id: 'claude-opus-4-5-20251101', + name: 'Opus 4.5', + description: expect.any(String), + })); }); it('resolves the on-disk Claude credentials when no env token is set', async () => { @@ -210,6 +235,29 @@ describe('claudePreflightModelsProbeAdapter', () => { expect(fetchAnthropicModelsMock).not.toHaveBeenCalled(); }); + it('requires explicit installed-runtime ultracode recognition beyond generic --effort support', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; + probeClaudeInstalledRuntimeCapabilitiesMock.mockResolvedValue({ + supportsEffort: true, + supportsUltracode: false, + }); + fetchAnthropicModelsMock.mockResolvedValue([ + { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: fullEffort() }, + ]); + + const raw = await runProbe(); + if (!raw) throw new Error('expected model list'); + + for (const model of raw) { + const optionIds = (model.modelOptions as Array> | undefined) + ?.map((option) => option.id) ?? []; + expect(optionIds).not.toContain('ultracode'); + } + const opus9Options = raw.find((model) => model.id === 'claude-opus-9') + ?.modelOptions as Array> | undefined; + expect(opus9Options?.some((option) => option.id === 'reasoning_effort')).toBe(true); + }); + it('routes the request at the configured base url instead of the Anthropic host', async () => { process.env.ANTHROPIC_BASE_URL = 'https://api.z.ai/api/anthropic'; process.env.ANTHROPIC_AUTH_TOKEN = 'zai-gateway-token'; @@ -252,7 +300,7 @@ describe('claudePreflightModelsProbeAdapter', () => { expect(fetchAnthropicModelsMock).toHaveBeenCalledWith(expect.objectContaining({ accessToken: 'sk-ant-oat01-disk' })); }); - it('drops discovered models from generations the curated catalog no longer covers', async () => { + it('preserves account-returned models from generations outside the curated catalog', async () => { process.env.ANTHROPIC_API_KEY = 'sk-ant-key'; fetchAnthropicModelsMock.mockResolvedValue([ { id: 'claude-opus-9', displayName: 'Opus 9', capabilities: fullEffort() }, @@ -264,18 +312,16 @@ describe('claudePreflightModelsProbeAdapter', () => { ]); const raw = await runProbe(); - if (!raw) throw new Error('expected augmented model list'); + if (!raw) throw new Error('expected authoritative model list'); - expect(raw.some((m) => m.id === 'claude-opus-9')).toBe(true); - for (const legacyId of [ + expect(raw.map((model) => model.id)).toEqual([ + 'claude-opus-9', 'claude-3-5-sonnet-20241022', 'claude-3-5-sonnet-20240620', 'claude-3-haiku-20240307', 'claude-2.1', 'claude-instant-1.2', - ]) { - expect(raw.some((m) => m.id === legacyId)).toBe(false); - } + ]); }); it('reads the selected connected account credential instead of the daemon own config dir', async () => { diff --git a/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.ts b/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.ts index 0f7bbd1258..0d012024db 100644 --- a/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.ts +++ b/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.ts @@ -2,15 +2,24 @@ import type { AgentModelDescriptor } from '@happier-dev/agents'; import type { PreflightSessionControlsProbeAdapter } from '@/capabilities/probes/preflightSessionControlsProbeAdapterTypes'; import { resolveClaudeModelCatalogResolution } from '@/backends/claude/models/resolveClaudeModelCatalog'; +import { + isClaudeModelOptionSupportedByInstalledRuntime, + probeClaudeInstalledRuntimeCapabilities, +} from '@/backends/claude/sessionControls/probeClaudeInstalledRuntimeCapabilities'; -function toProbeRawModel(model: AgentModelDescriptor): Record { +function toProbeRawModel( + model: AgentModelDescriptor, + installedCapabilities: Awaited>, +): Record { + const modelOptions = model.modelOptions?.filter((option) => + isClaudeModelOptionSupportedByInstalledRuntime(option.id, installedCapabilities)); return { id: model.id, name: model.name, ...(typeof model.description === 'string' ? { description: model.description } : {}), ...(typeof model.contextWindowTokens === 'number' ? { contextWindowTokens: model.contextWindowTokens } : {}), ...(typeof model.extendedContextModelId === 'string' ? { extendedContextModelId: model.extendedContextModelId } : {}), - ...(Array.isArray(model.modelOptions) && model.modelOptions.length > 0 ? { modelOptions: model.modelOptions } : {}), + ...(modelOptions && modelOptions.length > 0 ? { modelOptions } : {}), }; } @@ -24,7 +33,7 @@ function toProbeRawModel(model: AgentModelDescriptor): Record { export const claudePreflightModelsProbeAdapter: PreflightSessionControlsProbeAdapter = { modelProbeCachePolicy: 'provider-owned', failureCacheStrategy: 'cooldown', - probeModelsRaw: async ({ timeoutMs, connectedServices, credentials, accountSettings, profileId }) => { + probeModelsRaw: async ({ cwd, timeoutMs, connectedServices, credentials, accountSettings, profileId }) => { const resolution = await resolveClaudeModelCatalogResolution({ timeoutMs, connectedServices, @@ -33,6 +42,7 @@ export const claudePreflightModelsProbeAdapter: PreflightSessionControlsProbeAda profileId, }); if (resolution.source === 'static') return null; - return resolution.models.map(toProbeRawModel); + const installedCapabilities = await probeClaudeInstalledRuntimeCapabilities({ cwd, timeoutMs }); + return resolution.models.map((model) => toProbeRawModel(model, installedCapabilities)); }, }; diff --git a/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts b/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts index 8b38408a0e..6fd198ffcc 100644 --- a/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts +++ b/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts @@ -18,6 +18,18 @@ const agentStateUpdateSnapshots = vi.hoisted(() => [] as Array<{ reason: string; state: any; }>); +const probeClaudeInstalledRuntimeCapabilitiesMock = vi.hoisted(() => vi.fn(async () => ({ + supportsEffort: true, + supportsUltracode: true, +}))); + +vi.mock('@/backends/claude/sessionControls/probeClaudeInstalledRuntimeCapabilities', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + probeClaudeInstalledRuntimeCapabilities: probeClaudeInstalledRuntimeCapabilitiesMock, + }; +}); vi.mock('@/backends/claude/localPermissions/localPermissionBridge', () => ({ DEFAULT_LOCAL_PERMISSION_HOOK_RESPONSE: { @@ -161,6 +173,7 @@ let lastRuntimeSessionClient: { rpcHandlerManager: { registerHandler: ReturnType; invokeLocal: ReturnType }; setSessionRuntimeControls: ReturnType; registerSessionRuntimeControls: ReturnType; + onUserMessage: ReturnType; keepAlive: ReturnType; sendSessionDeath: ReturnType; flush: ReturnType; @@ -606,6 +619,71 @@ describe('runClaude fast-start', () => { if (testError) throw testError; }); + it('removes unsupported installed effort and ultracode from fast-start message launch modes after one probe', async () => { + vi.resetModules(); + vi.doMock('@/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort', () => ({ + publishClaudeSessionModelsMetadataBestEffort: vi.fn(async () => {}), + })); + probeClaudeInstalledRuntimeCapabilitiesMock.mockReset(); + probeClaudeInstalledRuntimeCapabilitiesMock.mockResolvedValue({ + supportsEffort: false, + supportsUltracode: false, + }); + loopStarted = createDeferred(); + loopExit = createDeferred(); + lastLoopOpts = null; + lastRuntimeSessionClient = null; + autoSessionReady = true; + awaitAutoSessionReadyCallback = false; + initResolved = false; + backendInitDelayMs = 0; + getOrCreateSessionSpy.mockImplementation(async () => ({ id: 'sess_fast_effort_gate', metadataVersion: 1 })); + + const { runClaude } = await import('./runClaude'); + const runPromise = runClaude(createLegacyCredentials(), { + startedBy: 'terminal', + startingMode: 'local', + model: 'claude-fable-5', + }); + + try { + await waitFor(loopStarted.promise, loopStartWaitMs); + await waitFor(new Promise((resolve, reject) => { + const startedAt = Date.now(); + const tick = () => { + if (lastRuntimeSessionClient?.onUserMessage.mock.calls[0]?.[0]) return resolve(); + if (Date.now() - startedAt > 1_000) return reject(new Error('Timed out waiting for fast-start user handler')); + setTimeout(tick, 0); + }; + tick(); + }), 2_000); + + const launchModes: unknown[] = []; + lastLoopOpts.messageQueue.push = vi.fn((_text: string, mode: unknown) => launchModes.push(mode)); + const handler = lastRuntimeSessionClient?.onUserMessage.mock.calls[0]?.[0]; + await handler({ + content: { type: 'text', text: 'ship it' }, + localId: 'fast-effort-gated', + createdAt: 101, + meta: { model: 'claude-fable-5', reasoningEffort: 'xhigh', ultracode: true }, + }); + + expect(launchModes).toEqual([ + expect.not.objectContaining({ reasoningEffort: expect.anything(), ultracode: expect.anything() }), + ]); + expect(probeClaudeInstalledRuntimeCapabilitiesMock).toHaveBeenCalledTimes(1); + } finally { + loopExit.resolve(0); + await runPromise; + probeClaudeInstalledRuntimeCapabilitiesMock.mockReset(); + probeClaudeInstalledRuntimeCapabilitiesMock.mockResolvedValue({ + supportsEffort: true, + supportsUltracode: true, + }); + vi.doUnmock('@/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort'); + } + }); + it('uses a persisted model override for the initial fast-start mode and tier identity', async () => { vi.resetModules(); const overrideSynced = createDeferred(); diff --git a/apps/cli/src/backends/claude/runClaude.startupMetadataOrdering.test.ts b/apps/cli/src/backends/claude/runClaude.startupMetadataOrdering.test.ts index f1341ce636..473de2e640 100644 --- a/apps/cli/src/backends/claude/runClaude.startupMetadataOrdering.test.ts +++ b/apps/cli/src/backends/claude/runClaude.startupMetadataOrdering.test.ts @@ -91,6 +91,10 @@ const runStartupCoordinatorMock = vi.fn(() => { }); const claudeLocalMock = vi.fn(async () => undefined); let lastResolveRunnerMcpServersParams: any = null; +const probeClaudeInstalledRuntimeCapabilitiesMock = vi.fn(async () => ({ + supportsEffort: true, + supportsUltracode: true, +})); vi.mock('@/ui/logger', () => ({ logger: { @@ -231,6 +235,14 @@ vi.mock('@/backends/claude/sdk/metadataExtractor', () => ({ extractSDKMetadataAsync: vi.fn(), })); +vi.mock('@/backends/claude/sessionControls/probeClaudeInstalledRuntimeCapabilities', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + probeClaudeInstalledRuntimeCapabilities: probeClaudeInstalledRuntimeCapabilitiesMock, + }; +}); + vi.mock('@/agent/runtime/runnerTerminationOutcome', () => ({ computeRunnerTerminationOutcome: vi.fn(() => ({ exitCode: 0, archive: false, archiveReason: null })), })); @@ -284,6 +296,11 @@ describe('runClaude startup metadata ordering', () => { lastSessionClient = null; lastRuntimeOverridesSynchronizerParams = null; lastResolveRunnerMcpServersParams = null; + probeClaudeInstalledRuntimeCapabilitiesMock.mockReset(); + probeClaudeInstalledRuntimeCapabilitiesMock.mockResolvedValue({ + supportsEffort: true, + supportsUltracode: true, + }); agentStateUpdateSnapshots.length = 0; runtimeActivityPublisherCloseMock.mockClear(); sessionCloseMock.mockReset(); @@ -590,6 +607,49 @@ describe('runClaude startup metadata ordering', () => { await expect(killHandler?.()).resolves.toBeUndefined(); }); + it('removes unsupported installed effort and ultracode from ordinary message launch modes after one probe', async () => { + currentMetadataVersion = 1; + probeClaudeInstalledRuntimeCapabilitiesMock.mockResolvedValue({ + supportsEffort: false, + supportsUltracode: false, + }); + initializeRuntimeOverridesSynchronizerMock.mockImplementationOnce(async (params: RuntimeOverridesSynchronizerParams) => { + lastRuntimeOverridesSynchronizerParams = params; + return createRuntimeOverridesSynchronizer({ + seedFromSession: vi.fn(async () => {}), + syncFromMetadata: vi.fn(), + }); + }); + const { loop } = await import('@/backends/claude/loop'); + const launchModes: unknown[] = []; + vi.mocked(loop).mockImplementationOnce(async (params: any) => { + params.messageQueue.push = vi.fn((_text: string, mode: unknown) => launchModes.push(mode)); + const handler = lastSessionClient?.onUserMessage.mock.calls[0]?.[0]; + await handler?.({ + content: { type: 'text', text: 'ship it' }, + localId: 'effort-gated', + createdAt: 101, + meta: { model: 'claude-fable-5', reasoningEffort: 'xhigh', ultracode: true }, + }); + return 0; + }); + const { runClaude } = await import('./runClaude'); + + const runPromise = runClaude(testCredentials, { + startedBy: 'daemon', + startingMode: 'remote', + model: 'claude-fable-5', + }); + await waitFor(() => applyStartupMetadataUpdateToSessionMock.mock.calls.length === 1); + metadataUpdateDeferred.resolve(); + await runPromise; + + expect(launchModes).toEqual([ + expect.not.objectContaining({ reasoningEffort: expect.anything(), ultracode: expect.anything() }), + ]); + expect(probeClaudeInstalledRuntimeCapabilitiesMock).toHaveBeenCalledTimes(1); + }); + it('disposes runtime Activity when standard session transport close fails', async () => { currentMetadataVersion = 1; const closeError = new Error('session-close-failed'); diff --git a/apps/cli/src/backends/claude/runClaude.ts b/apps/cli/src/backends/claude/runClaude.ts index 9c851063ec..30014d2f0c 100644 --- a/apps/cli/src/backends/claude/runClaude.ts +++ b/apps/cli/src/backends/claude/runClaude.ts @@ -96,6 +96,10 @@ import { archiveAndCloseRuntimeSession } from '@/session/services/archiveAndClos import { createSessionMetadataShutdownDeadline } from '@/session/services/sessionMetadataShutdownDeadline'; import { resolveRequestedSessionDirectory } from '@/agent/runtime/resolveRequestedSessionDirectory'; import { publishClaudeSessionModelsMetadataBestEffort } from '@/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort'; +import { + probeClaudeInstalledRuntimeCapabilities, + resolveClaudeInstalledRuntimeSessionMode, +} from '@/backends/claude/sessionControls/probeClaudeInstalledRuntimeCapabilities'; import { createClaudeModelEffortLevelsTracker } from '@/backends/claude/models/claudeModelEffortLevelsTracker'; import { resolveTerminationArchiveDecision } from '@/agent/runtime/terminationArchivePolicy'; import { buildClaudeAgentState } from '@/backends/claude/localControl/buildClaudeAgentState'; @@ -671,6 +675,10 @@ export async function runClaude(credentials: Credentials, options: StartOptions if (Number.isFinite(parsed) && parsed > 0) return parsed; return process.env.CI ? 3_000 : 1_500; }; + const installedRuntimeCapabilities = await probeClaudeInstalledRuntimeCapabilities({ + cwd: workingDirectory, + timeoutMs: resolveClaudeHelpProbeTimeoutMs(), + }); let localPermissionBridgeEnabled = currentClaudeRemoteMetaState.claudeLocalPermissionBridgeEnabled === true; let localPermissionBridgeWaitIndefinitely = currentClaudeRemoteMetaState.claudeLocalPermissionBridgeWaitIndefinitely === true; let localPermissionBridgeTimeoutMs = localPermissionBridgeWaitIndefinitely @@ -1013,7 +1021,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions await modelEffortTracker.refreshWithin(currentModel); // Push with resolved permission mode, model, system prompts, and tools - const enhancedMode: EnhancedMode = { + const enhancedMode: EnhancedMode = resolveClaudeInstalledRuntimeSessionMode({ permissionMode: messagePermissionMode || 'default', agentModeId: currentAgentModeId, replaySeedAllowed: structuredRouting ? true : parseSpecialCommand(message.content.text).type === null, @@ -1022,12 +1030,12 @@ export async function runClaude(credentials: Credentials, options: StartOptions fallbackModel: messageFallbackModel, customSystemPrompt: messageCustomSystemPrompt, appendSystemPrompt: messageAppendSystemPrompt, - reasoningEffort: currentReasoningEffort, modelEffortLevels: modelEffortTracker.getLevels(), modelEffortLevelsModelId: modelEffortTracker.getModelId(), + reasoningEffort: currentReasoningEffort, ultracode: currentUltracode, ...currentClaudeRemoteMetaState, - }; + }, installedRuntimeCapabilities); const baseQueuedText = structuredRouting?.queuedText ?? message.content.text; const deliveryAttribution = { @@ -1226,19 +1234,19 @@ export async function runClaude(credentials: Credentials, options: StartOptions permissionModeUpdatedAt: options.permissionModeUpdatedAt, startingMode: options.startingMode, claudeUnifiedTerminalEnabled: unifiedTerminalRuntimeActive, - initialClaudeUnifiedTerminalMode: pinClaudeRemoteModeToActiveRuntime({ + initialClaudeUnifiedTerminalMode: pinClaudeRemoteModeToActiveRuntime(resolveClaudeInstalledRuntimeSessionMode({ permissionMode: options.permissionMode ?? 'default', agentModeId: currentAgentModeId, model: currentModel, fallbackModel: currentFallbackModel, customSystemPrompt: currentCustomSystemPrompt, appendSystemPrompt: currentAppendSystemPrompt, - reasoningEffort: currentReasoningEffort, modelEffortLevels: modelEffortTracker.getLevels(), modelEffortLevelsModelId: modelEffortTracker.getModelId(), + reasoningEffort: currentReasoningEffort, ultracode: currentUltracode, ...currentClaudeRemoteMetaState, - }, sessionRuntimeModeKind), + }, installedRuntimeCapabilities), sessionRuntimeModeKind), claudeCodeExperimentalAgentTeamsEnabled: currentClaudeRemoteMetaState.claudeCodeExperimentalAgentTeamsEnabled, startedBy: options.startedBy, messageQueue, @@ -1286,6 +1294,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions timeoutMs: resolveClaudeHelpProbeTimeoutMs(), currentModelId, session, + probeInstalledRuntimeCapabilities: async () => installedRuntimeCapabilities, }); } const readinessReport = reportSessionToDaemonIfRunning({ @@ -1506,6 +1515,10 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO if (Number.isFinite(parsed) && parsed > 0) return parsed; return process.env.CI ? 3_000 : 1_500; }; + const installedRuntimeCapabilities = await probeClaudeInstalledRuntimeCapabilities({ + cwd: workingDirectory, + timeoutMs: resolveClaudeHelpProbeTimeoutMs(), + }); let pushSender: PushNotificationClient | null = null; let currentClaudeRemoteMetaState = resolveInitialClaudeRemoteMetaState({ metaDefaults: options.claudeRemoteMetaDefaults }); const sessionRuntimeModeKind = normalizeClaudeRemoteMode(currentClaudeRemoteMetaState).kind; @@ -1901,7 +1914,7 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO }); // See the sibling path: bounded resolve before the mode is built, not after. await modelEffortTracker.refreshWithin(currentModel); - const enhancedMode: EnhancedMode = { + const enhancedMode: EnhancedMode = resolveClaudeInstalledRuntimeSessionMode({ permissionMode: messagePermissionMode || 'default', agentModeId: currentAgentModeId, replaySeedAllowed: structuredRouting ? true : parseSpecialCommand(message.content.text).type === null, @@ -1910,12 +1923,12 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO fallbackModel: messageFallbackModel, customSystemPrompt: messageCustomSystemPrompt, appendSystemPrompt: messageAppendSystemPrompt, - reasoningEffort: currentReasoningEffort, modelEffortLevels: modelEffortTracker.getLevels(), modelEffortLevelsModelId: modelEffortTracker.getModelId(), + reasoningEffort: currentReasoningEffort, ultracode: currentUltracode, ...currentClaudeRemoteMetaState, - }; + }, installedRuntimeCapabilities); const baseQueuedText = structuredRouting?.queuedText ?? message.content.text; const deliveryAttribution = { userMessageSeq: deliveryInfo?.seq ?? null, @@ -2039,19 +2052,19 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO }); seedInitialAppendSystemPrompt(defaultSystemPromptText); - const initialClaudeUnifiedTerminalMode = pinClaudeRemoteModeToActiveRuntime({ + const initialClaudeUnifiedTerminalMode = pinClaudeRemoteModeToActiveRuntime(resolveClaudeInstalledRuntimeSessionMode({ permissionMode: options.permissionMode ?? 'default', agentModeId: currentAgentModeId, model: currentModel, fallbackModel: currentFallbackModel, customSystemPrompt: currentCustomSystemPrompt, appendSystemPrompt: currentAppendSystemPrompt, - reasoningEffort: currentReasoningEffort, modelEffortLevels: modelEffortTracker.getLevels(), modelEffortLevelsModelId: modelEffortTracker.getModelId(), + reasoningEffort: currentReasoningEffort, ultracode: currentUltracode, ...currentClaudeRemoteMetaState, - }, sessionRuntimeModeKind); + }, installedRuntimeCapabilities), sessionRuntimeModeKind); const exitCode = await loop({ path: workingDirectory, @@ -2101,6 +2114,7 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO ensureMetadataSnapshot: (opts: Readonly<{ timeoutMs: number }>) => Promise; updateMetadata: (updater: (prev: Metadata) => Metadata) => Promise; }, + probeInstalledRuntimeCapabilities: async () => installedRuntimeCapabilities, }); } const readySessionId = artifacts.deferredSession.sessionId; diff --git a/apps/cli/src/backends/claude/sessionControls/probeClaudeHelpText.ts b/apps/cli/src/backends/claude/sessionControls/probeClaudeHelpText.ts deleted file mode 100644 index 441229ce58..0000000000 --- a/apps/cli/src/backends/claude/sessionControls/probeClaudeHelpText.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { spawn } from 'node:child_process'; - -import { resolveWindowsCommandInvocation } from '@happier-dev/cli-common/process'; - -import { requireJavaScriptRuntimeExecutable } from '@/runtime/js/requireJavaScriptRuntimeExecutable'; -import { requireProviderCliLaunchSpec } from '@/runtime/managedTools/requireProviderCliLaunchSpec'; -import { isBun } from '@/utils/runtime'; -import { isClaudeCliJavaScriptFile } from '@/backends/claude/utils/resolveClaudeCliPath'; - -export async function probeClaudeHelpText(params: Readonly<{ cwd: string; timeoutMs: number }>): Promise { - const timeoutMs = Math.max(250, params.timeoutMs); - - let command: string; - let args: string[]; - let env: NodeJS.ProcessEnv | undefined; - let windowsVerbatimArguments: boolean | undefined; - - try { - const launch = requireProviderCliLaunchSpec('claude'); - if (isClaudeCliJavaScriptFile(launch.resolvedPath)) { - const runtimeExecutable = await requireJavaScriptRuntimeExecutable({ - isBunRuntime: isBun(), - targetLabel: 'Claude Code help probe', - }); - const invocation = resolveWindowsCommandInvocation({ - command: runtimeExecutable, - args: [launch.resolvedPath, '--help'], - env: process.env, - }); - command = invocation.command; - args = [...invocation.args]; - env = process.env; - windowsVerbatimArguments = invocation.windowsVerbatimArguments ? true : undefined; - } else { - const invocation = resolveWindowsCommandInvocation({ - command: launch.command, - args: [...launch.args, '--help'], - env: process.env, - }); - command = invocation.command; - args = [...invocation.args]; - windowsVerbatimArguments = invocation.windowsVerbatimArguments ? true : undefined; - } - } catch (error) { - // Fail closed: if Claude CLI is unavailable/unresolvable, skip probe. - if (error instanceof Error && error.name === 'ReferenceError') { - return null; - } - return null; - } - - return await new Promise((resolve) => { - let stdout = ''; - let stderr = ''; - let settled = false; - - const finish = (result: string | null) => { - if (settled) return; - settled = true; - resolve(result); - }; - - const child = spawn(command, args, { - cwd: params.cwd, - env: { ...process.env, CI: '1', ...(env ?? {}) }, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - ...(windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), - }); - - const timer = setTimeout(() => { - try { - child.kill('SIGKILL'); - } catch { - // ignore - } - finish(null); - }, timeoutMs); - - child.on('error', () => { - clearTimeout(timer); - finish(null); - }); - - if (child.stdout) { - child.stdout.on('data', (chunk: Buffer) => { - stdout += chunk.toString('utf8'); - }); - } - if (child.stderr) { - child.stderr.on('data', (chunk: Buffer) => { - stderr += chunk.toString('utf8'); - }); - } - - child.on('close', (code) => { - clearTimeout(timer); - if (typeof code !== 'number' || code !== 0) return finish(null); - // Prefer stdout but fall back to stderr for CLIs that print help there. - const output = stdout.trim() ? stdout : stderr; - finish(output.trim() ? output.trim() : null); - }); - }); -} diff --git a/apps/cli/src/backends/claude/sessionControls/probeClaudeInstalledRuntimeCapabilities.test.ts b/apps/cli/src/backends/claude/sessionControls/probeClaudeInstalledRuntimeCapabilities.test.ts new file mode 100644 index 0000000000..b8d8418706 --- /dev/null +++ b/apps/cli/src/backends/claude/sessionControls/probeClaudeInstalledRuntimeCapabilities.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + probeClaudeInstalledRuntimeCapabilities, + resolveClaudeInstalledRuntimeSessionMode, + resolveClaudeInstalledRuntimeSessionOptions, +} from './probeClaudeInstalledRuntimeCapabilities'; + +describe('probeClaudeInstalledRuntimeCapabilities', () => { + it('requires generic effort support before probing ultracode', async () => { + const probe = vi.fn(async () => 'Claude Code help without the flag'); + + await expect(probeClaudeInstalledRuntimeCapabilities({ cwd: '/', timeoutMs: 250 }, probe)) + .resolves.toEqual({ supportsEffort: false, supportsUltracode: false }); + expect(probe).toHaveBeenCalledTimes(1); + }); + + it('recognizes ultracode only when the installed parser rejects a sentinel but accepts ultracode', async () => { + const probe = vi.fn(async ({ args }: Readonly<{ args: readonly string[] }>) => { + const effort = args[0] === '--effort' ? args[1] : null; + if (effort === 'happier-ultracode-probe-invalid') { + return "Warning: Unknown --effort value 'happier-ultracode-probe-invalid' — ignoring it. Valid values: low, medium, high, xhigh, max.\n--effort "; + } + return '--effort '; + }); + + await expect(probeClaudeInstalledRuntimeCapabilities({ cwd: '/', timeoutMs: 250 }, probe)) + .resolves.toEqual({ supportsEffort: true, supportsUltracode: true }); + }); + + it('does not infer ultracode from generic effort or a permissive/failed sentinel probe', async () => { + const probe = vi.fn(async ({ args }: Readonly<{ args: readonly string[] }>) => { + if (args[0] !== '--effort') return '--effort '; + if (args[1] === 'ultracode') { + return "Warning: Unknown --effort value 'ultracode' — ignoring it. Valid values: low, medium, high, xhigh, max."; + } + return null; + }); + + await expect(probeClaudeInstalledRuntimeCapabilities({ cwd: '/', timeoutMs: 250 }, probe)) + .resolves.toEqual({ supportsEffort: true, supportsUltracode: false }); + }); + + it('recognizes unknown-value warnings that use double quotes', async () => { + const probe = vi.fn(async ({ args }: Readonly<{ args: readonly string[] }>) => { + if (args[0] !== '--effort') return '--effort '; + if (args[1] === 'ultracode') return '--effort '; + return `Warning: Unknown --effort value "${args[1]}" — ignoring it. Valid values: low, medium, high, xhigh, max.`; + }); + + await expect(probeClaudeInstalledRuntimeCapabilities({ cwd: '/', timeoutMs: 250 }, probe)) + .resolves.toEqual({ supportsEffort: true, supportsUltracode: true }); + }); + + it('fails ultracode closed when an installed-parser probe rejects', async () => { + const probe = vi.fn(async ({ args }: Readonly<{ args: readonly string[] }>) => { + if (args[0] !== '--effort') return '--effort '; + throw new Error('probe failed'); + }); + + await expect(probeClaudeInstalledRuntimeCapabilities({ cwd: '/', timeoutMs: 250 }, probe)) + .resolves.toEqual({ supportsEffort: true, supportsUltracode: false }); + }); + + it('admits generic effort and ultracode independently for create/resume launch modes', () => { + const requested = { reasoningEffort: 'xhigh', ultracode: true }; + + expect(resolveClaudeInstalledRuntimeSessionOptions(requested, { + supportsEffort: true, + supportsUltracode: false, + })).toEqual({ reasoningEffort: 'xhigh' }); + expect(resolveClaudeInstalledRuntimeSessionOptions(requested, { + supportsEffort: false, + supportsUltracode: false, + })).toEqual({}); + expect(resolveClaudeInstalledRuntimeSessionOptions(requested, { + supportsEffort: true, + supportsUltracode: true, + })).toEqual(requested); + }); + + it('removes unsupported controls after remote metadata is merged into a launch mode', () => { + expect(resolveClaudeInstalledRuntimeSessionMode({ + permissionMode: 'default', + reasoningEffort: 'xhigh', + ultracode: true, + }, { + supportsEffort: false, + supportsUltracode: false, + })).toEqual({ permissionMode: 'default' }); + }); +}); diff --git a/apps/cli/src/backends/claude/sessionControls/probeClaudeInstalledRuntimeCapabilities.ts b/apps/cli/src/backends/claude/sessionControls/probeClaudeInstalledRuntimeCapabilities.ts new file mode 100644 index 0000000000..61231493a1 --- /dev/null +++ b/apps/cli/src/backends/claude/sessionControls/probeClaudeInstalledRuntimeCapabilities.ts @@ -0,0 +1,198 @@ +import { spawn } from 'node:child_process'; + +import { resolveWindowsCommandInvocation } from '@happier-dev/cli-common/process'; + +import { isClaudeCliJavaScriptFile } from '@/backends/claude/utils/resolveClaudeCliPath'; +import { requireJavaScriptRuntimeExecutable } from '@/runtime/js/requireJavaScriptRuntimeExecutable'; +import { requireProviderCliLaunchSpec } from '@/runtime/managedTools/requireProviderCliLaunchSpec'; +import { isBun } from '@/utils/runtime'; + +const ULTRACODE_PROBE_SENTINEL = 'happier-ultracode-probe-invalid'; +const MAX_PROBE_OUTPUT_BYTES = 256 * 1024; + +export type ClaudeInstalledRuntimeCapabilities = Readonly<{ + supportsEffort: boolean; + supportsUltracode: boolean; +}>; + +/** Installed-runtime half of Claude model-option admission; model capability is checked separately. */ +export function isClaudeModelOptionSupportedByInstalledRuntime( + optionId: string, + capabilities: ClaudeInstalledRuntimeCapabilities, +): boolean { + if (optionId === 'reasoning_effort') return capabilities.supportsEffort; + if (optionId === 'ultracode') return capabilities.supportsUltracode; + return true; +} + +export function resolveClaudeInstalledRuntimeSessionOptions( + requested: Readonly<{ reasoningEffort?: string; ultracode?: boolean }>, + capabilities: ClaudeInstalledRuntimeCapabilities, +): Readonly<{ reasoningEffort?: string; ultracode?: boolean }> { + return { + ...(capabilities.supportsEffort + ? { reasoningEffort: requested.reasoningEffort } + : {}), + ...(capabilities.supportsEffort && capabilities.supportsUltracode + ? { ultracode: requested.ultracode } + : {}), + }; +} + +/** Apply installed-runtime admission at the final launch-mode assembly boundary. */ +export function resolveClaudeInstalledRuntimeSessionMode< + T extends Readonly<{ reasoningEffort?: string; ultracode?: boolean }>, +>( + requested: T, + capabilities: ClaudeInstalledRuntimeCapabilities, +): Omit & Readonly<{ reasoningEffort?: string; ultracode?: boolean }> { + const { reasoningEffort, ultracode, ...mode } = requested; + return { + ...mode, + ...resolveClaudeInstalledRuntimeSessionOptions({ reasoningEffort, ultracode }, capabilities), + }; +} + +type ProbeClaudeCli = (params: Readonly<{ + args: readonly string[]; + cwd: string; + timeoutMs: number; +}>) => Promise; + +function reportsUnknownEffortValue(output: string, value: string): boolean { + const normalized = output.toLowerCase(); + return normalized.includes('unknown --effort value') && normalized.includes(value.toLowerCase()); +} + +async function probeClaudeCli(params: Readonly<{ + args: readonly string[]; + cwd: string; + timeoutMs: number; +}>): Promise { + const timeoutMs = Math.max(250, params.timeoutMs); + + let command: string; + let args: string[]; + let windowsVerbatimArguments: boolean | undefined; + + try { + const launch = requireProviderCliLaunchSpec('claude'); + const launchArgs = [...launch.args, ...params.args]; + if (isClaudeCliJavaScriptFile(launch.resolvedPath)) { + const runtimeExecutable = await requireJavaScriptRuntimeExecutable({ + isBunRuntime: isBun(), + targetLabel: 'Claude Code capability probe', + }); + const invocation = resolveWindowsCommandInvocation({ + command: runtimeExecutable, + args: [launch.resolvedPath, ...params.args], + env: process.env, + }); + command = invocation.command; + args = [...invocation.args]; + windowsVerbatimArguments = invocation.windowsVerbatimArguments ? true : undefined; + } else { + const invocation = resolveWindowsCommandInvocation({ + command: launch.command, + args: launchArgs, + env: process.env, + }); + command = invocation.command; + args = [...invocation.args]; + windowsVerbatimArguments = invocation.windowsVerbatimArguments ? true : undefined; + } + } catch { + return null; + } + + return await new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + let outputBytes = 0; + let settled = false; + + const finish = (result: string | null) => { + if (settled) return; + settled = true; + resolve(result); + }; + + const child = spawn(command, args, { + cwd: params.cwd, + env: { ...process.env, CI: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + ...(windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), + }); + + const stopForFailure = () => { + try { + child.kill('SIGKILL'); + } catch { + // Best-effort process cleanup after a bounded capability probe. + } + finish(null); + }; + + const timer = setTimeout(stopForFailure, timeoutMs); + + child.on('error', () => { + clearTimeout(timer); + finish(null); + }); + + const append = (target: 'stdout' | 'stderr', chunk: Buffer) => { + if (settled) return; + outputBytes += chunk.length; + if (outputBytes > MAX_PROBE_OUTPUT_BYTES) { + clearTimeout(timer); + stopForFailure(); + return; + } + if (target === 'stdout') stdout += chunk.toString('utf8'); + else stderr += chunk.toString('utf8'); + }; + child.stdout?.on('data', (chunk: Buffer) => append('stdout', chunk)); + child.stderr?.on('data', (chunk: Buffer) => append('stderr', chunk)); + + child.on('close', (code) => { + clearTimeout(timer); + if (typeof code !== 'number' || code !== 0) return finish(null); + const output = `${stderr}\n${stdout}`.trim(); + finish(output || null); + }); + }); +} + +/** + * Resolve the controls recognized by the installed Claude Code parser. + * + * `--help` proves generic effort support. Ultracode is deliberately checked as a candidate against + * an invalid sentinel: current Claude exits successfully for both, but warns only for values the + * parser does not recognize. This is installed-runtime evidence; model xhigh support remains a + * separate catalog prerequisite at the option/launch resolver. + */ +export async function probeClaudeInstalledRuntimeCapabilities( + params: Readonly<{ cwd: string; timeoutMs: number }>, + probe: ProbeClaudeCli = probeClaudeCli, +): Promise { + const runProbeFailClosed = (args: readonly string[]) => probe({ args, ...params }).catch(() => null); + const helpText = await runProbeFailClosed(['--help']); + const supportsEffort = typeof helpText === 'string' && /\B--effort\b/i.test(helpText); + if (!supportsEffort) return { supportsEffort: false, supportsUltracode: false }; + + const [ultracodeOutput, sentinelOutput] = await Promise.all([ + runProbeFailClosed(['--effort', 'ultracode', '--help']), + runProbeFailClosed(['--effort', ULTRACODE_PROBE_SENTINEL, '--help']), + ]); + const sentinelIsRejected = typeof sentinelOutput === 'string' + && reportsUnknownEffortValue(sentinelOutput, ULTRACODE_PROBE_SENTINEL) + && /valid values\s*:/i.test(sentinelOutput); + const ultracodeIsRejected = typeof ultracodeOutput !== 'string' + || reportsUnknownEffortValue(ultracodeOutput, 'ultracode'); + + return { + supportsEffort: true, + supportsUltracode: sentinelIsRejected && !ultracodeIsRejected, + }; +} diff --git a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts index c6579cbaa1..a2e0301ec8 100644 --- a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts +++ b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts @@ -26,7 +26,7 @@ describe('publishClaudeSessionModelsMetadataBestEffort', () => { timeoutMs: 250, currentModelId: 'claude-sonnet-4-6', nowMs: () => 999, - probeHelpText: async () => 'Claude Code help output without effort', + probeInstalledRuntimeCapabilities: async () => ({ supportsEffort: false, supportsUltracode: false }), session: { ensureMetadataSnapshot: async () => state.metadata, updateMetadata: async (updater) => { @@ -60,7 +60,7 @@ describe('publishClaudeSessionModelsMetadataBestEffort', () => { timeoutMs: 250, currentModelId: 'claude-sonnet-4-6', nowMs: () => 999, - probeHelpText: async () => ' --effort (low, medium, high, max)', + probeInstalledRuntimeCapabilities: async () => ({ supportsEffort: true, supportsUltracode: false }), session: { ensureMetadataSnapshot: async () => state.metadata, updateMetadata: async (updater) => { @@ -93,7 +93,7 @@ describe('publishClaudeSessionModelsMetadataBestEffort', () => { timeoutMs: 250, currentModelId: 'claude-haiku-4-5', nowMs: () => 999, - probeHelpText: async () => ' --effort (low, medium, high, max)', + probeInstalledRuntimeCapabilities: async () => ({ supportsEffort: true, supportsUltracode: false }), session: { ensureMetadataSnapshot: async () => state.metadata, updateMetadata: async (updater) => { @@ -130,7 +130,7 @@ describe('publishClaudeSessionModelsMetadataBestEffort', () => { timeoutMs: 250, currentModelId: 'claude-sonnet-4-6[1m]', nowMs: () => 999, - probeHelpText: async () => ' --effort (low, medium, high, max)', + probeInstalledRuntimeCapabilities: async () => ({ supportsEffort: true, supportsUltracode: false }), session: { ensureMetadataSnapshot: async () => state.metadata, updateMetadata: async (updater) => { @@ -155,7 +155,7 @@ describe('publishClaudeSessionModelsMetadataBestEffort', () => { timeoutMs: 250, currentModelId: 'claude-sonnet-4-6', nowMs: () => 999, - probeHelpText: async () => ' --effort (low, medium, high, max)', + probeInstalledRuntimeCapabilities: async () => ({ supportsEffort: true, supportsUltracode: false }), session: { ensureMetadataSnapshot: async () => state.metadata, updateMetadata: async (updater) => { @@ -184,7 +184,7 @@ describe('publishClaudeSessionModelsMetadataBestEffort', () => { timeoutMs: 250, currentModelId: ' ', nowMs: () => 999, - probeHelpText: async () => ' --effort (low, medium, high, max)', + probeInstalledRuntimeCapabilities: async () => ({ supportsEffort: true, supportsUltracode: false }), session: { ensureMetadataSnapshot: async () => state.metadata, updateMetadata: async (updater) => { @@ -203,7 +203,7 @@ describe('publishClaudeSessionModelsMetadataBestEffort', () => { timeoutMs: 250, currentModelId: 'claude-sonnet-4-6', nowMs: () => 999, - probeHelpText: async () => ' --effort (low, medium, high, max)', + probeInstalledRuntimeCapabilities: async () => ({ supportsEffort: true, supportsUltracode: false }), session: { ensureMetadataSnapshot: async () => ({} as Metadata), updateMetadata: async () => { @@ -233,7 +233,7 @@ describe('publishClaudeSessionModelsMetadataBestEffort', () => { timeoutMs: 250, currentModelId: 'claude-fable-5', nowMs: () => nowMs, - probeHelpText: async () => ' --effort (low, medium, high, max)', + probeInstalledRuntimeCapabilities: async () => ({ supportsEffort: true, supportsUltracode: true }), session: { ensureMetadataSnapshot: async () => state.metadata, updateMetadata: async (updater) => { diff --git a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts index 32f703d90e..9653e760ce 100644 --- a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts +++ b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts @@ -2,7 +2,7 @@ import type { Metadata } from '@/api/types'; import { logger } from '@/ui/logger'; import { reconcileClaudeSessionModelsState } from '../sessionModels/reconcileClaudeSessionModelsState'; -import { probeClaudeHelpText } from './probeClaudeHelpText'; +import type { ClaudeInstalledRuntimeCapabilities } from './probeClaudeInstalledRuntimeCapabilities'; import { resolveClaudeSessionModelsState } from './resolveClaudeSessionModelsState'; /** Options that only exist while the installed CLI can apply `--effort`. */ @@ -53,7 +53,9 @@ export async function publishClaudeSessionModelsMetadataBestEffort(params: Reado updateMetadata: (updater: (prev: Metadata) => Metadata) => Promise; }>; nowMs?: () => number; - probeHelpText?: (params: Readonly<{ cwd: string; timeoutMs: number }>) => Promise; + probeInstalledRuntimeCapabilities?: ( + params: Readonly<{ cwd: string; timeoutMs: number }>, + ) => Promise; }>): Promise { const currentModelId = String(params.currentModelId ?? '').trim(); if (!currentModelId) return; @@ -66,7 +68,9 @@ export async function publishClaudeSessionModelsMetadataBestEffort(params: Reado timeoutMs: params.timeoutMs, currentModelId, nowMs: params.nowMs ?? (() => Date.now()), - probeHelpText: params.probeHelpText ?? probeClaudeHelpText, + ...(params.probeInstalledRuntimeCapabilities + ? { probeInstalledRuntimeCapabilities: params.probeInstalledRuntimeCapabilities } + : {}), }).catch(() => null); if (!state) return; diff --git a/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.test.ts b/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.test.ts index 04b7ddf9e0..6d988425d3 100644 --- a/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.test.ts +++ b/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.test.ts @@ -9,7 +9,7 @@ describe('resolveClaudeSessionModelsState', () => { timeoutMs: 250, currentModelId: 'claude-sonnet-4-6', nowMs: () => 123, - probeHelpText: async () => 'Claude Code help output without effort', + probeInstalledRuntimeCapabilities: async () => ({ supportsEffort: false, supportsUltracode: false }), }); // Missing `--effort` disables the effort CONTROL, not the list itself; suppressing the list @@ -27,8 +27,7 @@ describe('resolveClaudeSessionModelsState', () => { timeoutMs: 250, currentModelId: 'claude-sonnet-4-6', nowMs: () => 456, - probeHelpText: async () => - ' --effort Effort level for the current session (low, medium, high, max)', + probeInstalledRuntimeCapabilities: async () => ({ supportsEffort: true, supportsUltracode: false }), }); expect(res).toEqual( @@ -118,5 +117,10 @@ describe('resolveClaudeSessionModelsState', () => { ]), }), ); + + const fableOptionIds = res?.availableModels + .find((model) => model.id === 'claude-fable-5') + ?.modelOptions?.map((option) => option.id) ?? []; + expect(fableOptionIds).not.toContain('ultracode'); }); }); diff --git a/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts b/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts index 07a58711f1..70f31e2f33 100644 --- a/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts +++ b/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts @@ -1,5 +1,10 @@ import type { Metadata } from '@/api/types'; import { resolveClaudeModelCatalog } from '@/backends/claude/models/resolveClaudeModelCatalog'; +import { + isClaudeModelOptionSupportedByInstalledRuntime, + probeClaudeInstalledRuntimeCapabilities, + type ClaudeInstalledRuntimeCapabilities, +} from './probeClaudeInstalledRuntimeCapabilities'; type ClaudeSessionModelsState = NonNullable; @@ -8,15 +13,13 @@ export async function resolveClaudeSessionModelsState(params: Readonly<{ timeoutMs: number; currentModelId: string; nowMs: () => number; - probeHelpText: (params: Readonly<{ cwd: string; timeoutMs: number }>) => Promise; + probeInstalledRuntimeCapabilities?: ( + params: Readonly<{ cwd: string; timeoutMs: number }>, + ) => Promise; }>): Promise { - const helpText = await params.probeHelpText({ cwd: params.cwd, timeoutMs: params.timeoutMs }); - if (!helpText) return null; - - // `--effort` support gates the effort CONTROL, not the model list. Returning null here would - // leave the new-session picker showing an account-specific list while the running session - // published none. - const supportsEffort = /\B--effort\b/i.test(helpText); + const installedCapabilities = await ( + params.probeInstalledRuntimeCapabilities ?? probeClaudeInstalledRuntimeCapabilities + )({ cwd: params.cwd, timeoutMs: params.timeoutMs }); const updatedAt = params.nowMs(); // Same owner as the new-session preflight probe, so the in-session picker cannot disagree about @@ -40,8 +43,8 @@ export async function resolveClaudeSessionModelsState(params: Readonly<{ ...(typeof model.extendedContextModelId === 'string' ? { extendedContextModelId: model.extendedContextModelId } : {}), ...(() => { const modelOptions = Array.isArray(model.modelOptions) - ? model.modelOptions.filter((option) => supportsEffort - || (option.id !== 'reasoning_effort' && option.id !== 'ultracode')) + ? model.modelOptions.filter((option) => + isClaudeModelOptionSupportedByInstalledRuntime(option.id, installedCapabilities)) : []; return modelOptions.length > 0 ? { modelOptions } : {}; })(), diff --git a/apps/cli/src/capabilities/probes/agentModelsProbe.staticOnly.test.ts b/apps/cli/src/capabilities/probes/agentModelsProbe.staticOnly.test.ts index 73f81dba31..f4ea3cc1bc 100644 --- a/apps/cli/src/capabilities/probes/agentModelsProbe.staticOnly.test.ts +++ b/apps/cli/src/capabilities/probes/agentModelsProbe.staticOnly.test.ts @@ -221,6 +221,22 @@ describe('probeAgentModelsBestEffort (static-only providers)', () => { .toMatchObject({ extendedContextModelId: 'claude-opus-9[1m]' }); }); + it('treats an empty provider-owned model array as authoritative instead of restoring static rows', async () => { + claudePreflightModelsProbeMock.mockResolvedValue([]); + + const res = await probeAgentModelsBestEffort({ + agentId: 'claude', + cwd: process.cwd(), + timeoutMs: 100, + }); + + expect(res).toMatchObject({ + provider: 'claude', + source: 'dynamic', + availableModels: [{ id: 'default', name: 'Default' }], + }); + }); + it('falls back only to the default Codex model when dynamic probing is unavailable', async () => { const res = await probeAgentModelsBestEffort({ agentId: 'codex', diff --git a/apps/cli/src/capabilities/probes/agentModelsProbe.ts b/apps/cli/src/capabilities/probes/agentModelsProbe.ts index 6c1be91106..fbcebae9fa 100644 --- a/apps/cli/src/capabilities/probes/agentModelsProbe.ts +++ b/apps/cli/src/capabilities/probes/agentModelsProbe.ts @@ -189,6 +189,10 @@ function normalizeProbeModel(modelRaw: unknown): ProbedAgentModel | null { function normalizeDynamicModels(modelsRaw: unknown): ProbedAgentModel[] | null { if (!Array.isArray(modelsRaw)) return null; + // `null` is the adapter's failure signal. An actual empty array is a successful observation + // with no provider-listed rows, so preserve that distinction and suppress stale static + // membership while retaining Happier's explicit provider-default choice. + if (modelsRaw.length === 0) return [{ id: 'default', name: 'Default' }]; const parsed = modelsRaw .map((model) => normalizeProbeModel(model)) .filter((model): model is ProbedAgentModel => model !== null); diff --git a/docs/agents-catalog.md b/docs/agents-catalog.md index 0327e605e5..5af3bec072 100644 --- a/docs/agents-catalog.md +++ b/docs/agents-catalog.md @@ -165,6 +165,10 @@ the in-session `sessionModelsV1` publisher both read it, so the two pickers cann which models exist or which effort tiers they report. Its provider-owned cache identity is the normalized endpoint, credential kind, and full SHA-256 credential hash. A warm cache entry avoids a network request; a cold session start may fetch the catalog before publishing the resolved models. +For Claude, a successful account response is authoritative for membership and API capability/context +facts; curated rows only enrich matching ids and serve as the fallback before the first success. A +later failed refresh retains the bounded last successful account snapshot, serves it during the +failure cooldown, and retries discovery after that cooldown without replacing it on repeat failure. Effort tiers are resolved once when the session mode is built and travel on the mode, so spawn-time resolution and launch-option hashing see the same value and hashing stays pure. diff --git a/packages/agents/src/models.ts b/packages/agents/src/models.ts index ff1aa066c3..af6b02a00d 100644 --- a/packages/agents/src/models.ts +++ b/packages/agents/src/models.ts @@ -230,8 +230,8 @@ export const AGENT_MODEL_CONFIG: Readonly> = O supportsSelection: true, supportsFreeform: true, nonAcpApplyScope: 'next_prompt', - // Augment the curated static catalog with any models the account can run (fetched from the - // Anthropic Models API in the Claude preflight adapter). Falls back to static on any failure. + // Successful account discovery owns membership and API capability/context facts. These static + // rows enrich matching ids and are the cold fallback until the account has a dynamic snapshot. dynamicProbe: 'auto', defaultMode: 'default', allowedModes: [ From 139c0eeecd5b668e5e964d75e2c1adeef1c9ab22 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 12 Aug 2026 08:52:17 +0200 Subject: [PATCH 10/11] fix(claude): retain startup effort evidence --- .../runClaude.fastStart.integration.test.ts | 6 ++ .../runClaude.startupMetadataOrdering.test.ts | 67 +++++++++++++++++++ apps/cli/src/backends/claude/runClaude.ts | 65 +++++++++++------- 3 files changed, 114 insertions(+), 24 deletions(-) diff --git a/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts b/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts index 6fd198ffcc..8ddcff996c 100644 --- a/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts +++ b/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts @@ -620,6 +620,7 @@ describe('runClaude fast-start', () => { }); it('removes unsupported installed effort and ultracode from fast-start message launch modes after one probe', async () => { + const previousGetOrCreateSessionImplementation = getOrCreateSessionSpy.getMockImplementation(); vi.resetModules(); vi.doMock('@/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort', () => ({ publishClaudeSessionModelsMetadataBestEffort: vi.fn(async () => {}), @@ -680,6 +681,11 @@ describe('runClaude fast-start', () => { supportsEffort: true, supportsUltracode: true, }); + if (previousGetOrCreateSessionImplementation) { + getOrCreateSessionSpy.mockImplementation(previousGetOrCreateSessionImplementation); + } else { + getOrCreateSessionSpy.mockReset(); + } vi.doUnmock('@/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort'); } }); diff --git a/apps/cli/src/backends/claude/runClaude.startupMetadataOrdering.test.ts b/apps/cli/src/backends/claude/runClaude.startupMetadataOrdering.test.ts index 473de2e640..a28ea75efa 100644 --- a/apps/cli/src/backends/claude/runClaude.startupMetadataOrdering.test.ts +++ b/apps/cli/src/backends/claude/runClaude.startupMetadataOrdering.test.ts @@ -650,6 +650,73 @@ describe('runClaude startup metadata ordering', () => { expect(probeClaudeInstalledRuntimeCapabilitiesMock).toHaveBeenCalledTimes(1); }); + it('retains discovered model effort evidence for the initial ordinary launch after readiness', async () => { + vi.doMock('@/backends/claude/models/resolveClaudeModelCatalog', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveClaudeModelCatalog: vi.fn(async () => [{ + id: 'claude-opus-9', + name: 'Opus 9', + modelOptions: [{ + id: 'reasoning_effort', + name: 'Thinking', + type: 'select', + currentValue: 'high', + options: [ + { value: 'low', name: 'Low' }, + { value: 'high', name: 'High' }, + { value: 'xhigh', name: 'XHigh' }, + ], + }], + }]), + }; + }); + vi.doMock('@/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort', () => ({ + publishClaudeSessionModelsMetadataBestEffort: vi.fn(async () => {}), + })); + initializeRuntimeOverridesSynchronizerMock.mockImplementationOnce(async (params: RuntimeOverridesSynchronizerParams) => { + lastRuntimeOverridesSynchronizerParams = params; + return createRuntimeOverridesSynchronizer({ + seedFromSession: vi.fn(async () => {}), + syncFromMetadata: vi.fn(), + }); + }); + const { loop } = await import('@/backends/claude/loop'); + const reportMock = vi.mocked(reportSessionToDaemonIfRunning); + reportMock.mockRejectedValueOnce(stopAfterStartupCoordinator); + let initialMode: any = null; + vi.mocked(loop).mockImplementationOnce(async (params: any) => { + initialMode = params.initialClaudeUnifiedTerminalMode; + await params.onSessionReady(params.session); + return 0; + }); + const { runClaude } = await import('./runClaude'); + + try { + const runPromise = runClaude(testCredentials, { + startedBy: 'daemon', + startingMode: 'remote', + model: 'claude-opus-9', + }).then( + () => 'resolved', + (error) => error, + ); + await waitFor(() => applyStartupMetadataUpdateToSessionMock.mock.calls.length === 1); + metadataUpdateDeferred.resolve(); + await expect(runPromise).resolves.toBe(stopAfterStartupCoordinator); + + expect(initialMode).toMatchObject({ + model: 'claude-opus-9', + modelEffortLevels: ['low', 'high', 'xhigh'], + modelEffortLevelsModelId: 'claude-opus-9', + }); + } finally { + vi.doUnmock('@/backends/claude/models/resolveClaudeModelCatalog'); + vi.doUnmock('@/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort'); + } + }); + it('disposes runtime Activity when standard session transport close fails', async () => { currentMetadataVersion = 1; const closeError = new Error('session-close-failed'); diff --git a/apps/cli/src/backends/claude/runClaude.ts b/apps/cli/src/backends/claude/runClaude.ts index 30014d2f0c..5cccc38cf9 100644 --- a/apps/cli/src/backends/claude/runClaude.ts +++ b/apps/cli/src/backends/claude/runClaude.ts @@ -100,7 +100,10 @@ import { probeClaudeInstalledRuntimeCapabilities, resolveClaudeInstalledRuntimeSessionMode, } from '@/backends/claude/sessionControls/probeClaudeInstalledRuntimeCapabilities'; -import { createClaudeModelEffortLevelsTracker } from '@/backends/claude/models/claudeModelEffortLevelsTracker'; +import { + createClaudeModelEffortLevelsTracker, + type ClaudeModelEffortLevelsTracker, +} from '@/backends/claude/models/claudeModelEffortLevelsTracker'; import { resolveTerminationArchiveDecision } from '@/agent/runtime/terminationArchivePolicy'; import { buildClaudeAgentState } from '@/backends/claude/localControl/buildClaudeAgentState'; import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; @@ -110,6 +113,19 @@ import { createClaudeProviderRuntimeActivityBindingOwner } from './providerActiv type ClaudePermissionLifecycleHookEventName = 'PermissionRequest' | 'PermissionRequestCompleted'; +async function refreshClaudeInitialModeModelEffortEvidence(params: Readonly<{ + initialMode: EnhancedMode; + modelEffortTracker: ClaudeModelEffortLevelsTracker; + modelId: unknown; +}>): Promise { + const currentModelId = typeof params.modelId === 'string' ? params.modelId.trim() : ''; + await params.modelEffortTracker.refresh(currentModelId); + params.initialMode.model = currentModelId || undefined; + params.initialMode.modelEffortLevels = params.modelEffortTracker.getLevels(); + params.initialMode.modelEffortLevelsModelId = params.modelEffortTracker.getModelId(); + return currentModelId; +} + function buildPermissionLifecycleSessionHook( data: PermissionHookData, hookEventName: ClaudePermissionLifecycleHookEventName, @@ -1222,6 +1238,19 @@ export async function runClaude(credentials: Credentials, options: StartOptions } })(); const resolvedMcpPort = parsePortFromUrl(resolvedMcp.happierMcpServer.url); + const initialClaudeUnifiedTerminalMode = pinClaudeRemoteModeToActiveRuntime(resolveClaudeInstalledRuntimeSessionMode({ + permissionMode: options.permissionMode ?? 'default', + agentModeId: currentAgentModeId, + model: currentModel, + fallbackModel: currentFallbackModel, + customSystemPrompt: currentCustomSystemPrompt, + appendSystemPrompt: currentAppendSystemPrompt, + modelEffortLevels: modelEffortTracker.getLevels(), + modelEffortLevelsModelId: modelEffortTracker.getModelId(), + reasoningEffort: currentReasoningEffort, + ultracode: currentUltracode, + ...currentClaudeRemoteMetaState, + }, installedRuntimeCapabilities), sessionRuntimeModeKind); let exitCode = 0; let loopError: unknown = null; try { @@ -1234,19 +1263,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions permissionModeUpdatedAt: options.permissionModeUpdatedAt, startingMode: options.startingMode, claudeUnifiedTerminalEnabled: unifiedTerminalRuntimeActive, - initialClaudeUnifiedTerminalMode: pinClaudeRemoteModeToActiveRuntime(resolveClaudeInstalledRuntimeSessionMode({ - permissionMode: options.permissionMode ?? 'default', - agentModeId: currentAgentModeId, - model: currentModel, - fallbackModel: currentFallbackModel, - customSystemPrompt: currentCustomSystemPrompt, - appendSystemPrompt: currentAppendSystemPrompt, - modelEffortLevels: modelEffortTracker.getLevels(), - modelEffortLevelsModelId: modelEffortTracker.getModelId(), - reasoningEffort: currentReasoningEffort, - ultracode: currentUltracode, - ...currentClaudeRemoteMetaState, - }, installedRuntimeCapabilities), sessionRuntimeModeKind), + initialClaudeUnifiedTerminalMode, claudeCodeExperimentalAgentTeamsEnabled: currentClaudeRemoteMetaState.claudeCodeExperimentalAgentTeamsEnabled, startedBy: options.startedBy, messageQueue, @@ -1282,11 +1299,11 @@ export async function runClaude(credentials: Credentials, options: StartOptions onSessionReady: async (sessionInstance) => { // Store reference for hook server callback currentSession = sessionInstance; - const currentModelId = - typeof options.modelId === 'string' - ? options.modelId.trim() - : (typeof options.model === 'string' ? options.model.trim() : ''); - await modelEffortTracker.refresh(currentModelId); + const currentModelId = await refreshClaudeInitialModeModelEffortEvidence({ + initialMode: initialClaudeUnifiedTerminalMode, + modelEffortTracker, + modelId: typeof options.modelId === 'string' ? options.modelId : options.model, + }); if (!didPublishSessionModelsMetadata) { didPublishSessionModelsMetadata = true; void publishClaudeSessionModelsMetadataBestEffort({ @@ -2099,11 +2116,11 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO }, onSessionReady: async (sessionInstance) => { currentSession = sessionInstance; - const currentModelId = typeof currentModel === 'string' ? currentModel.trim() : ''; - await modelEffortTracker.refresh(currentModelId); - initialClaudeUnifiedTerminalMode.model = currentModelId || undefined; - initialClaudeUnifiedTerminalMode.modelEffortLevels = modelEffortTracker.getLevels(); - initialClaudeUnifiedTerminalMode.modelEffortLevelsModelId = modelEffortTracker.getModelId(); + const currentModelId = await refreshClaudeInitialModeModelEffortEvidence({ + initialMode: initialClaudeUnifiedTerminalMode, + modelEffortTracker, + modelId: currentModel, + }); if (!didPublishSessionModelsMetadata) { didPublishSessionModelsMetadata = true; void publishClaudeSessionModelsMetadataBestEffort({ From fa239ff4d70ba278822d065a1021a372cb511fe1 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Wed, 12 Aug 2026 09:03:36 +0200 Subject: [PATCH 11/11] test(claude): type startup mode regression --- .../claude/runClaude.startupMetadataOrdering.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/backends/claude/runClaude.startupMetadataOrdering.test.ts b/apps/cli/src/backends/claude/runClaude.startupMetadataOrdering.test.ts index a28ea75efa..dcffa541e3 100644 --- a/apps/cli/src/backends/claude/runClaude.startupMetadataOrdering.test.ts +++ b/apps/cli/src/backends/claude/runClaude.startupMetadataOrdering.test.ts @@ -683,10 +683,11 @@ describe('runClaude startup metadata ordering', () => { }); }); const { loop } = await import('@/backends/claude/loop'); + type LoopParams = Parameters[0]; const reportMock = vi.mocked(reportSessionToDaemonIfRunning); reportMock.mockRejectedValueOnce(stopAfterStartupCoordinator); - let initialMode: any = null; - vi.mocked(loop).mockImplementationOnce(async (params: any) => { + let initialMode: LoopParams['initialClaudeUnifiedTerminalMode']; + vi.mocked(loop).mockImplementationOnce(async (params: LoopParams) => { initialMode = params.initialClaudeUnifiedTerminalMode; await params.onSessionReady(params.session); return 0;