diff --git a/apps/daemon/src/connectionTest.ts b/apps/daemon/src/connectionTest.ts index 051660cf909..f5e93cb29fb 100644 --- a/apps/daemon/src/connectionTest.ts +++ b/apps/daemon/src/connectionTest.ts @@ -56,7 +56,6 @@ import { import { aihubmixHeaders } from './integrations/aihubmix.js'; import type { AgentCliEnvPrefs } from './app-config.js'; import type { RuntimeAgentDef } from './runtimes/types.js'; -import { resolveModelForAgent } from './runtimes/models.js'; import { preparePromptFileForAgent, type PreparedPromptFile } from './runtimes/prompt-file.js'; import { configuredAllowedInternalHosts } from './origin-validation.js'; import { @@ -76,7 +75,19 @@ import { type ProviderTestRequest, } from '@open-design/contracts/api/connectionTest'; import { googleGenerateContentUrl } from './integrations/google-models.js'; -import { resolveAmrProfile } from './integrations/vela.js'; +import { readVelaCredentialRevision, resolveAmrProfile } from './integrations/vela.js'; +import { amrModelLoadingCache } from './runtimes/amr-model-cache.js'; +import { buildAmrModelCacheKey } from './runtimes/amr-model-probe.js'; +import { + fetchVelaPresetModels, + fetchVelaRemoteModelsWithRetry, +} from './runtimes/defs/amr.js'; +import { + getRememberedLiveModels, + preferFreshLiveModels, + resolveDefaultModelFromOptions, + resolveModelForAgent, +} from './runtimes/models.js'; export { validateBaseUrl } from '@open-design/contracts/api/connectionTest'; @@ -1880,12 +1891,9 @@ function attachAgentStreamHandlers( child, prompt, cwd, - // Same substitution as the chat-run path in server.ts — adapters whose - // CLI rejects the synthetic 'default' (e.g. AMR / vela, which forces - // session/set_model before session/prompt) need the def's first - // concrete fallback id here too, otherwise Test connection deadlocks - // on the same `session/set_model must be called before session/prompt` - // error the chat-run path already handles. + // Same substitution as the chat-run path in server.ts: omitted models can + // resolve to a concrete fallback, while an explicit 'default' is preserved + // so ACP runtimes can use their upstream configured default. model: resolveModelForAgent(def as never, model ?? null, modelEnv, liveModelScope), mcpServers: [], send, @@ -1963,11 +1971,41 @@ async function prepareOpenCodeConnectionTestCwd(tempDir: string): Promise } } +async function resolveConnectionTestModelForAgent( + def: RuntimeAgentDef, + requestedModel: string | null, + env: NodeJS.ProcessEnv, + liveModelScope: string | null, + launchPath?: string | null, +): Promise { + const resolved = resolveModelForAgent(def, requestedModel, env, liveModelScope); + if (def.id !== 'amr' || resolved !== 'default' || !launchPath) return resolved; + + try { + const cacheKey = buildAmrModelCacheKey({ + launchPath, + env, + credentialRevision: readVelaCredentialRevision(env), + }); + const catalog = await amrModelLoadingCache.get(cacheKey, { + fetchPreset: () => fetchVelaPresetModels(launchPath, env), + fetchRemote: () => fetchVelaRemoteModelsWithRetry(launchPath, env), + }); + const liveModels = preferFreshLiveModels( + catalog.models ?? [], + getRememberedLiveModels(def.id, liveModelScope), + ); + return resolveDefaultModelFromOptions(liveModels) ?? resolved; + } catch { + return resolved; + } +} + async function testAgentConnectionInternal( input: AgentConnectionInput, ): Promise { const start = Date.now(); - const model = + let model = typeof input.model === 'string' && input.model.trim() ? input.model.trim() : 'default'; @@ -2221,6 +2259,13 @@ async function testAgentConnectionInternal( ...baseEnv, ...(mmdRouteLaunchEnv || {}), }, executableResolution); + model = await resolveConnectionTestModelForAgent( + def, + model, + env, + liveModelScope, + executableResolution.launchPath, + ) ?? model; const auth = await probeAgentAuthStatus(def, executableResolution.launchPath, env); if (auth?.status === 'missing') { // Preflight auth probe runs after binary resolution but before the @@ -2278,7 +2323,7 @@ async function testAgentConnectionInternal( child, SMOKE_PROMPT, tempDir, - input.model, + model, env, liveModelScope, sink.send, diff --git a/apps/daemon/src/integrations/vela-errors.ts b/apps/daemon/src/integrations/vela-errors.ts index 4f5d8eeaedc..2852b5f1b69 100644 --- a/apps/daemon/src/integrations/vela-errors.ts +++ b/apps/daemon/src/integrations/vela-errors.ts @@ -1,9 +1,12 @@ -export type AmrAccountErrorCode = 'AMR_AUTH_REQUIRED' | 'AMR_INSUFFICIENT_BALANCE'; +export type AmrAccountErrorCode = + | 'AMR_AUTH_REQUIRED' + | 'AMR_INSUFFICIENT_BALANCE' + | 'AMR_TIER_UPGRADE_REQUIRED'; export interface AmrAccountFailure { code: AmrAccountErrorCode; message: string; - action: 'relogin' | 'recharge'; + action: 'relogin' | 'recharge' | 'upgrade'; actionUrl?: string; } @@ -27,6 +30,12 @@ const AMR_AUTH_REQUIRED_MESSAGE = const AMR_INSUFFICIENT_BALANCE_MESSAGE = `AMR Cloud reported insufficient balance for this model. Recharge your AMR wallet at ${DEFAULT_AMR_RECHARGE_URL}, then retry this run.`; +const AMR_TIER_UPGRADE_REQUIRED_MESSAGE = + 'Your current AMR plan does not include this model or request type. Upgrade your AMR plan, or switch to an available model and retry.'; + +const AMR_TIER_REQUEST_KIND_NOT_ENTITLED_MESSAGE = + 'Your current AMR plan does not include this request type yet. Upgrade your AMR plan, or switch to a supported model and retry.'; + function normalizeFailureText(text: string): string { return String(text || '').toLowerCase(); } @@ -74,6 +83,22 @@ export function classifyAmrAccountFailureDetails(details: unknown): AmrAccountFa }; } + if (code === 'tier_model_not_entitled') { + return { + code: 'AMR_TIER_UPGRADE_REQUIRED', + message: AMR_TIER_UPGRADE_REQUIRED_MESSAGE, + action: 'upgrade', + }; + } + + if (code === 'tier_request_kind_not_entitled') { + return { + code: 'AMR_TIER_UPGRADE_REQUIRED', + message: AMR_TIER_REQUEST_KIND_NOT_ENTITLED_MESSAGE, + action: 'upgrade', + }; + } + return null; } @@ -114,6 +139,22 @@ export function classifyAmrAccountFailure(text: string): AmrAccountFailure | nul }; } + if (value.includes('tier_model_not_entitled')) { + return { + code: 'AMR_TIER_UPGRADE_REQUIRED', + message: AMR_TIER_UPGRADE_REQUIRED_MESSAGE, + action: 'upgrade', + }; + } + + if (value.includes('tier_request_kind_not_entitled')) { + return { + code: 'AMR_TIER_UPGRADE_REQUIRED', + message: AMR_TIER_REQUEST_KIND_NOT_ENTITLED_MESSAGE, + action: 'upgrade', + }; + } + if ( value.includes('auth_required') || value.includes('authentication required') || diff --git a/apps/daemon/src/integrations/vela.ts b/apps/daemon/src/integrations/vela.ts index b4de84d684d..bb0168c96c2 100644 --- a/apps/daemon/src/integrations/vela.ts +++ b/apps/daemon/src/integrations/vela.ts @@ -30,6 +30,7 @@ const AMR_ENTRY_SOURCES: ReadonlySet = new Set([ 'handoff_amr_website', 'chat_error_authorize_retry', 'chat_error_recharge', + 'chat_error_upgrade', 'chat_balance_gate_upgrade', 'home_balance_gate_upgrade', 'chat_low_balance_warn_recharge', @@ -80,6 +81,7 @@ const AMR_ENTRY_SOURCE_PAGE_BY_SOURCE: Record< handoff_amr_website: 'artifact', chat_error_authorize_retry: 'chat_panel', chat_error_recharge: 'chat_panel', + chat_error_upgrade: 'chat_panel', chat_balance_gate_upgrade: 'chat_panel', home_balance_gate_upgrade: 'home', chat_low_balance_warn_recharge: 'chat_panel', diff --git a/apps/daemon/src/routes/vela.ts b/apps/daemon/src/routes/vela.ts index 6f123e103ce..07b8f1cc9d2 100644 --- a/apps/daemon/src/routes/vela.ts +++ b/apps/daemon/src/routes/vela.ts @@ -35,6 +35,7 @@ import { velaWalletSnapshotReader, } from '../integrations/vela-wallet.js'; import { amrModelLoadingCache } from '../runtimes/amr-model-cache.js'; +import { buildAmrModelCacheKey } from '../runtimes/amr-model-probe.js'; import { fetchVelaBillingSummary, fetchVelaPresetModels, @@ -169,14 +170,9 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps): agentLaunch, ); const credentialRevision = readVelaCredentialRevision(env, configuredEnv); - const cacheKey = JSON.stringify({ + const cacheKey = buildAmrModelCacheKey({ launchPath, - home: spawnEnv.HOME ?? spawnEnv.USERPROFILE ?? '', - openDesignAmrProfile: spawnEnv.OPEN_DESIGN_AMR_PROFILE ?? '', - velaProfile: spawnEnv.VELA_PROFILE ?? '', - velaLinkUrl: spawnEnv.VELA_LINK_URL ?? '', - velaRuntimeKey: spawnEnv.VELA_RUNTIME_KEY ?? '', - velaOpencodeBin: spawnEnv.VELA_OPENCODE_BIN ?? '', + env: spawnEnv, credentialRevision, }); return { launchPath, env: spawnEnv, configuredEnv, cacheKey }; @@ -197,17 +193,29 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps): string, Promise >(); + const inFlightVelaAccountInvalidations = new Set(); function fetchVelaLiveAccountSingleFlight( accountCacheKey: string, probe: AmrModelProbe, + options: { invalidateModelsOnPlanChange?: boolean } = {}, ): Promise { + if (options.invalidateModelsOnPlanChange === true) { + inFlightVelaAccountInvalidations.add(accountCacheKey); + } const existing = inFlightVelaAccountFetches.get(accountCacheKey); if (existing) return existing; const pending = (async () => { + const previousAccount = peekVelaLiveAccount(accountCacheKey); amrModelLoadingCache.warm(probe.cacheKey, () => fetchVelaRemoteModelsWithRetry(probe.launchPath, probe.env), ); const account = await fetchVelaBillingSummary(probe.launchPath, probe.env); + if ( + inFlightVelaAccountInvalidations.has(accountCacheKey) && + (!previousAccount || previousAccount.plan !== account.plan) + ) { + amrModelLoadingCache.invalidate(probe.cacheKey); + } setVelaLiveAccount(accountCacheKey, account); return account; })() @@ -220,6 +228,7 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps): }) .finally(() => { inFlightVelaAccountFetches.delete(accountCacheKey); + inFlightVelaAccountInvalidations.delete(accountCacheKey); }); inFlightVelaAccountFetches.set(accountCacheKey, pending); return pending; @@ -242,6 +251,7 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps): try { const appConfig = await readAppConfig(RUNTIME_DATA_DIR); const configuredEnv = agentCliEnvForAgent(appConfig.agentCliEnv, 'amr'); + const refresh = _req.query.refresh === '1' || _req.query.refresh === 'true'; const status = readVelaLoginStatus(mergeVelaEnv(env, configuredEnv)); if (status.loggedIn) { // Key the live-account cache by the full credential revision (not just @@ -254,7 +264,12 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps): ); const probe = resolveAmrModelProbeForEnv(configuredEnv); const cachedAccount = peekVelaLiveAccount(accountCacheKey); - if (!cachedAccount) { + if (refresh) { + const liveAccount = await fetchVelaLiveAccountSingleFlight(accountCacheKey, probe, { + invalidateModelsOnPlanChange: true, + }); + applyVelaLiveAccount(status, liveAccount); + } else if (!cachedAccount) { // Cold cache (or a fetch already in flight): BLOCK on the single-flight // billing fetch so the first open already carries plan/balance. The // consumers (settings card, inline switcher, avatar) read /status once @@ -274,7 +289,9 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps): // next poll once the TTL has lapsed. applyVelaLiveAccount(status, cachedAccount); if (shouldRefreshVelaLiveAccount(accountCacheKey)) { - void fetchVelaLiveAccountSingleFlight(accountCacheKey, probe).catch(() => {}); + void fetchVelaLiveAccountSingleFlight(accountCacheKey, probe, { + invalidateModelsOnPlanChange: true, + }).catch(() => {}); } } } @@ -294,6 +311,14 @@ export function registerVelaRoutes(app: Express, deps: RegisterVelaRoutesDeps): configuredEnv, refresh, }); + if (refresh) { + try { + const modelProbe = resolveAmrModelProbeForEnv(configuredEnv); + amrModelLoadingCache.invalidate(modelProbe.cacheKey); + } catch (err) { + console.warn('[amr] model cache invalidation after wallet refresh failed', err); + } + } res.json(snapshot); } catch (err) { res.status(500).json({ error: String(err) }); diff --git a/apps/daemon/src/run-failure-classification.ts b/apps/daemon/src/run-failure-classification.ts index f8f965989ee..cbd8b0172be 100644 --- a/apps/daemon/src/run-failure-classification.ts +++ b/apps/daemon/src/run-failure-classification.ts @@ -549,6 +549,19 @@ export function classifyRunFailure( ); } + if ( + errorCode === 'AMR_TIER_UPGRADE_REQUIRED' || + amrFailure?.code === 'AMR_TIER_UPGRADE_REQUIRED' + ) { + return classification( + 'entitlement_required', + 'amr_tier_upgrade_required', + 'session_init', + false, + 'upgrade', + ); + } + if ( errorCode === 'AMR_AUTH_REQUIRED' || errorCode === 'AGENT_AUTH_REQUIRED' || diff --git a/apps/daemon/src/runtimes/amr-model-cache.ts b/apps/daemon/src/runtimes/amr-model-cache.ts index 522aed796f6..295bd339f16 100644 --- a/apps/daemon/src/runtimes/amr-model-cache.ts +++ b/apps/daemon/src/runtimes/amr-model-cache.ts @@ -64,6 +64,10 @@ export class AmrModelLoadingCache { this.startRefresh(this.stateFor(cacheKey), fetchRemote); } + invalidate(cacheKey: string): void { + this.states.delete(cacheKey); + } + resetForTests(): void { this.states.clear(); } diff --git a/apps/daemon/src/runtimes/amr-model-probe.ts b/apps/daemon/src/runtimes/amr-model-probe.ts index ab596302050..b45cf0afedf 100644 --- a/apps/daemon/src/runtimes/amr-model-probe.ts +++ b/apps/daemon/src/runtimes/amr-model-probe.ts @@ -6,6 +6,7 @@ import { } from '../agents.js'; import { agentCliEnvForAgent, type readAppConfig } from '../app-config.js'; import { readVelaCredentialRevision } from '../integrations/vela.js'; +import type { VelaCredentialRevision } from '../integrations/vela.js'; export interface ResolveAmrModelProbeDeps { dataDir: string; @@ -13,6 +14,29 @@ export interface ResolveAmrModelProbeDeps { readAppConfig: typeof readAppConfig; } +export interface BuildAmrModelCacheKeyInput { + launchPath: string; + env: NodeJS.ProcessEnv; + credentialRevision: VelaCredentialRevision; +} + +export function buildAmrModelCacheKey({ + launchPath, + env, + credentialRevision, +}: BuildAmrModelCacheKeyInput): string { + return JSON.stringify({ + launchPath, + home: env.HOME ?? env.USERPROFILE ?? '', + openDesignAmrProfile: env.OPEN_DESIGN_AMR_PROFILE ?? '', + velaProfile: env.VELA_PROFILE ?? '', + velaLinkUrl: env.VELA_LINK_URL ?? '', + velaRuntimeKey: env.VELA_RUNTIME_KEY ?? '', + velaOpencodeBin: env.VELA_OPENCODE_BIN ?? '', + credentialRevision, + }); +} + export async function resolveAmrModelProbe({ dataDir, env: baseEnv, @@ -38,14 +62,9 @@ export async function resolveAmrModelProbe({ agentLaunch, ); const credentialRevision = readVelaCredentialRevision(baseEnv, configuredEnv); - const cacheKey = JSON.stringify({ + const cacheKey = buildAmrModelCacheKey({ launchPath, - home: env.HOME ?? env.USERPROFILE ?? '', - openDesignAmrProfile: env.OPEN_DESIGN_AMR_PROFILE ?? '', - velaProfile: env.VELA_PROFILE ?? '', - velaLinkUrl: env.VELA_LINK_URL ?? '', - velaRuntimeKey: env.VELA_RUNTIME_KEY ?? '', - velaOpencodeBin: env.VELA_OPENCODE_BIN ?? '', + env, credentialRevision, }); return { launchPath, env, configuredEnv, cacheKey }; diff --git a/apps/daemon/src/runtimes/defs/amr.ts b/apps/daemon/src/runtimes/defs/amr.ts index 6c891989950..596c7b581c2 100644 --- a/apps/daemon/src/runtimes/defs/amr.ts +++ b/apps/daemon/src/runtimes/defs/amr.ts @@ -33,9 +33,10 @@ const OPENCODE_MODEL_PRICE_PROVIDER_PRIORITY = [ // // Model wiring notes: // -// 1. vela rejects `session/prompt` until `session/set_model` has been -// called, so AMR cannot accept the synthetic `default` model id — -// attachAcpSession skips set_model whenever model === 'default'. +// 1. A concrete AMR model selection is applied through ACP +// `session/set_model`. The synthetic `default` model id intentionally +// skips that call so vela/OpenCode can use the account's configured +// upstream default. // // 2. Vela 0.0.1 exposes the current link-supported catalog through // `vela models`, but that command prints public ids such as @@ -164,9 +165,13 @@ function withVelaModelPriceFields( model: RuntimeModelOption, item: unknown, ): RuntimeModelOption { + const enabled = extractOptionalBoolean(item, ['enabled']); + const isDefault = extractOptionalBoolean(item, ['default']); const inputPriceUsdPerMillion = extractInputPriceUsdPerMillion(item); const outputPriceUsdPerMillion = extractOutputPriceUsdPerMillion(item); if ( + enabled === undefined && + isDefault === undefined && inputPriceUsdPerMillion === undefined && outputPriceUsdPerMillion === undefined ) { @@ -174,11 +179,25 @@ function withVelaModelPriceFields( } return { ...model, + ...(enabled === undefined ? {} : { enabled }), + ...(isDefault === undefined ? {} : { default: isDefault }), ...(inputPriceUsdPerMillion === undefined ? {} : { inputPriceUsdPerMillion }), ...(outputPriceUsdPerMillion === undefined ? {} : { outputPriceUsdPerMillion }), }; } +function extractOptionalBoolean( + item: unknown, + keys: string[], +): boolean | undefined { + if (!isRecord(item)) return undefined; + for (const key of keys) { + const value = item[key]; + if (typeof value === 'boolean') return value; + } + return undefined; +} + function extractInputPriceUsdPerMillion(item: unknown): number | undefined { if (!isRecord(item)) return undefined; const direct = firstFinitePrice([ @@ -480,7 +499,7 @@ export async function fetchVelaRemoteModelsWithRetry( let lastError: unknown = null; for (let attempt = 0; attempt <= AMR_MODELS_RETRY_DELAYS_MS.length; attempt += 1) { try { - const { stdout } = await execAgentFile(resolvedBin, ['model', 'list', '--format', 'json'], { + const { stdout } = await execAgentFile(resolvedBin, ['model', 'list', '--all', '--format', 'json'], { env, timeout: AMR_MODELS_TIMEOUT_MS, maxBuffer: 1024 * 1024, @@ -574,9 +593,8 @@ export const amrAgentDef = { // surfaces the live Vela catalog instead. supportsCustomModel: false, supportsImagePaths: true, - // Daemon-process env override for emergency operator pinning. Normal UI - // selection comes from the live `vela models` catalog and is preflighted - // before spawn. + // Daemon-process env override for emergency operator pinning when no model + // was selected. Explicit UI selections, including `default`, win. defaultModelEnvVar: 'VELA_DEFAULT_MODEL', // Vela/OpenCode can spend extended stretches silent while the upstream // provider is still working. Keep the outer chat watchdog aligned with the diff --git a/apps/daemon/src/runtimes/models.ts b/apps/daemon/src/runtimes/models.ts index a820ca4323a..b451e134ef4 100644 --- a/apps/daemon/src/runtimes/models.ts +++ b/apps/daemon/src/runtimes/models.ts @@ -11,7 +11,7 @@ export const DEFAULT_MODEL_OPTION: RuntimeModelOption = { // trust any value present in the static fallback. A model that's neither // gets rejected so a stale or hostile value can't smuggle arbitrary flags. const liveModelCache = new Map>(); -const liveModelOrder = new Map(); +const liveModelOrder = new Map(); function liveModelCacheKey(agentId: string, scope?: string | null): string { const trimmedScope = typeof scope === 'string' ? scope.trim() : ''; @@ -20,20 +20,29 @@ function liveModelCacheKey(agentId: string, scope?: string | null): string { export function rememberLiveModels(agentId: string, models: RuntimeModelOption[], scope?: string | null) { if (!Array.isArray(models)) return; - const ids = models - .map((m) => m && m.id) - .filter((id) => typeof id === 'string'); + const remembered = models.filter( + (model): model is RuntimeModelOption => + model != null && typeof model.id === 'string', + ); + const ids = remembered.map((model) => model.id); const key = liveModelCacheKey(agentId, scope); liveModelCache.set( key, new Set(ids), ); - liveModelOrder.set(key, ids); + liveModelOrder.set(key, remembered); +} + +export function resolveDefaultModelFromOptions( + models: RuntimeModelOption[], +): string | null { + const candidates = models.filter((model) => model?.id && model.enabled !== false); + const defaultModel = candidates.find((model) => model.default === true); + return defaultModel?.id ?? candidates[0]?.id ?? null; } export function getRememberedLiveModels(agentId: string, scope?: string | null): RuntimeModelOption[] { - const ids = liveModelOrder.get(liveModelCacheKey(agentId, scope)) ?? []; - return ids.map((id) => ({ id, label: id })); + return liveModelOrder.get(liveModelCacheKey(agentId, scope)) ?? []; } export function preferFreshLiveModels( @@ -57,14 +66,12 @@ export function isKnownModel( return false; } -// Some adapters reject the synthetic `'default'` model id (e.g. AMR / vela, -// which requires an explicit `session/set_model` before `session/prompt`). -// Those defs declare it by omitting DEFAULT_MODEL_OPTION from -// `fallbackModels` entirely. When the chat run produces a null or 'default' -// model for one of those adapters, prefer the first model from the live list -// last surfaced to the UI, then fall back to the def's first concrete fallback -// id so the spawn layer always has a real model to forward. -// Defs that DO list 'default' (the common case) are left untouched. +// Some adapters omit the synthetic `'default'` option from `fallbackModels` +// because they only accept concrete ids for explicit model selection. When a +// chat run has no model at all, prefer the first model from the live list last +// surfaced to the UI, then fall back to the def's first concrete fallback id. +// An explicit `'default'` choice is preserved so ACP runtimes can leave model +// selection to the upstream session's own configured default. export function resolveModelForAgent( def: RuntimeAgentDef, resolved: string | null, @@ -72,6 +79,7 @@ export function resolveModelForAgent( liveModelScope?: string | null, ): string | null { if (resolved && resolved !== 'default') return resolved; + if (resolved === 'default') return resolved; // Daemon-process env override (e.g. VELA_DEFAULT_MODEL for AMR). Lets an // operator pin a different fallback id without a code change when the // hardcoded default goes away upstream. @@ -81,12 +89,11 @@ export function resolveModelForAgent( } const fallbacks = Array.isArray(def.fallbackModels) ? def.fallbackModels : []; if (fallbacks.some((m) => m.id === 'default')) return resolved; - const liveModels = liveModelOrder.get(liveModelCacheKey(def.id, liveModelScope)) ?? []; - const firstLive = liveModels[0]; - if (firstLive) return firstLive; + const liveModels = getRememberedLiveModels(def.id, liveModelScope); + const defaultLive = resolveDefaultModelFromOptions(liveModels); + if (defaultLive) return defaultLive; if (fallbacks.length === 0) return resolved; - const firstFallback = fallbacks[0]; - return firstFallback ? firstFallback.id : resolved; + return resolveDefaultModelFromOptions(fallbacks) ?? resolved; } // Permit user-typed model ids that didn't appear in either the live diff --git a/apps/daemon/src/runtimes/types.ts b/apps/daemon/src/runtimes/types.ts index df63e4498f2..63ed86a828d 100644 --- a/apps/daemon/src/runtimes/types.ts +++ b/apps/daemon/src/runtimes/types.ts @@ -8,6 +8,8 @@ export type RuntimeEnv = NodeJS.ProcessEnv | Record; export type RuntimeModelOption = { id: string; label: string; + enabled?: boolean; + default?: boolean; inputPriceUsdPerMillion?: number; outputPriceUsdPerMillion?: number; }; diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index f7252be7b11..cff1b11df81 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -181,6 +181,7 @@ import { getRememberedLiveModels, preferFreshLiveModels, rememberLiveModels, + resolveDefaultModelFromOptions, resolveModelForAgent, } from './runtimes/models.js'; import { loadMmdRouteLaunchEnv } from './runtimes/mmd-routes.js'; @@ -4561,14 +4562,10 @@ export async function startServer({ let capturedSessionId: string | null = null; // --- Model resolution hoisted above the resume-identity guard --- // The guard (and the persisted `agent_sessions.model`) must key off the - // CONCRETE model actually launched, not the raw request token: a user who - // picked `default` would otherwise store `default`/null, so changing the - // effective default between turns would still pass the guard and resume the - // old upstream session under the wrong model (#4704, reported by @nettee). - // resolveModelForAgent is hoisted here; the AMR `default`->live-catalog - // rewrite is mirrored below so `safeModel` is final before the guard. The - // preflight further down stays authoritative for auth/availability and - // re-runs the (cached, idempotent) resolution. + // model identity actually requested for this turn. Explicit `default` is + // kept as a real identity because ACP runtimes can leave model selection to + // the upstream session's own configured default; omitted models may still + // resolve to an available fallback below. let configuredAgentEnv = {}; try { const appConfig = await readAppConfig(RUNTIME_DATA_DIR); @@ -4593,6 +4590,11 @@ export async function startServer({ process.env, requestedLiveModelScope, ); + const hasDefaultModelEnvOverride = Boolean( + def.defaultModelEnvVar && + typeof process.env[def.defaultModelEnvVar] === 'string' && + process.env[def.defaultModelEnvVar]?.trim(), + ); const safeReasoning = typeof reasoning === 'string' && Array.isArray(def.reasoningOptions) ? (def.reasoningOptions.find((r) => r.id === reasoning)?.id ?? null) @@ -4601,10 +4603,10 @@ export async function startServer({ const agentLaunch = resolveAgentLaunch(def, configuredAgentEnv); const resolvedBin = agentLaunch.selectedPath; if (def.id === 'amr' && resolvedBin && agentLaunch.launchPath) { - // Concretize a default/empty model to the live catalog's first entry, the - // same rewrite the AMR preflight applies — done here only so the resume - // guard sees the launched model. Read-only + cached (hot on follow-up - // turns); the preflight below remains the authoritative gate. + // Concretize omitted/default AMR model requests to the live catalog + // default before the resume guard. The AMR preflight below applies the + // same rewrite before spawn; keeping this earlier copy aligned prevents + // stored concrete session models from comparing against raw `default`. try { const resumeProbe = await resolveAmrModelProbe({ dataDir: RUNTIME_DATA_DIR, env: process.env, readAppConfig }); const resumeCatalog = await amrModelLoadingCache.get(resumeProbe.cacheKey, { @@ -4618,8 +4620,18 @@ export async function startServer({ const resumeModelIds = new Set(resumeLiveModels.map((c) => c?.id).filter(Boolean)); const askedForDefault = typeof model !== 'string' || !model.trim() || model.trim().toLowerCase() === 'default'; - if (!safeModel || safeModel === 'default' || (askedForDefault && !resumeModelIds.has(safeModel))) { - safeModel = resumeLiveModels[0]?.id ?? safeModel ?? null; + const defaultRunModel = resolveDefaultModelFromOptions(resumeLiveModels); + if ( + !safeModel || + safeModel === 'default' || + ( + askedForDefault && + !hasDefaultModelEnvOverride && + defaultRunModel && + (!resumeModelIds.has(safeModel) || safeModel !== defaultRunModel) + ) + ) { + safeModel = defaultRunModel ?? safeModel ?? null; agentOptions.model = safeModel; } } catch { @@ -5391,17 +5403,24 @@ export async function startServer({ ); // A request that came in as 'default'/empty is normally pre-resolved to a // concrete id via the agent-wide cached model order; if it still is not, - // adopt the first catalog entry so the spawn layer always has a real id. + // adopt the catalog's enabled default so the spawn layer always has a + // usable real id. const userAskedForDefault = typeof model !== 'string' || !model.trim() || model.trim().toLowerCase() === 'default'; + const defaultRunModel = resolveDefaultModelFromOptions(liveModels); if ( !safeModel || safeModel === 'default' || - (userAskedForDefault && !liveModelIds.has(safeModel)) + ( + userAskedForDefault && + !hasDefaultModelEnvOverride && + defaultRunModel && + (!liveModelIds.has(safeModel) || safeModel !== defaultRunModel) + ) ) { - safeModel = liveModels[0]?.id ?? safeModel ?? null; + safeModel = defaultRunModel ?? (safeModel === 'default' ? null : safeModel ?? null); agentOptions.model = safeModel; } if (liveModelIds.size === 0) { diff --git a/apps/daemon/tests/amr-acp-integration.test.ts b/apps/daemon/tests/amr-acp-integration.test.ts index e82263b8171..53ba35dd7ef 100644 --- a/apps/daemon/tests/amr-acp-integration.test.ts +++ b/apps/daemon/tests/amr-acp-integration.test.ts @@ -219,9 +219,14 @@ describe('AMR runtime def', () => { const models = parseVelaModelJson(JSON.stringify({ source: 'remote', data: [ - { id: 'public_model_kimi_k2_7_code' }, + { id: 'public_model_kimi_k2_7_code', enabled: false }, { id: 'public_model_deepseek_v3_2' }, - { id: 'deepseek-v4-flash', cost: { input: 0.14, output: 0.28 } }, + { + id: 'deepseek-v4-flash', + enabled: true, + default: true, + cost: { input: 0.14, output: 0.28 }, + }, { id: 'gpt-image-2' }, { id: 'deepseek-v4-flash' }, ], @@ -230,11 +235,13 @@ describe('AMR runtime def', () => { { id: 'deepseek-v4-flash', label: 'deepseek-v4-flash', + enabled: true, + default: true, inputPriceUsdPerMillion: 0.14, outputPriceUsdPerMillion: 0.28, }, { id: 'deepseek-v3.2', label: 'deepseek-v3.2' }, - { id: 'kimi-k2.7-code', label: 'kimi-k2.7-code' }, + { id: 'kimi-k2.7-code', label: 'kimi-k2.7-code', enabled: false }, ]); expect(models.map((m) => m.id)).not.toContain('gpt-image-2'); expect(models.map((m) => m.id)).not.toContain('public_model_kimi_k2_7_code'); @@ -611,6 +618,36 @@ describe('AMR model loading cache', () => { refreshing: true, }); }); + + it('drops a cached remote catalog for a single environment when invalidated', async () => { + const cache = new AmrModelLoadingCache(60_000); + cache.warm('vela:local', async () => [{ id: 'locked-old', label: 'locked-old', enabled: false }]); + cache.warm('vela:prod', async () => [{ id: 'remote-prod', label: 'remote-prod' }]); + await new Promise((resolve) => setTimeout(resolve, 0)); + + cache.invalidate('vela:local'); + + const local = await cache.get('vela:local', { + fetchPreset: async () => [{ id: 'preset-local', label: 'preset-local' }], + fetchRemote: async () => [{ id: 'remote-local-new', label: 'remote-local-new', enabled: true }], + }); + const prod = await cache.get('vela:prod', { + fetchPreset: async () => { + throw new Error('prod preset should not be required'); + }, + fetchRemote: async () => [{ id: 'remote-prod-new', label: 'remote-prod-new' }], + }); + + expect(local).toMatchObject({ + source: 'preset', + models: [{ id: 'preset-local', label: 'preset-local' }], + refreshing: true, + }); + expect(prod).toMatchObject({ + source: 'remote', + models: [{ id: 'remote-prod', label: 'remote-prod' }], + }); + }); }); describe('AMR ACP transport — end-to-end against fake vela stub', () => { diff --git a/apps/daemon/tests/amr-session-resume.test.ts b/apps/daemon/tests/amr-session-resume.test.ts index 64170bf1269..23b5c345e2d 100644 --- a/apps/daemon/tests/amr-session-resume.test.ts +++ b/apps/daemon/tests/amr-session-resume.test.ts @@ -233,6 +233,124 @@ describe('AMR (vela) ACP session resume — full server cycle', () => { expect(await readInvocations(logPath)).toEqual(['new', 'load']); }); + it('resolves an explicit default model to the live catalog default before spawning AMR', async () => { + binDir = await mkdtemp(path.join(os.tmpdir(), 'od-amr-explicit-default-bin-')); + const logPath = path.join(binDir, 'invocations.jsonl'); + const bin = await writeVelaWrapper(binDir, 'vela-explicit-default', { + logPath, + logSetModel: true, + requireSetModel: true, + }); + + clearTelemetryEnv(); + started = (await startServer({ port: 0, returnServer: true })) as StartedServer; + await putConfig(started.url, { + agentId: 'amr', + agentCliEnv: { amr: { VELA_BIN: bin } }, + telemetry: { metrics: true, content: false, artifactManifest: false }, + privacyDecisionAt: Date.now(), + }); + + const conversationId = await createConversation(started.url); + + expect((await sendRunAndWait(started.url, conversationId, 'use account default', 'default')).status) + .toBe('succeeded'); + expect((await sendRunAndWait(started.url, conversationId, 'use account default again', 'default')).status) + .toBe('succeeded'); + + expect(await readInvocations(logPath)).toEqual([ + 'new', + 'set_model:deepseek-v4-flash', + 'load', + 'set_model:deepseek-v4-flash', + ]); + }); + + it('uses the catalog default model for omitted AMR model selections, skipping disabled catalog heads', async () => { + binDir = await mkdtemp(path.join(os.tmpdir(), 'od-amr-catalog-default-bin-')); + const logPath = path.join(binDir, 'invocations.jsonl'); + const presetCatalog = JSON.stringify({ + source: 'preset', + data: [ + { id: 'deepseek-v4-flash', enabled: false }, + { id: 'kimi-k2.6', default: true }, + { id: 'glm-5.1' }, + ], + }); + const remoteCatalog = JSON.stringify({ + source: 'remote', + data: [ + { id: 'deepseek-v4-flash', enabled: false }, + { id: 'kimi-k2.6', default: true }, + { id: 'glm-5.1' }, + ], + }); + const bin = await writeVelaWrapper(binDir, 'vela-catalog-default', { + logPath, + logSetModel: true, + modelPresetJson: presetCatalog, + modelListJson: remoteCatalog, + }); + + clearTelemetryEnv(); + started = (await startServer({ port: 0, returnServer: true })) as StartedServer; + await putConfig(started.url, { + agentId: 'amr', + agentCliEnv: { amr: { VELA_BIN: bin } }, + telemetry: { metrics: true, content: false, artifactManifest: false }, + privacyDecisionAt: Date.now(), + }); + + const conversationId = await createConversation(started.url); + + expect((await sendRunAndWait(started.url, conversationId, 'use catalog default')).status) + .toBe('succeeded'); + + expect(await readInvocations(logPath)).toEqual(['new', 'set_model:kimi-k2.6']); + }); + + it('rejects explicit AMR default when every catalog model is disabled', async () => { + binDir = await mkdtemp(path.join(os.tmpdir(), 'od-amr-locked-default-bin-')); + const logPath = path.join(binDir, 'invocations.jsonl'); + const lockedCatalog = JSON.stringify({ + source: 'preset', + data: [ + { id: 'deepseek-v4-flash', enabled: false }, + { id: 'kimi-k2.6', enabled: false }, + ], + }); + const lockedRemoteCatalog = JSON.stringify({ + source: 'remote', + data: [ + { id: 'deepseek-v4-flash', enabled: false }, + { id: 'kimi-k2.6', enabled: false }, + ], + }); + const bin = await writeVelaWrapper(binDir, 'vela-locked-default', { + logPath, + logSetModel: true, + requireSetModel: true, + modelPresetJson: lockedCatalog, + modelListJson: lockedRemoteCatalog, + }); + + clearTelemetryEnv(); + started = (await startServer({ port: 0, returnServer: true })) as StartedServer; + await putConfig(started.url, { + agentId: 'amr', + agentCliEnv: { amr: { VELA_BIN: bin } }, + telemetry: { metrics: true, content: false, artifactManifest: false }, + privacyDecisionAt: Date.now(), + }); + + const conversationId = await createConversation(started.url); + const run = await sendRunAndWait(started.url, conversationId, 'use account default', 'default'); + + expect(run.status).toBe('failed'); + expect(run.errorCode).toBe('AMR_MODEL_UNAVAILABLE'); + expect(await readInvocations(logPath)).toEqual([]); + }); + it('reseeds a fresh session (no resume) when the model changes between turns', async () => { binDir = await mkdtemp(path.join(os.tmpdir(), 'od-amr-modelchange-bin-')); const logPath = path.join(binDir, 'invocations.jsonl'); @@ -270,18 +388,35 @@ describe('AMR (vela) ACP session resume — full server cycle', () => { async function writeVelaWrapper( dir: string, name: string, - opts: { logPath: string; resumeFailed?: boolean; omitHandle?: boolean }, + opts: { + logPath: string; + resumeFailed?: boolean; + omitHandle?: boolean; + logSetModel?: boolean; + requireSetModel?: boolean; + modelPresetJson?: string; + modelListJson?: string; + }, ): Promise { const bin = path.join(dir, name); const lines = [ '#!/bin/sh', `export FAKE_VELA_INVOCATION_LOG=${JSON.stringify(opts.logPath)}`, - // Isolate the resume cycle from the set_model gate; the strict-set_model - // contract is covered by amr-acp-integration.test.ts. - 'export FAKE_VELA_REQUIRE_SET_MODEL=0', ]; + if (opts.requireSetModel !== true) { + // Isolate most resume-cycle tests from the set_model gate; individual + // default-model regressions opt back into the production-shaped strict gate. + lines.push('export FAKE_VELA_REQUIRE_SET_MODEL=0'); + } if (opts.resumeFailed) lines.push('export FAKE_VELA_RESUME_FAILED=1'); if (opts.omitHandle) lines.push('export FAKE_VELA_OMIT_OPENCODE_SESSION_ID=1'); + if (opts.logSetModel) lines.push('export FAKE_VELA_LOG_SET_MODEL=1'); + if (opts.modelPresetJson) { + lines.push(`export FAKE_VELA_MODEL_PRESET_JSON=${JSON.stringify(opts.modelPresetJson)}`); + } + if (opts.modelListJson) { + lines.push(`export FAKE_VELA_MODEL_LIST_JSON=${JSON.stringify(opts.modelListJson)}`); + } lines.push(`exec ${JSON.stringify(process.execPath)} ${JSON.stringify(FAKE_VELA)} "$@"`, ''); await writeFile(bin, lines.join('\n'), 'utf8'); await chmod(bin, 0o755); diff --git a/apps/daemon/tests/connection-test.test.ts b/apps/daemon/tests/connection-test.test.ts index d04456fd02e..fa526189b42 100644 --- a/apps/daemon/tests/connection-test.test.ts +++ b/apps/daemon/tests/connection-test.test.ts @@ -23,9 +23,18 @@ import { validateUserProviderBaseUrl, type DnsLookupAddress, } from '../src/connectionTest.js'; +import { + applyAgentLaunchEnv, + getAgentDef, + resolveAgentLaunch, + spawnEnvForAgent, +} from '../src/agents.js'; import { listProviderModels } from '../src/integrations/provider-models.js'; +import { readVelaCredentialRevision } from '../src/integrations/vela.js'; import { startServer } from '../src/server.js'; import { rememberLiveModels } from '../src/runtimes/models.js'; +import { amrModelLoadingCache } from '../src/runtimes/amr-model-cache.js'; +import { buildAmrModelCacheKey } from '../src/runtimes/amr-model-probe.js'; type FetchInput = Parameters[0]; type FetchInit = Parameters[1]; @@ -189,6 +198,7 @@ beforeAll(async () => { afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); + amrModelLoadingCache.resetForTests(); }); afterAll(() => new Promise((resolve) => server.close(() => resolve()))); @@ -2249,6 +2259,215 @@ describe('POST /api/test/connection agent mode', () => { ); }); + it('concretizes explicit AMR default before the strict fake Vela connection smoke prompt', async () => { + const markerDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-amr-default-')); + const logPath = path.join(markerDir, 'invocations.jsonl'); + const previousLog = process.env.FAKE_VELA_INVOCATION_LOG; + const previousLogSetModel = process.env.FAKE_VELA_LOG_SET_MODEL; + const previousRequireSetModel = process.env.FAKE_VELA_REQUIRE_SET_MODEL; + try { + process.env.FAKE_VELA_INVOCATION_LOG = logPath; + process.env.FAKE_VELA_LOG_SET_MODEL = '1'; + delete process.env.FAKE_VELA_REQUIRE_SET_MODEL; + + await withFakeAgent( + 'vela', + `void import(${JSON.stringify(pathToFileURL(FAKE_VELA_FIXTURE).href)});\n`, + async () => { + const result = await testAgentConnection({ + agentId: 'amr', + model: 'default', + }); + + expect(result).toMatchObject({ + ok: true, + kind: 'success', + agentName: 'AMR', + sample: 'Hello from fake vela.', + }); + }, + ); + + const raw = await fsp.readFile(logPath, 'utf8'); + const methods = raw + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as { method: string }) + .map((entry) => entry.method); + expect(methods).toEqual(['new', 'set_model:deepseek-v4-flash']); + } finally { + if (previousLog === undefined) delete process.env.FAKE_VELA_INVOCATION_LOG; + else process.env.FAKE_VELA_INVOCATION_LOG = previousLog; + if (previousLogSetModel === undefined) delete process.env.FAKE_VELA_LOG_SET_MODEL; + else process.env.FAKE_VELA_LOG_SET_MODEL = previousLogSetModel; + if (previousRequireSetModel === undefined) delete process.env.FAKE_VELA_REQUIRE_SET_MODEL; + else process.env.FAKE_VELA_REQUIRE_SET_MODEL = previousRequireSetModel; + await fsp.rm(markerDir, { recursive: true, force: true }); + } + }); + + it('refreshes AMR connection-test default resolution when file credentials change', async () => { + const tempHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-amr-home-')); + const logPath = path.join(tempHome, 'invocations.jsonl'); + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; + const previousLog = process.env.FAKE_VELA_INVOCATION_LOG; + const previousLogSetModel = process.env.FAKE_VELA_LOG_SET_MODEL; + const previousRequireSetModel = process.env.FAKE_VELA_REQUIRE_SET_MODEL; + const previousPreset = process.env.FAKE_VELA_MODEL_PRESET_JSON; + const previousList = process.env.FAKE_VELA_MODEL_LIST_JSON; + const writeAmrConfig = async (runtimeKey: string, userId: string) => { + const configPath = path.join(tempHome, '.amr', 'config.json'); + await fsp.mkdir(path.dirname(configPath), { recursive: true }); + await fsp.writeFile( + configPath, + JSON.stringify({ + profiles: { + local: { + runtimeKey, + linkUrl: 'https://openrouter.example/v1', + user: { id: userId, email: `${userId}@example.test` }, + }, + }, + }), + 'utf8', + ); + }; + const setCatalog = (modelId: string) => { + const preset = JSON.stringify({ + source: 'preset', + data: [{ id: modelId, default: true }], + }); + const remote = JSON.stringify({ + source: 'remote', + data: [{ id: modelId, default: true }], + }); + process.env.FAKE_VELA_MODEL_PRESET_JSON = preset; + process.env.FAKE_VELA_MODEL_LIST_JSON = remote; + }; + try { + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + process.env.FAKE_VELA_INVOCATION_LOG = logPath; + process.env.FAKE_VELA_LOG_SET_MODEL = '1'; + delete process.env.FAKE_VELA_REQUIRE_SET_MODEL; + + await withFakeAgent( + 'vela', + `void import(${JSON.stringify(pathToFileURL(FAKE_VELA_FIXTURE).href)});\n`, + async () => { + await writeAmrConfig('rt-before', 'user-before'); + setCatalog('before-upgrade-model'); + expect(await testAgentConnection({ agentId: 'amr', model: 'default' })) + .toMatchObject({ ok: true, kind: 'success', model: 'before-upgrade-model' }); + + await writeAmrConfig('rt-after', 'user-after'); + setCatalog('after-upgrade-model'); + expect(await testAgentConnection({ agentId: 'amr', model: 'default' })) + .toMatchObject({ ok: true, kind: 'success', model: 'after-upgrade-model' }); + }, + ); + + const methods = (await fsp.readFile(logPath, 'utf8')) + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as { method: string }) + .map((entry) => entry.method); + expect(methods).toEqual([ + 'new', + 'set_model:before-upgrade-model', + 'new', + 'set_model:after-upgrade-model', + ]); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserProfile; + if (previousLog === undefined) delete process.env.FAKE_VELA_INVOCATION_LOG; + else process.env.FAKE_VELA_INVOCATION_LOG = previousLog; + if (previousLogSetModel === undefined) delete process.env.FAKE_VELA_LOG_SET_MODEL; + else process.env.FAKE_VELA_LOG_SET_MODEL = previousLogSetModel; + if (previousRequireSetModel === undefined) delete process.env.FAKE_VELA_REQUIRE_SET_MODEL; + else process.env.FAKE_VELA_REQUIRE_SET_MODEL = previousRequireSetModel; + if (previousPreset === undefined) delete process.env.FAKE_VELA_MODEL_PRESET_JSON; + else process.env.FAKE_VELA_MODEL_PRESET_JSON = previousPreset; + if (previousList === undefined) delete process.env.FAKE_VELA_MODEL_LIST_JSON; + else process.env.FAKE_VELA_MODEL_LIST_JSON = previousList; + await fsp.rm(tempHome, { recursive: true, force: true }); + } + }); + + it('shares AMR model cache invalidation with connection-test default resolution', async () => { + const previousPreset = process.env.FAKE_VELA_MODEL_PRESET_JSON; + const previousList = process.env.FAKE_VELA_MODEL_LIST_JSON; + const setCatalog = (presetModelId: string, remoteModelId: string) => { + process.env.FAKE_VELA_MODEL_PRESET_JSON = JSON.stringify({ + source: 'preset', + data: [{ id: presetModelId, default: true }], + }); + process.env.FAKE_VELA_MODEL_LIST_JSON = JSON.stringify({ + source: 'remote', + data: [{ id: remoteModelId, default: true }], + }); + }; + try { + await withFakeAgent( + 'vela', + `void import(${JSON.stringify(pathToFileURL(FAKE_VELA_FIXTURE).href)});\n`, + async () => { + const def = getAgentDef('amr'); + expect(def).toBeDefined(); + const launch = resolveAgentLaunch(def!, {}); + expect(launch.launchPath).toBeTruthy(); + const env = applyAgentLaunchEnv( + spawnEnvForAgent( + def!.id, + { + ...process.env, + ...(def!.env || {}), + }, + {}, + undefined, + { resolvedBin: launch.selectedPath }, + ), + launch, + ); + const normalProbeCacheKey = buildAmrModelCacheKey({ + launchPath: launch.launchPath!, + env, + credentialRevision: readVelaCredentialRevision(env), + }); + + setCatalog('preset-before-upgrade', 'remote-before-upgrade'); + expect(await testAgentConnection({ agentId: 'amr', model: 'default' })) + .toMatchObject({ ok: true, kind: 'success', model: 'preset-before-upgrade' }); + + for (let attempt = 0; attempt < 20; attempt += 1) { + const warmed = await testAgentConnection({ agentId: 'amr', model: 'default' }); + if (warmed.model === 'remote-before-upgrade') break; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + expect(await testAgentConnection({ agentId: 'amr', model: 'default' })) + .toMatchObject({ ok: true, kind: 'success', model: 'remote-before-upgrade' }); + + setCatalog('preset-after-upgrade', 'remote-after-upgrade'); + amrModelLoadingCache.invalidate(normalProbeCacheKey); + + expect(await testAgentConnection({ agentId: 'amr', model: 'default' })) + .toMatchObject({ ok: true, kind: 'success', model: 'preset-after-upgrade' }); + }, + ); + } finally { + if (previousPreset === undefined) delete process.env.FAKE_VELA_MODEL_PRESET_JSON; + else process.env.FAKE_VELA_MODEL_PRESET_JSON = previousPreset; + if (previousList === undefined) delete process.env.FAKE_VELA_MODEL_LIST_JSON; + else process.env.FAKE_VELA_MODEL_LIST_JSON = previousList; + } + }); + it('resolves the AMR connection-test scope from the merged launch env', async () => { rememberLiveModels('amr', [{ id: 'local-env-model', label: 'local-env-model' }], 'local'); const previousProfile = process.env.OPEN_DESIGN_AMR_PROFILE; diff --git a/apps/daemon/tests/fixtures/fake-vela.mjs b/apps/daemon/tests/fixtures/fake-vela.mjs index 28e5469defe..e7a6a70e4af 100755 --- a/apps/daemon/tests/fixtures/fake-vela.mjs +++ b/apps/daemon/tests/fixtures/fake-vela.mjs @@ -3,7 +3,7 @@ * Fake vela CLI used by AMR integration tests. Routes by the first argv: * * `vela model preset --format json` → prints the local AMR picker seed. - * `vela model list --format json` → prints the authoritative remote + * `vela model list --all --format json` → prints the authoritative remote * AMR model catalog. * * `vela login` → writes ~/.amr/config.json (the @@ -44,10 +44,12 @@ * FAKE_VELA_PROMPT_ERROR – when set, session/prompt returns a JSON-RPC error * FAKE_VELA_MODELS – newline-separated `vela models` stdout * FAKE_VELA_MODEL_PRESET_JSON – JSON stdout for `model preset --format json` - * FAKE_VELA_MODEL_LIST_JSON – JSON stdout for `model list --format json` + * FAKE_VELA_MODEL_LIST_JSON – JSON stdout for `model list --all --format json` * FAKE_VELA_REQUIRE_SET_MODEL – strict gate (default on); set to '0' to * accept session/prompt without prior * session/set_model (legacy behaviour) + * FAKE_VELA_LOG_SET_MODEL – when set to '1', include session/set_model + * entries in FAKE_VELA_INVOCATION_LOG */ import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'; @@ -246,6 +248,9 @@ function handleMessage(msg) { const next = typeof params?.modelId === 'string' ? params.modelId.trim() : ''; const sessionId = typeof params?.sessionId === 'string' ? params.sessionId : SESSION_ID; if (next) currentModelId = next; + if (env.FAKE_VELA_LOG_SET_MODEL === '1') { + logInvocation(`set_model:${next || ''}`); + } sessionsWithModel.add(sessionId); writeResult(id, {}); return; @@ -472,7 +477,7 @@ if (argv[2] === 'billing' && argv[3] === 'summary') { } } -if (argv[2] === 'model' && argv[4] === '--format' && argv[5] === 'json') { +if (argv[2] === 'model' && argv.includes('--format') && argv.includes('json')) { if (argv[3] === 'preset') { stdout.write(`${env.FAKE_VELA_MODEL_PRESET_JSON || DEFAULT_MODEL_PRESET_JSON}\n`); exit(0); diff --git a/apps/daemon/tests/integrations/vela-errors.test.ts b/apps/daemon/tests/integrations/vela-errors.test.ts index 8ca7ed1dbba..1fddaf5e67d 100644 --- a/apps/daemon/tests/integrations/vela-errors.test.ts +++ b/apps/daemon/tests/integrations/vela-errors.test.ts @@ -56,6 +56,32 @@ describe('AMR account failure classification', () => { }); }); + it('classifies structured tier_model_not_entitled details as an upgrade-required AMR error', () => { + const failure = classifyAmrAccountFailureDetails({ + kind: 'opencode_prompt_error', + code: 'tier_model_not_entitled', + }); + + expect(failure).toMatchObject({ + code: 'AMR_TIER_UPGRADE_REQUIRED', + action: 'upgrade', + }); + expect(failure?.message).toContain('does not include this model'); + }); + + it('classifies structured tier_request_kind_not_entitled details as an upgrade-required AMR error', () => { + const failure = classifyAmrAccountFailureDetails({ + kind: 'opencode_prompt_error', + code: 'tier_request_kind_not_entitled', + }); + + expect(failure).toMatchObject({ + code: 'AMR_TIER_UPGRADE_REQUIRED', + action: 'upgrade', + }); + expect(failure?.message).toContain('request type'); + }); + it('does not classify unrelated structured ACP details as AMR balance errors', () => { expect(classifyAmrAccountFailureDetails({ kind: 'opencode_prompt_error', @@ -109,6 +135,26 @@ describe('AMR account failure classification', () => { }); }); + it('classifies raw tier entitlement error codes into user-friendly upgrade copy', () => { + expect( + classifyAmrAccountFailure( + 'HTTP 403 [code=tier_model_not_entitled] model access denied for current tier', + ), + ).toMatchObject({ + code: 'AMR_TIER_UPGRADE_REQUIRED', + action: 'upgrade', + }); + + expect( + classifyAmrAccountFailure( + 'HTTP 403 [code=tier_request_kind_not_entitled] image generation is not allowed for current tier', + ), + ).toMatchObject({ + code: 'AMR_TIER_UPGRADE_REQUIRED', + action: 'upgrade', + }); + }); + it('classifies 429 wallet balance payloads as AMR balance errors', () => { const failure = classifyAmrAccountFailure( 'HTTP 429 Too Many Requests: quota exceeded because wallet balance is empty', diff --git a/apps/daemon/tests/integrations/vela.routes.test.ts b/apps/daemon/tests/integrations/vela.routes.test.ts index c3484ed9fb7..b2d9c2fc896 100644 --- a/apps/daemon/tests/integrations/vela.routes.test.ts +++ b/apps/daemon/tests/integrations/vela.routes.test.ts @@ -28,8 +28,11 @@ import { startServer } from '../../src/server.js'; import { readAppConfig, writeAppConfig } from '../../src/app-config.js'; import { clearAllVelaLiveAccounts, + clearVelaLiveAccountRefreshThrottle, parseAmrEntryAnalyticsPayload, parseAmrOnboardingProfileAnalyticsPayload, + readVelaCredentialRevision, + velaLiveAccountCacheKey, } from '../../src/integrations/vela.js'; interface StartedServer { @@ -225,6 +228,8 @@ afterEach(() => { delete process.env.FAKE_VELA_BILLING_LOG; delete process.env.FAKE_VELA_BILLING_DELAY_MS; delete process.env.FAKE_VELA_BILLING_UNKNOWN_COMMAND; + delete process.env.FAKE_VELA_MODEL_LIST_JSON; + delete process.env.FAKE_VELA_MODEL_PRESET_JSON; delete process.env.FAKE_VELA_ENV_DUMP_PATH; delete process.env.OD_PUBLIC_BASE_URL; delete process.env.VELA_RUNTIME_KEY; @@ -300,6 +305,196 @@ describe('GET /api/integrations/vela/wallet', () => { } }); + it('invalidates the AMR model catalog cache on explicit wallet refresh', async () => { + const walletApi = await startWalletApi((_req, res) => { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ + balanceUsd: '20.0000', + updatedAt: '2026-07-09T07:30:00.000Z', + })); + }); + process.env.FAKE_VELA_MODEL_LIST_JSON = JSON.stringify({ + source: 'remote', + data: [ + { id: 'public_model_deepseek_v4_flash', enabled: false }, + ], + }); + seedLogin('local', { + apiUrl: walletApi.url, + controlKey: 'ck-wallet-refresh', + runtimeKey: 'rt-wallet-refresh', + user: { id: 'wallet-user', email: 'wallet@example.com', plan: 'free' }, + }); + try { + const warmed = await waitForAmrModels('remote'); + expect(warmed.body.models).toEqual([ + { id: 'deepseek-v4-flash', label: 'deepseek-v4-flash', enabled: false }, + ]); + + const refresh = await getJson<{ status: string; balanceUsd: string | null }>( + `${baseUrl}/api/integrations/vela/wallet?refresh=1`, + ); + expect(refresh.status).toBe(200); + expect(refresh.body.status).toBe('available'); + + const afterRefresh = await getJson<{ + source: 'preset' | 'remote'; + refreshing?: boolean; + models: Array<{ id: string }>; + }>(`${baseUrl}/api/amr/models`); + expect(afterRefresh.status).toBe(200); + expect(afterRefresh.body.source).toBe('preset'); + expect(afterRefresh.body.refreshing).toBe(true); + expect(afterRefresh.body.models.map((model) => model.id)).toEqual([ + 'deepseek-v4-flash', + 'deepseek-v3.2', + 'gemini-2.5-flash', + 'glm-5.1', + ]); + } finally { + await walletApi.close(); + } + }); + + it('invalidates the AMR model catalog cache when a forced status refresh observes a plan change', async () => { + process.env.FAKE_VELA_BILLING_TIER = 'free'; + process.env.FAKE_VELA_BILLING_BALANCE_USD = '1.00'; + process.env.FAKE_VELA_MODEL_LIST_JSON = JSON.stringify({ + source: 'remote', + data: [ + { id: 'public_model_deepseek_v4_flash', enabled: false }, + ], + }); + seedLogin('local', { + controlKey: 'ck-status-refresh', + runtimeKey: 'rt-status-refresh', + user: { id: 'status-user', email: 'status@example.com', plan: 'free' }, + }); + + const firstStatus = await getJson<{ account?: { plan?: string } }>( + `${baseUrl}/api/integrations/vela/status?refresh=1`, + ); + expect(firstStatus.status).toBe(200); + expect(firstStatus.body.account?.plan).toBe('free'); + + const warmed = await waitForAmrModels('remote'); + expect(warmed.body.models).toEqual([ + { id: 'deepseek-v4-flash', label: 'deepseek-v4-flash', enabled: false }, + ]); + + process.env.FAKE_VELA_BILLING_TIER = 'pro'; + const upgradedStatus = await getJson<{ account?: { plan?: string } }>( + `${baseUrl}/api/integrations/vela/status?refresh=1`, + ); + expect(upgradedStatus.status).toBe(200); + expect(upgradedStatus.body.account?.plan).toBe('pro'); + + const afterPlanChange = await getJson<{ + source: 'preset' | 'remote'; + refreshing?: boolean; + models: Array<{ id: string }>; + }>(`${baseUrl}/api/amr/models`); + expect(afterPlanChange.status).toBe(200); + expect(afterPlanChange.body.source).toBe('preset'); + expect(afterPlanChange.body.refreshing).toBe(true); + }); + + it('invalidates the AMR model catalog cache on forced status refresh without a prior account snapshot', async () => { + process.env.FAKE_VELA_BILLING_TIER = 'pro'; + process.env.FAKE_VELA_BILLING_BALANCE_USD = '1.00'; + process.env.FAKE_VELA_MODEL_LIST_JSON = JSON.stringify({ + source: 'remote', + data: [ + { id: 'public_model_deepseek_v4_flash', enabled: false }, + ], + }); + seedLogin('local', { + controlKey: 'ck-status-refresh-cold-account', + runtimeKey: 'rt-status-refresh-cold-account', + user: { + id: 'status-cold-account-user', + email: 'status-cold-account@example.com', + plan: 'pro', + }, + }); + + const warmed = await waitForAmrModels('remote'); + expect(warmed.body.models).toEqual([ + { id: 'deepseek-v4-flash', label: 'deepseek-v4-flash', enabled: false }, + ]); + + clearAllVelaLiveAccounts(); + const refreshedStatus = await getJson<{ account?: { plan?: string } }>( + `${baseUrl}/api/integrations/vela/status?refresh=1`, + ); + expect(refreshedStatus.status).toBe(200); + expect(refreshedStatus.body.account?.plan).toBe('pro'); + + const afterRefresh = await getJson<{ + source: 'preset' | 'remote'; + refreshing?: boolean; + models: Array<{ id: string }>; + }>(`${baseUrl}/api/amr/models`); + expect(afterRefresh.status).toBe(200); + expect(afterRefresh.body.source).toBe('preset'); + expect(afterRefresh.body.refreshing).toBe(true); + }); + + it('preserves model-cache invalidation when forced status refresh joins an in-flight probe', async () => { + clearAllVelaLiveAccounts(); + process.env.FAKE_VELA_BILLING_TIER = 'free'; + process.env.FAKE_VELA_BILLING_BALANCE_USD = '1.00'; + process.env.FAKE_VELA_MODEL_LIST_JSON = JSON.stringify({ + source: 'remote', + data: [ + { id: 'public_model_deepseek_v4_flash', enabled: false }, + ], + }); + seedLogin('local', { + controlKey: 'ck-status-refresh-inflight', + runtimeKey: 'rt-status-refresh-inflight', + user: { id: 'status-inflight-user', email: 'status-inflight@example.com', plan: 'free' }, + }); + + const firstStatus = await getJson<{ account?: { plan?: string } }>( + `${baseUrl}/api/integrations/vela/status?refresh=1`, + ); + expect(firstStatus.status).toBe(200); + expect(firstStatus.body.account?.plan).toBe('free'); + + const warmed = await waitForAmrModels('remote'); + expect(warmed.body.models).toEqual([ + { id: 'deepseek-v4-flash', label: 'deepseek-v4-flash', enabled: false }, + ]); + + const accountCacheKey = velaLiveAccountCacheKey( + readVelaCredentialRevision(process.env, {}), + ); + clearVelaLiveAccountRefreshThrottle(accountCacheKey); + process.env.FAKE_VELA_BILLING_TIER = 'pro'; + process.env.FAKE_VELA_BILLING_DELAY_MS = '150'; + + const warmStatus = getJson<{ account?: { plan?: string } }>( + `${baseUrl}/api/integrations/vela/status`, + ); + await new Promise((resolve) => setTimeout(resolve, 25)); + const forcedStatus = await getJson<{ account?: { plan?: string } }>( + `${baseUrl}/api/integrations/vela/status?refresh=1`, + ); + expect((await warmStatus).body.account?.plan).toBe('free'); + expect(forcedStatus.status).toBe(200); + expect(forcedStatus.body.account?.plan).toBe('pro'); + + const afterPlanChange = await getJson<{ + source: 'preset' | 'remote'; + refreshing?: boolean; + models: Array<{ id: string }>; + }>(`${baseUrl}/api/amr/models`); + expect(afterPlanChange.status).toBe(200); + expect(afterPlanChange.body.source).toBe('preset'); + expect(afterPlanChange.body.refreshing).toBe(true); + }); + it('does not serve a cached wallet balance after the control key is rejected', async () => { let requestCount = 0; const walletApi = await startWalletApi((_req, res) => { @@ -1707,8 +1902,8 @@ describe('POST /api/integrations/vela/logout', () => { expect(first.body.models.map((model) => model.id)).toEqual([ 'deepseek-v4-flash', 'deepseek-v3.2', - 'glm-5.1', 'gemini-2.5-flash', + 'glm-5.1', ]); const warmed = await waitForAmrModels('remote'); @@ -1735,8 +1930,8 @@ describe('POST /api/integrations/vela/logout', () => { expect(afterLogout.body.models.map((model) => model.id)).toEqual([ 'deepseek-v4-flash', 'deepseek-v3.2', - 'glm-5.1', 'gemini-2.5-flash', + 'glm-5.1', ]); }); diff --git a/apps/daemon/tests/run-failure-classification.test.ts b/apps/daemon/tests/run-failure-classification.test.ts index f5bf6f245f6..f57feafce11 100644 --- a/apps/daemon/tests/run-failure-classification.test.ts +++ b/apps/daemon/tests/run-failure-classification.test.ts @@ -16,6 +16,9 @@ vi.mock('../src/integrations/vela-errors.js', () => ({ if (value.includes('authentication required') || value.includes('not authenticated') || value.includes('unauthorized')) { return { code: 'AMR_AUTH_REQUIRED' as const }; } + if (value.includes('tier_model_not_entitled') || value.includes('tier_request_kind_not_entitled')) { + return { code: 'AMR_TIER_UPGRADE_REQUIRED' as const }; + } return null; }, })); @@ -1204,6 +1207,36 @@ describe('classifyRunFailure — AMR/vela reclassification out of execution_fail expect(result?.user_action).toBe('recharge'); }); + it('classifies structured AMR tier entitlement failures as upgrade-required analytics', () => { + const result = classify( + 'AMR_TIER_UPGRADE_REQUIRED', + 'AMR tier upgrade required', + ); + + expect(result).toMatchObject({ + failure_category: 'entitlement_required', + failure_detail: 'amr_tier_upgrade_required', + failure_stage: 'session_init', + retryable: false, + user_action: 'upgrade', + }); + }); + + it('classifies raw AMR tier entitlement texts as upgrade-required analytics', () => { + const result = classify( + 'AGENT_EXECUTION_FAILED', + 'HTTP 403 [code=tier_model_not_entitled] model access denied for current tier', + ); + + expect(result).toMatchObject({ + failure_category: 'entitlement_required', + failure_detail: 'amr_tier_upgrade_required', + failure_stage: 'session_init', + retryable: false, + user_action: 'upgrade', + }); + }); + it('classifies a Chinese 429 rate-limit text as a retryable rate_limit_429', () => { const result = classify( 'AGENT_EXECUTION_FAILED', diff --git a/apps/daemon/tests/runtimes/resolve-model.test.ts b/apps/daemon/tests/runtimes/resolve-model.test.ts index fac7995545e..8f7c3159d60 100644 --- a/apps/daemon/tests/runtimes/resolve-model.test.ts +++ b/apps/daemon/tests/runtimes/resolve-model.test.ts @@ -1,18 +1,14 @@ /** * Coverage for `resolveModelForAgent` — the safety net that turns the - * synthetic `'default'` / null model into a concrete fallback id for - * adapters whose CLI cannot accept "default" (e.g. AMR / vela, which - * requires an explicit `session/set_model` before `session/prompt` and - * has no notion of a CLI-side saved default). + * null model into a concrete fallback id for adapters that need an + * explicit model when the caller did not choose one. * * The chat-run path in server.ts goes: * * user/plugin model -> isKnownModel | sanitizeCustomModel -> resolveModelForAgent * - * so the substitution kicks in even when a plugin or stored chat state - * sends `model: 'default'` (or omits the field). Without this, AMR turns - * fail in production with `session/set_model must be called before - * session/prompt`. + * Explicit `model: 'default'` is intentionally preserved: for ACP runtimes, + * it means "do not send session/set_model; use the upstream default". */ import { describe, expect, it } from 'vitest'; @@ -22,6 +18,7 @@ import { isKnownModel, preferFreshLiveModels, rememberLiveModels, + resolveDefaultModelFromOptions, resolveModelForAgent, } from '../../src/runtimes/models.js'; import type { RuntimeAgentDef } from '../../src/runtimes/types.js'; @@ -51,12 +48,12 @@ describe('resolveModelForAgent', () => { expect(resolveModelForAgent(def, null)).toBe('gpt-5.4-mini'); }); - it('substitutes when the resolved model is the synthetic "default" id and the def omits "default"', () => { + it('preserves an explicit synthetic "default" id even when the def omits "default"', () => { const def = defWith(['gpt-5.4-mini', 'gpt-5.4']); - expect(resolveModelForAgent(def, 'default')).toBe('gpt-5.4-mini'); + expect(resolveModelForAgent(def, 'default')).toBe('default'); }); - it('prefers the first remembered live model when the def cannot accept the synthetic default model', () => { + it('prefers the first remembered live model when no model was selected', () => { const def = defWithId('live-default-test', []); rememberLiveModels(def.id, [ { id: 'deepseek-v3.2', label: 'deepseek-v3.2' }, @@ -64,13 +61,39 @@ describe('resolveModelForAgent', () => { ]); expect(resolveModelForAgent(def, null)).toBe('deepseek-v3.2'); - expect(resolveModelForAgent(def, 'default')).toBe('deepseek-v3.2'); + expect(resolveModelForAgent(def, 'default')).toBe('default'); expect(getRememberedLiveModels(def.id)).toEqual([ { id: 'deepseek-v3.2', label: 'deepseek-v3.2' }, { id: 'glm-5.1', label: 'glm-5.1' }, ]); }); + it('prefers an enabled default remembered model over a disabled first catalog entry', () => { + const def = defWithId('amr-disabled-default-test', []); + const models = [ + { id: 'locked-upgrade-model', label: 'Locked', enabled: false }, + { id: 'enabled-default-model', label: 'Enabled default', enabled: true, default: true }, + { id: 'enabled-model', label: 'Enabled', enabled: true }, + ]; + rememberLiveModels(def.id, models); + + expect(resolveModelForAgent(def, null)).toBe('enabled-default-model'); + expect(resolveModelForAgent(def, 'default')).toBe('default'); + expect(isKnownModel(def, 'locked-upgrade-model')).toBe(true); + expect(getRememberedLiveModels(def.id)).toEqual(models); + }); + + it('uses the first enabled remembered model when no enabled model is marked default', () => { + const def = defWithId('amr-disabled-first-test', []); + rememberLiveModels(def.id, [ + { id: 'locked-upgrade-model', label: 'Locked', enabled: false }, + { id: 'enabled-model', label: 'Enabled', enabled: true }, + ]); + + expect(resolveModelForAgent(def, null)).toBe('enabled-model'); + expect(resolveModelForAgent(def, 'default')).toBe('default'); + }); + it('isolates remembered AMR live models by environment profile scope', () => { const def = defWithId('amr', []); rememberLiveModels(def.id, [ @@ -103,6 +126,21 @@ describe('resolveModelForAgent', () => { expect(preferFreshLiveModels([], remembered)).toEqual(remembered); }); + it('resolves fresh default candidates from enabled models only', () => { + expect(resolveDefaultModelFromOptions([ + { id: 'locked-upgrade-model', label: 'Locked', enabled: false, default: true }, + { id: 'enabled-default-model', label: 'Enabled default', enabled: true, default: true }, + { id: 'enabled-model', label: 'Enabled', enabled: true }, + ])).toBe('enabled-default-model'); + expect(resolveDefaultModelFromOptions([ + { id: 'locked-upgrade-model', label: 'Locked', enabled: false }, + { id: 'enabled-model', label: 'Enabled' }, + ])).toBe('enabled-model'); + expect(resolveDefaultModelFromOptions([ + { id: 'locked-upgrade-model', label: 'Locked', enabled: false, default: true }, + ])).toBeNull(); + }); + it('keeps common default-capable defs untouched even when live models are remembered', () => { const def = defWithId('live-default-capable-test', ['default', 'sonnet']); rememberLiveModels(def.id, [ @@ -130,7 +168,7 @@ describe('resolveModelForAgent', () => { expect(resolveModelForAgent(def, 'default')).toBe('default'); }); - it('honors defaultModelEnvVar over the hardcoded fallback when the env var is set', () => { + it('honors defaultModelEnvVar over the hardcoded fallback when no model is set', () => { const def: RuntimeAgentDef = { ...defWith(['gpt-5.4-mini']), defaultModelEnvVar: 'VELA_DEFAULT_MODEL', @@ -140,7 +178,7 @@ describe('resolveModelForAgent', () => { ).toBe('gpt-5.5'); expect( resolveModelForAgent(def, 'default', { VELA_DEFAULT_MODEL: 'gpt-5.5' }), - ).toBe('gpt-5.5'); + ).toBe('default'); }); it('falls back to the static list when defaultModelEnvVar is set but the env var is empty / missing', () => { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f2efdc25354..eab5b7f181c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -768,20 +768,31 @@ function AppInner() { // unmounted before their poll settled. useEffect(() => { let cancelled = false; - const sync = async () => { - const status = await fetchVelaLoginStatus(); + const sync = async (options: { refresh?: boolean } = {}) => { + const status = await fetchVelaLoginStatus(options); if (!cancelled && status) setAmrLoginStatus(status); + if (!cancelled && status?.loggedIn === true && options.refresh) { + restartAmrPolling(); + } }; void sync(); const onStatusEvent = () => { void sync(); }; + const onReturnToApp = () => { + if (document.visibilityState === 'hidden') return; + void sync({ refresh: true }); + }; window.addEventListener(AMR_LOGIN_STATUS_EVENT, onStatusEvent); + window.addEventListener('focus', onReturnToApp); + document.addEventListener('visibilitychange', onReturnToApp); return () => { cancelled = true; window.removeEventListener(AMR_LOGIN_STATUS_EVENT, onStatusEvent); + window.removeEventListener('focus', onReturnToApp); + document.removeEventListener('visibilitychange', onReturnToApp); }; - }, [daemonLive]); + }, [daemonLive, restartAmrPolling]); useEffect(() => { analytics.setUserId( diff --git a/apps/web/src/analytics/amr-attribution.ts b/apps/web/src/analytics/amr-attribution.ts index 61ca1e67fb7..39edf6f8f19 100644 --- a/apps/web/src/analytics/amr-attribution.ts +++ b/apps/web/src/analytics/amr-attribution.ts @@ -41,6 +41,7 @@ const ENTRY_PAGE_BY_SOURCE: Record = { handoff_amr_website: 'artifact', chat_error_authorize_retry: 'chat_panel', chat_error_recharge: 'chat_panel', + chat_error_upgrade: 'chat_panel', chat_balance_gate_upgrade: 'chat_panel', home_balance_gate_upgrade: 'home', chat_low_balance_warn_recharge: 'chat_panel', diff --git a/apps/web/src/components/AvatarMenu.tsx b/apps/web/src/components/AvatarMenu.tsx index 72fea005bf1..809ede41958 100644 --- a/apps/web/src/components/AvatarMenu.tsx +++ b/apps/web/src/components/AvatarMenu.tsx @@ -9,7 +9,11 @@ import { AgentIcon } from './AgentIcon'; import { PlanBadge } from './PlanBadge'; import { RemixIcon } from './RemixIcon'; import { orderAgentsWithOpenDesignFirst } from './agentOrdering'; -import { SearchableModelSelect } from './modelOptions'; +import { defaultAgentModelId, effectiveAgentModelChoice } from './agentModelSelection'; +import { + orderModelOptionsByAvailability, + SearchableModelSelect, +} from './modelOptions'; import type { AgentInfo, AppConfig, ExecMode, ProviderModelOption } from '../types'; import { SUGGESTED_MODELS_BY_PROTOCOL } from '../state/apiProtocols'; import { KNOWN_PROVIDERS } from '../state/config'; @@ -171,6 +175,11 @@ export function AvatarMenu({ () => agents.find((a) => a.id === config.agentId) ?? null, [agents, config.agentId], ); + const currentAgentModelOptions = useMemo(() => { + const models = currentAgent?.models ?? []; + if (currentAgent?.id !== 'amr') return models; + return orderModelOptionsByAvailability(models); + }, [currentAgent]); const installedAgents = orderAgentsWithOpenDesignFirst( agents.filter((a) => a.available && isVisibleLocalCliAgent(a)), @@ -246,8 +255,9 @@ export function AvatarMenu({ // hasn't touched the picker yet so the labels don't read as empty. const currentChoice = (config.agentId && config.agentModels?.[config.agentId]) || {}; + const normalizedCurrentChoice = effectiveAgentModelChoice(currentAgent, currentChoice) ?? currentChoice; const currentModelId = - currentChoice.model ?? currentAgent?.models?.[0]?.id ?? null; + normalizedCurrentChoice.model ?? defaultAgentModelId(currentAgent); const currentReasoningId = currentChoice.reasoning ?? currentAgent?.reasoningOptions?.[0]?.id ?? null; const currentModelLabel = currentAgent?.models?.find( @@ -517,7 +527,7 @@ export function AvatarMenu({ model: value, }) } - models={currentAgent.models} + models={currentAgentModelOptions} additionalOptions={ currentModelId && !currentAgent.models.some((m) => m.id === currentModelId) @@ -533,6 +543,44 @@ export function AvatarMenu({ searchInputTestId="avatar-model-search" popoverTestId="avatar-model-popover" minSearchableOptions={5} + disabledOptionHint={ + currentAgent.id === 'amr' + ? (option) => + option.enabled === false + ? t('settings.amrModelUpgradeHint') + : null + : undefined + } + onDisabledOptionUpgrade={ + currentAgent.id === 'amr' + ? () => { + const attribution = recordAmrEntry( + analytics.track, + 'avatar_amr_upgrade', + new Date(), + { + metricsConsent: + config.telemetry?.metrics === true, + }, + ); + const deviceId = amrHandoffDeviceId({ + metricsConsent: + config.telemetry?.metrics === true, + resolvedDeviceId: getResolvedDeviceId(), + installationId: config.installationId, + }); + window.open( + attributedAmrUrl( + amrPlansUrl, + attribution, + deviceId, + ), + '_blank', + 'noopener,noreferrer', + ); + } + : undefined + } /> ) : null} diff --git a/apps/web/src/components/ChatPane.tsx b/apps/web/src/components/ChatPane.tsx index b5c8c32e80e..afe4b548238 100644 --- a/apps/web/src/components/ChatPane.tsx +++ b/apps/web/src/components/ChatPane.tsx @@ -58,7 +58,11 @@ import { AMR_LOGIN_STATUS_EVENT, amrLoginStatusEventReason, } from './amrLoginPolling'; -import { amrRechargeUrlForProfile, resolveRunFailureUi } from '../runtime/amr-guidance'; +import { + amrPlansUrlForProfile, + amrRechargeUrlForProfile, + resolveRunFailureUi, +} from '../runtime/amr-guidance'; import { fetchVelaLoginStatus, type VelaLoginStatus, @@ -915,8 +919,8 @@ export function ChatPane({ // shouldn't be yanked back the moment the next chunk streams in. const pinnedToBottomRef = useRef(true); const scrolledToFormRef = useRef>(new Set()); - const refreshInlineAmrLoginStatus = useCallback(async () => { - const next = await fetchVelaLoginStatus().catch(() => null); + const refreshInlineAmrLoginStatus = useCallback(async (options: { refresh?: boolean } = {}) => { + const next = await fetchVelaLoginStatus(options).catch(() => null); if (next) setInlineAmrLoginStatus(next); return next; }, []); @@ -934,6 +938,19 @@ export function ChatPane({ }; }, [refreshInlineAmrLoginStatus]); + useEffect(() => { + const refreshAfterExternalAmrReturn = () => { + if (document.visibilityState === 'hidden') return; + void refreshInlineAmrLoginStatus({ refresh: true }); + }; + window.addEventListener('focus', refreshAfterExternalAmrReturn); + document.addEventListener('visibilitychange', refreshAfterExternalAmrReturn); + return () => { + window.removeEventListener('focus', refreshAfterExternalAmrReturn); + document.removeEventListener('visibilitychange', refreshAfterExternalAmrReturn); + }; + }, [refreshInlineAmrLoginStatus]); + // "Anchor the just-sent turn to the top" (ChatGPT-style). On send we pin // the user's message to the top of the viewport and let the reply stream // below it instead of following the bottom. `pending` is armed by the @@ -1231,7 +1248,9 @@ export function ChatPane({ // — the commercial recovery path; warn (amber) for the self-healing // connection drop; error (red) for everything else. Purely visual. const runErrorTone: 'error' | 'warn' | 'brand' = - runFailureUi?.primaryAction === 'authorize' || runFailureUi?.primaryAction === 'recharge' + runFailureUi?.primaryAction === 'authorize' || + runFailureUi?.primaryAction === 'recharge' || + runFailureUi?.primaryAction === 'upgrade' ? 'brand' : failedRunErrorEvent?.code === 'AGENT_CONNECTION_DROPPED' ? 'warn' @@ -2518,6 +2537,39 @@ export function ChatPane({ > {t('chat.amrError.rechargeCta')} + ) : runFailureUi.primaryAction === 'upgrade' ? ( + ) : null} {canResumeFailedRun ? ( // Resumable failure: continue the agent's existing diff --git a/apps/web/src/components/EntryShell.tsx b/apps/web/src/components/EntryShell.tsx index fc5e6555296..ff1262ae945 100644 --- a/apps/web/src/components/EntryShell.tsx +++ b/apps/web/src/components/EntryShell.tsx @@ -119,6 +119,7 @@ import { ONBOARDING_ARTIFACT_CHIP_IDS } from './home-hero/chips'; import { homeHeroChipLabel } from './home-hero/chip-labels'; import type { PluginUseAction } from './plugins-home/useActions'; import { Icon } from './Icon'; +import { defaultAgentModelId, effectiveAgentModelChoice } from './agentModelSelection'; import { AgentIcon } from './AgentIcon'; import { getModelCapabilityTag, @@ -1417,7 +1418,8 @@ function OnboardingView({ const amrSelectedAndSignedOut = runtime === 'amr' && !amrSignedIn; const selectedAgent = visibleAgents.find((agent) => agent.id === config.agentId) ?? null; const selectedAgentChoice = selectedAgent ? (config.agentModels?.[selectedAgent.id] ?? {}) : {}; - const selectedAgentTestModel = selectedAgentChoice.model ?? selectedAgent?.models?.[0]?.id ?? ''; + const normalizedSelectedAgentChoice = effectiveAgentModelChoice(selectedAgent, selectedAgentChoice) ?? selectedAgentChoice; + const selectedAgentTestModel = normalizedSelectedAgentChoice.model ?? defaultAgentModelId(selectedAgent) ?? ''; const selectedAgentTestReasoning = selectedAgentChoice.reasoning ?? ''; const agentTestInputKey = [ selectedAgent?.id ?? '', @@ -2650,7 +2652,7 @@ function OnboardingView({ daemonLive={daemonLive} selectedAgentId={config.agentId} selectedAgent={selectedAgent} - selectedModel={selectedAgentChoice.model ?? selectedAgent?.models?.[0]?.id ?? ''} + selectedModel={normalizedSelectedAgentChoice.model ?? defaultAgentModelId(selectedAgent) ?? ''} modelOptions={agentModelOptions} scanStatus={cliScanStatus} onRefresh={() => void scanCliAgents()} diff --git a/apps/web/src/components/Icon.tsx b/apps/web/src/components/Icon.tsx index beea03866ca..b2c05d04d87 100644 --- a/apps/web/src/components/Icon.tsx +++ b/apps/web/src/components/Icon.tsx @@ -46,6 +46,7 @@ export type IconName = | 'layout' | 'lightbulb' | 'link' + | 'lock' | 'log-out' | 'integrations-filled' | 'maximize' @@ -466,6 +467,13 @@ export function Icon({ name, size = 14, strokeWidth = 1.6, ...rest }: Props) { ); + case 'lock': + return ( + + + + + ); case 'integrations-filled': return ( diff --git a/apps/web/src/components/InlineModelSwitcher.tsx b/apps/web/src/components/InlineModelSwitcher.tsx index f3a5de5bf63..1bd40f81185 100644 --- a/apps/web/src/components/InlineModelSwitcher.tsx +++ b/apps/web/src/components/InlineModelSwitcher.tsx @@ -65,8 +65,15 @@ import { notifyAmrLoginStatusChanged, } from './amrLoginPolling'; import { orderAgentsWithOpenDesignFirst } from './agentOrdering'; -import { normalizeAgentModelChoice } from './agentModelSelection'; -import { SearchableModelSelect } from './modelOptions'; +import { + defaultAgentModelId, + effectiveAgentModelChoice, + normalizeAgentModelChoice, +} from './agentModelSelection'; +import { + orderModelOptionsByAvailability, + SearchableModelSelect, +} from './modelOptions'; import { mergeProviderModelOptions, providerModelsCacheKey, @@ -401,24 +408,23 @@ export function InlineModelSwitcher({ const currentChoice = (config.agentId && config.agentModels?.[config.agentId]) || {}; - const normalizedCurrentChoice = normalizeAgentModelChoice( - currentAgent, - currentChoice, - ); + const normalizedCurrentChoice = normalizeAgentModelChoice(currentAgent, currentChoice); + const effectiveCurrentChoice = effectiveAgentModelChoice(currentAgent, currentChoice) ?? currentChoice; const currentAgentId = currentAgent?.id ?? null; const normalizedCurrentModelId = normalizedCurrentChoice?.model ?? null; const normalizedCurrentReasoning = normalizedCurrentChoice?.reasoning; const currentAgentModelIds = currentAgent?.models?.map((m) => m.id) ?? []; const configuredModelId = - typeof currentChoice.model === 'string' && currentChoice.model - ? currentChoice.model + typeof effectiveCurrentChoice.model === 'string' && effectiveCurrentChoice.model + ? effectiveCurrentChoice.model : null; const currentModelId = currentAgent?.id === 'amr' && configuredModelId && + configuredModelId !== 'default' && !currentAgentModelIds.includes(configuredModelId) - ? currentAgent?.models?.[0]?.id ?? null - : configuredModelId ?? currentAgent?.models?.[0]?.id ?? null; + ? defaultAgentModelId(currentAgent) + : configuredModelId ?? defaultAgentModelId(currentAgent); useEffect(() => { if (!currentAgentId || !normalizedCurrentModelId) return; @@ -435,6 +441,11 @@ export function InlineModelSwitcher({ const currentModelLabel = currentAgent?.models?.find((m) => m.id === currentModelId)?.label ?? null; + const inlineAgentModelOptions = useMemo(() => { + const models = currentAgent?.models ?? []; + if (currentAgent?.id !== 'amr') return models; + return orderModelOptionsByAvailability(models); + }, [currentAgent]); const amrLoggedIn = amrStatus?.loggedIn === true; useEffect(() => { @@ -962,7 +973,7 @@ export function InlineModelSwitcher({ popoverTestId="inline-model-switcher-agent-model-popover" searchPlaceholder={t('designs.searchPlaceholder')} aria-label={t('inlineSwitcher.modelLabel')} - models={currentAgent.models} + models={inlineAgentModelOptions} value={currentModelId ?? ''} onChange={(nextValue) => { trackExecutionSettingsPopoverClick(analytics.track, { @@ -988,6 +999,48 @@ export function InlineModelSwitcher({ ] : undefined } + disabledOptionHint={ + currentAgent?.id === 'amr' + ? (option) => + option.enabled === false + ? t('settings.amrModelUpgradeHint') + : null + : undefined + } + onDisabledOptionUpgrade={ + currentAgent?.id === 'amr' + ? () => { + const attribution = recordAmrEntry( + analytics.track, + 'inline_amr_upgrade', + new Date(), + { + metricsConsent: + config.telemetry?.metrics === true, + }, + ); + const deviceId = amrHandoffDeviceId({ + metricsConsent: + config.telemetry?.metrics === true, + resolvedDeviceId: getResolvedDeviceId(), + installationId: config.installationId, + }); + window.open( + attributedAmrUrl( + amrPlansUrlForProfile( + amrStatus?.profile ?? + config.agentCliEnv?.amr + ?.OPEN_DESIGN_AMR_PROFILE, + ), + attribution, + deviceId, + ), + '_blank', + 'noopener,noreferrer', + ); + } + : undefined + } /> ) : null} diff --git a/apps/web/src/components/SettingsDialog.tsx b/apps/web/src/components/SettingsDialog.tsx index 7875019ed23..6cd88f73193 100644 --- a/apps/web/src/components/SettingsDialog.tsx +++ b/apps/web/src/components/SettingsDialog.tsx @@ -60,8 +60,10 @@ import { import { isVisibleLocalCliAgent } from '../utils/visibleAgents'; import { ExportDiagnosticsRow } from './ExportDiagnosticsButton'; import { Icon } from './Icon'; +import { defaultAgentModelId, effectiveAgentModelChoice } from './agentModelSelection'; import { CUSTOM_MODEL_SENTINEL, + orderModelOptionsByAvailability, SearchableModelSelect, } from './modelOptions'; import { @@ -1542,7 +1544,7 @@ export function SettingsDialog({ // login, ping-pongs the action between "Signing in…" and "Authorize". const resyncAmrStatus = () => { if (document.visibilityState === 'hidden') return; - void fetchVelaLoginStatus().then((next) => { + void fetchVelaLoginStatus({ refresh: true }).then((next) => { if (cancelled || !next) return; setAmrCardStatus(next); if (next.loggedIn) void refreshAmrWalletSnapshot({ refresh: true }); @@ -3564,8 +3566,8 @@ export function SettingsDialog({ }; const agentModelSummary = (agent: AgentInfo) => { if (!Array.isArray(agent.models) || agent.models.length === 0) return null; - const choice = cfg.agentModels?.[agent.id] ?? {}; - const modelValue = choice.model ?? agent.models[0]?.id ?? ''; + const choice = effectiveAgentModelChoice(agent, cfg.agentModels?.[agent.id]) ?? cfg.agentModels?.[agent.id] ?? {}; + const modelValue = choice.model ?? defaultAgentModelId(agent) ?? ''; if (!modelValue) return t('settings.modelCustom'); return agentModelOptionLabel( agent.models.find((m) => m.id === modelValue), @@ -3615,6 +3617,11 @@ export function SettingsDialog({ } if (!hasModels && !hasReasoning) return null; const choice = cfg.agentModels?.[selected.id] ?? {}; + const effectiveChoice = effectiveAgentModelChoice(selected, choice) ?? choice; + const modelsForSelect = + selected.id === 'amr' && selected.models + ? orderModelOptionsByAvailability(selected.models) + : selected.models; const knownModelIds = selected.models?.map((m) => m.id) ?? []; // Adapters opt out via `supportsCustomModel: false` on their // RuntimeAgentDef when their CLI has no `--model` flag (Antigravity, @@ -3623,8 +3630,8 @@ export function SettingsDialog({ // a live catalog). Undefined === allow, matching today's UX. const allowCustomModel = selected.supportsCustomModel !== false; const configuredModel = - typeof choice.model === 'string' && choice.model - ? choice.model + typeof effectiveChoice.model === 'string' && effectiveChoice.model + ? effectiveChoice.model : null; const setChoice = ( next: { model?: string; reasoning?: string }, @@ -3644,9 +3651,10 @@ export function SettingsDialog({ selected.id === 'amr' && configuredModel && !knownModelIds.includes(configuredModel) - ? selected.models?.[0]?.id ?? '' - : configuredModel ?? selected.models?.[0]?.id ?? ''; + ? defaultAgentModelId(selected) ?? '' + : configuredModel ?? defaultAgentModelId(selected) ?? ''; const reasoningValue = + effectiveChoice.reasoning ?? choice.reasoning ?? selected.reasoningOptions?.[0]?.id ?? ''; const customActive = @@ -3695,7 +3703,7 @@ export function SettingsDialog({ popoverTestId={`settings-agent-model-popover-${selected.id}`} minSearchableOptions={5} popoverMinWidth={340} - models={selected.models!} + models={modelsForSelect!} onChange={(nextValue) => { if (nextValue === CUSTOM_MODEL_SENTINEL) { setAgentCustomModelIds((prev) => { @@ -3724,6 +3732,25 @@ export function SettingsDialog({ ] : undefined } + disabledOptionHint={ + selected.id === 'amr' + ? (option) => + option.enabled === false + ? t('settings.amrModelUpgradeHint') + : null + : undefined + } + onDisabledOptionUpgrade={ + selected.id === 'amr' + ? () => + void openExternalUrl( + attributedAmrSettingsUrl( + amrPlansUrlForProfile(amrCardStatus?.profile), + 'settings_amr_upgrade', + ), + ) + : undefined + } /> diff --git a/apps/web/src/components/agentModelSelection.ts b/apps/web/src/components/agentModelSelection.ts index c455339a49c..9dad73bd359 100644 --- a/apps/web/src/components/agentModelSelection.ts +++ b/apps/web/src/components/agentModelSelection.ts @@ -1,6 +1,21 @@ import type { AgentInfo, AgentModelChoice } from '../types'; -type AgentModelSource = Pick | null | undefined; +type AgentModelSource = + | { + id: AgentInfo['id']; + models?: Array<{ id: string; enabled?: boolean; default?: boolean }>; + } + | null + | undefined; + +export function defaultAgentModelId(agent: AgentModelSource): string | null { + const models = agent?.models ?? []; + return ( + models.find((model) => model.default === true && model.enabled !== false)?.id ?? + models.find((model) => model.enabled !== false)?.id ?? + null + ); +} export function normalizeAgentModelChoice( agent: AgentModelSource, @@ -9,15 +24,20 @@ export function normalizeAgentModelChoice( const configuredModel = typeof choice?.model === 'string' && choice.model ? choice.model : null; if (agent?.id !== 'amr' || !configuredModel) return null; + if (configuredModel === 'default') return null; - const modelIds = agent.models?.map((model) => model.id) ?? []; - if (modelIds.length === 0 || modelIds.includes(configuredModel)) { + const matchingModel = agent.models?.find((model) => model.id === configuredModel) ?? null; + if (!matchingModel && (agent.models?.length ?? 0) === 0) { return null; } + if (matchingModel && matchingModel.enabled !== false) return null; + + const fallbackModel = defaultAgentModelId(agent); + if (!fallbackModel || fallbackModel === configuredModel) return null; return { ...choice, - model: modelIds[0], + model: fallbackModel, }; } diff --git a/apps/web/src/components/modelOptions.tsx b/apps/web/src/components/modelOptions.tsx index bec7095b05f..610a912d530 100644 --- a/apps/web/src/components/modelOptions.tsx +++ b/apps/web/src/components/modelOptions.tsx @@ -2,6 +2,7 @@ import { createPortal } from 'react-dom'; import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type ButtonHTMLAttributes, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import type { AgentModelOption } from '../types'; import { useT } from '../i18n'; +import { Icon } from './Icon'; import { getModelCostTier, getModelCapabilityTag, @@ -57,6 +58,18 @@ export function renderModelOptions(models: AgentModelOption[]) { ); } +export function orderModelOptionsByAvailability( + models: AgentModelOption[], +): AgentModelOption[] { + const enabled: AgentModelOption[] = []; + const disabled: AgentModelOption[] = []; + for (const model of models) { + if (model.enabled === false) disabled.push(model); + else enabled.push(model); + } + return [...enabled, ...disabled]; +} + function matchesModelSearch(model: AgentModelOption, query: string): boolean { const haystack = `${model.id}\n${model.label}`.toLowerCase(); return haystack.includes(query); @@ -72,6 +85,15 @@ interface SearchableModelSelectProps popoverTestId?: string; popoverClassName?: string; additionalOptions?: Array<{ value: string; label: string }>; + disabledOptionHint?: (option: AgentModelOption) => string | null | undefined; + /** + * When provided together with a `disabledOptionHint`, a disabled option + * renders a Lock icon at its trailing edge instead of the inline hint text. + * Hovering the lock surfaces the hint; clicking it invokes this callback + * (e.g. open the AMR console upgrade destination). Available models are + * unaffected. Shared by InlineModelSwitcher, SettingsDialog, and AvatarMenu. + */ + onDisabledOptionUpgrade?: (option: AgentModelOption) => void; minSearchableOptions?: number; popoverMinWidth?: number; } @@ -89,6 +111,8 @@ export const SearchableModelSelect = forwardRef< popoverTestId, popoverClassName, additionalOptions, + disabledOptionHint, + onDisabledOptionUpgrade, minSearchableOptions = 8, popoverMinWidth, className, @@ -215,7 +239,7 @@ export const SearchableModelSelect = forwardRef< window.removeEventListener('resize', updatePosition); window.removeEventListener('scroll', updatePosition, true); }; - }, [open]); + }, [open, popoverMinWidth]); useEffect(() => { if (!open || !shouldShowSearch) return; @@ -305,6 +329,10 @@ export const SearchableModelSelect = forwardRef< > {filteredOptions.map((option, index) => { const active = option.id === value; + const disabled = option.enabled === false; + const disabledHint = disabled ? disabledOptionHint?.(option) : null; + const showUpgradeLock = + disabled && !!disabledHint && !!onDisabledOptionUpgrade; const tag = getModelCapabilityTag(option); const tagLabel = tag ? t(MODEL_CAPABILITY_TAG_LABEL_KEYS[tag]) @@ -317,48 +345,98 @@ export const SearchableModelSelect = forwardRef< const optionLabelId = `${optionId}-label`; const optionCostId = costLabel ? `${optionId}-cost` : undefined; const optionTagId = tagLabel ? `${optionId}-tag` : undefined; - const optionDescriptionIds = [optionCostId, optionTagId] + const optionDisabledId = disabledHint ? `${optionId}-disabled` : undefined; + const optionDescriptionIds = [optionCostId, optionTagId, optionDisabledId] .filter(Boolean) .join(' ') || undefined; + const optionContent = ( + + + + {option.label} + {showUpgradeLock ? ( + + ) : null} + + {costLabel ? ( + + {costLabel} + + ) : null} + {disabledHint && !showUpgradeLock ? ( + + {disabledHint} + + ) : null} + + {tagLabel ? ( + + {tagLabel} + + ) : null} + + ); + if (showUpgradeLock) { + return ( +
+ {optionContent} +
+ ); + } return ( ); })} diff --git a/apps/web/src/i18n/locales/ar.ts b/apps/web/src/i18n/locales/ar.ts index 21c238aa344..a3751d00b4e 100644 --- a/apps/web/src/i18n/locales/ar.ts +++ b/apps/web/src/i18n/locales/ar.ts @@ -393,6 +393,7 @@ export const ar: Dict = { 'settings.amrBalance': 'الرصيد', 'settings.amrPlan': 'الخطة', 'settings.amrUpgrade': 'ترقية', + 'settings.amrModelUpgradeHint': 'قم بالترقية للاستخدام', 'settings.amrLoginErrorCompact': 'فشل تسجيل الدخول إلى Open Design.', 'settings.advanced': 'متقدّم', 'settings.amrLogin': 'تسجيل الدخول', diff --git a/apps/web/src/i18n/locales/de.ts b/apps/web/src/i18n/locales/de.ts index d52b45ede79..aa831e69947 100644 --- a/apps/web/src/i18n/locales/de.ts +++ b/apps/web/src/i18n/locales/de.ts @@ -393,6 +393,7 @@ export const de: Dict = { 'settings.amrBalance': 'Guthaben', 'settings.amrPlan': 'Tarif', 'settings.amrUpgrade': 'Upgrade', + 'settings.amrModelUpgradeHint': 'Zum Nutzen upgraden', 'settings.amrLoginErrorCompact': 'Open Design-Anmeldung fehlgeschlagen.', 'settings.advanced': 'Erweitert', 'settings.amrLogin': 'Anmelden', diff --git a/apps/web/src/i18n/locales/en.ts b/apps/web/src/i18n/locales/en.ts index 81474dfa4ef..fedaf1c673d 100644 --- a/apps/web/src/i18n/locales/en.ts +++ b/apps/web/src/i18n/locales/en.ts @@ -393,6 +393,7 @@ export const en: Dict = { 'settings.amrBalance': 'Balance', 'settings.amrPlan': 'Plan', 'settings.amrUpgrade': 'Upgrade', + 'settings.amrModelUpgradeHint': 'Upgrade to use', 'settings.amrLoginErrorCompact': 'Sign-in failed.', 'settings.advanced': 'Advanced', 'settings.amrLogin': 'Sign in', diff --git a/apps/web/src/i18n/locales/es-ES.ts b/apps/web/src/i18n/locales/es-ES.ts index 802c3ff593d..4843127b0a8 100644 --- a/apps/web/src/i18n/locales/es-ES.ts +++ b/apps/web/src/i18n/locales/es-ES.ts @@ -393,6 +393,7 @@ export const esES: Dict = { 'settings.amrBalance': 'Saldo', 'settings.amrPlan': 'Plan', 'settings.amrUpgrade': 'Mejorar', + 'settings.amrModelUpgradeHint': 'Mejora para usarlo', 'settings.amrLoginErrorCompact': 'Error al iniciar sesión en Open Design.', 'settings.advanced': 'Avanzado', 'settings.amrLogin': 'Iniciar sesión', diff --git a/apps/web/src/i18n/locales/fa.ts b/apps/web/src/i18n/locales/fa.ts index b6ff391c283..67230bc7dc2 100644 --- a/apps/web/src/i18n/locales/fa.ts +++ b/apps/web/src/i18n/locales/fa.ts @@ -393,6 +393,7 @@ export const fa: Dict = { 'settings.amrBalance': 'موجودی', 'settings.amrPlan': 'طرح', 'settings.amrUpgrade': 'ارتقا', + 'settings.amrModelUpgradeHint': 'برای استفاده ارتقا دهید', 'settings.amrLoginErrorCompact': 'ورود به Open Design ناموفق بود.', 'settings.advanced': 'پیشرفته', 'settings.amrLogin': 'ورود', diff --git a/apps/web/src/i18n/locales/fr.ts b/apps/web/src/i18n/locales/fr.ts index a0f86fe0e53..c2ca491e224 100644 --- a/apps/web/src/i18n/locales/fr.ts +++ b/apps/web/src/i18n/locales/fr.ts @@ -393,6 +393,7 @@ export const fr: Dict = { 'settings.amrBalance': 'Solde', 'settings.amrPlan': 'Forfait', 'settings.amrUpgrade': 'Mettre à niveau', + 'settings.amrModelUpgradeHint': 'Mettre à niveau pour l’utiliser', 'settings.amrLoginErrorCompact': 'Échec de la connexion Open Design.', 'settings.advanced': 'Avancé', 'settings.amrLogin': 'Se connecter', diff --git a/apps/web/src/i18n/locales/hu.ts b/apps/web/src/i18n/locales/hu.ts index 7bcd063eb9d..061069fe3d1 100644 --- a/apps/web/src/i18n/locales/hu.ts +++ b/apps/web/src/i18n/locales/hu.ts @@ -393,6 +393,7 @@ export const hu: Dict = { 'settings.amrBalance': 'Egyenleg', 'settings.amrPlan': 'Csomag', 'settings.amrUpgrade': 'Frissítés', + 'settings.amrModelUpgradeHint': 'Frissíts a használathoz', 'settings.amrLoginErrorCompact': 'Az Open Design bejelentkezés sikertelen.', 'settings.advanced': 'Speciális', 'settings.amrLogin': 'Bejelentkezés', diff --git a/apps/web/src/i18n/locales/id.ts b/apps/web/src/i18n/locales/id.ts index 4c694ebb493..17a2704469d 100644 --- a/apps/web/src/i18n/locales/id.ts +++ b/apps/web/src/i18n/locales/id.ts @@ -393,6 +393,7 @@ export const id: Dict = { 'settings.amrBalance': 'Saldo', 'settings.amrPlan': 'Paket', 'settings.amrUpgrade': 'Tingkatkan', + 'settings.amrModelUpgradeHint': 'Upgrade untuk memakai', 'settings.amrLoginErrorCompact': 'Proses masuk Open Design gagal.', 'settings.advanced': 'Lanjutan', 'settings.amrLogin': 'Masuk', diff --git a/apps/web/src/i18n/locales/it.ts b/apps/web/src/i18n/locales/it.ts index 286a27736c7..a5a73be6837 100644 --- a/apps/web/src/i18n/locales/it.ts +++ b/apps/web/src/i18n/locales/it.ts @@ -393,6 +393,7 @@ export const it: Dict = { 'settings.amrBalance': 'Saldo', 'settings.amrPlan': 'Piano', 'settings.amrUpgrade': 'Esegui upgrade', + 'settings.amrModelUpgradeHint': 'Esegui upgrade per usarlo', 'settings.amrLoginErrorCompact': 'Accesso Open Design non riuscito.', 'settings.advanced': 'Avanzate', 'settings.amrLogin': 'Accedi', diff --git a/apps/web/src/i18n/locales/ja.ts b/apps/web/src/i18n/locales/ja.ts index 56b66caff74..7487baeb211 100644 --- a/apps/web/src/i18n/locales/ja.ts +++ b/apps/web/src/i18n/locales/ja.ts @@ -393,6 +393,7 @@ export const ja: Dict = { 'settings.amrBalance': '残高', 'settings.amrPlan': 'プラン', 'settings.amrUpgrade': 'アップグレード', + 'settings.amrModelUpgradeHint': 'アップグレード後に利用', 'settings.amrLoginErrorCompact': 'Open Design へのサインインに失敗しました。', 'settings.advanced': '詳細設定', 'settings.amrLogin': 'サインイン', diff --git a/apps/web/src/i18n/locales/ko.ts b/apps/web/src/i18n/locales/ko.ts index 49972c29bda..5810f050509 100644 --- a/apps/web/src/i18n/locales/ko.ts +++ b/apps/web/src/i18n/locales/ko.ts @@ -393,6 +393,7 @@ export const ko: Dict = { 'settings.amrBalance': '잔액', 'settings.amrPlan': '플랜', 'settings.amrUpgrade': '업그레이드', + 'settings.amrModelUpgradeHint': '업그레이드 후 사용', 'settings.amrLoginErrorCompact': 'Open Design 로그인에 실패했습니다.', 'settings.advanced': '고급', 'settings.amrLogin': '로그인', diff --git a/apps/web/src/i18n/locales/pl.ts b/apps/web/src/i18n/locales/pl.ts index c3426222dfe..b1e6411f21d 100644 --- a/apps/web/src/i18n/locales/pl.ts +++ b/apps/web/src/i18n/locales/pl.ts @@ -393,6 +393,7 @@ export const pl: Dict = { 'settings.amrBalance': 'Saldo', 'settings.amrPlan': 'Plan', 'settings.amrUpgrade': 'Ulepsz', + 'settings.amrModelUpgradeHint': 'Ulepsz, aby użyć', 'settings.amrLoginErrorCompact': 'Logowanie Open Design nie powiodło się.', 'settings.advanced': 'Zaawansowane', 'settings.amrLogin': 'Zaloguj się', diff --git a/apps/web/src/i18n/locales/pt-BR.ts b/apps/web/src/i18n/locales/pt-BR.ts index 7c0b4273f64..839ec4ea07f 100644 --- a/apps/web/src/i18n/locales/pt-BR.ts +++ b/apps/web/src/i18n/locales/pt-BR.ts @@ -393,6 +393,7 @@ export const ptBR: Dict = { 'settings.amrBalance': 'Saldo', 'settings.amrPlan': 'Plano', 'settings.amrUpgrade': 'Fazer upgrade', + 'settings.amrModelUpgradeHint': 'Faça upgrade para usar', 'settings.amrLoginErrorCompact': 'Falha no login do Open Design.', 'settings.advanced': 'Avançado', 'settings.amrLogin': 'Entrar', diff --git a/apps/web/src/i18n/locales/ru.ts b/apps/web/src/i18n/locales/ru.ts index 18d21c793dc..07ef094be34 100644 --- a/apps/web/src/i18n/locales/ru.ts +++ b/apps/web/src/i18n/locales/ru.ts @@ -393,6 +393,7 @@ export const ru: Dict = { 'settings.amrBalance': 'Баланс', 'settings.amrPlan': 'Тариф', 'settings.amrUpgrade': 'Улучшить', + 'settings.amrModelUpgradeHint': 'Обновите, чтобы использовать', 'settings.amrLoginErrorCompact': 'Не удалось войти в Open Design.', 'settings.advanced': 'Дополнительно', 'settings.amrLogin': 'Войти', diff --git a/apps/web/src/i18n/locales/th.ts b/apps/web/src/i18n/locales/th.ts index fde91167f44..18fbe2f6f99 100644 --- a/apps/web/src/i18n/locales/th.ts +++ b/apps/web/src/i18n/locales/th.ts @@ -393,6 +393,7 @@ export const th: Dict = { 'settings.amrBalance': 'ยอดคงเหลือ', 'settings.amrPlan': 'แพ็กเกจ', 'settings.amrUpgrade': 'อัปเกรด', + 'settings.amrModelUpgradeHint': 'อัปเกรดเพื่อใช้งาน', 'settings.amrLoginErrorCompact': 'การลงชื่อเข้าใช้ Open Design ล้มเหลว', 'settings.advanced': 'ขั้นสูง', 'settings.amrLogin': 'ลงชื่อเข้าใช้', diff --git a/apps/web/src/i18n/locales/tr.ts b/apps/web/src/i18n/locales/tr.ts index c892b3633b1..06c7d3f83c9 100644 --- a/apps/web/src/i18n/locales/tr.ts +++ b/apps/web/src/i18n/locales/tr.ts @@ -393,6 +393,7 @@ export const tr: Dict = { 'settings.amrBalance': 'Bakiye', 'settings.amrPlan': 'Plan', 'settings.amrUpgrade': 'Yükselt', + 'settings.amrModelUpgradeHint': 'Kullanmak için yükselt', 'settings.amrLoginErrorCompact': 'Open Design oturum açma başarısız oldu.', 'settings.advanced': 'Gelişmiş', 'settings.amrLogin': 'Oturum aç', diff --git a/apps/web/src/i18n/locales/uk.ts b/apps/web/src/i18n/locales/uk.ts index dc5db763313..5748614d835 100644 --- a/apps/web/src/i18n/locales/uk.ts +++ b/apps/web/src/i18n/locales/uk.ts @@ -393,6 +393,7 @@ export const uk: Dict = { 'settings.amrBalance': 'Баланс', 'settings.amrPlan': 'Тариф', 'settings.amrUpgrade': 'Покращити', + 'settings.amrModelUpgradeHint': 'Оновіть, щоб використовувати', 'settings.amrLoginErrorCompact': 'Не вдалося ввійти в Open Design.', 'settings.advanced': 'Розширені', 'settings.amrLogin': 'Увійти', diff --git a/apps/web/src/i18n/locales/zh-CN.ts b/apps/web/src/i18n/locales/zh-CN.ts index 74950c3be96..f5d84c8fe3b 100644 --- a/apps/web/src/i18n/locales/zh-CN.ts +++ b/apps/web/src/i18n/locales/zh-CN.ts @@ -3888,6 +3888,7 @@ export const zhCN: Dict = { 'settings.amrBalance': '余额', 'settings.amrPlan': '套餐', 'settings.amrUpgrade': '升级', + 'settings.amrModelUpgradeHint': '升级后使用', 'settings.updateCheck': '检查更新', 'settings.updateNow': '立即更新', 'settings.updateRecheck': '重新检查', diff --git a/apps/web/src/i18n/locales/zh-TW.ts b/apps/web/src/i18n/locales/zh-TW.ts index ebe5775d1b5..a2afe736ca5 100644 --- a/apps/web/src/i18n/locales/zh-TW.ts +++ b/apps/web/src/i18n/locales/zh-TW.ts @@ -3894,6 +3894,7 @@ export const zhTW: Dict = { 'settings.amrBalance': '餘額', 'settings.amrPlan': '方案', 'settings.amrUpgrade': '升級', + 'settings.amrModelUpgradeHint': '升級後使用', 'settings.updateCheck': '檢查更新', 'settings.updateNow': '立即更新', 'settings.updateRecheck': '重新檢查', diff --git a/apps/web/src/i18n/types.ts b/apps/web/src/i18n/types.ts index 2496789ccc1..72adafc90d8 100644 --- a/apps/web/src/i18n/types.ts +++ b/apps/web/src/i18n/types.ts @@ -380,6 +380,7 @@ export interface Dict { 'settings.amrBalance': string; 'settings.amrPlan': string; 'settings.amrUpgrade': string; + 'settings.amrModelUpgradeHint': string; 'settings.amrLoginErrorCompact': string; 'settings.apiSection': string; 'settings.quickFillProvider': string; diff --git a/apps/web/src/providers/daemon.ts b/apps/web/src/providers/daemon.ts index aa49dff4852..5f5b9ed8fe4 100644 --- a/apps/web/src/providers/daemon.ts +++ b/apps/web/src/providers/daemon.ts @@ -844,9 +844,10 @@ export interface VelaLoginStatus { // POST /api/integrations/vela/login/cancel — terminate a still-pending login // POST /api/integrations/vela/logout — clear ~/.amr auth and Settings-backed AMR auth env // The Settings UI polls /status after kicking off /login to detect completion. -export async function fetchVelaLoginStatus(): Promise { +export async function fetchVelaLoginStatus(options: { refresh?: boolean } = {}): Promise { try { - const resp = await fetch('/api/integrations/vela/status'); + const query = options.refresh ? '?refresh=1' : ''; + const resp = await fetch(`/api/integrations/vela/status${query}`, { cache: 'no-store' }); if (!resp.ok) return null; return (await resp.json()) as VelaLoginStatus; } catch { diff --git a/apps/web/src/runtime/amr-guidance.ts b/apps/web/src/runtime/amr-guidance.ts index 6a10518091a..42e8171d3af 100644 --- a/apps/web/src/runtime/amr-guidance.ts +++ b/apps/web/src/runtime/amr-guidance.ts @@ -55,6 +55,7 @@ const PROMOTE_AMR_CODES = new Set([ // - retry: re-run with the current agent. // - authorize: AMR sign-in/authorize flow, then auto-retry on success. // - recharge: open the AMR wallet (manual retry afterwards). +// - upgrade: open the AMR plans view (manual retry afterwards). // - launch-terminal-auth: Antigravity-specific. agy's `-p` // print mode cannot complete Google // Sign-In on its own (no input field @@ -77,6 +78,7 @@ export type RunFailurePrimaryAction = | 'retry' | 'authorize' | 'recharge' + | 'upgrade' | 'launch-terminal-auth' | 'launch-terminal-switch-model'; @@ -109,6 +111,7 @@ export type RunFailureTitleKey = | 'chat.runError.title.connectionDropped' | 'chat.runError.title.signInRequired' | 'chat.runError.title.rateLimited' + | 'chat.amrBalanceGate.title' | 'chat.runError.title.cliMissing' | 'chat.runError.title.promptTooLarge' | 'chat.runError.title.modelUnavailable' @@ -195,6 +198,7 @@ const AGENT_AGNOSTIC_FAILURE_UI: Record = { // unavailable, tool loop, bad output, bad runtime def) → named type + fix // - AMR agent, auth required → authorize-and-retry button, clearer copy // - AMR agent, insufficient funds → recharge button + manual retry, clearer copy +// - AMR agent, tier entitlement → upgrade button + manual retry // - AMR agent, anything else → plain retry // - non-AMR agent, model/auth/quota error → plain retry + promotion card // - non-AMR agent, generic failure → plain retry @@ -229,6 +233,15 @@ export function resolveRunFailureUi( showSwitchCard: false, }; } + if (code === 'AMR_TIER_UPGRADE_REQUIRED') { + return { + primaryAction: 'upgrade', + titleKey: 'chat.amrBalanceGate.title', + messageKey: null, + secondaryRetry: true, + showSwitchCard: false, + }; + } return { primaryAction: 'retry', titleKey: 'chat.runError.title.generic', diff --git a/apps/web/src/styles/home/entry-layout.css b/apps/web/src/styles/home/entry-layout.css index bb9b8302e98..535b7b086aa 100644 --- a/apps/web/src/styles/home/entry-layout.css +++ b/apps/web/src/styles/home/entry-layout.css @@ -1741,6 +1741,13 @@ .model-select-searchable__option:hover { background: var(--bg-subtle); } +.model-select-searchable__option.is-disabled { + opacity: 0.5; + cursor: not-allowed; +} +.model-select-searchable__option.is-disabled:hover { + background: transparent; +} .model-select-searchable__option.is-active { background: var(--accent-tint); color: var(--accent-strong); @@ -1776,6 +1783,8 @@ } .model-select-searchable__value-label, .model-select-searchable__option-label { + display: inline-flex; + align-items: center; font-size: 12.5px; line-height: 1.35; min-width: 0; @@ -1833,6 +1842,30 @@ .model-select-searchable__option.is-active .model-select-searchable__option-meta { color: color-mix(in srgb, var(--accent-strong) 60%, var(--text-muted)); } +.model-select-searchable__option-lock-inline { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 22px; + height: 22px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + vertical-align: middle; + margin-left: 6px; + transition: + color 160ms cubic-bezier(0.23, 1, 0.32, 1), + background 160ms cubic-bezier(0.23, 1, 0.32, 1); +} +.model-select-searchable__option-lock-inline:hover, +.model-select-searchable__option-lock-inline:focus-visible { + color: var(--accent-strong); + background: var(--accent-tint); +} .model-select-searchable__value-badge[data-tag='fast'], .model-select-searchable__option-badge[data-tag='fast'] { --model-tag-color: #39a9a2; diff --git a/apps/web/src/styles/workspace/artifacts.css b/apps/web/src/styles/workspace/artifacts.css index d04b1af8be3..4d0328fe52a 100644 --- a/apps/web/src/styles/workspace/artifacts.css +++ b/apps/web/src/styles/workspace/artifacts.css @@ -2145,6 +2145,13 @@ .model-select-searchable__option:hover { background: var(--bg-subtle); } +.model-select-searchable__option.is-disabled { + opacity: 0.5; + cursor: not-allowed; +} +.model-select-searchable__option.is-disabled:hover { + background: transparent; +} .model-select-searchable__option.is-active { background: var(--accent-tint); color: var(--accent-strong); @@ -2190,6 +2197,8 @@ } .model-select-searchable__value-label, .model-select-searchable__option-label { + display: inline-flex; + align-items: center; font-size: 12.5px; line-height: 1.35; min-width: 0; @@ -2247,6 +2256,30 @@ .model-select-searchable__option.is-active .model-select-searchable__option-meta { color: color-mix(in srgb, var(--accent-strong) 60%, var(--text-muted)); } +.model-select-searchable__option-lock-inline { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 22px; + height: 22px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + vertical-align: middle; + margin-left: 6px; + transition: + color 160ms cubic-bezier(0.23, 1, 0.32, 1), + background 160ms cubic-bezier(0.23, 1, 0.32, 1); +} +.model-select-searchable__option-lock-inline:hover, +.model-select-searchable__option-lock-inline:focus-visible { + color: var(--accent-strong); + background: var(--accent-tint); +} .model-select-searchable__value-badge[data-tag='fast'], .model-select-searchable__option-badge[data-tag='fast'] { --model-tag-color: #39a9a2; diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index c9076d4d175..74bb4a458a7 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -521,6 +521,8 @@ export interface ExamplePreview { export interface AgentModelOption { id: string; label: string; + enabled?: boolean; + default?: boolean; inputPriceUsdPerMillion?: number; outputPriceUsdPerMillion?: number; } diff --git a/apps/web/tests/analytics/amr-attribution.test.ts b/apps/web/tests/analytics/amr-attribution.test.ts index 8d1bd045a8b..54ea837458b 100644 --- a/apps/web/tests/analytics/amr-attribution.test.ts +++ b/apps/web/tests/analytics/amr-attribution.test.ts @@ -38,6 +38,7 @@ describe('AMR attribution helper', () => { 'handoff_amr_website', 'chat_error_authorize_retry', 'chat_error_recharge', + 'chat_error_upgrade', 'chat_error_switch_retry_card', 'generation_preview_authorize_retry', 'generation_preview_recharge', diff --git a/apps/web/tests/components/App.amr-polling.test.tsx b/apps/web/tests/components/App.amr-polling.test.tsx index e50829cf59e..92e671c584d 100644 --- a/apps/web/tests/components/App.amr-polling.test.tsx +++ b/apps/web/tests/components/App.amr-polling.test.tsx @@ -272,6 +272,45 @@ describe('App AMR polling', () => { expect(mockedFetchAmrModels).toHaveBeenCalledTimes(3); }); + it('refreshes AMR status and model catalog when returning from an external upgrade flow', async () => { + mockedFetchAmrModels.mockReset(); + mockedFetchAmrModels + .mockResolvedValueOnce({ + source: 'remote', + refreshing: false, + models: [{ id: 'locked-model', label: 'locked-model', enabled: false }], + }) + .mockResolvedValueOnce({ + source: 'remote', + refreshing: false, + models: [{ id: 'unlocked-model', label: 'unlocked-model', enabled: true }], + }); + mockedFetchVelaLoginStatus.mockResolvedValue({ + loggedIn: true, + loginInFlight: false, + profile: 'local', + user: null, + configPath: '/tmp/amr-config.json', + account: { plan: 'pro' }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('amr-model').textContent).toBe('locked-model'); + }); + + fireEvent(window, new Event('focus')); + + await waitFor(() => { + expect(mockedFetchVelaLoginStatus).toHaveBeenCalledWith({ refresh: true }); + }); + await waitFor(() => { + expect(screen.getByTestId('amr-model').textContent).toBe('unlocked-model'); + }); + expect(mockedFetchAmrModels).toHaveBeenCalledTimes(2); + }); + it('starts AMR preset polling before the agent probe resolves', { timeout: 10_000 }, async () => { let resolveAgents!: (value: Array<{ id: string; diff --git a/apps/web/tests/components/ChatPane.conversation-title.test.tsx b/apps/web/tests/components/ChatPane.conversation-title.test.tsx index 266a04246fa..c2bc97af706 100644 --- a/apps/web/tests/components/ChatPane.conversation-title.test.tsx +++ b/apps/web/tests/components/ChatPane.conversation-title.test.tsx @@ -182,6 +182,47 @@ describe('ChatPane session switcher', () => { ); expect(parsedWalletUrl.searchParams.get('od_entry_source')).toBe('chat_error_recharge'); }); + + it('opens the profile-scoped plans view from the AMR tier upgrade action', () => { + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + render( + 'project-1'} + onSend={vi.fn()} + onStop={vi.fn()} + onRetry={vi.fn()} + conversations={[conversation({ id: 'conv-1', title: 'Current' })]} + activeConversationId="conv-1" + onSelectConversation={vi.fn()} + onDeleteConversation={vi.fn()} + config={{ agentCliEnv: { amr: { OPEN_DESIGN_AMR_PROFILE: 'test' } } } as unknown as AppConfig} + />, + ); + + fireEvent.click(screen.getByText('chat.amrBalanceGate.plansCta')); + + const [plansUrl, target, features] = openSpy.mock.calls[0] ?? []; + expect(target).toBe('_blank'); + expect(features).toBe('noopener,noreferrer'); + const parsedPlansUrl = new URL(String(plansUrl)); + expect(`${parsedPlansUrl.origin}${parsedPlansUrl.pathname}`).toBe( + 'https://vela.powerformer.net/wallet', + ); + expect(parsedPlansUrl.searchParams.get('view')).toBe('plans'); + expect(parsedPlansUrl.searchParams.get('od_entry_source')).toBe('chat_error_upgrade'); + }); }); function renderChatPane(props: { diff --git a/apps/web/tests/components/InlineModelSwitcher.test.tsx b/apps/web/tests/components/InlineModelSwitcher.test.tsx index 132a16b7ca6..ed2a59d9004 100644 --- a/apps/web/tests/components/InlineModelSwitcher.test.tsx +++ b/apps/web/tests/components/InlineModelSwitcher.test.tsx @@ -190,6 +190,32 @@ describe('InlineModelSwitcher AMR row', () => { expect(chip.getAttribute('aria-label')).toMatch(/·/u); }); + it('shows an explicit AMR default choice instead of the concrete catalog fallback', () => { + renderSwitcher( + { + agentId: 'amr', + agentModels: { amr: { model: 'default', reasoning: 'default' } }, + }, + [ + { + ...amrAgent, + models: [ + { id: 'kimi-k2.6', label: 'Kimi K2.6', default: true }, + { id: 'glm-5.1', label: 'GLM 5.1' }, + ], + }, + ], + ); + + const chip = screen.getByTestId('inline-model-switcher-chip'); + expect(chip.getAttribute('aria-label')).toContain('Open Design'); + expect(chip.getAttribute('aria-label')).toContain('default'); + expect(chip.getAttribute('aria-label')).not.toContain('Kimi K2.6'); + + fireEvent.click(chip); + expect(screen.getByTestId('inline-model-switcher-agent-model')).toHaveTextContent('default'); + }); + it('does not show the AMR reminder dot when AMR is already selected', () => { renderSwitcher({}, [amrAgent, codexAgent]); diff --git a/apps/web/tests/components/agentModelSelection.test.ts b/apps/web/tests/components/agentModelSelection.test.ts index 0004b317e80..e741b926cdb 100644 --- a/apps/web/tests/components/agentModelSelection.test.ts +++ b/apps/web/tests/components/agentModelSelection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + defaultAgentModelId, effectiveAgentModelChoice, normalizeAgentModelChoice, } from '../../src/components/agentModelSelection'; @@ -51,6 +52,29 @@ describe('agent model selection', () => { }); }); + it('preserves explicit AMR default choices instead of normalizing them to a concrete fallback', () => { + const choice = { + model: 'default', + reasoning: 'default', + }; + + expect(normalizeAgentModelChoice(amrAgent, choice)).toBeNull(); + expect(effectiveAgentModelChoice(amrAgent, choice)).toEqual(choice); + }); + + it('does not select a disabled model as the AMR default when every catalog row is locked', () => { + const lockedAmrAgent: AgentInfo = { + ...amrAgent, + models: [ + { id: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash', enabled: false }, + { id: 'kimi-k2.6', label: 'Kimi K2.6', enabled: false, default: true }, + ], + }; + + expect(defaultAgentModelId(lockedAmrAgent)).toBeNull(); + expect(effectiveAgentModelChoice(lockedAmrAgent, undefined)).toBeUndefined(); + }); + it('keeps non-AMR custom model choices unchanged', () => { expect( effectiveAgentModelChoice(codexAgent, { diff --git a/apps/web/tests/components/modelOptions.test.tsx b/apps/web/tests/components/modelOptions.test.tsx index 060ec5c112f..639114b1551 100644 --- a/apps/web/tests/components/modelOptions.test.tsx +++ b/apps/web/tests/components/modelOptions.test.tsx @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { CUSTOM_MODEL_SENTINEL, isCustomModel, + orderModelOptionsByAvailability, renderModelOptions, SearchableModelSelect, } from '../../src/components/modelOptions'; @@ -85,6 +86,19 @@ describe('isCustomModel', () => { }); }); +describe('orderModelOptionsByAvailability', () => { + it('keeps available models before unavailable models without reordering within each group', () => { + expect( + orderModelOptionsByAvailability([ + { id: 'locked-a', label: 'Locked A', enabled: false }, + { id: 'ready-a', label: 'Ready A' }, + { id: 'locked-b', label: 'Locked B', enabled: false }, + { id: 'ready-b', label: 'Ready B', enabled: true }, + ]).map((model) => model.id), + ).toEqual(['ready-a', 'ready-b', 'locked-a', 'locked-b']); + }); +}); + describe('SearchableModelSelect', () => { it('renders capability tag and cost metadata as option text', async () => { render( @@ -108,9 +122,77 @@ describe('SearchableModelSelect', () => { const option = await screen.findByRole('option', { name: /^deepseek-v4-flash$/ }); expect(option.textContent).toContain('Lowest cost'); expect(option.textContent).toContain('Fast'); - expect(option).toHaveAccessibleName('deepseek-v4-flash'); - expect(option).toHaveAccessibleDescription('Lowest cost Fast'); + expect(option.getAttribute('aria-labelledby')).toBeTruthy(); + expect(option.getAttribute('aria-describedby')).toBeTruthy(); expect(option.querySelector('[data-description]')).toBeNull(); expect(option.querySelector('[data-label]')).toBeNull(); }); + + it('disables unavailable options and shows the provided hint', async () => { + const onChange = vi.fn(); + render( + + option.enabled === false ? '请升级后使用高级模型' : null + } + />, + ); + + fireEvent.click(screen.getByRole('combobox')); + + const disabledOption = await screen.findByRole('option', { name: /^deepseek-v4-pro$/ }); + expect(disabledOption.hasAttribute('disabled')).toBe(true); + expect(disabledOption.getAttribute('aria-describedby')).toBeTruthy(); + expect(disabledOption.textContent).toContain('请升级后使用高级模型'); + + fireEvent.click(disabledOption); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('renders a lock affordance for disabled options that opens the upgrade destination', async () => { + const onChange = vi.fn(); + const onDisabledOptionUpgrade = vi.fn(); + render( + + option.enabled === false ? 'Upgrade to use' : null + } + onDisabledOptionUpgrade={onDisabledOptionUpgrade} + />, + ); + + fireEvent.click(screen.getByRole('combobox')); + + // The inline hint text is replaced by the lock affordance whose accessible + // name (and tooltip) carry the hint. + const disabledOption = await screen.findByRole('option', { + name: /^deepseek-v4-pro$/, + }); + expect(disabledOption.getAttribute('aria-disabled')).toBe('true'); + + const lock = screen.getByTestId('model-option-upgrade-lock'); + expect(lock.getAttribute('aria-label')).toBe('Upgrade to use'); + expect(lock.getAttribute('title')).toBe('Upgrade to use'); + + fireEvent.click(lock); + expect(onDisabledOptionUpgrade).toHaveBeenCalledTimes(1); + expect(onDisabledOptionUpgrade).toHaveBeenCalledWith( + expect.objectContaining({ id: 'deepseek-v4-pro' }), + ); + expect(onChange).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/tests/runtime/amr-guidance.test.ts b/apps/web/tests/runtime/amr-guidance.test.ts index b44412105a0..308aa1b5d21 100644 --- a/apps/web/tests/runtime/amr-guidance.test.ts +++ b/apps/web/tests/runtime/amr-guidance.test.ts @@ -133,6 +133,17 @@ describe('resolveRunFailureUi', () => { }); }); + it('offers upgrade + manual retry for an AMR tier entitlement failure', () => { + const ui = resolveRunFailureUi('AMR_TIER_UPGRADE_REQUIRED', 'amr'); + expect(ui).toMatchObject({ + primaryAction: 'upgrade', + titleKey: 'chat.amrBalanceGate.title', + messageKey: null, + secondaryRetry: true, + showSwitchCard: false, + }); + }); + it('falls back to plain retry for other AMR failures', () => { const ui = resolveRunFailureUi('AGENT_EXECUTION_FAILED', 'amr'); expect(ui).toMatchObject({ primaryAction: 'retry', showSwitchCard: false }); diff --git a/e2e/lib/amr.ts b/e2e/lib/amr.ts index a90286cb5da..9896c4843c1 100644 --- a/e2e/lib/amr.ts +++ b/e2e/lib/amr.ts @@ -10,6 +10,7 @@ export type FakeVelaOptions = { failBalanceAtPromptOnce?: boolean; failModelListInvalidApiKey?: boolean; requireLoginConfig?: boolean; + requireSetModel?: boolean; sessionId?: string; }; @@ -101,6 +102,7 @@ const BALANCE_FAIL = ${options.failBalanceAtPrompt === true ? 'true' : 'false'}; const BALANCE_FAIL_ONCE = ${options.failBalanceAtPromptOnce === true ? 'true' : 'false'}; const MODEL_LIST_INVALID_API_KEY = ${options.failModelListInvalidApiKey === true ? 'true' : 'false'}; const REQUIRE_LOGIN = ${options.requireLoginConfig === false ? 'false' : 'true'}; +const REQUIRE_SET_MODEL = ${options.requireSetModel === false ? 'false' : 'true'}; function writeMessage(obj) { stdout.write(JSON.stringify(obj) + '\\n'); @@ -241,7 +243,7 @@ function handle(msg) { } if (method === 'session/prompt') { const sid = (params && params.sessionId) || SESSION_ID; - if (!sessionsWithModel.has(sid)) { + if (REQUIRE_SET_MODEL && !sessionsWithModel.has(sid)) { writeError(id, 'session/set_model must be called before session/prompt'); return; } diff --git a/e2e/tests/amr/auth-error-convergence.test.ts b/e2e/tests/amr/auth-error-convergence.test.ts index bf4cabdb788..dec7f064a5f 100644 --- a/e2e/tests/amr/auth-error-convergence.test.ts +++ b/e2e/tests/amr/auth-error-convergence.test.ts @@ -19,6 +19,7 @@ describe('AMR auth error convergence', () => { endpoints: suite.amr, failAuthAtPrompt: true, requireLoginConfig: false, + requireSetModel: false, }); await putAmrAppConfig(webUrl, { diff --git a/e2e/tests/amr/insufficient-balance.test.ts b/e2e/tests/amr/insufficient-balance.test.ts index 1d420eb0db8..56b32880428 100644 --- a/e2e/tests/amr/insufficient-balance.test.ts +++ b/e2e/tests/amr/insufficient-balance.test.ts @@ -19,6 +19,7 @@ describe('AMR insufficient balance run failures', () => { endpoints: suite.amr, failBalanceAtPrompt: true, requireLoginConfig: false, + requireSetModel: false, }); await putAmrAppConfig(webUrl, { diff --git a/e2e/tests/amr/logout-state-persistence.test.ts b/e2e/tests/amr/logout-state-persistence.test.ts index 7992aa01fbe..a3aa8231b61 100644 --- a/e2e/tests/amr/logout-state-persistence.test.ts +++ b/e2e/tests/amr/logout-state-persistence.test.ts @@ -21,10 +21,12 @@ describe('AMR logout state persistence', () => { assistantText: 'AMR logout persistence success', endpoints: suite.amr, requireLoginConfig: false, + requireSetModel: false, }); const strictVelaBin = await writeFakeVelaBin(join(suite.scratchDir, 'fake-vela-logout-strict'), { assistantText: 'AMR logout persistence strict', endpoints: suite.amr, + requireSetModel: false, }); await putAmrAppConfig(webUrl, { diff --git a/e2e/tests/amr/relogin-required.test.ts b/e2e/tests/amr/relogin-required.test.ts index e59a01195c9..dc7f0685cd0 100644 --- a/e2e/tests/amr/relogin-required.test.ts +++ b/e2e/tests/amr/relogin-required.test.ts @@ -18,6 +18,7 @@ describe('AMR relogin-required run failures', () => { await suite.with.toolsDev(async ({ webUrl }) => { const velaBin = await writeFakeVelaBin(join(suite.scratchDir, 'fake-vela-missing-login'), { endpoints: suite.amr, + requireSetModel: false, }); await putAmrAppConfig(webUrl, { @@ -62,6 +63,7 @@ describe('AMR relogin-required run failures', () => { await suite.with.toolsDev(async ({ webUrl }) => { const velaBin = await writeFakeVelaBin(join(suite.scratchDir, 'fake-vela-configured-profile'), { endpoints: suite.amr, + requireSetModel: false, }); await putAmrAppConfig(webUrl, { @@ -108,6 +110,7 @@ describe('AMR relogin-required run failures', () => { const velaBin = await writeFakeVelaBin(join(suite.scratchDir, 'fake-vela-daemon-env-credentials'), { endpoints: suite.amr, requireLoginConfig: false, + requireSetModel: false, }); await putAmrAppConfig(webUrl, { diff --git a/e2e/tests/amr/turn.test.ts b/e2e/tests/amr/turn.test.ts index c2188cfe8b1..4600f17618b 100644 --- a/e2e/tests/amr/turn.test.ts +++ b/e2e/tests/amr/turn.test.ts @@ -14,13 +14,8 @@ * `agentId: 'amr'` through `attachAcpSession` (not the legacy * json-event-stream parser the old `incongruous-megaraptor` branch * used). - * 2. AMR preflight refreshes `vela models` and substitutes the synthetic - * `'default'` model id with the first live model (`glm-5`), so vela - * receives a real `session/set_model` before `session/prompt` — a - * regression here would manifest as - * `session/set_model must be called before session/prompt` on the - * real `vela` binary, but the fake here enforces the same gate - * so it surfaces locally without a vela install. + * 2. The synthetic `'default'` model id is preserved so vela can use the + * upstream account default without an explicit `session/set_model`. * 3. The full ACP transport (`initialize` → `session/new` → * `session/set_model` → `session/prompt` → `session/update*`) flows * between the daemon and a spawned subprocess that respects vela's @@ -160,14 +155,6 @@ function handle(msg) { } if (method === 'session/prompt') { const sid = (params && params.sessionId) || SESSION_ID; - if (!sessionsWithModel.has(sid)) { - writeMessage({ - jsonrpc: '2.0', - id, - error: { code: -32602, message: 'session/set_model must be called before session/prompt' }, - }); - return; - } writeNotification('session/update', { sessionId: sid, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: ASSISTANT_TEXT } }, diff --git a/e2e/ui/amr-logout-requires-relogin.test.ts b/e2e/ui/amr-logout-requires-relogin.test.ts index e1edc90c576..a8caecfff8d 100644 --- a/e2e/ui/amr-logout-requires-relogin.test.ts +++ b/e2e/ui/amr-logout-requires-relogin.test.ts @@ -47,6 +47,7 @@ test('[P0] after local Sign out, AMR runs require re-login and Settings keeps AM const reloginVelaBin = await writeFakeVelaBin(join(root, 'bin-relogin'), { failAuthAtPrompt: true, requireLoginConfig: false, + requireSetModel: false, }); await mkdir(root, { recursive: true }); let loggedIn = true; diff --git a/e2e/ui/amr-run-failure-recovery.test.ts b/e2e/ui/amr-run-failure-recovery.test.ts index 3ee9202323f..f8aaf3303b6 100644 --- a/e2e/ui/amr-run-failure-recovery.test.ts +++ b/e2e/ui/amr-run-failure-recovery.test.ts @@ -911,6 +911,7 @@ async function setupAmrWorkspace( ? { failModelListInvalidApiKey: options.failModelListInvalidApiKey } : {}), ...(options.requireLoginConfig !== undefined ? { requireLoginConfig: options.requireLoginConfig } : {}), + requireSetModel: false, }); await mkdir(homeDir, { recursive: true }); if (options.seedLoginConfig !== false) { diff --git a/nix/pnpm-deps.nix b/nix/pnpm-deps.nix index aed867b2f2d..114d1ee1121 100644 --- a/nix/pnpm-deps.nix +++ b/nix/pnpm-deps.nix @@ -9,6 +9,6 @@ # 1. Temporarily set the consuming `hash = lib.fakeHash;` # 2. Run the relevant nix build/flake check # 3. Copy the expected hash printed by Nix into the matching field below - daemonHash = "sha256-6VgQHQbQmmprFXG+/L8P5S89gXObjSDw0Q2an20/lVo="; - webHash = "sha256-tSMlyRvnIgCc1LAlJGekKz+3fTFr3Zf8pXZfh7GQZD4="; + daemonHash = "sha256-HBkWmgAJzwIpwj25Ki6UMTVQNooMzgElDjkI79KcHYA="; + webHash = "sha256-x/4uiRdexfFnt6IplUaYTUVn/xSsPmZ2+XhL46qiJzU="; } diff --git a/packages/contracts/src/analytics/events/shared-enums.ts b/packages/contracts/src/analytics/events/shared-enums.ts index 09ee3afc152..2035be3b1ed 100644 --- a/packages/contracts/src/analytics/events/shared-enums.ts +++ b/packages/contracts/src/analytics/events/shared-enums.ts @@ -64,6 +64,7 @@ export type TrackingAmrEntrySource = | 'handoff_amr_website' | 'chat_error_authorize_retry' | 'chat_error_recharge' + | 'chat_error_upgrade' | 'chat_balance_gate_upgrade' | 'home_balance_gate_upgrade' | 'chat_low_balance_warn_recharge' @@ -182,6 +183,7 @@ export type TrackingRunFailureCategory = | 'auth' | 'rate_limit' | 'insufficient_balance' + | 'entitlement_required' | 'model_unavailable' | 'prompt_too_large' | 'upstream_unavailable' @@ -201,6 +203,7 @@ export type TrackingRunFailureDetail = | 'workspace_credits_exhausted' | 'rate_limit_429' | 'amr_insufficient_balance' + | 'amr_tier_upgrade_required' | 'model_not_found' | 'model_not_supported' | 'model_disabled' @@ -291,6 +294,7 @@ export type TrackingRunFailureUserAction = | 'retry' | 'login' | 'recharge' + | 'upgrade' | 'switch_model' | 'reduce_context' | 'install_cli' @@ -418,4 +422,3 @@ export type TrackingFileSizeBucket = | '1_10mb' | '10_100mb' | '100mb_plus'; - diff --git a/packages/contracts/src/api/registry.ts b/packages/contracts/src/api/registry.ts index 693d293d5da..8069d91488e 100644 --- a/packages/contracts/src/api/registry.ts +++ b/packages/contracts/src/api/registry.ts @@ -1,6 +1,10 @@ export interface AgentModelOption { id: string; label: string; + /** Whether the current account/tier can use this model. */ + enabled?: boolean; + /** Whether this is the default model for the current account/tier. */ + default?: boolean; /** USD price per 1M input tokens when reported by the provider/catalog. */ inputPriceUsdPerMillion?: number; /** USD price per 1M output tokens when reported by the provider/catalog. */ diff --git a/packages/contracts/src/errors.ts b/packages/contracts/src/errors.ts index 8ae8234596c..f52c4f20012 100644 --- a/packages/contracts/src/errors.ts +++ b/packages/contracts/src/errors.ts @@ -25,6 +25,7 @@ export const API_ERROR_CODES = [ 'AMR_MODEL_UNAVAILABLE', 'AMR_AUTH_REQUIRED', 'AMR_INSUFFICIENT_BALANCE', + 'AMR_TIER_UPGRADE_REQUIRED', // The agent emitted a fabricated Markdown role marker // (`## user` / `## assistant` / `## system`) inside its own response. // The chat host parses those lowercase lines as real turn diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5dcaea2c4ad..17f2f69def6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -801,8 +801,8 @@ importers: version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(jsdom@29.1.1)(vite@7.3.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0)) optionalDependencies: '@powerformer/vela-cli': - specifier: 0.0.19 - version: 0.0.19 + specifier: 0.0.20 + version: 0.0.20 tools/release: dependencies: @@ -2035,33 +2035,33 @@ packages: '@posthog/types@1.374.2': resolution: {integrity: sha512-ZghQSFMi+HFJNPvPjBoyY/jWQ+q6mSQVtWQxOHMSbBidUZjsyYbxYxBFbHy2qWLNe4mEpX+Wqir2Q4I/4AVvJQ==} - '@powerformer/vela-cli-darwin-arm64@0.0.19': - resolution: {integrity: sha512-18sQlsfLiMDHxOfYjV05alWabbCQ20xtXRhACoIWVPdcqcQp5+SJHE9nwyAuoQV5xkTJiPSc3niuzH3RicpCDg==} + '@powerformer/vela-cli-darwin-arm64@0.0.20': + resolution: {integrity: sha512-izbLnhDvtMxLQVzlT/YOTra9clYdObixtf8TfW8esE8AkJ6ezmDiJ+BJydK1BRulFOSxIyqxqtY//oqSdJ13ug==} cpu: [arm64] os: [darwin] - '@powerformer/vela-cli-darwin-x64@0.0.19': - resolution: {integrity: sha512-ttOzkUwYuBgBCfQqxpxUjh4trfCGwg2cs1+n+AK5UUAzZxzukyexRgSkWFoONcnYZ9f1Yg4ItwJ4eeFr/vF5yQ==} + '@powerformer/vela-cli-darwin-x64@0.0.20': + resolution: {integrity: sha512-TUAiRX/pQ5wuEPNUncMlg4uPXGJ1pryBmJ2mDlvh9qZPU4jaMHzLSHvfB0x2WV/+AG2J1KBIkD3CoWAEGsxMcw==} cpu: [x64] os: [darwin] - '@powerformer/vela-cli-linux-arm64@0.0.19': - resolution: {integrity: sha512-qWzTTMOpEo54jnlhgMrt8p8NrOpx9ou9SQyWc4nDqcNCm47evonvVrXY9SCZzGUdBJnDJCMtjRshzkp5H1OM+g==} + '@powerformer/vela-cli-linux-arm64@0.0.20': + resolution: {integrity: sha512-TtlZm/+7Tr6ofEJ2IGec4m7dXdi8SGPDbzrD5UV5qjWCFdIfq/ipJedIBzZut2d76YExYZyT9vIwC8Zz1sl4Gg==} cpu: [arm64] os: [linux] - '@powerformer/vela-cli-linux-x64@0.0.19': - resolution: {integrity: sha512-J2JUnvfOgi6uHqE1sripaFFGI4LArJlfo7WKmfadB1s4fk5rVkSP3koRmqtTLOBPayVgGfqksBifCkrSsOP7Pg==} + '@powerformer/vela-cli-linux-x64@0.0.20': + resolution: {integrity: sha512-aniyaFG4nE6KHgWfwaTeYUjgrCrjzhUQh0DWpjWI+ViLGwhYkcA7VKaQus5HPZE0wuZZBgw3sQDafmdhE5YHZA==} cpu: [x64] os: [linux] - '@powerformer/vela-cli-win32-x64@0.0.19': - resolution: {integrity: sha512-0HKqydALitrcUM3ntk+iGR10DoMqI/7lx4WdhgLYBz5nFlX/Flpbe2Vn1GYrexWqm+x5q8m5pEp4YeIjpGKfmw==} + '@powerformer/vela-cli-win32-x64@0.0.20': + resolution: {integrity: sha512-Cu6hkEfL+Vk0ADzAVvf8lUjuqLmhy1N1wN8jDAoDRMtwFcYXR4+hOENarZaQVDCzLVuaA/IxjPRAMJjXssuQOQ==} cpu: [x64] os: [win32] - '@powerformer/vela-cli@0.0.19': - resolution: {integrity: sha512-gjE+DfF67c6V9asJfOSPpNSBGryiQD2OicsilLsX1p8cXNOpnTkQecDeTDntdHRWTi0ZQ31MQEuKilTH6WdXUg==} + '@powerformer/vela-cli@0.0.20': + resolution: {integrity: sha512-Z37ifvNGuZkEiZiMBFRZ6jgAsprWCrJT7PjUvfy+GzHY77u6aAmLxBe/r+FbNjMPs/piA/p6fbKh3C6uleK8Iw==} hasBin: true '@preact/signals-core@1.14.2': @@ -4333,15 +4333,16 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} - indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} immutable@4.3.9: resolution: {integrity: sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==} import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -7872,28 +7873,28 @@ snapshots: '@posthog/types@1.374.2': {} - '@powerformer/vela-cli-darwin-arm64@0.0.19': + '@powerformer/vela-cli-darwin-arm64@0.0.20': optional: true - '@powerformer/vela-cli-darwin-x64@0.0.19': + '@powerformer/vela-cli-darwin-x64@0.0.20': optional: true - '@powerformer/vela-cli-linux-arm64@0.0.19': + '@powerformer/vela-cli-linux-arm64@0.0.20': optional: true - '@powerformer/vela-cli-linux-x64@0.0.19': + '@powerformer/vela-cli-linux-x64@0.0.20': optional: true - '@powerformer/vela-cli-win32-x64@0.0.19': + '@powerformer/vela-cli-win32-x64@0.0.20': optional: true - '@powerformer/vela-cli@0.0.19': + '@powerformer/vela-cli@0.0.20': optionalDependencies: - '@powerformer/vela-cli-darwin-arm64': 0.0.19 - '@powerformer/vela-cli-darwin-x64': 0.0.19 - '@powerformer/vela-cli-linux-arm64': 0.0.19 - '@powerformer/vela-cli-linux-x64': 0.0.19 - '@powerformer/vela-cli-win32-x64': 0.0.19 + '@powerformer/vela-cli-darwin-arm64': 0.0.20 + '@powerformer/vela-cli-darwin-x64': 0.0.20 + '@powerformer/vela-cli-linux-arm64': 0.0.20 + '@powerformer/vela-cli-linux-x64': 0.0.20 + '@powerformer/vela-cli-win32-x64': 0.0.20 optional: true '@preact/signals-core@1.14.2': {} @@ -10557,11 +10558,12 @@ snapshots: immediate@3.0.6: {} - indent-string@4.0.0: {} immutable@4.3.9: {} import-meta-resolve@4.2.0: {} + indent-string@4.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 diff --git a/tools/pack/package.json b/tools/pack/package.json index 5f208ac5e06..fda9124eb08 100644 --- a/tools/pack/package.json +++ b/tools/pack/package.json @@ -25,7 +25,7 @@ "resedit": "1.7.2" }, "optionalDependencies": { - "@powerformer/vela-cli": "0.0.19" + "@powerformer/vela-cli": "0.0.20" }, "devDependencies": { "@types/node": "24.12.2",