Files
nexu-io-open-design/apps/web/tests/components/InlineModelSwitcher.test.tsx
Caprika 3652fb7237 [codex] restore AMR wallet balance follow-ups (#4771)
* feat: show AMR wallet balance

* fix: preserve AMR wallet reauth state

* chore: retrigger PR validation

* fix: bound AMR wallet balance fetch

* chore: retrigger wallet validation

* chore: bump bundled Vela CLI to 0.0.18

* chore: retrigger Vela CLI validation

* chore(nix): refresh pnpm deps hash

* feat(web): reposition AMR as Open Design's cloud subscription (#4684)

* feat(web): reposition AMR as Open Design's cloud subscription

Fold the standalone "AMR" product into Open Design's own cloud service:
unify all user-facing copy, surface the signed-in account's plan +
wallet balance, and add upgrade CTAs.

- Copy: user-facing "AMR" / "Open Design AMR" -> "Open Design" across
  19 locales, agent labels, and daemon error strings (balance/recharge
  wording preserved).
- Account: daemon reads plan + balance via `vela billing summary`
  (CLI route, control-key) and injects them into the vela status; web
  shows plan + balance on the settings card, the model-switcher account
  row, and the runtime-switcher avatar row (balance field aligned with
  the console wallet's headline balanceUsd).
- Upgrade: filled "Upgrade" CTA next to the plan on all three surfaces
  when the tier is below max; opens the console wallet at ?view=plans.
- Runtime-switcher Open Design row restructured to carry account info
  inline, with a selected-state highlight.
- Analytics: new amr_entry elements settings_amr_upgrade /
  inline_amr_upgrade / avatar_amr_upgrade / avatar_amr_agent_card.
- specs/: design doc for the Open Design account/avatar direction.

* fix(daemon): surface live account for env-backed auth + isolate cache per credential

Address @nettee review on #4684:
- applyVelaLiveAccount() now populates plan/balance even when the login
  status has user:null (VELA_RUNTIME_KEY / VELA_LINK_URL env auth), so
  env-backed Open Design sessions no longer drop the fetched billing data.
- Key the live-account cache by the full credential revision (auth source
  + profile + userId + config mtime), not just profile, and clear it on
  logout — a logout / account switch can no longer surface the previous
  account's plan or wallet balance before the background refresh runs.
- Regression tests for both in tests/integrations/vela.test.ts.

Sync tests to the AMR -> Open Design copy:
- web: tests/providers/sse.test.ts error-string assertions.
- e2e: onboarding / logout / run-failure / login-pill specs + visual fixture.

* fix(i18n): drop duplicated "Open Design" in switchBody fallback copy

The AMR -> Open Design rename sweep collided with the existing brand
mention in chat.amrCard.switchBody, rendering "Open Design official
Open Design model service" across 13 locales (zh-TW, ja, ko, ar, es-ES,
hu, id, fa, pl, tr, ru, pt-BR, uk). Remove the redundant second mention
so the retry/switch card reads cleanly. Flagged by @nettee on #4684.

Also start aligning InlineModelSwitcher unit tests to the new copy
(agent display name AMR -> Open Design, compact error string).

* fix(amr): resolve live billing before first /status; hide Upgrade until plan known

Address @nettee review on #4684 (two correctness issues in the new
plan/balance path):

- Cold cache: the signed-in /status handler now BLOCKS the first response
  on fetchVelaBillingSummary() when the cache is empty, instead of
  returning config-only and refreshing in the background. The new account
  surfaces read /status once and never re-poll, so the old flow left
  plan/balance hidden until the user refocused. Warm cache keeps the
  non-blocking fast path. fake-vela gains a `billing summary` handler and
  vela.routes.test.ts covers the cold-cache-first-open case.
- Upgrade CTA: canUpgradeVelaPlan() now treats an unknown/empty plan as
  NOT upgradeable, so a signed-in session whose live summary hasn't
  resolved no longer flashes Upgrade at top-tier users. Unit-tested.

* style(amr): bold the plan/balance text in the switcher + avatar account rows

The settings card already renders both bold (the plan value reuses
.agent-card-amr-balance-value); align the inline switcher account row and
the runtime-switcher avatar row so plan + balance read with the same
emphasis everywhere.

* fix(amr): single-flight cold billing fetch + surface plan/balance on its own field

Address @nettee follow-up review on #4684:

- Race: the cold-cache decision keyed off the refresh throttle, so a second
  /status arriving during the first billing fetch saw refreshAccount=false,
  fell through, and returned config-only — which the read-once surfaces can't
  recover from. Now `peekVelaLiveAccount(key) === null` is the cold signal and
  the billing fetch is single-flighted per credential revision: concurrent
  cold callers await the same in-flight promise. setVelaLiveAccount stamps the
  fetch time so the warm path doesn't immediately re-refresh.
- Identity: applyVelaLiveAccount no longer fabricates a `{ id: '', email: '' }`
  user for env-backed sessions. The live billing projection now rides on a new
  VelaLoginStatus.account field, so `user` stays null (preserving the analytics
  `user.id` null signal) and plan/balance are surfaced uniformly. SettingsDialog
  / InlineModelSwitcher / AvatarMenu read status.account.{plan,balanceUsd}.

Regression coverage updated in vela.test.ts (applyVelaLiveAccount) and
vela.routes.test.ts (cold-cache first open now asserts body.account).

* style(amr): match the switcher plan text color to the balance

The inline switcher account row showed the balance in --text-strong but
the plan in --text-muted, so "$247.51 Plus" read with two different
weights of grey. Use --text-strong for the plan too so balance + plan
sit at the same emphasis.

* fix(amr): treat tier-less billing summary as free; register new entry sources

Address @nettee review on #4684:

- Free vs unknown: fetchVelaBillingSummary() returned plan:undefined when
  `membershipTier` was absent, but that field is omitted for free accounts. The
  new surfaces read status.account.plan for both display and canUpgradeVelaPlan,
  so a successful free-account read rendered as "unknown" — plan hidden and the
  Upgrade CTA suppressed for the exact users who should see it. A successful
  summary without a tier now normalizes to the explicit 'free' sentinel;
  "unknown" stays signalled by a null account (failed fetch). Regression in
  vela.routes.test.ts (balance, no tier → account.plan === 'free').
- Analytics: the four entry sources this PR adds (settings_amr_upgrade,
  inline_amr_upgrade, avatar_amr_upgrade, avatar_amr_agent_card) were in the
  source→page map but not in the AMR_ENTRY_SOURCES validator set, so
  parseAmrEntryAnalyticsPayload rejected them and /analytics-entry 400ed for the
  new buttons. Added them to the set; parser regression covers all four.

* fix(amr): invalidate the env-backed live-account cache on config/account change

Address @nettee 11:19 review (the inline comment did not post, but the flagged
env-backed live-account cache path has a real leak): readVelaCredentialRevision
forced configMtimeMs to null for env-backed auth, so the live-account cache key
became `env|profile|1||` with nothing to distinguish accounts. Because the live
billing summary is fetched with the config profile's controlKey, a config
rewrite (account switch) left an env-backed (VELA_RUNTIME_KEY) session serving
the previous account's plan/balance. Keep configMtimeMs for env auth too so a
config/account change yields a fresh key. Regression added in vela.test.ts.

* fix(amr): fingerprint the configured AMR env in the live-account cache key

Address @nettee 11:35 review on #4684: configMtimeMs alone wasn't enough. The
AMR env credentials (VELA_RUNTIME_KEY / VELA_LINK_URL) can come from
agentCliEnv.amr in app-config, and env-backed sessions report user:null — so
switching the Settings-backed credential from account A to B on the same
profile, without touching ~/.amr/config.json, left the revision at
`env|profile|1||<same-mtime>` and let B reuse A's cached plan/balance.

readVelaCredentialRevision now adds a non-secret credentialFingerprint (sha256
of the runtime key + link url, truncated) and velaLiveAccountCacheKey includes
it. Regressions: a unit test (env key differs by fingerprint) and a route test
that rewrites agentCliEnv.amr's VELA_RUNTIME_KEY without touching the config and
asserts the second account does not inherit the first's cached billing.

* feat(amr): label + stack balance/plan in the switcher account row; fix its tests

UI: the inline switcher Open Design account row rendered "$247.51 Plus" with no
labels, so users couldn't tell what the two values were. Balance and plan now
stack on two labelled lines ("可用余额 / Available balance" and "当前套餐 /
Current plan"), with the Upgrade CTA on the plan line. The settings.amrBalance /
settings.amrPlan labels are made descriptive across all 19 locales (the settings
card reuses them), so the meaning is explicit everywhere.

Tests: rewrite InlineModelSwitcher.test.tsx for the account-row structure — the
agent radio now carries status only in its aria-label (icon-only visible), and
the sign-in / signing-in / cancel affordances live on the
inline-model-switcher-account-action button. Redundant within(amrButton)
getByText assertions are dropped (the radio-name query already asserts status);
pending/error assertions target the account row. 22/22 green.

* test(amr): align AmrLoginPill tests to the Open Design copy

Account-status group is labelled 'Open Design account status', the console
link is 'Console', and the compact sign-in error reads 'Sign-in failed.' —
update the queries to match. 29/29 green.

* feat(amr): redesign the account row — tier badge + balance subtitle; short labels

Iterated on the Open Design account row in both the inline model switcher and
the runtime-switcher (avatar) list so the plan/balance no longer collide:

- Plan tier renders as a small neutral pill right after the "Open Design" name
  (identity + tier read as one group, à la Claude's "name · Max").
- Balance moves to a second line below the name, labelled "余额 $247.51"
  (small grey label + bold value) so users know what the number is.
- Upgrade stays as the trailing action, vertically centred against the two
  lines. Account logo bumped to 24px to balance the taller row.
- Revert the amrBalance / amrPlan labels back to the short "余额 / 套餐"
  ("Balance" / "Plan") across all 19 locales.

* test(amr): align remaining web specs to the Open Design copy + account row

Update the rest of the web component specs that still asserted the pre-rename
wording / old account-row shape (the `Web workspace tests` red CI @nettee
flagged):

- EntryShell.onboarding: "Sign in to Open Design Cloud" -> "Sign in to Open
  Design"; "AMR sign-in failed." -> "Sign-in failed.".
- SettingsDialog.execution: AMR card queried as /^Open Design\b/ (was
  /^Open Design AMR\b/).
- HandoffButton: agent label "Open Design"; website link "打开 Open Design 官网".
- AvatarMenu: drop the two obsolete avatar console-shortcut specs (the avatar
  console link was removed when the row was consolidated; the upgrade entry
  remains covered by the daemon parser + contract).

Full web suite green: 3403 passed.

* test(amr): cover the avatar-row upgrade CTA (plan/balance + stamped link)

Replaces the two removed avatar console-shortcut specs with focused coverage
for the new runtime-switcher upgrade path (@nettee): mocks a signed-in
/api/integrations/vela/status, opens the menu on the non-prod `test` profile,
asserts the rendered plan badge + balance, and verifies the clicked upgrade
link carries view=plans and od_entry_source=avatar_amr_upgrade attribution.

* test(e2e): update avatar visual spec to the redesigned account row

The Playwright visual (settings-workspace) job was red on a functional
assertion, not a pixel diff: the spec queried the removed
`.avatar-amr-account-link` console entry and 'AMR account' / 'Balance &
recharge' copy. Rewrite it to override the signed-out status with a signed-in
plan/balance, then assert the Open Design row renders the Plus badge + $247.51
and the upgrade link points at ?view=plans.

* chore: re-trigger CI on updated main — needs-validation gate moved to merge_group (#4714)

* test: capture Open Design account balance visuals

* chore: retrigger workspace validation

---------

Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>
Co-authored-by: a1chzt <chizblank@gmail.com>

* [codex] keep AMR wallet balance stable during refresh (#4730)

* fix: keep AMR wallet balance visible during refresh

* fix: remove AMR wallet refresh hover tooltip

* chore: retrigger validation

* fix(ci): align AMR wallet branch validation

* feat(web): Open Design 云账户入口 UI 优化(名牌/置顶/新 logo) (#4787)

* feat(web): polish Open Design cloud account surfaces (plan badge, pin-to-top, new logo)

- 模型切换器(InlineModelSwitcher): Open Design 账号卡片上位为可点选入口并置顶, 移除重复的代理行
- 头像菜单(AvatarMenu): Open Design 置顶代码代理列表; 执行模式选中态改为勾选标记(不再与代理选中态争色块)
- 统一套餐名牌: 新增共享 PlanBadge 组件(全局 CSS, 跟随主题色), 替换设置页/切换器/头像菜单三处各自实现
- 设置页 CLI 卡片: 删除无用的钱包刷新按钮与"更新于…缓存"行; 余额统一为两位小数
- 全局替换 AMR logo 资产(amr.svg)

* test(web): align AgentIcon AMR assertion with new logo fill

---------

Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>

* fix(web): preserve AMR wallet balance in switcher

* fix(amr): avoid stale wallet status on refresh failures

* fix(web): align avatar AMR balance row

* fix(web): avoid stale AMR wallet snapshots

* fix(web): keep avatar AMR console link

* fix(amr): support wallet fallback for older vela cli

---------

Co-authored-by: michaeltaxman911-hue <michaeltaxman911@gmail.com>
Co-authored-by: NJUHua <113895241+NJUHua@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>
2026-06-30 03:04:52 +00:00

1188 lines
41 KiB
TypeScript

// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { InlineModelSwitcher } from '../../src/components/InlineModelSwitcher';
import { AMR_LOGIN_TIMEOUT_MS } from '../../src/components/amrLoginPolling';
import { fetchProviderModels } from '../../src/providers/provider-models';
import { providerModelsCacheKey } from '../../src/components/providerModelsCache';
import type { AgentInfo, AppConfig, ProviderModelOption } from '../../src/types';
vi.mock('../../src/providers/provider-models', () => ({
fetchProviderModels: vi.fn(),
}));
const baseConfig: AppConfig = {
mode: 'daemon',
apiKey: '',
apiProtocol: 'anthropic',
apiVersion: '',
baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5',
apiProviderBaseUrl: 'https://api.anthropic.com',
apiProtocolConfigs: {},
agentId: 'amr',
skillId: null,
designSystemId: null,
onboardingCompleted: true,
mediaProviders: {},
agentModels: {},
agentCliEnv: {},
};
const amrAgent: AgentInfo = {
id: 'amr',
name: 'AMR (vela)',
bin: 'amr',
available: true,
version: '1.0.0',
models: [
{ id: 'default', label: 'Default' },
{ id: 'amr-cloud-latest', label: 'AMR Cloud Latest' },
],
};
const codexAgent: AgentInfo = {
id: 'codex',
name: 'Codex CLI',
bin: 'codex',
available: true,
version: '0.133.0-alpha.1',
models: [{ id: 'default', label: 'Default' }],
};
function renderSwitcher(
config: Partial<AppConfig> = {},
agents: AgentInfo[] = [amrAgent],
providerModelsCache: Record<string, ProviderModelOption[]> = {},
options: { compact?: boolean } = {},
) {
const onAgentModelChange = vi.fn();
const view = render(
<InlineModelSwitcher
config={{ ...baseConfig, ...config }}
agents={agents}
providerModelsCache={providerModelsCache}
compact={options.compact}
daemonLive={true}
onModeChange={vi.fn()}
onAgentChange={vi.fn()}
onAgentModelChange={onAgentModelChange}
onApiProtocolChange={vi.fn()}
onApiModelChange={vi.fn()}
onOpenSettings={vi.fn()}
/>,
);
return { ...view, onAgentModelChange };
}
function expectVelaLoginWithAttribution(
fetchMock: ReturnType<typeof vi.fn>,
sourceDetail: string,
) {
const loginCall = fetchMock.mock.calls.find(([input, init]) => (
input.toString() === '/api/integrations/vela/login'
&& (init as RequestInit | undefined)?.method === 'POST'
));
expect(loginCall).toBeDefined();
const init = loginCall?.[1] as RequestInit | undefined;
expect(init).toEqual(expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: expect.any(String),
}));
const body = JSON.parse(String(init?.body)) as {
attribution?: {
entryId?: string;
sourceProduct?: string;
sourceDetail?: string;
occurredAt?: string;
};
};
expect(body.attribution).toEqual(expect.objectContaining({
entryId: expect.stringMatching(/^od-amr-/u),
sourceProduct: 'open_design',
sourceDetail,
}));
expect(Number.isFinite(Date.parse(body.attribution?.occurredAt ?? ''))).toBe(true);
}
describe('InlineModelSwitcher AMR row', () => {
afterEach(() => {
cleanup();
vi.mocked(fetchProviderModels).mockReset();
vi.unstubAllGlobals();
vi.useRealTimers();
try {
window.localStorage.clear();
} catch {
// jsdom normally exposes localStorage; keep cleanup tolerant.
}
});
it('shows the AMR reminder dot once when another CLI is selected', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: false,
profile: 'default',
user: null,
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const view = renderSwitcher(
{ agentId: 'codex' },
[amrAgent, codexAgent],
);
expect(screen.getByTestId('inline-model-switcher-amr-reminder')).toBeTruthy();
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
expect(screen.queryByTestId('inline-model-switcher-amr-reminder')).toBeNull();
const popover = screen.getByTestId('inline-model-switcher-popover');
expect(
within(popover).getByTestId('inline-model-switcher-account-amr-reminder'),
).toBeTruthy();
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
expect(
screen.queryByTestId('inline-model-switcher-account-amr-reminder'),
).toBeNull();
view.unmount();
renderSwitcher({ agentId: 'codex' }, [amrAgent, codexAgent]);
expect(screen.queryByTestId('inline-model-switcher-amr-reminder')).toBeNull();
});
it('keeps an accessible name on the chip when the icon-only treatment hides its text', () => {
// Regression: in the icon-only topbar treatment `.inline-switcher__chip-text`
// is `display: none`, so the visible label is removed from the accessibility
// tree. The button must still expose a real accessible name (CLI/model state)
// for screen-reader users, not just an icon plus a `data-tooltip` hint.
renderSwitcher({}, [amrAgent, codexAgent]);
const chip = screen.getByRole('button', {
name: /Open Design/i,
});
expect(chip).toBe(screen.getByTestId('inline-model-switcher-chip'));
expect(chip.getAttribute('aria-label')).toMatch(/·/u);
});
it('does not show the AMR reminder dot when AMR is already selected', () => {
renderSwitcher({}, [amrAgent, codexAgent]);
expect(screen.queryByTestId('inline-model-switcher-amr-reminder')).toBeNull();
});
it('can render the compact home-hero chip variant', () => {
renderSwitcher({}, [amrAgent, codexAgent], {}, { compact: true });
expect(screen.getByTestId('inline-model-switcher').className).toContain(
'inline-switcher--compact',
);
});
it('labels AMR without vela branding and keeps AMR models from AgentInfo.models', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: false,
profile: 'default',
user: null,
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSwitcher();
expect(screen.getByTestId('inline-model-switcher-chip').textContent).toContain(
'Open Design',
);
expect(screen.getByTestId('inline-model-switcher-chip').textContent).not.toContain('AMR');
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const popover = screen.getByTestId('inline-model-switcher-popover');
expect(within(popover).getByTestId('inline-model-switcher-open-settings')).toBeTruthy();
expect(within(popover).getByRole('button', { name: /settings/i })).toBeTruthy();
const amrButton = await within(popover).findByRole('radio', {
name: /^Open Design\s+Sign in$/i,
});
expect(amrButton.querySelector('.inline-switcher__agent-status-icon')).toBeNull();
expect(
amrButton.querySelector('.inline-switcher__account-name')?.textContent,
).toBe('Open Design');
expect(within(popover).queryByText(/AMR \(vela\)/i)).toBeNull();
expect(within(popover).queryByText(/vela/i)).toBeNull();
expect(within(popover).queryByText(/Not signed in/i)).toBeNull();
const modelPicker = within(popover).getByTestId(
'inline-model-switcher-agent-model',
);
expect(modelPicker.textContent).toContain('Default');
fireEvent.click(modelPicker);
const modelPopover = screen.getByTestId('inline-model-switcher-agent-model-popover');
expect(
within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()),
).toEqual(['Default', 'AMR Cloud Latest']);
});
it('persists the live AMR fallback when the saved AMR model is stale', async () => {
vi.stubGlobal('fetch', vi.fn(async () =>
new Response(
JSON.stringify({
loggedIn: true,
profile: 'default',
user: null,
configPath: '/Users/test/.vela/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
));
const { onAgentModelChange } = renderSwitcher({
agentModels: { amr: { model: 'gpt-5.4-mini', reasoning: 'default' } },
});
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const popover = screen.getByTestId('inline-model-switcher-popover');
const modelPicker = within(popover).getByTestId(
'inline-model-switcher-agent-model',
);
expect(modelPicker.textContent).toContain('Default');
fireEvent.click(modelPicker);
const modelPopover = screen.getByTestId('inline-model-switcher-agent-model-popover');
expect(
within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()),
).toEqual(['Default', 'AMR Cloud Latest']);
await waitFor(() => {
expect(onAgentModelChange).toHaveBeenCalledWith('amr', {
model: 'default',
reasoning: 'default',
});
});
});
it('shows icon-only signed-in status instead of account information in the AMR button', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: true,
profile: 'default',
user: {
id: 'user-1',
email: 'manual-amr@example.local',
name: 'Manual AMR Test User',
},
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSwitcher();
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const popover = screen.getByTestId('inline-model-switcher-popover');
const amrButton = await within(popover).findByRole('radio', {
name: /^Open Design\s+Signed in$/i,
});
expect(within(popover).queryByText(/manual-amr@example\.local/i)).toBeNull();
expect(within(popover).queryByRole('button', { name: 'Sign out' })).toBeNull();
});
it('shows wallet balance in the Open Design account row when signed-in status has no account summary', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: true,
profile: 'test',
user: {
id: 'user-1',
email: 'manual-amr@example.local',
name: 'Manual AMR Test User',
},
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/wallet') {
return new Response(
JSON.stringify({
status: 'available',
profile: 'test',
user: { id: 'user-1', email: 'manual-amr@example.local' },
balanceUsd: '0.1000',
updatedAt: '2026-06-23T06:05:18.782Z',
fetchedAt: '2026-06-23T06:05:19.000Z',
stale: false,
source: 'daemon_cache',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSwitcher();
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const popover = screen.getByTestId('inline-model-switcher-popover');
await within(popover).findByRole('radio', {
name: /^Open Design\s+Signed in$/i,
});
await waitFor(() => {
expect(within(popover).getByText('Balance')).toBeTruthy();
expect(within(popover).getByText('$0.10')).toBeTruthy();
});
});
it('prefers fresh signed-in status balance over an older wallet snapshot', async () => {
let statusCalls = 0;
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/integrations/vela/status') {
statusCalls += 1;
return new Response(
JSON.stringify(
statusCalls === 1
? {
loggedIn: true,
profile: 'test',
user: null,
configPath: '/Users/test/.amr/config.json',
}
: {
loggedIn: true,
profile: 'test',
user: null,
account: { plan: 'plus', balanceUsd: '42.0000' },
configPath: '/Users/test/.amr/config.json',
},
),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/wallet') {
return new Response(
JSON.stringify({
status: 'available',
profile: 'test',
user: null,
balanceUsd: '0.1000',
updatedAt: '2026-06-23T06:05:18.782Z',
fetchedAt: '2026-06-23T06:05:19.000Z',
stale: false,
source: 'daemon_cache',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSwitcher();
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
let popover = screen.getByTestId('inline-model-switcher-popover');
await waitFor(() => {
expect(within(popover).getByText('$0.10')).toBeTruthy();
});
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
expect(screen.queryByTestId('inline-model-switcher-popover')).toBeNull();
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
popover = screen.getByTestId('inline-model-switcher-popover');
await waitFor(() => {
expect(within(popover).getByText('$42.00')).toBeTruthy();
});
expect(within(popover).queryByText('$0.10')).toBeNull();
});
it('routes inline upgrades through the signed-in AMR profile', async () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: true,
profile: 'test',
user: { id: 'user-1', email: 'manual-amr@example.local' },
account: { plan: 'plus', balanceUsd: '42.0000' },
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSwitcher({
telemetry: { metrics: true },
installationId: 'od-install-abc',
});
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const popover = screen.getByTestId('inline-model-switcher-popover');
await within(popover).findByText('$42.00');
fireEvent.click(screen.getByTestId('inline-model-switcher-account-upgrade'));
const [url, target, features] = openSpy.mock.calls[0] ?? [];
const parsed = new URL(String(url));
expect(parsed.origin).toBe('https://vela.powerformer.net');
expect(parsed.searchParams.get('view')).toBe('plans');
expect(parsed.searchParams.get('od_entry_source')).toBe('inline_amr_upgrade');
expect(parsed.searchParams.get('od_device_id')).toBe('od-install-abc');
expect(target).toBe('_blank');
expect(features).toBe('noopener,noreferrer');
});
it('filters fetched BYOK provider models in the Home switcher search box', async () => {
renderSwitcher(
{
mode: 'api',
apiProtocol: 'openai',
baseUrl: 'https://api.openai.com/v1',
apiProviderBaseUrl: 'https://api.openai.com/v1',
apiKey: 'sk-test',
model: 'gpt-4.1-mini',
},
[amrAgent, codexAgent],
{
[providerModelsCacheKey(
'openai',
'https://api.openai.com/v1',
'sk-test',
)]: [
{ id: 'gpt-4.1-mini', label: 'gpt-4.1-mini' },
{ id: 'gpt-4.1', label: 'gpt-4.1' },
{ id: 'gpt-5.5', label: 'gpt-5.5' },
{ id: 'o4-mini', label: 'o4-mini' },
{ id: 'o3', label: 'o3' },
{ id: 'o1', label: 'o1' },
{ id: 'gpt-4o', label: 'gpt-4o' },
{ id: 'gpt-4o-mini', label: 'gpt-4o-mini' },
],
},
);
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const modelPicker = screen.getByTestId('inline-model-switcher-api-model');
fireEvent.click(modelPicker);
const searchInput = screen.getByTestId(
'inline-model-switcher-api-model-search',
) as HTMLInputElement;
fireEvent.change(searchInput, { target: { value: '5.5' } });
const modelPopover = screen.getByTestId('inline-model-switcher-api-model-popover');
expect(
within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()),
).toEqual(['gpt-4.1-mini', 'gpt-5.5']);
});
it('prefers fetched BYOK provider models over only showing the currently selected custom model', async () => {
renderSwitcher(
{
mode: 'api',
apiProtocol: 'openai',
baseUrl: 'https://api.openai.com/v1',
apiProviderBaseUrl: 'https://api.openai.com/v1',
apiKey: 'sk-test',
model: 'gpt-4.1-mini',
},
[amrAgent, codexAgent],
{
[providerModelsCacheKey(
'openai',
'https://api.openai.com/v1',
'sk-test',
)]: [
{ id: 'gpt-4.1-mini', label: 'gpt-4.1-mini' },
{ id: 'gpt-4.1', label: 'gpt-4.1' },
{ id: 'gpt-5.5', label: 'gpt-5.5' },
],
},
);
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const modelPicker = screen.getByTestId('inline-model-switcher-api-model');
fireEvent.click(modelPicker);
const modelPopover = screen.getByTestId('inline-model-switcher-api-model-popover');
expect(
within(modelPopover).getAllByRole('option').map((option) => option.textContent?.trim()),
).toEqual(expect.arrayContaining(['gpt-4.1-mini', 'gpt-4.1', 'gpt-5.5']));
expect(within(modelPopover).getAllByRole('option').length).toBeGreaterThan(1);
});
it('warms the shared provider-models cache from the home picker for keyless AIHubMix', async () => {
// Regression: the home picker only READ the cache, so on a fresh load (no
// Settings/onboarding fetch yet) the AIHubMix BYOK list fell back to the
// small static seed list. It must fetch the live catalogue itself. AIHubMix
// is keyless, so the fetch fires with an empty apiKey.
const fetchMock = vi.mocked(fetchProviderModels);
fetchMock.mockResolvedValue({
ok: true,
kind: 'success',
latencyMs: 1,
models: [
{ id: 'claude-opus-4-8', label: 'claude-opus-4-8' },
{ id: 'gemini-3.5-flash', label: 'gemini-3.5-flash' },
{ id: 'minimax-m3', label: 'minimax-m3' },
],
});
const onProviderModelsCacheChange = vi.fn();
render(
<InlineModelSwitcher
config={{
...baseConfig,
mode: 'api',
apiProtocol: 'aihubmix',
baseUrl: 'https://aihubmix.com/v1',
apiProviderBaseUrl: 'https://aihubmix.com/v1',
apiKey: '',
model: 'claude-opus-4-8',
}}
agents={[amrAgent, codexAgent]}
daemonLive={true}
onModeChange={vi.fn()}
onAgentChange={vi.fn()}
onAgentModelChange={vi.fn()}
onApiProtocolChange={vi.fn()}
onApiModelChange={vi.fn()}
providerModelsCache={{}}
onProviderModelsCacheChange={onProviderModelsCacheChange}
onOpenSettings={vi.fn()}
/>,
);
// No fetch until the user opens the switcher panel.
expect(fetchMock).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith({
protocol: 'aihubmix',
baseUrl: 'https://aihubmix.com/v1',
apiKey: '',
});
expect(onProviderModelsCacheChange).toHaveBeenCalled();
});
// The updater populates the slot under the Settings-shared cache key, so
// one fetch serves both surfaces.
const updater = onProviderModelsCacheChange.mock.calls[0]![0] as (
current: Record<string, ProviderModelOption[]>,
) => Record<string, ProviderModelOption[]>;
const key = providerModelsCacheKey('aihubmix', 'https://aihubmix.com/v1', '', '');
const next = updater({});
expect(next[key]?.map((m) => m.id)).toEqual([
'claude-opus-4-8',
'gemini-3.5-flash',
'minimax-m3',
]);
});
it('does not fetch from the home picker for a keyed protocol with no API key', async () => {
const fetchMock = vi.mocked(fetchProviderModels);
render(
<InlineModelSwitcher
config={{
...baseConfig,
mode: 'api',
apiProtocol: 'openai',
baseUrl: 'https://api.openai.com/v1',
apiProviderBaseUrl: 'https://api.openai.com/v1',
apiKey: '',
model: 'gpt-4.1-mini',
}}
agents={[amrAgent, codexAgent]}
daemonLive={true}
onModeChange={vi.fn()}
onAgentChange={vi.fn()}
onAgentModelChange={vi.fn()}
onApiProtocolChange={vi.fn()}
onApiModelChange={vi.fn()}
providerModelsCache={{}}
onProviderModelsCacheChange={vi.fn()}
onOpenSettings={vi.fn()}
/>,
);
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
await act(async () => {
await Promise.resolve();
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('lists AIHubMix as a BYOK provider chip and marks it active when selected', () => {
const onApiProtocolChange = vi.fn();
render(
<InlineModelSwitcher
config={{
...baseConfig,
mode: 'api',
apiProtocol: 'aihubmix',
baseUrl: 'https://aihubmix.com/v1',
apiProviderBaseUrl: 'https://aihubmix.com/v1',
apiKey: '',
model: 'gemini-3.5-flash',
}}
agents={[amrAgent, codexAgent]}
daemonLive={true}
onModeChange={vi.fn()}
onAgentChange={vi.fn()}
onAgentModelChange={vi.fn()}
onApiProtocolChange={onApiProtocolChange}
onApiModelChange={vi.fn()}
providerModelsCache={{}}
onOpenSettings={vi.fn()}
/>,
);
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const chip = screen.getByTestId('inline-model-switcher-provider-aihubmix');
expect(chip.getAttribute('aria-selected')).toBe('true');
});
it('keeps the panel open and applies the choice when picking a BYOK model from the portaled list', async () => {
// Regression: the model list renders in a portal on `document.body`, so a
// mousedown on an option lands OUTSIDE the switcher's `wrapRef`. The panel's
// outside-click handler used to close the whole panel on that mousedown,
// unmounting the picker before its click fired — the model never changed.
const onApiModelChange = vi.fn();
render(
<InlineModelSwitcher
config={{
...baseConfig,
mode: 'api',
apiProtocol: 'openai',
baseUrl: 'https://api.openai.com/v1',
apiProviderBaseUrl: 'https://api.openai.com/v1',
apiKey: 'sk-test',
model: 'gpt-4.1-mini',
}}
agents={[amrAgent, codexAgent]}
daemonLive={true}
onModeChange={vi.fn()}
onAgentChange={vi.fn()}
onAgentModelChange={vi.fn()}
onApiProtocolChange={vi.fn()}
onApiModelChange={onApiModelChange}
providerModelsCache={{
[providerModelsCacheKey('openai', 'https://api.openai.com/v1', 'sk-test', '')]: [
{ id: 'gpt-4.1-mini', label: 'gpt-4.1-mini' },
{ id: 'gpt-4.1', label: 'gpt-4.1' },
{ id: 'gpt-5.5', label: 'gpt-5.5' },
],
}}
onOpenSettings={vi.fn()}
/>,
);
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
fireEvent.click(screen.getByTestId('inline-model-switcher-api-model'));
const modelPopover = screen.getByTestId('inline-model-switcher-api-model-popover');
const option = within(modelPopover).getByRole('option', { name: 'gpt-5.5' });
// The real browser fires mousedown before the option's click. The panel's
// document-level mousedown listener must NOT treat this portal click as
// "outside" and close the switcher.
fireEvent.mouseDown(option);
expect(screen.queryByTestId('inline-model-switcher-popover')).not.toBeNull();
expect(
screen.queryByTestId('inline-model-switcher-api-model-popover'),
).not.toBeNull();
fireEvent.click(option);
expect(onApiModelChange).toHaveBeenCalledWith('gpt-5.5');
});
it('treats env-backed AMR login as signed in even when no user profile is available', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: true,
profile: 'default',
user: null,
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSwitcher();
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const popover = screen.getByTestId('inline-model-switcher-popover');
const amrButton = await within(popover).findByRole('radio', {
name: /^Open Design\s+Signed in$/i,
});
expect(within(popover).queryByText(/@/i)).toBeNull();
expect(within(popover).queryByRole('button', { name: 'Sign out' })).toBeNull();
});
it('renders daemon-reported in-flight login attempts as cancelable', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: false,
loginInFlight: true,
profile: 'default',
user: null,
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSwitcher();
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const popover = screen.getByTestId('inline-model-switcher-popover');
const amrButton = await within(popover).findByRole('radio', {
name: /^Open Design\s+Signing in/i,
});
expect(
within(popover)
.getByTestId('inline-model-switcher-account-action')
.getAttribute('title'),
).toBe('Cancel sign-in');
});
it('refreshes stale signed-in AMR status before starting login', async () => {
let statusCalls = 0;
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/integrations/vela/status') {
statusCalls += 1;
return new Response(
JSON.stringify(
statusCalls === 1
? {
loggedIn: true,
loginInFlight: false,
profile: 'default',
user: { id: 'user-1', email: 'manual-amr@example.local' },
configPath: '/Users/test/.amr/config.json',
}
: {
loggedIn: false,
loginInFlight: false,
profile: 'default',
user: null,
configPath: '/Users/test/.amr/config.json',
},
),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/login' && init?.method === 'POST') {
return new Response(JSON.stringify({ pid: 123 }), {
status: 202,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSwitcher();
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const popover = screen.getByTestId('inline-model-switcher-popover');
const amrButton = await within(popover).findByRole('radio', {
name: /^Open Design\s+Signed in$/i,
});
fireEvent.click(amrButton);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expectVelaLoginWithAttribution(fetchMock, 'inline_model_switcher_amr_row');
expect(
within(popover).getByRole('radio', { name: /^Open Design\s+Signing in/i }),
).toBeTruthy();
});
it('shows daemon startup errors when AMR sign-in fails immediately', async () => {
const startupError = 'profile "prod" api URL: is not configured';
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: false,
loginInFlight: false,
profile: 'prod',
user: null,
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/login' && init?.method === 'POST') {
return new Response(JSON.stringify({ error: startupError }), {
status: 500,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSwitcher();
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const popover = screen.getByTestId('inline-model-switcher-popover');
const amrButton = await within(popover).findByRole('radio', {
name: /^Open Design\s+Sign in$/i,
});
fireEvent.click(amrButton);
await waitFor(() => {
expect(
within(popover).getByRole('radio', {
name: /^Open Design\s+profile "prod" api URL: is not configured/i,
}),
).toBeTruthy();
});
expect(
within(popover).queryByRole('radio', {
name: /^Open Design\s+Sign-in failed\./i,
}),
).toBeNull();
expect(
popover.querySelector('.inline-switcher__account-status.is-error')
?.textContent,
).toMatch(/api URL: is not configured/i);
});
it('cancels a timed-out AMR sign-in from the inline switcher', async () => {
let loginStarted = false;
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: false,
loginInFlight: loginStarted,
profile: 'default',
user: null,
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/login' && init?.method === 'POST') {
loginStarted = true;
return new Response(JSON.stringify({ pid: 123 }), {
status: 202,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/integrations/vela/login/cancel' && init?.method === 'POST') {
loginStarted = false;
return new Response(JSON.stringify({ canceled: true, pids: [123] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSwitcher();
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const popover = screen.getByTestId('inline-model-switcher-popover');
const amrButton = await within(popover).findByRole('radio', {
name: /^Open Design\s+Sign in$/i,
});
vi.useFakeTimers();
fireEvent.click(amrButton);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expectVelaLoginWithAttribution(fetchMock, 'inline_model_switcher_amr_row');
expect(
within(popover).getByRole('radio', { name: /^Open Design\s+Signing in/i }),
).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(AMR_LOGIN_TIMEOUT_MS);
});
expect(fetchMock).toHaveBeenCalledWith('/api/integrations/vela/login/cancel', { method: 'POST' });
expect(
within(popover).getByRole('radio', { name: /^Open Design\s+Sign-in failed\./i }),
).toBeTruthy();
expect(
popover.querySelector('.inline-switcher__account-status.is-error'),
).toBeTruthy();
expect(popover.querySelector('.inline-switcher__agent-status-icon.is-error')).toBeNull();
});
it('turns the pending AMR row into a cancel action', async () => {
let loginStarted = false;
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: false,
loginInFlight: loginStarted,
profile: 'default',
user: null,
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/login' && init?.method === 'POST') {
loginStarted = true;
return new Response(JSON.stringify({ pid: 123 }), {
status: 202,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/integrations/vela/login/cancel' && init?.method === 'POST') {
loginStarted = false;
return new Response(JSON.stringify({ canceled: true, pids: [123] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSwitcher();
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const popover = screen.getByTestId('inline-model-switcher-popover');
let amrButton = await within(popover).findByRole('radio', {
name: /^Open Design\s+Sign in$/i,
});
vi.useFakeTimers();
fireEvent.click(amrButton);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
amrButton = within(popover).getByRole('radio', {
name: /^Open Design\s+Signing in/i,
});
expect(
within(popover)
.getByTestId('inline-model-switcher-account-action')
.getAttribute('title'),
).toBe('Cancel sign-in');
fireEvent.click(amrButton);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(fetchMock).toHaveBeenCalledWith('/api/integrations/vela/login/cancel', { method: 'POST' });
expect(
within(popover).getByRole('radio', { name: /^Open Design\s+Sign in$/i }),
).toBeTruthy();
});
it('re-reads AMR status on reopen and converges from signed-in back to Sign in when later status is loggedOut', async () => {
let statusCalls = 0;
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/integrations/vela/status') {
statusCalls += 1;
return new Response(
JSON.stringify(
statusCalls === 1
? {
loggedIn: true,
profile: 'default',
user: { id: 'user-1', email: 'manual-amr@example.local' },
configPath: '/Users/test/.amr/config.json',
}
: {
loggedIn: false,
profile: 'default',
user: null,
configPath: '/Users/test/.amr/config.json',
},
),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSwitcher();
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
let popover = screen.getByTestId('inline-model-switcher-popover');
await within(popover).findByRole('radio', { name: /^Open Design\s+Signed in$/i });
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
expect(screen.queryByTestId('inline-model-switcher-popover')).toBeNull();
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
popover = screen.getByTestId('inline-model-switcher-popover');
await within(popover).findByRole('radio', { name: /^Open Design\s+Sign in$/i });
expect(within(popover).queryByRole('radio', { name: /^Open Design\s+Signed in$/i })).toBeNull();
});
it('starts AMR re-login only after the user explicitly clicks the signed-out AMR row', async () => {
let loginCalls = 0;
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: false,
profile: 'default',
user: null,
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/login' && init?.method === 'POST') {
loginCalls += 1;
return new Response(JSON.stringify({ pid: 4242 }), {
status: 202,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const onAgentChange = vi.fn();
render(
<InlineModelSwitcher
config={baseConfig}
agents={[amrAgent]}
daemonLive={true}
onModeChange={vi.fn()}
onAgentChange={onAgentChange}
onAgentModelChange={vi.fn()}
onApiProtocolChange={vi.fn()}
onApiModelChange={vi.fn()}
onOpenSettings={vi.fn()}
/>,
);
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const popover = screen.getByTestId('inline-model-switcher-popover');
await within(popover).findByRole('radio', { name: /^Open Design\s+Sign in$/i });
expect(loginCalls).toBe(0);
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const reopenedPopover = screen.getByTestId('inline-model-switcher-popover');
const reopenedAmrButton = await within(reopenedPopover).findByRole('radio', {
name: /^Open Design\s+Sign in$/i,
});
expect(loginCalls).toBe(0);
fireEvent.click(reopenedAmrButton);
await waitFor(() => {
expect(loginCalls).toBe(1);
expect(onAgentChange).toHaveBeenCalledWith('amr');
});
});
it('lists fetched BYOK provider models from the shared cache', () => {
const cacheKey = providerModelsCacheKey(
'anthropic',
baseConfig.baseUrl,
'sk-test',
'',
);
renderSwitcher(
{
mode: 'api',
apiKey: 'sk-test',
model: 'claude-3-5-haiku-latest',
},
[amrAgent],
{
[cacheKey]: [
{ id: 'claude-3-5-haiku-latest', label: 'Claude 3.5 Haiku' },
],
},
);
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const select = screen.getByTestId(
'inline-model-switcher-api-model',
);
fireEvent.click(select);
const modelPopover = screen.getByTestId(
'inline-model-switcher-api-model-popover',
);
expect(
within(modelPopover).getByRole('option', { name: 'Claude 3.5 Haiku' }),
).toBeTruthy();
});
});