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/claudeRemote.ts b/apps/cli/src/backends/claude/claudeRemote.ts index 15aad0ba41..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, @@ -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: 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 6c138957de..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,6 +196,7 @@ export function resolveClaudeTerminalCliOptions(params: Readonly<{ extraArgs.push(...buildClaudeEffortCliArgs({ modelId: effectiveModel, effort: argOverrides.effort ?? params.mode.reasoningEffort, + supportedLevels: resolveModeEffortLevelsForModel(params.mode, effectiveModel), })); if (effectiveModel) { extraArgs.push('--model', effectiveModel); @@ -233,6 +238,7 @@ export function resolveClaudeTerminalCliOptions(params: Readonly<{ ultracodeEnabled: resolveClaudeUltracodeForModel({ modelId: effectiveModel, ultracode: params.mode.ultracode, + supportedLevels: resolveModeEffortLevelsForModel(params.mode, effectiveModel), }), 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..035819083b 100644 --- a/apps/cli/src/backends/claude/index.ts +++ b/apps/cli/src/backends/claude/index.ts @@ -17,6 +17,7 @@ import { buildClaudeRuntimeLocalHandoffMetadata } from '@/backends/claude/sessio 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 +123,7 @@ export const agent = { .hasClaudeEndpointDescriptorForSession(params), vendorResumeSupport: AGENTS_CORE.claude.resume.vendorResume, buildRuntimeLocalHandoffMetadata: buildClaudeRuntimeLocalHandoffMetadata, + 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/loop.ts b/apps/cli/src/backends/claude/loop.ts index 983ace7d3d..4d48ef5979 100644 --- a/apps/cli/src/backends/claude/loop.ts +++ b/apps/cli/src/backends/claude/loop.ts @@ -54,6 +54,22 @@ 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[]; + /** + * 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..e1fb1a7f85 --- /dev/null +++ b/apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.test.ts @@ -0,0 +1,233 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createEnvKeyScope } from '@/testkit/env/envScope'; + +import type { AnthropicModelEntry } from './fetchAnthropicModels'; + +const { fetchAnthropicModelsMock, readClaudeCodeNativeCredentialMock } = vi.hoisted(() => ({ + fetchAnthropicModelsMock: vi.fn<(...args: unknown[]) => Promise>(), + readClaudeCodeNativeCredentialMock: vi.fn<(...args: unknown[]) => Promise>(), +})); + +vi.mock('./fetchAnthropicModels', 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); + }); + + 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 new file mode 100644 index 0000000000..01326449b9 --- /dev/null +++ b/apps/cli/src/backends/claude/models/claudeModelEffortLevelsTracker.ts @@ -0,0 +1,122 @@ +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; + let settledModelId: string | null = null; + let inFlight: { modelId: string; promise: Promise } | null = null; + + 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 = []; + settledModelId = null; + inFlight = null; + return Promise.resolve(); + } + + 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)) { + 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 => { + 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/deriveDiscoveredClaudeModel.test.ts b/apps/cli/src/backends/claude/models/deriveDiscoveredClaudeModel.test.ts new file mode 100644 index 0000000000..396fdd7daa --- /dev/null +++ b/apps/cli/src/backends/claude/models/deriveDiscoveredClaudeModel.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; + +import type { AnthropicModelEntry } from './fetchAnthropicModels'; +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/models/deriveDiscoveredClaudeModel.ts b/apps/cli/src/backends/claude/models/deriveDiscoveredClaudeModel.ts new file mode 100644 index 0000000000..598bf0dff7 --- /dev/null +++ b/apps/cli/src/backends/claude/models/deriveDiscoveredClaudeModel.ts @@ -0,0 +1,77 @@ +import { + buildClaudeUltracodeModelOption, + providers, + type AgentModelDescriptor, + type AgentModelOption, +} from '@happier-dev/agents'; + +import type { AnthropicModelEntry } from './fetchAnthropicModels'; + +const EFFORT_TIER_ORDER = ['low', 'medium', 'high', 'xhigh', 'max'] as const; +type EffortTier = (typeof EFFORT_TIER_ORDER)[number]; + +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) })), + }); + // Same option the curated catalog builds, from one owner. + if (tiers.includes('xhigh')) modelOptions.push(buildClaudeUltracodeModelOption()); + } + + 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/models/fetchAnthropicModels.test.ts b/apps/cli/src/backends/claude/models/fetchAnthropicModels.test.ts new file mode 100644 index 0000000000..805e7cd661 --- /dev/null +++ b/apps/cli/src/backends/claude/models/fetchAnthropicModels.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { fetchAnthropicModels, parseAnthropicModelsResponse } from './fetchAnthropicModels'; + +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, preserves valid empty success, and rejects malformed envelopes', () => { + expect(parseAnthropicModelsResponse({ data: [{ display_name: 'no id' }, { id: 'ok' }] })) + .toEqual([{ id: 'ok' }]); + expect(parseAnthropicModelsResponse({ data: [] })).toEqual([]); + expect(parseAnthropicModelsResponse({ data: [{ display_name: 'no id' }] })).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 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 }); + 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('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', + baseUrl: 'not a url', + timeoutMs: 1_000, + fetchImpl: fetchImpl as unknown as typeof fetch, + }); + 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 () => { + 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/models/fetchAnthropicModels.ts b/apps/cli/src/backends/claude/models/fetchAnthropicModels.ts new file mode 100644 index 0000000000..4257100717 --- /dev/null +++ b/apps/cli/src/backends/claude/models/fetchAnthropicModels.ts @@ -0,0 +1,186 @@ +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 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 | 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 null; + } +} + +/** + * 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; + + 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 } : {}), + ...(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 } : {}), + }, + }; +} + +/** + * 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. 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); + const data = root?.data; + 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() : ''; + 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; + const capabilities = readCapabilities(entry?.capabilities); + entries.push({ + id, + ...(displayName ? { displayName } : {}), + ...(maxInputTokens !== undefined ? { maxInputTokens } : {}), + ...(capabilities ? { capabilities } : {}), + }); + } + return entries.length > 0 || wasExplicitlyEmpty ? 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` 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; + 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). A + * successful response may return an empty array. 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 (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(modelsUrl, { + method: 'GET', + headers, + signal: controller.signal, + redirect: 'error', + }); + 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/models/resolveClaudeModelCatalog.test.ts b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts new file mode 100644 index 0000000000..ad1d962099 --- /dev/null +++ b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.test.ts @@ -0,0 +1,393 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createEnvKeyScope } from '@/testkit/env/envScope'; + +import type { AnthropicModelEntry } from './fetchAnthropicModels'; + +const { fetchAnthropicModelsMock, readClaudeCodeNativeCredentialMock } = vi.hoisted(() => ({ + fetchAnthropicModelsMock: vi.fn<(...args: unknown[]) => Promise>(), + readClaudeCodeNativeCredentialMock: vi.fn<(...args: unknown[]) => Promise>(), +})); + +vi.mock('./fetchAnthropicModels', 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 { buildClaudeEffortCliArgs } from '@/backends/claude/utils/claudeEffort'; +import { + resolveClaudeEffortLevelsFromModelDescriptor, + resolveClaudeModelCatalog, + resolveClaudeModelCatalogResolution, + 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(() => { + // 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('resolveClaudeModelCatalog', () => { + 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-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).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('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-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 }); + + 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' }), + ]); + }); + + 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('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' }]); + + 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', + }); + const boundResult = await resolveClaudeModelCatalog({ + timeoutMs: 1_000, + connectedServices: { + v: 1, + bindingsByServiceId: { + 'claude-subscription': { source: 'connected', selection: 'profile', profileId: 'profile-a' }, + }, + }, + }); + expect(fetchAnthropicModelsMock).toHaveBeenCalledTimes(1); + expect(boundResult.some((model) => model.id === 'claude-opus-9')).toBe(false); + }); + + 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('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: [] } }, + 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); + + 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); + }); + + 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', () => { + 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..839e09b497 --- /dev/null +++ b/apps/cli/src/backends/claude/models/resolveClaudeModelCatalog.ts @@ -0,0 +1,221 @@ +import { + AGENT_MODEL_CONFIG, + providers, + type AgentModelDescriptor, +} from '@happier-dev/agents'; +import type { ConnectedServiceBindingsV1 } from '@happier-dev/protocol'; + +import { buildDiscoveredClaudeModelDescriptor } from './deriveDiscoveredClaudeModel'; +import { fetchAnthropicModels, type AnthropicModelEntry } from './fetchAnthropicModels'; +import type { Credentials } from '@/persistence'; +import { + resolveClaudeModelProbeTarget, +} from './resolveClaudeModelProbeTarget'; + +export { + resolveClaudeProbeBinding, + type ClaudeProbeBinding, +} from './resolveClaudeModelProbeTarget'; + +/** + * 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; +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 suffix (`-YYYYMMDD`) for curated-row matching only. */ +function normalizeDatedId(rawId: string): string { + return rawId.trim().toLowerCase().replace(/-\d{8}$/u, ''); +} + +function resolveStaticClaudeModels(): readonly AgentModelDescriptor[] { + return AGENT_MODEL_CONFIG.claude.staticModels ?? []; +} + +/** + * 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 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<{ + models: readonly AgentModelDescriptor[]; + source: 'dynamic' | 'static'; +}>; + +type CatalogCacheEntry = Readonly<{ resolution: ClaudeModelCatalogResolution; 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 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 + || 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); + } +} + +export type ResolveClaudeModelCatalogParams = Readonly<{ + timeoutMs: number; + connectedServices?: ConnectedServiceBindingsV1 | null; + credentials?: Credentials | null; + accountSettings?: Readonly> | null; + profileId?: string | null; + nowMs?: () => number; +}>; + +/** + * 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, +): 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 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 { models: resolveStaticClaudeModels(), source: 'static' }; + + const cacheKey = target.cacheIdentity; + const cached = catalogCache.get(cacheKey); + 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({ + ...(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 resolution: ClaudeModelCatalogResolution = entries !== null + ? { models: buildAuthoritativeDynamicCatalog(entries), source: 'dynamic' } + : cached?.resolution.source === 'dynamic' + ? cached.resolution + : { models: resolveStaticClaudeModels(), source: 'static' }; + const resolvedAtMs = nowMs(); + 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 !== null ? CATALOG_SUCCESS_TTL_MS : CATALOG_FAILURE_TTL_MS), + }); + trimCatalogEntries(cacheKey); + return resolution; + })(); + + inFlightCatalogResolutions.set(cacheKey, resolution); + try { + return await resolution; + } finally { + inFlightCatalogResolutions.delete(cacheKey); + } +} + +export async function resolveClaudeModelCatalog( + params: ResolveClaudeModelCatalogParams, +): Promise { + return (await resolveClaudeModelCatalogResolution(params)).models; +} + +/** + * 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/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 be2b6c6ec6..551562cdff 100644 --- a/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts +++ b/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.test.ts @@ -1,192 +1,426 @@ -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 type { ConnectedServiceCredentialRecordV1 } from '@happier-dev/protocol'; import { createEnvKeyScope } from '@/testkit/env/envScope'; -import { writeExecutableShimSync } from '@/testkit/fs/executableShim'; +import type { Credentials } from '@/persistence'; + +import type { AnthropicModelEntry } from '@/backends/claude/models/fetchAnthropicModels'; + +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>(), +})); + +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 }; +}); + +vi.mock('@/backends/claude/connectedServices/nativeAuth/claudeCodeCredentialFile', async (importOriginal) => { + const actual = await importOriginal(); + 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'; -function makeTempDir(prefix: string): string { - return mkdtempSync(join(tmpdir(), prefix)); +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 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, + }); } -const envKeys = ['HAPPIER_CLAUDE_PATH', 'PATH'] as const; -let envScope = createEnvKeyScope(envKeys); +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, + }; +} -afterEach(() => { +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 { + effort: { + supported: true, + low: { supported: true }, + medium: { supported: true }, + high: { supported: true }, + xhigh: { supported: true }, + max: { supported: true }, + }, + }; +} + +async function runProbe() { + return claudePreflightModelsProbeAdapter.probeModelsRaw?.({ + cwd: '/tmp', + timeoutMs: 1_500, + backendTarget: undefined, + accountSettings: null, + }) as Promise> | null>; +} + +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); + probeClaudeInstalledRuntimeCapabilitiesMock.mockReset(); + probeClaudeInstalledRuntimeCapabilitiesMock.mockResolvedValue({ supportsEffort: true, supportsUltracode: true }); 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('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 — 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 authoritative model list'); + + expect(fetchAnthropicModelsMock).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'sk-ant-key' })); + + 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(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'); + 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); + + 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 () => { + 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); - afterEach(() => { - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }); - tempDir = null; + const raw = await runProbe(); + + expect(raw).toBeNull(); + 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('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, - timeoutMs: 1_500, - backendTarget: undefined, - accountSettings: null, + 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', }); - 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' }), - ]), - })]), - }), - ])); + const raw = await runProbe(); - // 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' }), - ]), - })]), - }), - ])); + expect(raw).toBeNull(); + expect(fetchAnthropicModelsMock).not.toHaveBeenCalled(); + }); - // 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' }), - ]), - })]), - }), - ])); + 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' }]); - // 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' }), - ]), - })]), - }), - ])); + const raw = await runProbe(); - // 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' }), - ]), - })]), - }), - ])); + expect(raw).not.toBeNull(); + expect(fetchAnthropicModelsMock).toHaveBeenCalledWith(expect.objectContaining({ accessToken: 'sk-ant-oat01-disk' })); + }); - // Haiku does not support effort. - expect(raw).toEqual(expect.arrayContaining([ - expect.objectContaining({ - id: 'claude-haiku-4-5', - modelOptions: undefined, - }), - ])); + 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() }, + { 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 authoritative model list'); + + 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', + ]); }); - 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('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' }]); - process.env.PATH = '/usr/bin:/bin'; - process.env.HAPPIER_CLAUDE_PATH = fakeClaude; + await claudePreflightModelsProbeAdapter.probeModelsRaw?.({ + cwd: '/tmp', + timeoutMs: 1_500, + backendTarget: undefined, + accountSettings: null, + credentials: probeCredentials, + 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-profile', + })); + expect(readClaudeCodeNativeCredentialMock).not.toHaveBeenCalled(); + }); + + 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'; + 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?.({ + cwd: '/tmp', timeoutMs: 1_500, backendTarget: undefined, accountSettings: null, + credentials: probeCredentials, + connectedServices: { + v: 1, + bindingsByServiceId: { + 'claude-subscription': { source: 'connected', selection: 'profile', profileId: 'profile-a' }, + }, + }, }); + 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'; + setConnectedCredentialRecord(buildConnectedTokenRecord({ + serviceId: 'anthropic', + profileId: 'profile-a', + token: 'sk-ant-bound-key', + })); + fetchAnthropicModelsMock.mockResolvedValue([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); + + await claudePreflightModelsProbeAdapter.probeModelsRaw?.({ + cwd: '/tmp', + timeoutMs: 1_500, + backendTarget: undefined, + accountSettings: null, + credentials: probeCredentials, + 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..0d012024db 100644 --- a/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.ts +++ b/apps/cli/src/backends/claude/preflight/claudePreflightModelsProbeAdapter.ts @@ -1,25 +1,48 @@ -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 { resolveClaudeModelCatalogResolution } from '@/backends/claude/models/resolveClaudeModelCatalog'; +import { + isClaudeModelOptionSupportedByInstalledRuntime, + probeClaudeInstalledRuntimeCapabilities, +} from '@/backends/claude/sessionControls/probeClaudeInstalledRuntimeCapabilities'; -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, + 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 } : {}), + ...(modelOptions && modelOptions.length > 0 ? { 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 = { + modelProbeCachePolicy: 'provider-owned', + failureCacheStrategy: 'cooldown', + probeModelsRaw: async ({ cwd, timeoutMs, connectedServices, credentials, accountSettings, profileId }) => { + const resolution = await resolveClaudeModelCatalogResolution({ + timeoutMs, + connectedServices, + credentials, + accountSettings, + profileId, + }); + if (resolution.source === 'static') return null; + const installedCapabilities = await probeClaudeInstalledRuntimeCapabilities({ cwd, timeoutMs }); + return resolution.models.map((model) => toProbeRawModel(model, installedCapabilities)); }, }; 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/remote/claudeRemoteAgentSdk.ts b/apps/cli/src/backends/claude/remote/claudeRemoteAgentSdk.ts index 6bf18dcd52..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, @@ -693,16 +694,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 = resolveModeEffortLevelsForModel(mode, effortModelId); 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..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,13 +63,16 @@ function buildClaudeUnifiedTerminalLaunchOptionsHashInput(mode: EnhancedMode): R permissionMode: mode.permissionMode, agentModeId: effectiveAgentModeId, }); + const supportedLevels = resolveModeEffortLevelsForModel(mode, mode.model); 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 +112,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 = resolveModeEffortLevelsForModel(mode, mode.model); 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.fastStart.integration.test.ts b/apps/cli/src/backends/claude/runClaude.fastStart.integration.test.ts index f4c122ef5c..8ddcff996c 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 }; @@ -17,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: { @@ -84,6 +97,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; @@ -125,9 +139,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(), @@ -157,6 +173,7 @@ let lastRuntimeSessionClient: { rpcHandlerManager: { registerHandler: ReturnType; invokeLocal: ReturnType }; setSessionRuntimeControls: ReturnType; registerSessionRuntimeControls: ReturnType; + onUserMessage: ReturnType; keepAlive: ReturnType; sendSessionDeath: ReturnType; flush: ReturnType; @@ -532,6 +549,235 @@ describe('runClaude fast-start', () => { } }); + it('does not complete true fast-start 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; + }), + }; + }); + vi.doMock('@/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort', () => ({ + publishClaudeSessionModelsMetadataBestEffort: vi.fn(async () => {}), + })); + + loopEntered = createDeferred(); + loopStarted = createDeferred(); + loopExit = createDeferred(); + lastLoopOpts = null; + autoSessionReady = true; + awaitAutoSessionReadyCallback = true; + initResolved = false; + backendInitDelayMs = 200; + 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: 'local', + 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(initResolved).toBe(false); + + catalogResult.resolve([{ id: 'claude-opus-9', displayName: 'Opus 9' }]); + await waitFor(loopStarted.promise, loopStartWaitMs); + if (testError) throw testError; + 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('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 () => {}), + })); + 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, + }); + if (previousGetOrCreateSessionImplementation) { + getOrCreateSessionSpy.mockImplementation(previousGetOrCreateSessionImplementation); + } else { + getOrCreateSessionSpy.mockReset(); + } + 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(); + 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; + 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 = { + 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(previousGetOrCreateSessionImpl); + } + + 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.startupMetadataOrdering.test.ts b/apps/cli/src/backends/claude/runClaude.startupMetadataOrdering.test.ts index f1341ce636..dcffa541e3 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,117 @@ 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('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'); + type LoopParams = Parameters[0]; + const reportMock = vi.mocked(reportSessionToDaemonIfRunning); + reportMock.mockRejectedValueOnce(stopAfterStartupCoordinator); + let initialMode: LoopParams['initialClaudeUnifiedTerminalMode']; + vi.mocked(loop).mockImplementationOnce(async (params: LoopParams) => { + 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 9723412b93..5cccc38cf9 100644 --- a/apps/cli/src/backends/claude/runClaude.ts +++ b/apps/cli/src/backends/claude/runClaude.ts @@ -96,6 +96,14 @@ 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, + 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'; @@ -105,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, @@ -670,6 +691,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 @@ -825,6 +850,12 @@ 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. + const modelEffortTracker = createClaudeModelEffortLevelsTracker({ + resolveTimeoutMs: () => resolveClaudeHelpProbeTimeoutMs(), + }); let currentUltracode: boolean | undefined = undefined; let currentUltracodeUpdatedAt = 0; let currentFallbackModel: string | undefined = undefined; // Track current fallback model @@ -833,7 +864,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, @@ -999,8 +1030,14 @@ 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 = { + const enhancedMode: EnhancedMode = resolveClaudeInstalledRuntimeSessionMode({ permissionMode: messagePermissionMode || 'default', agentModeId: currentAgentModeId, replaySeedAllowed: structuredRouting ? true : parseSpecialCommand(message.content.text).type === null, @@ -1009,10 +1046,12 @@ export async function runClaude(credentials: Credentials, options: StartOptions fallbackModel: messageFallbackModel, customSystemPrompt: messageCustomSystemPrompt, appendSystemPrompt: messageAppendSystemPrompt, + modelEffortLevels: modelEffortTracker.getLevels(), + modelEffortLevelsModelId: modelEffortTracker.getModelId(), reasoningEffort: currentReasoningEffort, ultracode: currentUltracode, ...currentClaudeRemoteMetaState, - }; + }, installedRuntimeCapabilities); const baseQueuedText = structuredRouting?.queuedText ?? message.content.text; const deliveryAttribution = { @@ -1199,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 { @@ -1211,17 +1263,7 @@ export async function runClaude(credentials: Credentials, options: StartOptions 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, - ultracode: currentUltracode, - ...currentClaudeRemoteMetaState, - }, sessionRuntimeModeKind), + initialClaudeUnifiedTerminalMode, claudeCodeExperimentalAgentTeamsEnabled: currentClaudeRemoteMetaState.claudeCodeExperimentalAgentTeamsEnabled, startedBy: options.startedBy, messageQueue, @@ -1257,6 +1299,21 @@ export async function runClaude(credentials: Credentials, options: StartOptions onSessionReady: async (sessionInstance) => { // Store reference for hook server callback currentSession = sessionInstance; + const currentModelId = await refreshClaudeInitialModeModelEffortEvidence({ + initialMode: initialClaudeUnifiedTerminalMode, + modelEffortTracker, + modelId: typeof options.modelId === 'string' ? options.modelId : options.model, + }); + if (!didPublishSessionModelsMetadata) { + didPublishSessionModelsMetadata = true; + void publishClaudeSessionModelsMetadataBestEffort({ + cwd: workingDirectory, + timeoutMs: resolveClaudeHelpProbeTimeoutMs(), + currentModelId, + session, + probeInstalledRuntimeCapabilities: async () => installedRuntimeCapabilities, + }); + } const readinessReport = reportSessionToDaemonIfRunning({ sessionId: baseSession.id, metadata, @@ -1269,19 +1326,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 publishClaudeSessionModelsMetadataBestEffort({ - cwd: workingDirectory, - timeoutMs: resolveClaudeHelpProbeTimeoutMs(), - currentModelId, - session, - }); - } if (!localPermissionBridge) { localPermissionBridge = new ClaudeLocalPermissionBridge(sessionInstance, { responseTimeoutMs: localPermissionBridgeTimeoutMs }); localPermissionBridge.activate(); @@ -1464,6 +1508,10 @@ 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. + const modelEffortTracker = createClaudeModelEffortLevelsTracker({ + resolveTimeoutMs: () => resolveClaudeHelpProbeTimeoutMs(), + }); let currentUltracode: boolean | undefined = undefined; let currentUltracodeUpdatedAt = 0; let currentFallbackModel: string | undefined = undefined; @@ -1484,6 +1532,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; @@ -1671,6 +1723,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; }, }); @@ -1735,7 +1789,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, @@ -1875,7 +1929,9 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO text: message.content.text, meta: message.meta, }); - const enhancedMode: EnhancedMode = { + // See the sibling path: bounded resolve before the mode is built, not after. + await modelEffortTracker.refreshWithin(currentModel); + const enhancedMode: EnhancedMode = resolveClaudeInstalledRuntimeSessionMode({ permissionMode: messagePermissionMode || 'default', agentModeId: currentAgentModeId, replaySeedAllowed: structuredRouting ? true : parseSpecialCommand(message.content.text).type === null, @@ -1884,10 +1940,12 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO fallbackModel: messageFallbackModel, customSystemPrompt: messageCustomSystemPrompt, appendSystemPrompt: messageAppendSystemPrompt, + 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, @@ -2011,6 +2069,20 @@ async function runClaudeLocalFastStart(credentials: Credentials, options: StartO }); seedInitialAppendSystemPrompt(defaultSystemPromptText); + 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); + const exitCode = await loop({ path: workingDirectory, model: options.model, @@ -2018,17 +2090,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, - ultracode: currentUltracode, - ...currentClaudeRemoteMetaState, - }, sessionRuntimeModeKind), + initialClaudeUnifiedTerminalMode, claudeCodeExperimentalAgentTeamsEnabled: currentClaudeRemoteMetaState.claudeCodeExperimentalAgentTeamsEnabled, startedBy: options.startedBy, terminalRuntime: options.terminalRuntime ?? null, @@ -2054,20 +2116,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 = await refreshClaudeInitialModeModelEffortEvidence({ + initialMode: initialClaudeUnifiedTerminalMode, + modelEffortTracker, + modelId: currentModel, + }); if (!didPublishSessionModelsMetadata) { didPublishSessionModelsMetadata = true; - const currentModelId = - typeof options.modelId === 'string' - ? options.modelId.trim() - : (typeof options.model === 'string' ? options.model.trim() : ''); void publishClaudeSessionModelsMetadataBestEffort({ cwd: workingDirectory, timeoutMs: resolveClaudeHelpProbeTimeoutMs(), @@ -2076,6 +2131,15 @@ 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; + const readyMetadata = artifacts.deferredSession.getMetadataSnapshot?.() as Metadata | null | undefined; + if (readySessionId && readyMetadata) { + await reportSessionToDaemonIfRunning({ + sessionId: readySessionId, + metadata: readyMetadata, }); } if (!localPermissionBridge) { 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 0dd10549f6..a2e0301ec8 100644 --- a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts +++ b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.test.ts @@ -1,10 +1,152 @@ import { describe, expect, it } from 'vitest'; import type { Metadata } from '@/api/types'; +import { buildClaudeSessionModelsMetadataFromSupportedModels } from '@/backends/claude/remote/buildClaudeSessionModelsMetadataFromSupportedModels'; 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, + probeInstalledRuntimeCapabilities: async () => ({ supportsEffort: false, supportsUltracode: false }), + 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, + probeInstalledRuntimeCapabilities: async () => ({ supportsEffort: true, supportsUltracode: false }), + 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('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, + probeInstalledRuntimeCapabilities: async () => ({ supportsEffort: true, supportsUltracode: false }), + 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, + probeInstalledRuntimeCapabilities: async () => ({ supportsEffort: true, supportsUltracode: false }), + 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 }; @@ -13,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) => { @@ -42,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) => { @@ -61,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 () => { @@ -70,4 +212,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, + probeInstalledRuntimeCapabilities: async () => ({ supportsEffort: true, supportsUltracode: true }), + 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 53444fcbc9..9653e760ce 100644 --- a/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts +++ b/apps/cli/src/backends/claude/sessionControls/publishClaudeSessionModelsMetadataBestEffort.ts @@ -1,9 +1,49 @@ import type { Metadata } from '@/api/types'; import { logger } from '@/ui/logger'; -import { probeClaudeHelpText } from './probeClaudeHelpText'; +import { reconcileClaudeSessionModelsState } from '../sessionModels/reconcileClaudeSessionModelsState'; +import type { ClaudeInstalledRuntimeCapabilities } from './probeClaudeInstalledRuntimeCapabilities'; 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 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 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; + + for (const key of ['sessionConfigOptionOverridesV1', 'acpConfigOptionOverridesV1'] as const) { + const state = prev[key]; + const overrides = state?.overrides; + if (!overrides) continue; + if (!unsupportedOptionIds.some((id) => id in overrides)) continue; + + const retained = Object.fromEntries( + Object.entries(overrides).filter( + ([id]) => !unsupportedOptionIds.includes(id as typeof unsupportedOptionIds[number]), + ), + ); + next[key] = { ...state, overrides: retained }; + changed = true; + } + + return changed ? next : prev; +} + export async function publishClaudeSessionModelsMetadataBestEffort(params: Readonly<{ cwd: string; timeoutMs: number; @@ -13,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; @@ -26,16 +68,33 @@ 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; + 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) => ({ - ...prev, - sessionModelsV1: state, - acpSessionModelsV1: state, - })); + await params.session.updateMetadata((prev) => { + const base = withoutUnsupportedEffortDependentOverrides(prev, selectedModelOptionIds); + 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/sessionControls/resolveClaudeSessionModelsState.test.ts b/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.test.ts index 1667671b91..6d988425d3 100644 --- a/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.test.ts +++ b/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.test.ts @@ -3,16 +3,22 @@ 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, currentModelId: 'claude-sonnet-4-6', nowMs: () => 123, - probeHelpText: async () => 'Claude Code help output without effort', + probeInstalledRuntimeCapabilities: async () => ({ supportsEffort: false, supportsUltracode: false }), }); - 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 () => { @@ -21,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( @@ -112,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 37ff1d1d6c..70f31e2f33 100644 --- a/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts +++ b/apps/cli/src/backends/claude/sessionControls/resolveClaudeSessionModelsState.ts @@ -1,6 +1,10 @@ -import { AGENT_MODEL_CONFIG } from '@happier-dev/agents'; - import type { Metadata } from '@/api/types'; +import { resolveClaudeModelCatalog } from '@/backends/claude/models/resolveClaudeModelCatalog'; +import { + isClaudeModelOptionSupportedByInstalledRuntime, + probeClaudeInstalledRuntimeCapabilities, + type ClaudeInstalledRuntimeCapabilities, +} from './probeClaudeInstalledRuntimeCapabilities'; type ClaudeSessionModelsState = NonNullable; @@ -9,16 +13,20 @@ 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; - - const supportsEffort = /\B--effort\b/i.test(helpText); - if (!supportsEffort) return null; + const installedCapabilities = await ( + params.probeInstalledRuntimeCapabilities ?? probeClaudeInstalledRuntimeCapabilities + )({ cwd: params.cwd, timeoutMs: params.timeoutMs }); 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. 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 { v: 1, @@ -32,9 +40,14 @@ export async function resolveClaudeSessionModelsState(params: Readonly<{ name: model.name, ...(description ? { description } : {}), ...(typeof model.contextWindowTokens === 'number' ? { contextWindowTokens: model.contextWindowTokens } : {}), - ...(Array.isArray(model.modelOptions) && model.modelOptions.length > 0 - ? { modelOptions: model.modelOptions } - : {}), + ...(typeof model.extendedContextModelId === 'string' ? { extendedContextModelId: model.extendedContextModelId } : {}), + ...(() => { + const modelOptions = Array.isArray(model.modelOptions) + ? model.modelOptions.filter((option) => + isClaudeModelOptionSupportedByInstalledRuntime(option.id, installedCapabilities)) + : []; + return modelOptions.length > 0 ? { modelOptions } : {}; + })(), }; }), } satisfies ClaudeSessionModelsState; 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/resumeChoice/claudeUnifiedResumeChoiceStartupResolver.test.ts b/apps/cli/src/backends/claude/unifiedTerminal/resumeChoice/claudeUnifiedResumeChoiceStartupResolver.test.ts index fc97dea4af..c60ae6f116 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,37 @@ 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([]); + expect(port.sentRaw).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); 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] : []; } 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 0841bc6065..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 } 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') { @@ -178,7 +186,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: 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 03247baaa5..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', () => { @@ -36,6 +37,70 @@ 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('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([]); + 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 +119,35 @@ 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); + }); +}); + +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(); }); }); diff --git a/apps/cli/src/backends/claude/utils/claudeEffort.ts b/apps/cli/src/backends/claude/utils/claudeEffort.ts index b8707d157d..577d178874 100644 --- a/apps/cli/src/backends/claude/utils/claudeEffort.ts +++ b/apps/cli/src/backends/claude/utils/claudeEffort.ts @@ -1,9 +1,66 @@ -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 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 { const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; if (!value) return null; @@ -93,16 +150,48 @@ function resolveBestSupportedClaudeEffort( return null; } -export function resolveClaudeEffortForModel(params: Readonly<{ +/** + * 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 resolveClaudeEffectiveEffortForModel(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); + 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); @@ -112,6 +201,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 +227,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/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 78f4a49d8d..f4ea3cc1bc 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 () => { @@ -151,7 +161,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' }), @@ -182,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]', }), ])); @@ -193,6 +203,40 @@ 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('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 8ebbde4e1c..fbcebae9fa 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) } : {}), @@ -180,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); @@ -413,12 +426,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 +446,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 +486,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 +507,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 +529,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 +555,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 +599,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/capabilities/probes/resolveAgentProbeVariant.test.ts b/apps/cli/src/capabilities/probes/resolveAgentProbeVariant.test.ts new file mode 100644 index 0000000000..37b99bbce5 --- /dev/null +++ b/apps/cli/src/capabilities/probes/resolveAgentProbeVariant.test.ts @@ -0,0 +1,67 @@ +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('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', + 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/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.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/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/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..d584e75a18 100644 --- a/apps/ui/sources/sync/domains/models/modelOptions.ts +++ b/apps/ui/sources/sync/domains/models/modelOptions.ts @@ -47,21 +47,38 @@ export type PreflightModelList = Readonly<{ name: string; description?: string; contextWindowTokens?: number; + extendedContextModelId?: string; modelOptions?: readonly SessionConfigOption[]; }>>; 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; - modelOptions?: unknown; - }>; + availableModels?: DynamicModelRowInput[]; }>; +/** + * 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) => { @@ -71,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, @@ -79,10 +113,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 } + : {}), }; } @@ -135,14 +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 : '', - ...(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: '' }, @@ -259,22 +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, - ...(modelOptionsRaw ? { modelOptions: modelOptionsRaw } : {}), - }; - }); + const dynamic = projectDynamicModelRows(state.availableModels); return appendSelectedFreeformModelOption({ options: mergeModelOptionsWithCatalog({ 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/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/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/docs/agents-catalog.md b/docs/agents-catalog.md index f9019b81c5..5af3bec072 100644 --- a/docs/agents-catalog.md +++ b/docs/agents-catalog.md @@ -143,6 +143,49 @@ 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. 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. + +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 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. + --- ## Adding a new agent/provider (end-to-end) diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index 2b1f3e04f3..df836cb02f 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -118,6 +118,9 @@ export { type AgentModelConfig, type AgentModelDescriptor, type AgentModelNonAcpApplyScope, + buildClaudeUltracodeModelOption, + type AgentModelOption, + type AgentModelOptionValueId, } from './models.js'; export { AGENT_LOCAL_CLI_CONFIG, diff --git a/packages/agents/src/models.ts b/packages/agents/src/models.ts index e6f69d962e..af6b02a00d 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', + // 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: [ ...CLAUDE_STATIC_MODELS.map((model) => model.id), 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) {