feat(runtimes): register AMR (vela) as an ACP stdio agent (#2355)

* feat(runtimes): register AMR (vela) as an ACP stdio agent

AMR is the vela CLI's ACP runtime mode. `vela agent run --runtime opencode`
speaks ACP JSON-RPC over stdio (see vela's
`specs/current/runtime/manual-agent-run-openrouter.md`); per
`docs/new-agent-runtime-acp.md` we expose it through the same `streamFormat:
'acp-json-rpc'` transport that already powers Hermes, Devin, Kimi, etc.

The new `defs/amr.ts` is the entire wiring — `buildArgs` returns
`['agent', 'run', '--runtime', 'opencode']`, `fetchModels` reuses
`detectAcpModels`, and the fallback list seeds the OpenRouter ids vela's
e2e baseline uses. `executables.ts`/`app-config.ts`/`metadata.ts` get the
matching `VELA_BIN`/`VELA_LINK_URL`/`VELA_RUNTIME_KEY`/`VELA_OPENCODE_BIN`
allowlist + install/docs URLs, so users can configure the per-agent env in
Settings without leaking into other adapters.

Coverage: `tests/fixtures/fake-vela.mjs` is a minimal ACP stub that returns
the documented `initialize` / `session/new` / `session/set_model` /
`session/prompt` shapes; `tests/amr-acp-integration.test.ts` spawns it via
`child_process.spawn` and drives a full turn through `attachAcpSession` and
`detectAcpModels`, so the ACP transport contract for AMR is end-to-end
verified locally even before a real `vela` binary is installed.

Validated:
- pnpm guard
- pnpm typecheck (all workspace projects)
- pnpm --filter @open-design/daemon test (2881/2881)

Deferred: real OpenRouter-backed turn through a built `vela` binary —
the runtime def needs no changes for that path, only `VELA_RUNTIME_KEY`
and `VELA_LINK_URL` in env (or Settings).

* fix(runtimes/amr): pin a concrete default model and bare openai ids

End-to-end validation against a freshly-built `vela` (nexu-io/vela@main)
+ OpenRouter surfaced two contract details the first AMR runtime def
got wrong:

1. vela rejects `session/prompt` with `session/set_model must be called
   before session/prompt`. attachAcpSession in apps/daemon/src/acp.ts
   skips set_model whenever the picked model is the synthetic 'default'
   id, so AMR's fallback list must NOT include DEFAULT_MODEL_OPTION. The
   def now ships a concrete `gpt-5.4-mini` as both `fetchModels`'
   default option and `fallbackModels[0]`, which makes attachAcpSession
   always send a real `session/set_model` for AMR turns.

2. `vela --runtime opencode` auto-prepends `openai/` to whatever modelId
   it forwards to opencode's openai provider. With OpenRouter-style ids
   like `openai/gpt-5.4-mini`, opencode receives the double-prefixed
   `openai/openai/gpt-5.4-mini` and replies `ProviderModelNotFoundError`.
   The new fallback list ships the bare ids opencode's openai registry
   actually knows about (gpt-5.4, gpt-5.4-mini, gpt-5.4-fast, etc.).

Stub + tests:
- tests/fixtures/fake-vela.mjs now enforces the set_model gate the same
  way real vela does, so a regression that silently goes back to
  model: 'default' would surface as a fatal error in tests instead of a
  hidden production failure.
- tests/amr-acp-integration.test.ts pins both contracts: no 'default' /
  no 'openai/' prefix in fallbackModels, and a negative case that
  asserts session/prompt fails when no model is set.

Adds `apps/daemon/scripts/verify-amr-real-vela.mjs` — a small dev-time
runner that drives `attachAcpSession` against a real `vela` binary and
prints the daemon's chat events, so future protocol drift can be checked
against an actual OpenRouter call.

Verified locally: `vela agent run --runtime opencode` + OpenRouter
returns the prompted string ("AMR-E2E-PASS") through the full daemon
pipeline; daemon test suite stays 2883/2883.

* fix(runtimes/amr): substitute concrete model when chat run sends 'default'

A plugin-driven AMR run from the UI surfaced a real-world hole in the
prior commit:

  json-rpc id 3: session/set_model must be called before session/prompt

The Default-design-router plugin (and any caller that doesn't pin a
real model) sends `model: 'default'` straight through, which the AMR
runtime def cannot accept — vela rejects `session/prompt` without
`session/set_model` and attachAcpSession skips set_model whenever
model === 'default'. Just leaving DEFAULT_MODEL_OPTION out of the
adapter's `fallbackModels` is not enough: the chat-run handler in
server.ts still forwarded 'default' verbatim.

This adds `resolveModelForAgent(def, resolved, env?)` as the
single source of truth for the substitution:

  1. If the caller picked a real id, pass it through.
  2. Else, if `def.defaultModelEnvVar` is set and the daemon process
     env has a non-empty value for it, return that (operator escape
     hatch — see below).
  3. Else, if the def's `fallbackModels` does NOT contain a 'default'
     id, return `fallbackModels[0].id`.
  4. Else, return the original value (the historic shape — defs that
     list 'default' themselves are untouched).

AMR sets `defaultModelEnvVar: 'VELA_DEFAULT_MODEL'`, so when
opencode's openai-provider registry deprecates `gpt-5.4-mini`
upstream, an operator can swap the fallback id without a code change
by exporting `VELA_DEFAULT_MODEL=gpt-5.5` before launching tools-dev
/ od. Worth noting the env var must live in the daemon's `process.env`
(Settings-UI per-agent env values only reach the spawned child, not
the daemon's resolver) — the new field's docblock spells this out.

Coverage:
- `tests/runtimes/resolve-model.test.ts` — 8 unit tests covering all
  four resolver branches plus the env-override happy path / fallback /
  ignore-when-user-picked-a-real-id case.
- `pnpm --filter @open-design/daemon typecheck` clean.

* chore(runtimes/amr): move AMR to the top of the base agent list

So `AMR (vela)` shows up first in the agent picker / status views,
ahead of claude / codex. Pure ordering change; no behavior delta.

* feat(amr): Sign-in / Sign-out button on the AMR Settings card

The first half of the AMR work assumed the operator would set
VELA_RUNTIME_KEY / VELA_LINK_URL on the daemon process and never
surfaced login state to users. This adds the missing UX so a fresh
install can drive the full path from Settings:

  - GET  /api/integrations/vela/status   reads ~/.vela/config.json
    for the active profile and returns { loggedIn, profile, user }
    (without leaking the runtime/control keys themselves).
  - POST /api/integrations/vela/login    spawns `vela login` once
    (409 if one is already in flight). The vela CLI opens the user's
    browser to the device-authorization page itself — Open Design
    only needs to kick the subprocess off.
  - POST /api/integrations/vela/logout   removes ~/.vela/config.json
    so the next status read returns logged-out.

`AmrAgentCard` is a dedicated agent-card component for AMR because
the existing `<button>` row can't host an interactive sub-control
(nested interactive elements). It polls /status after a login click
until the daemon reports loggedIn=true (or 5 minutes elapse), and
exposes a Sign-out action on hover. Other adapters (claude, codex,
hermes, …) keep their existing `<button>` card.

i18n: 8 new keys (settings.amrLogin / Logout / LoggingIn / etc.)
added to en + zh-CN. Other locales spread `en` and inherit the
English copy until translations land.

Coverage:
- `tests/integrations/vela.test.ts` pins the config.json reader
  against a tmp HOME — including the negative case where a profile
  has user info but no runtimeKey (still logged-out), and the
  secret-leak guard ("rt-secret-*" must not appear in the projection
  payload).
- `tests/components/AmrAgentCard.test.tsx` covers all four UI
  states (logged-out, logging-in, logged-in, logging-out) plus the
  click-propagation invariant the divergent card was built to keep.

`pnpm --filter @open-design/daemon test` 2901 / 2901 passing.
`pnpm --filter @open-design/web test` 1719 / 1719 passing.
`pnpm typecheck` + `pnpm guard` clean.

Dev script side-effects: `apps/daemon/scripts/verify-amr-real-vela.mjs`
no longer requires both VELA_RUNTIME_KEY and VELA_LINK_URL — if
VELA_PROFILE is set, the vela CLI is allowed to resolve credentials
from `~/.vela/config.json`. Added the two AMR `.mjs` fixtures to
`scripts/guard.ts` allowlist with the executable-fixture / dev-runner
rationale.

* fix(connection-test): substitute model for AMR before attachAcpSession

The chat-run path in server.ts already routes the requested model through
`resolveModelForAgent` so AMR / vela (whose CLI demands an explicit
`session/set_model` before `session/prompt`) gets the def's first
concrete fallback id when the chat run ships `model: 'default'`.
`connectionTest.ts` was wiring `attachAcpSession({ ..., model: model ?? null })`
directly, which made the Test Connection button on the AMR Settings
card deadlock with the same `session/set_model must be called before
session/prompt` error the chat-run path already handles — surfaced as a
permanent "Testing connection…" spinner in the UI.

Reuse the same helper here so Test Connection mirrors chat-run behavior.

* test(amr): three-layer end-to-end coverage for the AMR login + turn flow

The PR up to this point shipped runtime + UI code with unit-level Vitest
coverage. This commit adds the cross-layer regression net the live demo
relied on:

1. apps/daemon/tests/integrations/vela.routes.test.ts (HTTP, Vitest)
   Spins up the real daemon Express app via `startServer({port:0,...})`,
   persists `agentCliEnv.amr.VELA_BIN = <fake>` into app-config.json,
   and exercises every /api/integrations/vela/* endpoint against the
   extended fake-vela stub:
     - status reads ~/.vela/config.json under various states
     - login spawns the fake, waits for config.json to appear, returns
       pid + startedAt + profile
     - 409 already-running guard with the stub's delay knob
     - logout removes the file (idempotent)
     - secrets (runtimeKey / controlKey) never leak in the projection
     - login → status round-trip flips loggedIn=false → true

2. e2e/tests/amr/turn.test.ts (tools-dev orchestrated, Vitest)
   Boots a namespaced daemon + web pair through `createSmokeSuite`,
   inlines a self-contained fake `vela` binary that handles BOTH
   `vela login` (writes ~/.vela/config.json) and
   `vela agent run --runtime opencode` (ACP stdio with the
   `session/set_model must precede session/prompt` gate the real binary
   enforces), then drives a complete /api/runs lifecycle for
   `agentId: 'amr', model: 'default'` and asserts the assistant message
   captures the fake's streamed text. This is the test that would have
   surfaced today's plugin-default-model regression (the `set_model
   before prompt` error) at PR time instead of demo time.

3. e2e/ui/amr-login-pill.test.ts (Playwright)
   Mocks /api/agents + /api/integrations/vela/{status,login,logout}
   to drive the Settings AMR card through the full Sign in → Signed in
   → Sign out cycle. Pins the AmrLoginPill polling contract and the
   aria-label semantics (the pill's accessible name is "Sign out" once
   logged in, regardless of which label the hover-state text shows).

fake-vela.mjs extensions:
   - Handles `vela login` argv by writing
     ~/.vela/config.json for the active VELA_PROFILE and exiting 0 —
     mirrors real vela's on-disk side-effect without the device-auth
     loop.
   - FAKE_VELA_LOGIN_DELAY_MS knob so route tests can observe the
     in-flight state of the spawn lifecycle.
   - FAKE_VELA_LOGIN_USER_EMAIL / _USER_PLAN to assert the surfaced
     user fields end-to-end.

Validated:
   - `pnpm guard` + `pnpm typecheck` (all workspace projects)
   - `pnpm --filter @open-design/daemon test`: 2998 / 2998 passing,
     including the new 8-test integration suite.
   - `cd e2e && pnpm test tests/amr`: 1 / 1 passing.
   - `cd e2e && pnpm exec playwright test ui/amr-login-pill.test.ts`:
     1 / 1 passing (6.7s).

* feat(amr): package native cli and refine login ui

* feat(amr): wire vela cli beta packaging

* docs(amr): document vela ci packaging review

* docs(amr): refine vela ci integration review

* fix(ci): refresh nix pnpm dependency hashes

* fix(pack): clean up Vela CLI packaging

* fix(pack): bundle Vela CLI support files

* fix(amr): recover login attempts from stale auth state

* test: expand AMR and automations coverage

* fix(amr): address review follow-ups

* test(web): align tasks fixtures with contracts

* fix(daemon): type wildcard route params

* fix(ci): refresh PR merge validation

* fix(amr): clear env credentials on logout

* feat(settings): inline local CLI model configuration

* fix(amr): recognize daemon env credentials

* [codex] Fix Vela companion packaging (#2979)

* Fix Vela companion packaging

* Update Nix pnpm dependency hashes

* [codex] Surface AMR account failures (#2980)

* fix: surface AMR account failures

* fix: cover AMR recovery error guidance

* chore: bump beta base version to 0.8.1 (#2990)

* Fix AMR profile and packaged runtime review issues

* Detect packaged AMR OpenCode companion tree

* feat(web): polish AMR frontend flows

* Polish AMR onboarding card

* fix: read AMR login state from dot-amr config (#3048)

* test: tighten AMR credential and packaging coverage

* test: restore AMR executable test env helper

* [codex] Fix packaged mac Dock identity and AMR label (#3076)

* Fix packaged mac sidecar Dock identity

* Rename AMR assistant label

* Fix AMR live models and dot-amr login state (#3073)

* fix: read AMR login state from dot-amr config

* fix: load live AMR models before runs

* fix: point AMR onboarding link to production wallet

* fix: address AMR model review feedback

* fix: persist live AMR model fallback

* [codex] Fix AMR link catalog model ids (#3088)

* Fix packaged mac sidecar Dock identity

* Rename AMR assistant label

* Fix AMR link catalog model ids

* Fix AMR model normalization typecheck

* Use live AMR model for default runs

* fix: polish AMR runtime settings UI

* Accelerate AMR startup defaults (#3092)

* Surface AMR insufficient balance wallet URL (#3099)

* fix(web): polish onboarding controls (#3112)

* fix(web): show CLI scan loading state

* Avoid duplicate AMR wallet recharge links (#3117)

* Avoid duplicate AMR wallet recharge links

* Use Vela CLI 0.0.3 test package

* chore(nix): refresh pnpm deps hash

* Fix AMR wallet guidance display

---------

Co-authored-by: open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>

* chore(pack): pin Vela CLI 0.0.3-test.1 (#3127)

* chore(nix): refresh pnpm deps hash

* chore(pack): pin Vela CLI 0.0.3

* chore(nix): refresh pnpm deps hash

* fix(web): suppress AMR exit 130 fallback (#3136)

* feat(web): nudge users to hosted AMR on model/auth/quota failures (#3083)

* feat(web): nudge users to hosted AMR on model/auth/quota failures

When a non-AMR agent run fails with an auth / quota / upstream model
error, surface an inline nudge under the error pill linking to Open
Design's hosted AMR gateway (https://open-design.ai/amr). The nudge
fires `surface_view` (element=run_failed_toast) on impression and
`ui_click` (element=go_amr) on the link.

Also teach the daemon to classify CLI-agent auth/quota/upstream failures
(Claude Code, codex, ...) into specific API error codes
(AGENT_AUTH_REQUIRED / RATE_LIMITED / UPSTREAM_UNAVAILABLE) instead of
the generic AGENT_EXECUTION_FAILED, so both the error message and the
nudge key off accurate codes. AMR's own runs are excluded from the
nudge — they keep the dedicated sign-in / recharge affordances.

* feat(web): rework failed-run AMR guidance into per-case error UI

Replace the single inline nudge with a per-case failed-run experience
driven by the run's error code + agent:

- The error card is now neutral gray (was red) and always carries a
  retry button; it is driven by the persisted per-message error event so
  it survives a reload.
- Non-AMR agent hitting a model/auth/quota wall: a theme-color promotion
  card under the error card offers "switch to AMR & retry" — switches the
  run to AMR, opens Settings on the AMR card, and auto-retries once the
  account signs in (ProjectView polls vela login status, independent of
  the Settings pill lifecycle, with success / 5-min-timeout / unmount
  exits).
- AMR agent unauthorized: clearer copy + an "authorize & retry" button.
- AMR agent out of balance: clearer copy + a "top up" button to the AMR
  wallet, with manual retry.
- Settings AMR card: when opened from the nudge, it scrolls into view and
  pulses, and an authorize-button coachmark (a fake hand cursor that
  rises in and dismisses on hover) points at the sign-in control when not
  yet authorized.

analytics: surface_view (run_failed_toast) on the promotion card and
ui_click (go_amr) on its action are retained. i18n adds chat.amrCard.*
and chat.amrError.* (en / zh-CN / zh-TW translated; other locales fall
back to en) and drops the old chat.amrErrorGuidance keys.

* fix(daemon): require status context for numeric service-failure codes

Per review on #3083: the model-service classifier matched bare HTTP
status numbers (`500`, `502`, `429`, `401`), so ordinary CLI output like
`line 500`, `read 502 bytes`, or `exit code 401` could be misclassified
as a provider outage / auth wall and wrongly surface the AMR nudge. Now
a status number only counts when it carries explicit context (`HTTP 500`,
`status 503`, `code: 401`, `502 Bad Gateway`); textual provider phrases
(overloaded, bad gateway, service unavailable, rate limit, …) are
unchanged. Adds fixtures proving unrelated numeric output stays null.

* fix(web): keep error pill for failed runs ChatPane's card doesn't cover

Per review on #3083: the per-message gray error pill was suppressed for
every persisted error status event, but ChatPane only renders the
replacement top-level error card for `retryableAssistantMessage` (the
last failed assistant). So a failed turn that is no longer last (after a
follow-up) or an older failed run in history showed neither the pill nor
the card — its error detail vanished, undercutting reload/history
survival. ChatPane now passes `errorCardOwnerId` (the assistant id whose
error the card represents); AssistantMessage suppresses only that one
pill and keeps rendering StatusPill for all other error events.

* fix(daemon): don't treat a process exit code as an HTTP status

Follow-up to review on #3083: the status-context helper accepted a bare
`code` prefix, so `exit code 401` / `process exited with code 429` still
matched and got classified as AGENT_AUTH_REQUIRED / RATE_LIMITED (the
very `exit code 401` case the comment calls out as noise). `code` now
only counts when qualified (`status code` / `error code` / `response
code`) or punctuation-bound (`code: 401`); bare `exit code N` no longer
matches. Adds fixtures for exit-code lines returning null.

* chore(web): translate AMR card / error keys for 16 remaining locales

PR #3083 added 10 new `chat.amrCard.*` / `chat.amrError.*` keys but only
provided en/zh-CN/zh-TW translations; the other 16 locales fell back to
English. Translate the card title/body, three chips, primary CTA, and
the AMR self-error (auth / balance) messages and buttons for ar, de,
es-ES, fa, fr, hu, id, it, ja, ko, pl, pt-BR, ru, th, tr, uk.

* fix(amr): address review feedback on #2355

Targeted fixes for the unresolved review threads on #2355. Each fix
includes / updates a focused test.

- runtimes/executables.ts: `packagedVelaOpenCodeCompanionTree` now
  verifies the inner `opencode` executable exists + is runnable, not
  just the directory. This closes the false-positive availability path
  that let `detectAgents()` surface AMR as available even when the
  packaged companion was empty / partially copied (mrcfps, 4 threads).

- runtimes/executables.ts: `resolveAmrOpenCodeExecutable` now prefers
  the bundled `<OD_RESOURCE_ROOT>/bin/libexec/opencode/opencode` over a
  stale `opencode` on the user's PATH, so packaged AMR builds can't be
  hijacked by a global installation.

- web/EntryShell.tsx: when the Local CLI scan returns an available
  agent and the previously-selected agent is AMR, switch the selection
  to the first available local agent so the runtime and persisted
  agent agree before Continue.

- server.ts (model-probe branch): for AMR, check `readVelaLoginStatus`
  BEFORE rejecting on an empty live-model catalog — a signed-out user
  was getting `AMR_MODEL_UNAVAILABLE` ("choose a model") instead of
  the correct `AMR_AUTH_REQUIRED` (sign-in affordance).

- server.ts (default model fallback): if the user asked for the AMR
  agent default and the cached id is no longer in the FRESH catalog,
  fall back to `liveModels[0]` from the probe instead of rejecting the
  run as `AMR_MODEL_UNAVAILABLE`.

- integrations/vela.ts: route `vela login` through
  `createCommandInvocation` so an npm/Node-style `vela.cmd` / `.bat`
  shim on Windows gets the correct `cmd.exe /d /s /c …` wrapping with
  verbatim args (matches `execAgentFile` / chat-run spawning).

- tools/pack/src/linux.ts: in containerized Linux builds, bind-mount
  the host directory of `OPEN_DESIGN_VELA_CLI_BIN` and rewrite the env
  to the container-side path. The host path was being passed in as-is
  even though the default container only mounts /project, /tools-pack
  and cache/home — `copyOptionalVelaCliBinary` saw a missing path.

Deferred (out of scope for this PR):
- `od amr status/login/logout/cancel` CLI subcommands (AGENTS.md
  UI/CLI dual-track rule, server.ts:5763) — sizable surface; tracked
  for a separate focused PR.
- Strict `--require-vela-cli` for Windows + mac-x64 beta builds:
  prematurely blocked — `@powerformer/vela-cli` only publishes the
  `darwin-arm64` platform binary today; adding the flag elsewhere
  would fail the builds. Revisit once win/x64/linux binaries ship.

* fix(amr): hoist sendAmrAccountFailure above the AMR catalog preflight (TDZ)

The new signed-out AMR branch in the catalog preflight at server.ts:10875
calls `sendAmrAccountFailure(...)` to emit AMR_AUTH_REQUIRED, but the
const declaration sat ~100 lines below at the outer function scope. Because
`const` is TDZ-aware, that branch would have thrown `ReferenceError:
Cannot access 'sendAmrAccountFailure' before initialization` for the
exact users it tries to help — defeating the original intent.

Hoist the helper to just above the AMR preflight block so it's available
to every AMR code path in this function. Behavior elsewhere is unchanged.

Also rerun the daemon test suite: `launch.test.ts > resolveAgentLaunch
uses packaged built-in Vela for AMR` was creating the
`<resourceRoot>/bin/libexec/opencode/` companion *directory* only, but
this PR's earlier tightening of `packagedVelaOpenCodeCompanionTree`
also requires the inner `opencode` executable. Add it to that fixture
to match the new contract; the test was a sibling of the executables /
env-and-detection fixtures already updated in 13fc4f4.

Addresses #2355 review (mrcfps, 2026-05-28).

* feat(web): add hover cancel for AMR login (#3158)

* feat(web): add hover cancel for AMR login

* fix(web): don't bounce AmrLoginPill back to 'Signing in…' after local cancel

Both codex-connector (P2) and looper (CHANGES_REQUESTED) on this PR
flagged the same race in the new local-cancel path: `handleCancelLogin`
dispatches `notifyAmrLoginStatusChanged('login-canceled')` immediately
after `/login/cancel` returns, but the `AMR_LOGIN_STATUS_EVENT` listener
unconditionally re-enters `refresh()` and then restarts polling
whenever `/api/integrations/vela/status` still reports
`loginInFlight: true`.

That is a real race because the daemon's `cancelVelaLogin()` only sends
SIGTERM (escalating to SIGKILL after `LOGIN_CANCEL_KILL_GRACE_MS` =
2000 ms) and keeps the child in `activeLoginProcs` until it actually
exits — so the first `/status` read after a successful cancel can
legally still come back as in-flight. Under that window the pill flips
back to 'Signing in…' and can later surface the timeout/error path even
though the user already canceled, defeating the behavior promised in
the PR description.

Fix the listener instead of every dispatch site: in the
`login-canceled` branch, after the local reset (stopPolling +
setPending(null) + clear refs), optimistically mark every subscribed
pill instance as not-in-flight (`setStatus((c) => c ? { ...c,
loginInFlight: false } : c)`) and `return` — skip the
refresh-and-reconcile branch below entirely. The next explicit refresh
(component mount, user interaction, or a `status-changed` event) will
pick up the daemon's confirmed state once the child has actually
exited.

Add a focused regression test that holds `/api/integrations/vela/status`
at `loginInFlight: true` even after a successful `/login/cancel`,
asserting that the pill stays at the Canceled → Authorize sequence and
never bounces back to 'Signing in…'. This test fails on the pre-fix
listener and passes on the new behavior; existing
'cancels an in-flight AMR sign-in…' and 'reconciles late AMR browser
completion to Signed in after local cancel' tests continue to pass.

Addresses review feedback on #3158 (chatgpt-codex-connector, nettee).

---------

Co-authored-by: lefarcen <935902669@qq.com>

---------

Co-authored-by: a1chzt <chizblank@gmail.com>
Co-authored-by: Amy <1184569493@qq.com>
Co-authored-by: Mason <jinmeihong0201@gmail.com>
Co-authored-by: Caprika <56862773+alchemistklk@users.noreply.github.com>
Co-authored-by: open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>
This commit is contained in:
lefarcen 2026-05-28 13:09:55 +08:00 committed by GitHub
parent 72426b942a
commit df8a0faff6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
159 changed files with 16394 additions and 459 deletions

View file

@ -177,6 +177,7 @@ jobs:
--mac-compression normal
--to dmg
--json
--require-vela-cli
--signed
)
if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$build_log_path" >&2))"; then

View file

@ -52,6 +52,34 @@ _Avoid_: generic subject field, hidden prompt text
The default voice option shown when the Home Audio composer cannot load configured ElevenLabs voices. It keeps ElevenLabs speech runnable by selecting the same default voice id the daemon uses when no explicit voice is supplied.
_Avoid_: required credential setup, empty voice selector
**AMR Cloud**:
The user-facing cloud runtime option for Open Design's official model router, shown in onboarding and login-oriented product surfaces.
_Avoid_: Vela, local CLI label
**AMR CLI**:
The local command-line runtime adapter used to run AMR from an installed or packaged native CLI.
_Avoid_: AMR Cloud, cloud account
**AMR CLI Distribution Contract**:
The separately owned release contract that provides the native AMR CLI builds Open Design can package.
_Avoid_: Open Design release channel, package build step, source checkout
**AMR CLI Distribution Slice**:
The set of native AMR CLI platforms currently available through the distribution contract; platforms outside the slice do not bundle the AMR CLI.
_Avoid_: Open Design supported platforms, release channel, future platform promise
**AMR Account Status**:
Whether the user has authenticated the account needed to use AMR Cloud.
_Avoid_: profile badge, environment, CLI version
**AMR Environment Profile**:
The target AMR service environment a packaged runtime is configured to use.
_Avoid_: release channel, account status, app identity
**Onboarding Skip**:
The explicit path that lets a user leave onboarding without completing the currently selected setup option.
_Avoid_: continue, finish setup, passive close
## Relationships
- A **Project** contains zero or more **Normal Artifacts**.
@ -62,6 +90,12 @@ _Avoid_: required credential setup, empty voice selector
- A **Home Composer Media Surface** maps user intent to an existing project kind and project metadata at submit time.
- The **Chip Rail** is the visible Home entry point for choosing a **Home Composer Media Surface**.
- **Essential Audio Generation** uses an **Audio Source Field** plus model options before creating an audio **Project**.
- **AMR Cloud** is the user-facing product choice; **AMR CLI** is the local execution adapter behind that capability.
- The **AMR CLI Distribution Contract** is owned separately from Open Design; Open Design release packaging consumes it instead of defining the native CLI release itself.
- The first **AMR CLI Distribution Slice** is mac arm64 only.
- **AMR Account Status** describes account readiness for **AMR Cloud**, not the environment profile or CLI installation state.
- An **AMR Environment Profile** is independent from release channel identity; a beta, preview, nightly, or stable package can target different AMR service environments when explicitly configured.
- **Onboarding Skip** bypasses setup completion requirements that belong to the normal onboarding continue path.
## Example dialogue

View file

@ -1,6 +1,6 @@
{
"name": "@open-design/daemon",
"version": "0.8.0",
"version": "0.8.1",
"private": true,
"type": "module",
"main": "./dist/cli.js",

View file

@ -0,0 +1,101 @@
#!/usr/bin/env node
/**
* Ad-hoc end-to-end verifier: drives the real `vela` binary through Open
* Design's `attachAcpSession`. Not part of the test suite it makes a real
* OpenRouter request when VELA_RUNTIME_KEY is a live key.
*
* Usage:
* VELA_BIN=/path/to/vela \
* VELA_RUNTIME_KEY=<openrouter-key> \
* VELA_LINK_URL=https://openrouter.ai/api/v1 \
* PATH=<dir-with-opencode>:$PATH \
* node apps/daemon/scripts/verify-amr-real-vela.mjs
*
* Behaviour:
* - Runs initialize session/new session/set_model (if --model given)
* session/prompt with the prompt from VELA_VERIFY_PROMPT (defaults to a
* short hello).
* - Logs every Open Design `send(event, payload)` to stdout so you can see
* the same text_delta / usage events the chat UI would receive.
* - Exits 0 on completedSuccessfully, 1 otherwise.
*/
import { spawn } from 'node:child_process';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const { attachAcpSession } = await import(
path.join(HERE, '..', 'dist', 'acp.js')
);
const velaBin = process.env.VELA_BIN || 'vela';
const prompt = process.env.VELA_VERIFY_PROMPT || 'Reply with the exact text: AMR-E2E-OK.';
const model = process.env.VELA_VERIFY_MODEL || null;
if (
(!process.env.VELA_RUNTIME_KEY || !process.env.VELA_LINK_URL) &&
!process.env.VELA_PROFILE
) {
console.error(
'Provide credentials via either:\n' +
' - VELA_RUNTIME_KEY + VELA_LINK_URL env vars, or\n' +
' - VELA_PROFILE (e.g. "local") with a logged-in ~/.amr/config.json.',
);
process.exit(2);
}
const child = spawn(velaBin, ['agent', 'run', '--runtime', 'opencode'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
});
child.stderr.on('data', (chunk) => {
process.stderr.write(`[vela.stderr] ${chunk}`);
});
child.on('error', (err) => {
console.error('[child.error]', err.message);
});
child.on('exit', (code, signal) => {
console.error(`[child.exit] code=${code} signal=${signal}`);
});
child.on('close', (code, signal) => {
console.error(`[child.close] code=${code} signal=${signal}`);
});
const overallTimeoutMs = Number(process.env.VELA_VERIFY_TIMEOUT_MS) || 120_000;
const overallTimer = setTimeout(() => {
console.error(`[verify-amr] overall timeout after ${overallTimeoutMs}ms; SIGTERM child`);
if (!child.killed) child.kill('SIGTERM');
}, overallTimeoutMs);
overallTimer.unref?.();
const session = attachAcpSession({
child,
prompt,
cwd: process.cwd(),
model,
mcpServers: [],
send: (event, payload) => {
const stamp = new Date().toISOString();
if (event === 'agent' && payload?.type === 'text_delta') {
process.stdout.write(payload.delta);
return;
}
console.log(`\n[${stamp}] ${event} ${JSON.stringify(payload)}`);
},
});
await new Promise((resolve) => child.on('close', resolve));
process.stdout.write('\n');
if (session.hasFatalError()) {
console.error('Session reported fatal error.');
process.exit(1);
}
if (!session.completedSuccessfully()) {
console.error('Session did not complete successfully.');
process.exit(1);
}
console.log('verify-amr-real-vela: OK');

View file

@ -70,6 +70,7 @@ interface AttachAcpSessionOptions {
clientName?: string;
clientVersion?: string;
stageTimeoutMs?: number;
modelUnavailableErrorCode?: 'AMR_MODEL_UNAVAILABLE';
}
function errorMessage(err: unknown): string {
@ -426,6 +427,7 @@ export function attachAcpSession({
clientName = 'open-design',
clientVersion = 'runtime-adapter',
stageTimeoutMs = DEFAULT_STAGE_TIMEOUT_MS,
modelUnavailableErrorCode,
}: AttachAcpSessionOptions) {
const runStartedAt = Date.now();
const effectiveCwd = path.resolve(cwd || process.cwd());
@ -443,6 +445,7 @@ export function attachAcpSession({
let modelConfigId: string | null = null;
let emittedThinkingStart = false;
let emittedFirstTokenStatus = false;
let emittedTextChunk = false;
let finished = false;
let fatal = false;
let aborted = false;
@ -467,12 +470,41 @@ export function attachAcpSession({
stageTimer = null;
};
const fail = (message: string) => {
const amrModelUnavailablePayload = (message: string) => ({
message,
error: {
code: 'AMR_MODEL_UNAVAILABLE',
message,
retryable: false,
details: { kind: 'amr_model', action: 'choose_model' },
},
});
const isModelUnavailableError = (message: string) => {
const value = message.toLowerCase();
return (
value.includes('model not found') ||
value.includes('providermodelnotfounderror') ||
value.includes('unknown model') ||
value.includes('invalid model')
);
};
const fail = (
message: string,
options: { forceModelUnavailable?: boolean } = {},
) => {
if (finished) return;
finished = true;
fatal = true;
clearStageTimer();
send('error', { message });
const useModelUnavailable =
modelUnavailableErrorCode &&
(options.forceModelUnavailable || isModelUnavailableError(message));
send(
'error',
useModelUnavailable ? amrModelUnavailablePayload(message) : { message },
);
if (!child.killed) child.kill('SIGTERM');
};
@ -575,6 +607,7 @@ export function attachAcpSession({
if (update.sessionUpdate === 'agent_message_chunk') {
const text = asObject(update.content)?.text;
if (typeof text === 'string' && text.length > 0) {
emittedTextChunk = true;
if (!emittedFirstTokenStatus) {
emittedFirstTokenStatus = true;
send('agent', {
@ -638,6 +671,13 @@ export function attachAcpSession({
return;
}
if (promptRequestId !== null && obj.id === promptRequestId) {
if (!emittedTextChunk && modelUnavailableErrorCode) {
fail(
'ACP session completed without producing any assistant text. Refresh the AMR model list, choose a supported model, and retry this run.',
{ forceModelUnavailable: true },
);
return;
}
const usage = formatUsage(result.usage);
if (usage) {
send('agent', {
@ -672,9 +712,12 @@ export function attachAcpSession({
});
stdout.on('data', (chunk: string) => parser.feed(chunk));
child.on('close', () => {
child.on('close', (code, signal) => {
clearStageTimer();
parser.flush();
if (!finished && !aborted && !fatal) {
fail(`ACP session exited before completion (code=${code ?? 'null'}, signal=${signal ?? 'none'})`);
}
});
child.on('error', (err: Error) => fail(err.message));
stdin.on('error', (err: Error) => fail(`stdin error: ${err.message}`));

View file

@ -142,6 +142,14 @@ function validateTelemetry(raw: unknown): TelemetryPrefs | undefined {
}
const AGENT_CLI_ENV_KEYS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
['amr', new Set([
'VELA_BIN',
'VELA_LINK_URL',
'VELA_RUNTIME_KEY',
'VELA_OPENCODE_BIN',
'OPEN_DESIGN_AMR_PROFILE',
'OPENCODE_TEST_HOME',
])],
['aider', new Set(['AIDER_BIN'])],
['claude', new Set(['CLAUDE_CONFIG_DIR', 'CLAUDE_BIN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_API_KEY'])],
['codex', new Set(['CODEX_HOME', 'CODEX_BIN', 'OPENAI_BASE_URL', 'CODEX_API_KEY', 'OPENAI_API_KEY'])],

View file

@ -48,6 +48,7 @@ import {
} from './openai-chat-token-params.js';
import type { AgentCliEnvPrefs } from './app-config.js';
import type { RuntimeAgentDef } from './runtimes/types.js';
import { resolveModelForAgent } from './runtimes/models.js';
import {
isBlockedExternalApiHostname,
isLoopbackApiHost,
@ -1127,7 +1128,13 @@ function attachAgentStreamHandlers(
child,
prompt,
cwd,
model: model ?? null,
// 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.
model: resolveModelForAgent(def as never, model ?? null),
mcpServers: [],
send,
});

View file

@ -0,0 +1,85 @@
export type AmrAccountErrorCode = 'AMR_AUTH_REQUIRED' | 'AMR_INSUFFICIENT_BALANCE';
export interface AmrAccountFailure {
code: AmrAccountErrorCode;
message: string;
action: 'relogin' | 'recharge';
actionUrl?: string;
}
export const DEFAULT_AMR_RECHARGE_URL = 'https://open-design.ai/amr/wallet';
const AMR_AUTH_REQUIRED_MESSAGE =
'AMR sign-in is required. Sign in to AMR Cloud again, then retry this run.';
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.`;
function normalizeFailureText(text: string): string {
return String(text || '').toLowerCase();
}
function containsInsufficientBalanceSignal(value: string): boolean {
if (
value.includes('insufficient_balance') ||
value.includes('insufficient balance') ||
value.includes('insufficient wallet balance') ||
value.includes('insufficient credits') ||
value.includes('insufficient credit') ||
value.includes('insufficient funds') ||
value.includes('not enough balance') ||
value.includes('not enough credits') ||
value.includes('balance is empty') ||
value.includes('balance too low') ||
value.includes('billing balance')
) {
return true;
}
return value.includes('quota') && /\b(wallet|balance|credit|billing|funds?)\b/.test(value);
}
export function classifyAmrAccountFailure(text: string): AmrAccountFailure | null {
const value = normalizeFailureText(text);
if (!value.trim()) return null;
if (containsInsufficientBalanceSignal(value)) {
return {
code: 'AMR_INSUFFICIENT_BALANCE',
message: AMR_INSUFFICIENT_BALANCE_MESSAGE,
action: 'recharge',
actionUrl: DEFAULT_AMR_RECHARGE_URL,
};
}
if (
value.includes('auth_required') ||
value.includes('authentication required') ||
value.includes('not authenticated') ||
value.includes('unauthenticated') ||
value.includes('not logged in') ||
value.includes('login missing') ||
value.includes('sign in again') ||
value.includes('sign-in required') ||
value.includes('signin required') ||
value.includes('token has expired') ||
value.includes('expired token') ||
value.includes('invalid session') ||
value.includes('session expired')
) {
return {
code: 'AMR_AUTH_REQUIRED',
message: AMR_AUTH_REQUIRED_MESSAGE,
action: 'relogin',
};
}
return null;
}
export function amrAccountFailureDetails(failure: AmrAccountFailure) {
return {
kind: 'amr_account',
action: failure.action,
...(failure.actionUrl ? { actionUrl: failure.actionUrl } : {}),
};
}

View file

@ -0,0 +1,21 @@
const AMR_PROFILE_ENV = 'OPEN_DESIGN_AMR_PROFILE';
const DEFAULT_PROFILE = 'prod';
const ALLOWED_PROFILES = new Set(['prod', 'test', 'local']);
export type AmrProfile = 'prod' | 'test' | 'local';
type EnvMap = NodeJS.ProcessEnv | Record<string, string | undefined>;
export function resolveAmrProfile(env: EnvMap = process.env): AmrProfile {
const raw = (env[AMR_PROFILE_ENV] || '').trim();
if (!raw) return DEFAULT_PROFILE;
if (ALLOWED_PROFILES.has(raw)) return raw as AmrProfile;
console.warn(
`[amr] invalid ${AMR_PROFILE_ENV}="${raw}"; falling back to ${DEFAULT_PROFILE}`,
);
return DEFAULT_PROFILE;
}
export function amrVelaProfileEnv(env: EnvMap = process.env): { VELA_PROFILE: AmrProfile } {
return { VELA_PROFILE: resolveAmrProfile(env) };
}

View file

@ -0,0 +1,279 @@
import { spawn, type ChildProcess } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
import { createCommandInvocation } from '@open-design/platform';
import { resolveAgentLaunch } from '../runtimes/launch.js';
import { spawnEnvForAgent } from '../runtimes/env.js';
import { getAgentDef } from '../runtimes/registry.js';
import { resolveAmrProfile } from './vela-profile.js';
export { resolveAmrProfile } from './vela-profile.js';
export interface VelaUser {
id: string;
email: string;
name?: string;
image?: string | null;
plan?: string;
}
export interface VelaLoginStatus {
loggedIn: boolean;
loginInFlight: boolean;
profile: string;
user: VelaUser | null;
configPath: string;
}
interface VelaProfileShape {
controlKey?: string;
runtimeKey?: string;
apiUrl?: string;
linkUrl?: string;
user?: VelaUser | null;
}
interface VelaConfigFileShape {
profiles?: Record<string, VelaProfileShape>;
}
export function mergeVelaEnv(
env: NodeJS.ProcessEnv = process.env,
configuredEnv: Record<string, string> = {},
): NodeJS.ProcessEnv {
return {
...env,
...configuredEnv,
};
}
function configDir(): string {
return path.join(homedir(), '.amr');
}
export function amrConfigPath(): string {
return path.join(configDir(), 'config.json');
}
function readConfigFile(): VelaConfigFileShape | null {
const file = amrConfigPath();
if (!existsSync(file)) return null;
try {
const data = readFileSync(file, 'utf8');
const parsed = JSON.parse(data) as unknown;
if (!parsed || typeof parsed !== 'object') return null;
return parsed as VelaConfigFileShape;
} catch {
return null;
}
}
export function readVelaLoginStatus(
env: NodeJS.ProcessEnv = process.env,
configuredEnv: Record<string, string> = {},
): VelaLoginStatus {
const mergedEnv = mergeVelaEnv(env, configuredEnv);
const profile = resolveAmrProfile(mergedEnv);
const configPath = amrConfigPath();
const loginInFlight = isVelaLoginInFlight();
const runtimeKey = mergedEnv.VELA_RUNTIME_KEY?.trim() ?? '';
const linkUrl = mergedEnv.VELA_LINK_URL?.trim() ?? '';
if (runtimeKey && linkUrl) {
return { loggedIn: true, loginInFlight, profile, user: null, configPath };
}
const file = readConfigFile();
const stored = file?.profiles?.[profile];
const storedRuntimeKey = stored?.runtimeKey?.trim() ?? '';
if (!storedRuntimeKey) {
return { loggedIn: false, loginInFlight, profile, user: null, configPath };
}
const rawUser = stored?.user ?? null;
const user: VelaUser | null = rawUser
? {
id: typeof rawUser.id === 'string' ? rawUser.id : '',
email: typeof rawUser.email === 'string' ? rawUser.email : '',
...(typeof rawUser.name === 'string' ? { name: rawUser.name } : {}),
...(typeof rawUser.image === 'string' ? { image: rawUser.image } : {}),
...(typeof rawUser.plan === 'string' ? { plan: rawUser.plan } : {}),
}
: null;
return { loggedIn: true, loginInFlight, profile, user, configPath };
}
export function forgetVelaLogin(env: NodeJS.ProcessEnv = process.env): void {
const file = amrConfigPath();
if (!existsSync(file)) return;
const parsed = readConfigFile();
if (!parsed?.profiles) return;
const profile = resolveAmrProfile(env);
if (!Object.prototype.hasOwnProperty.call(parsed.profiles, profile)) return;
const keptProfileConfig = { ...(parsed.profiles[profile] ?? {}) };
delete keptProfileConfig.controlKey;
delete keptProfileConfig.runtimeKey;
delete keptProfileConfig.user;
const nextProfiles = { ...parsed.profiles };
nextProfiles[profile] = keptProfileConfig;
writeFileSync(
file,
JSON.stringify({ ...parsed, profiles: nextProfiles }, null, 2),
'utf8',
);
}
export interface SpawnedVelaLogin {
pid: number;
startedAt: string;
profile: string;
}
const activeLoginProcs = new Map<number, ChildProcess>();
const LOGIN_STARTUP_GRACE_MS = 250;
const LOGIN_CANCEL_KILL_GRACE_MS = 2000;
function isChildRunning(child: ChildProcess): boolean {
return child.exitCode === null && child.signalCode === null;
}
export function isVelaLoginInFlight(): boolean {
for (const [pid, child] of activeLoginProcs) {
if (isChildRunning(child)) return true;
activeLoginProcs.delete(pid);
}
return false;
}
export interface CancelVelaLoginResult {
canceled: boolean;
pids: number[];
}
export function cancelVelaLogin(): CancelVelaLoginResult {
const pids: number[] = [];
for (const [pid, child] of activeLoginProcs) {
if (!isChildRunning(child)) {
activeLoginProcs.delete(pid);
continue;
}
try {
child.kill('SIGTERM');
} catch {
activeLoginProcs.delete(pid);
continue;
}
pids.push(pid);
const killTimer = setTimeout(() => {
try {
if (isChildRunning(child)) child.kill('SIGKILL');
} catch {
activeLoginProcs.delete(pid);
}
}, LOGIN_CANCEL_KILL_GRACE_MS);
killTimer.unref?.();
}
return { canceled: pids.length > 0, pids };
}
export interface SpawnVelaLoginDeps {
configuredEnv?: Record<string, string>;
baseEnv?: NodeJS.ProcessEnv;
}
async function waitForImmediateLoginFailure(child: ChildProcess): Promise<void> {
let stderr = '';
let stdout = '';
child.stderr?.setEncoding('utf8');
child.stdout?.setEncoding('utf8');
child.stderr?.on('data', (chunk) => {
if (stderr.length < 4096) stderr += String(chunk);
});
child.stdout?.on('data', (chunk) => {
if (stdout.length < 4096) stdout += String(chunk);
});
const result = await new Promise<
| { kind: 'running' }
| { kind: 'exit'; code: number | null; signal: NodeJS.Signals | null }
| { kind: 'error'; error: Error }
>((resolve) => {
let settled = false;
const finish = (
value:
| { kind: 'running' }
| { kind: 'exit'; code: number | null; signal: NodeJS.Signals | null }
| { kind: 'error'; error: Error },
) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(value);
};
const timer = setTimeout(
() => finish({ kind: 'running' }),
LOGIN_STARTUP_GRACE_MS,
);
child.once('exit', (code, signal) => finish({ kind: 'exit', code, signal }));
child.once('error', (error) => finish({ kind: 'error', error }));
});
if (result.kind === 'running') return;
if (result.kind === 'error') {
throw new Error(`vela login failed to start: ${result.error.message}`);
}
if (result.code === 0) return;
const detail = (stderr || stdout).trim();
throw new Error(
detail ||
`vela login exited before authentication completed (code ${result.code ?? 'null'}, signal ${result.signal ?? 'null'})`,
);
}
export async function spawnVelaLogin(
deps: SpawnVelaLoginDeps = {},
): Promise<SpawnedVelaLogin> {
if (isVelaLoginInFlight()) {
throw new Error('vela login already running');
}
const def = getAgentDef('amr');
if (!def) throw new Error('AMR runtime def not registered');
const baseEnv = deps.baseEnv ?? process.env;
const configuredEnv = deps.configuredEnv ?? {};
const launch = resolveAgentLaunch(def, configuredEnv);
const bin = launch.selectedPath;
if (!bin) {
throw new Error('vela binary not found; install vela or configure VELA_BIN');
}
const env = spawnEnvForAgent('amr', baseEnv, configuredEnv);
// Route through createCommandInvocation so an npm/Node-style `vela.cmd` or
// `vela.bat` shim on Windows gets wrapped under `cmd.exe /d /s /c …` with
// verbatim args, matching what `execAgentFile` / chat-run spawning do. A
// direct `spawn(bin, args)` on a `.cmd` shim quietly fails to find the
// shim's actual entry point. POSIX is unchanged (no wrapping needed).
const invocation = createCommandInvocation({ command: bin, args: ['login'], env });
const child = spawn(invocation.command, invocation.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env,
detached: false,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
if (typeof child.pid !== 'number') {
throw new Error('failed to spawn vela login');
}
activeLoginProcs.set(child.pid, child);
const cleanup = () => {
if (typeof child.pid === 'number') activeLoginProcs.delete(child.pid);
};
child.once('exit', cleanup);
child.once('error', cleanup);
await waitForImmediateLoginFailure(child);
// We don't surface URL/code in this API — vela CLI opens the browser itself
// (via OpenBrowser in apps/cli/internal/commands/login.go). Callers poll
// readVelaLoginStatus() to detect completion.
return {
pid: child.pid,
startedAt: new Date().toISOString(),
profile: resolveAmrProfile(env),
};
}

View file

@ -134,6 +134,7 @@ Form authoring rules:
- If you keep the \`brand\` question, its \`id\` must stay \`"brand"\`. Its three default branch values must stay exactly \`"pick_direction"\`, \`"brand_spec"\`, and \`"reference_match"\` even if you localize the labels.
- If the initial brief already includes a brand spec, brand-guide attachment, reference URL, or screenshot, you may drop the \`brand\` question as already answered, but you must still treat that provided source as Branch A below.
- Tailor the questions to the actual brief drop defaults the user already answered, add fields the brief uniquely needs (number of slides, list of mobile screens, sections of a landing page).
- Emit exactly ONE \`<question-form>\` in this turn. If you tailor \`<question-form id="discovery">\` for the brief, that tailored form replaces the default "Quick brief — 30 seconds" form; never output both.
- **Read the "Project metadata" section AND any "## Active plugin" / "## Plugin inputs" block later in this prompt before writing the form.** "Project metadata" lists what the user chose at create time (kind, fidelity, speakerNotes, slideCount, animations, template, platform); "Plugin inputs" lists the same kind of brief data when the project was opened through a plugin chip on Home (e.g. \`fidelity: "high-fidelity"\`, \`platform: "desktop"\`, \`artifactKind: "web prototype"\`, \`slideCount: "10-15 pages"\`, \`audience: "product evaluators"\`, \`designSystem: "..."\`). **Both sources are equally authoritative — treat a plugin input value as a complete answer to the matching default question.** Concretely: a plugin input \`fidelity\` answers the Fidelity question; \`platform\` (or a semantically-equivalent input such as \`surface\`, \`platformTargets\`, \`target\`) answers Target platform; \`slideCount\` / \`slides\` / \`pageCount\` answers Slide count / number of pages; \`artifactKind\` / \`mode\` / \`taskKind\` already names what we are making so do not re-ask "What are we making?"; \`audience\` answers "Who is this for?"; \`designSystem\` / \`brand\` answers Brand context. Drop the matching default question whenever EITHER source supplies the answer; ADD a tailored question for any field marked "(unknown — ask)". For example, on a deck with \`speakerNotes: (unknown — ask…)\`, include a yes/no on speaker notes; on a template project where animations is unknown, include a motion radio; on a cross-platform project, ask which screens need native variants instead of re-asking platform. Don't re-ask the kind itself if metadata.kind is set or the active plugin's \`od.kind\` / \`taskKind\` already names it — the user already told you.
- Keep it under ~7 questions. Second batch in a follow-up form if needed.
- Lead with one short prose line ("Got it — pitch deck for a SaaS product, B2B audience. Tell me the rest:") then the form. Do **not** write a long pre-amble.

View file

@ -76,6 +76,69 @@ export function classifyAgentAuthFailure(
return null;
}
// Model-service failure classes that map a CLI agent's raw error text to a
// structured API error code. `classifyAgentAuthFailure` only covers the two
// agents (cursor-agent, deepseek) that ship a tailored sign-in hint; every
// other CLI agent (Claude Code, codex, …) used to collapse auth / quota /
// upstream failures into the generic `AGENT_EXECUTION_FAILED`. This agent-
// agnostic, text-based classifier recovers the specific class so the chat
// shows an accurate reason — and so the hosted-AMR nudge can key off it.
export type AgentServiceFailureCode =
| 'AGENT_AUTH_REQUIRED'
| 'RATE_LIMITED'
| 'UPSTREAM_UNAVAILABLE';
// A bare HTTP status number (`500`, `429`, …) is too noisy to trust on its own
// — agent stderr is full of unrelated numbers (`line 500`, `read 502 bytes`,
// `took 503ms`, `exit code 401`, `process exited with code 429`). Only treat a
// status number as a signal when it carries explicit HTTP-status context
// (`HTTP 500`, `status 429`, `status code 401`, `error code 502`,
// `server error 503`, or a punctuation-bound `code: 401`). Crucially `code`
// alone is NOT enough — that would still match process-exit lines like `exit
// code 401`; it only counts when qualified (status/error/response code) or
// immediately followed by `:`/`=`/`#`. Phrasing per review on #3083.
const STATUS_CTX =
'(?:' +
'\\bhttp(?:[ /]?\\d(?:\\.\\d)?)?\\b' + // HTTP, HTTP/1.1
'|\\b(?:status|error|response)(?:[ _-]?code)?\\b' + // status / status code / error code / response code
'|\\bcode(?=\\s*[:=#])' + // code: 401 / code=429 (NOT "exit code 401")
'|\\b(?:server|http)[ _-]?error\\b' + // server error / http error
')[\\s:=#-]*';
// Authentication / authorization: a missing, invalid, or expired credential.
const AGENT_AUTH_FAILURE_RE = new RegExp(
`(\\b(unauthor(?:ized|ised)|authenticat(?:e|ed|ion)|invalid[ _-]?(?:api[ _-]?)?key|incorrect api key|x-api-key|not (?:authenticated|logged[ _-]?in)|please (?:sign|log)[ _-]?in|oauth token (?:has )?expired|session expired|credentials? (?:are )?(?:missing|invalid|required))\\b|\\/login\\b|${STATUS_CTX}401\\b)`,
'i',
);
// Quota / rate limit / billing balance — the wall the hosted gateway avoids.
const AGENT_RATE_FAILURE_RE = new RegExp(
`(\\b(rate[ _-]?limit|too many requests|quota|insufficient[ _-]?(?:quota|balance|credit|funds)|credit balance is too low|exceeded your current quota|usage limit|billing (?:hard )?limit)\\b|${STATUS_CTX}429\\b)`,
'i',
);
// Upstream model/provider problems: overloaded, 5xx, temporarily unavailable.
const AGENT_UPSTREAM_FAILURE_RE = new RegExp(
`(\\b(overloaded(?:_error)?|service (?:is )?(?:temporarily )?unavailable|bad gateway|gateway timeout|internal server error|upstream (?:error|unavailable)|provider (?:error|unavailable)|temporarily unavailable|model is currently overloaded|5xx)\\b|${STATUS_CTX}5\\d\\d\\b|\\b5\\d\\d\\s+(?:bad gateway|service unavailable|internal server error|gateway timeout))`,
'i',
);
// Returns the model-service failure class implied by an agent's combined
// stdout/stderr/error text, or null when the text looks like an ordinary
// process failure. Auth is checked before rate/upstream so a `401` is never
// misread as a `5xx`. Pure text match — no agent-specific assumptions — so it
// applies uniformly to any CLI agent.
export function classifyAgentServiceFailure(
text: string,
): AgentServiceFailureCode | null {
const value = String(text || '');
if (!value.trim()) return null;
if (AGENT_AUTH_FAILURE_RE.test(value)) return 'AGENT_AUTH_REQUIRED';
if (AGENT_RATE_FAILURE_RE.test(value)) return 'RATE_LIMITED';
if (AGENT_UPSTREAM_FAILURE_RE.test(value)) return 'UPSTREAM_UNAVAILABLE';
return null;
}
// Tail length matches the smoke-test sink so the diagnostics block
// stays compact when it folds probe output back into its overrides.
const PROBE_TAIL_BYTES = 400;

View file

@ -0,0 +1,153 @@
import { execAgentFile } from './shared.js';
import type { RuntimeAgentDef, RuntimeModelOption } from '../types.js';
const PREFERRED_AMR_CHAT_MODEL_ORDER = [
'deepseek-v4-flash',
'deepseek-v3.2',
'glm-5.1',
'gemini-2.5-flash',
] as const;
const PREFERRED_AMR_CHAT_MODEL_RANK: ReadonlyMap<string, number> = new Map(
PREFERRED_AMR_CHAT_MODEL_ORDER.map((id, index) => [id, index]),
);
// AMR is the vela CLI's ACP stdio mode. `vela agent run --runtime opencode`
// starts a private OpenCode server and forwards stream-json over ACP JSON-RPC.
// Required env (set on the daemon process or via Settings → CLI env):
// VELA_RUNTIME_KEY — OpenRouter (or compatible) API key
// VELA_LINK_URL — OpenAI-compatible endpoint, e.g. https://openrouter.ai/api/v1
// VELA_OPENCODE_BIN — optional; absolute path to opencode when not on PATH
// See docs/new-agent-runtime-acp.md and the vela
// `specs/current/runtime/manual-agent-run-openrouter.md`.
//
// 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'.
//
// 2. Vela 0.0.1 exposes the current link-supported catalog through
// `vela models`, but that command prints public ids such as
// `public_model_deepseek_v3_2`. The ACP `session/set_model` call accepts
// the link-facing slug (`deepseek-v3.2` / `glm-5.1`), so Open Design
// normalizes those public ids at the daemon boundary until Vela exposes
// canonical ACP ids directly.
export function normalizeVelaModelId(rawId: string): string | null {
const trimmed = rawId.trim();
if (!trimmed) return null;
const withoutProvider = trimmed.startsWith('vela/')
? trimmed.slice('vela/'.length)
: trimmed;
const withoutPrefix = withoutProvider.startsWith('public_model_')
? withoutProvider.slice('public_model_'.length)
: withoutProvider;
if (!withoutPrefix) return null;
if (/^deepseek_v3_2$/i.test(withoutPrefix)) return 'deepseek-v3.2';
if (/^kimi_k2_6$/i.test(withoutPrefix)) return 'kimi-k2.6';
if (/^glm_5_1$/i.test(withoutPrefix)) return 'glm-5.1';
if (/^glm_5$/i.test(withoutPrefix)) return 'glm-5';
const versioned = normalizeKnownVelaVersionId(withoutPrefix);
if (versioned) return versioned;
return withoutPrefix.replace(/_/g, '-');
}
function normalizeKnownVelaVersionId(rawId: string): string | null {
const claude = /^claude[_-](haiku|opus|sonnet)[_-](\d+)[_-](\d+)(.*)$/i.exec(rawId);
if (claude) {
const [, family, major, minor, suffix = ''] = claude;
if (!family || !major || !minor) return null;
return `claude-${family.toLowerCase()}-${major}.${minor}${suffix.replace(/_/g, '-')}`;
}
const gpt = /^gpt_(\d+)_(\d+)(.*)$/i.exec(rawId);
if (gpt) {
const [, major, minor, suffix = ''] = gpt;
if (!major || !minor) return null;
return `gpt-${major}.${minor}${suffix.replace(/_/g, '-')}`;
}
const gemini = /^gemini_(\d+)_(\d+)(.*)$/i.exec(rawId);
if (gemini) {
const [, major, minor, suffix = ''] = gemini;
if (!major || !minor) return null;
return `gemini-${major}.${minor}${suffix.replace(/_/g, '-')}`;
}
const minimax = /^minimax_m(\d+)_(\d+)(.*)$/i.exec(rawId);
if (minimax) {
const [, major, minor, suffix = ''] = minimax;
if (!major || !minor) return null;
return `minimax-m${major}.${minor}${suffix.replace(/_/g, '-')}`;
}
return null;
}
function isVelaChatModelId(modelId: string): boolean {
// Temporary chat-surface guard: Vela already lists media-generation models,
// but Open Design's AMR runtime currently drives only chat completions.
// Remove this filter when AMR grows first-class image/video execution.
const id = modelId.toLowerCase();
if (id.startsWith('gpt-image-')) return false;
if (id.startsWith('seedance-')) return false;
if (id.startsWith('doubao-seedance-')) return false;
if (id.startsWith('veo-')) return false;
if (id.startsWith('imagen-')) return false;
return true;
}
export function parseVelaModels(stdout: string): RuntimeModelOption[] {
const seen = new Set<string>();
const models: RuntimeModelOption[] = [];
for (const line of String(stdout || '').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const [rawId] = trimmed.split(/\s+/);
if (!rawId) continue;
const id = normalizeVelaModelId(rawId);
if (!id || seen.has(id) || !isVelaChatModelId(id)) continue;
seen.add(id);
models.push({ id, label: id });
}
return orderAmrChatModels(models);
}
function orderAmrChatModels(
models: RuntimeModelOption[],
): RuntimeModelOption[] {
return models
.map((model, index) => ({ model, index }))
.sort((a, b) => {
const aRank =
PREFERRED_AMR_CHAT_MODEL_RANK.get(a.model.id) ?? Number.MAX_SAFE_INTEGER;
const bRank =
PREFERRED_AMR_CHAT_MODEL_RANK.get(b.model.id) ?? Number.MAX_SAFE_INTEGER;
return aRank - bRank || a.index - b.index;
})
.map(({ model }) => model);
}
export const amrAgentDef = {
id: 'amr',
name: 'AMR',
bin: 'vela',
versionArgs: ['--version'],
fetchModels: async (resolvedBin, env) => {
const { stdout } = await execAgentFile(resolvedBin, ['models'], {
env,
timeout: 10_000,
maxBuffer: 1024 * 1024,
});
return parseVelaModels(String(stdout));
},
// Fail closed when Vela's live catalog is unavailable. Stale static
// fallbacks let users select models that link/opencode no longer accepts.
fallbackModels: [] as RuntimeModelOption[],
buildArgs: () => ['agent', 'run', '--runtime', 'opencode'],
streamFormat: 'acp-json-rpc',
// Daemon-process env override for emergency operator pinning. Normal UI
// selection comes from the live `vela models` catalog and is preflighted
// before spawn.
defaultModelEnvVar: 'VELA_DEFAULT_MODEL',
} satisfies RuntimeAgentDef;

View file

@ -1,4 +1,8 @@
import path from 'node:path';
import { expandConfiguredEnv } from './paths.js';
import { resolveAmrOpenCodeExecutable } from './executables.js';
import { amrVelaProfileEnv } from '../integrations/vela-profile.js';
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
@ -37,6 +41,21 @@ export function spawnEnvForAgent(
...baseEnv,
...expandConfiguredEnv(configuredEnv),
};
if (agentId === 'amr') {
Object.assign(env, amrVelaProfileEnv(env));
if (!env.OPENCODE_TEST_HOME?.trim() && env.OD_DATA_DIR?.trim()) {
env.OPENCODE_TEST_HOME = path.join(
env.OD_DATA_DIR.trim(),
'amr',
'opencode-home',
);
}
if (!env.VELA_OPENCODE_BIN?.trim()) {
const opencodeBin = resolveAmrOpenCodeExecutable(env);
if (opencodeBin) env.VELA_OPENCODE_BIN = opencodeBin;
}
return env;
}
if (agentId === 'claude') {
stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']);
return env;

View file

@ -7,6 +7,7 @@ import { expandHomePath } from './paths.js';
import type { RuntimeAgentDef } from './types.js';
const AGENT_BIN_ENV_KEYS = new Map<string, string>([
['amr', 'VELA_BIN'],
['aider', 'AIDER_BIN'],
['claude', 'CLAUDE_BIN'],
['codex', 'CODEX_BIN'],
@ -101,18 +102,7 @@ function looksExecutableOnWindows(filePath: string): boolean {
return executableExts.includes(ext);
}
// Resolve the first available binary for an agent definition. Tries
// `def.bin` first, then walks `def.fallbackBins` in order. Used for
// agents whose forks ship under a different binary name but speak the
// exact same CLI (Claude Code → OpenClaude, issue #235). Returns null
// when no candidate is on PATH.
function configuredExecutableOverride(
def: RuntimeAgentDef,
configuredEnv: Record<string, string> = {},
): string | null {
const envKey = AGENT_BIN_ENV_KEYS.get(def?.id);
if (!envKey) return null;
const raw = configuredEnv?.[envKey];
function executableFilePath(raw: string | undefined): string | null {
if (typeof raw !== 'string' || raw.trim().length === 0) return null;
const expanded = expandHomePath(raw.trim());
if (!path.isAbsolute(expanded)) return null;
@ -129,6 +119,104 @@ function configuredExecutableOverride(
}
}
// Resolve the first available binary for an agent definition. Tries
// `def.bin` first, then walks `def.fallbackBins` in order. Used for
// agents whose forks ship under a different binary name but speak the
// exact same CLI (Claude Code → OpenClaude, issue #235). Returns null
// when no candidate is on PATH.
function configuredExecutableOverride(
def: RuntimeAgentDef,
configuredEnv: Record<string, string> = {},
): string | null {
const envKey = AGENT_BIN_ENV_KEYS.get(def?.id);
if (!envKey) return null;
return executableFilePath(configuredEnv?.[envKey]);
}
export function resolveAmrOpenCodeExecutable(
env: Record<string, string | undefined> = process.env,
): string | null {
const configured = executableFilePath(env.VELA_OPENCODE_BIN);
if (configured) return configured;
// In packaged builds prefer the bundled companion under
// `OD_RESOURCE_ROOT/bin/libexec/opencode/opencode` so a stale global
// `opencode` on the user's PATH can't override the known-good build that
// shipped with this app. PATH is only consulted as a last resort.
const resourceRoot = (
env.OD_RESOURCE_ROOT ?? process.env.OD_RESOURCE_ROOT
)?.trim();
if (resourceRoot) {
const bundledDir = packagedVelaOpenCodeCompanionTree(resourceRoot);
if (bundledDir) {
const bundled = executableFilePath(
path.join(
bundledDir,
process.platform === 'win32' ? 'opencode.exe' : 'opencode',
),
);
if (bundled) return bundled;
}
}
return resolveOnPath('opencode-cli') ?? resolveOnPath('opencode');
}
// `tools/pack/tests/resources.test.ts` ships the AMR OpenCode companion as a
// `<resourceRoot>/bin/libexec/opencode/opencode` *executable file*, not just
// the directory. Treating any directory there as a valid companion produces a
// false-positive availability path: `detectAgents()` would surface AMR as
// available even though the first real run can't launch (`vela` would spawn
// a missing/non-executable inner binary). Verify the inner executable too.
function packagedVelaOpenCodeCompanionTree(resourceRoot: string): string | null {
const candidate = path.join(resourceRoot, 'bin', 'libexec', 'opencode');
const exe = path.join(
candidate,
process.platform === 'win32' ? 'opencode.exe' : 'opencode',
);
try {
if (!statSync(candidate).isDirectory()) return null;
if (!statSync(exe).isFile()) return null;
if (process.platform === 'win32') {
if (!looksExecutableOnWindows(exe)) return null;
} else {
accessSync(exe, constants.X_OK);
}
return candidate;
} catch {
return null;
}
}
function packagedBuiltInExecutable(
def: RuntimeAgentDef,
configuredEnv: Record<string, string> = {},
): string | null {
if (def.id !== 'amr') return null;
const resourceRoot = process.env.OD_RESOURCE_ROOT?.trim();
if (!resourceRoot) return null;
if (
!resolveAmrOpenCodeExecutable({ ...process.env, ...configuredEnv }) &&
!packagedVelaOpenCodeCompanionTree(resourceRoot)
) {
return null;
}
const candidate = path.join(
resourceRoot,
'bin',
process.platform === 'win32' ? 'vela.exe' : 'vela',
);
try {
if (!statSync(candidate).isFile()) return null;
if (process.platform === 'win32') {
if (!looksExecutableOnWindows(candidate)) return null;
} else {
accessSync(candidate, constants.X_OK);
}
return candidate;
} catch {
return null;
}
}
export function resolveAgentExecutable(
def: RuntimeAgentDef,
configuredEnv: Record<string, string> = {},
@ -164,9 +252,10 @@ export function inspectAgentExecutableResolution(
break;
}
}
const builtInPath = packagedBuiltInExecutable(def, configuredEnv);
return {
configuredOverridePath,
pathResolvedPath,
selectedPath: configuredOverridePath || pathResolvedPath,
selectedPath: configuredOverridePath || builtInPath || pathResolvedPath,
};
}

View file

@ -3,6 +3,10 @@ const AGENT_INSTALL_LINKS: Record<
string,
{ installUrl?: string; docsUrl?: string }
> = {
amr: {
installUrl: 'https://github.com/nexu-io/vela',
docsUrl: 'https://github.com/nexu-io/open-design/blob/main/docs/new-agent-runtime-acp.md',
},
claude: {
installUrl: 'https://docs.anthropic.com/en/docs/claude-code/setup',
docsUrl: 'https://docs.anthropic.com/en/docs/claude-code',

View file

@ -11,15 +11,18 @@ 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<string, Set<string>>();
const liveModelOrder = new Map<string, string[]>();
export function rememberLiveModels(agentId: string, models: RuntimeModelOption[]) {
if (!Array.isArray(models)) return;
const ids = models
.map((m) => m && m.id)
.filter((id) => typeof id === 'string');
liveModelCache.set(
agentId,
new Set(
models.map((m) => m && m.id).filter((id) => typeof id === 'string'),
),
new Set(ids),
);
liveModelOrder.set(agentId, ids);
}
export function isKnownModel(def: RuntimeAgentDef, modelId: string | null | undefined) {
@ -32,6 +35,37 @@ export function isKnownModel(def: RuntimeAgentDef, modelId: string | null | unde
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.
export function resolveModelForAgent(
def: RuntimeAgentDef,
resolved: string | null,
env: Record<string, string | undefined> = process.env,
): string | null {
if (resolved && 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.
if (def.defaultModelEnvVar) {
const raw = env[def.defaultModelEnvVar];
if (typeof raw === 'string' && raw.trim()) return raw.trim();
}
const fallbacks = Array.isArray(def.fallbackModels) ? def.fallbackModels : [];
if (fallbacks.some((m) => m.id === 'default')) return resolved;
const liveModels = liveModelOrder.get(def.id) ?? [];
const firstLive = liveModels[0];
if (firstLive) return firstLive;
if (fallbacks.length === 0) return resolved;
const firstFallback = fallbacks[0];
return firstFallback ? firstFallback.id : resolved;
}
// Permit user-typed model ids that didn't appear in either the live
// listing or the static fallback (e.g. the user is on a brand-new model
// the CLI's `models` command hasn't surfaced yet). The CLI gets the value

View file

@ -1,3 +1,4 @@
import { amrAgentDef } from './defs/amr.js';
import { claudeAgentDef } from './defs/claude.js';
import { codexAgentDef } from './defs/codex.js';
import { devinAgentDef } from './defs/devin.js';
@ -21,6 +22,7 @@ import { readLocalAgentProfileDefs as readLocalAgentProfileDefsFromFile } from '
import type { RuntimeAgentDef } from './types.js';
const BASE_AGENT_DEFS: RuntimeAgentDef[] = [
amrAgentDef,
claudeAgentDef,
codexAgentDef,
devinAgentDef,

View file

@ -101,6 +101,15 @@ export type RuntimeAgentDef = {
| 'opencode-env-content';
installUrl?: string;
docsUrl?: string;
// Optional name of a daemon-process environment variable that overrides
// the default model id when the chat run reaches the spawn layer with
// null or the synthetic 'default'. Used by adapters whose CLI rejects
// 'default' (e.g. AMR / vela) so an operator can swap the hardcoded
// fallback without a code change — set the env var on the daemon
// process when launching `tools-dev` / `od` daemon. The value must be
// present in the daemon's `process.env`; Settings-UI per-agent env
// values only reach the spawned child and are NOT consulted here.
defaultModelEnvVar?: string;
};
export type DetectedAgent = Omit<

View file

@ -40,6 +40,18 @@ import {
sanitizeCustomModel,
spawnEnvForAgent,
} from './agents.js';
import { rememberLiveModels, resolveModelForAgent } from './runtimes/models.js';
import {
cancelVelaLogin,
forgetVelaLogin,
mergeVelaEnv,
readVelaLoginStatus,
spawnVelaLogin,
} from './integrations/vela.js';
import {
amrAccountFailureDetails,
classifyAmrAccountFailure,
} from './integrations/vela-errors.js';
import { migrateLegacyDataDirSync } from './legacy-data-migrator.js';
import {
consumedImportNonces,
@ -192,7 +204,11 @@ import {
import { narrowProjectCritiqueOverride } from './critique/spawn-inputs.js';
import { createCopilotStreamHandler } from './copilot-stream.js';
import { createJsonEventStreamHandler } from './json-event-stream.js';
import { classifyAgentAuthFailure, cursorAuthGuidance } from './runtimes/auth.js';
import {
classifyAgentAuthFailure,
classifyAgentServiceFailure,
cursorAuthGuidance,
} from './runtimes/auth.js';
import { createQoderStreamHandler } from './qoder-stream.js';
import { subscribe as subscribeFileEvents } from './project-watchers.js';
import { renderDesignSystemPreview } from './design-system-preview.js';
@ -2891,6 +2907,25 @@ function createSseErrorPayload(code, message, init = {}) {
return { message, error: createCompatApiError(code, message, init) };
}
function createAmrModelUnavailablePayload(model, init = {}) {
const modelText = typeof model === 'string' && model.trim()
? `"${model.trim()}"`
: 'the selected model';
return createSseErrorPayload(
'AMR_MODEL_UNAVAILABLE',
`AMR model ${modelText} is not available from Vela. Refresh the AMR model list, choose a supported model, and retry this run.`,
{
retryable: false,
details: {
kind: 'amr_model',
action: 'choose_model',
...(typeof model === 'string' && model.trim() ? { model: model.trim() } : {}),
...init,
},
},
);
}
const UPLOAD_DIR = path.join(os.tmpdir(), 'od-uploads');
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
@ -5708,6 +5743,66 @@ export async function startServer({
}
});
// AMR (vela) login integration — see `apps/daemon/src/integrations/vela.ts`.
// The vela CLI owns the device-authorization UX (URL + code + browser open);
// these routes only surface enough state for Open Design's Settings card to
// show login status and trigger a login from a button.
app.get('/api/integrations/vela/status', async (_req, res) => {
try {
const appConfig = await readAppConfig(RUNTIME_DATA_DIR);
const configuredEnv = agentCliEnvForAgent(appConfig.agentCliEnv, 'amr');
res.json(readVelaLoginStatus(mergeVelaEnv(process.env, configuredEnv)));
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.post('/api/integrations/vela/login', async (_req, res) => {
try {
const appConfig = await readAppConfig(RUNTIME_DATA_DIR);
const configuredEnv = agentCliEnvForAgent(appConfig.agentCliEnv, 'amr');
const spawned = await spawnVelaLogin({ configuredEnv });
res.status(202).json(spawned);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// "already running" is a 409 (resolvable by waiting/polling); everything
// else (missing vela binary, spawn failure) is a 500.
const status = /already running/i.test(message) ? 409 : 500;
res.status(status).json({ error: message });
}
});
app.post('/api/integrations/vela/login/cancel', (_req, res) => {
try {
res.json(cancelVelaLogin());
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.post('/api/integrations/vela/logout', async (_req, res) => {
try {
const appConfig = await readAppConfig(RUNTIME_DATA_DIR);
const configuredEnv = agentCliEnvForAgent(appConfig.agentCliEnv, 'amr');
forgetVelaLogin(mergeVelaEnv(process.env, configuredEnv));
delete process.env.VELA_RUNTIME_KEY;
delete process.env.VELA_LINK_URL;
const agentCliEnv = { ...(appConfig.agentCliEnv ?? {}) };
const amrEnv = { ...(agentCliEnv.amr ?? {}) };
delete amrEnv.VELA_RUNTIME_KEY;
delete amrEnv.VELA_LINK_URL;
if (Object.keys(amrEnv).length > 0) {
agentCliEnv.amr = amrEnv;
} else {
delete agentCliEnv.amr;
}
await writeAppConfig(RUNTIME_DATA_DIR, { agentCliEnv });
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: String(err) });
}
});
app.get('/api/skills', async (_req, res) => {
try {
const skills = await listAllSkills();
@ -10583,17 +10678,23 @@ export async function startServer({
// (live or fallback). Otherwise allow it through if it passes a
// permissive sanitizer — that's the path for user-typed custom model
// ids the CLI's listing didn't surface yet.
const safeModel =
let safeModel = resolveModelForAgent(
def,
typeof model === 'string'
? isKnownModel(def, model)
? model
: sanitizeCustomModel(model)
: null;
: null,
);
const safeReasoning =
typeof reasoning === 'string' && Array.isArray(def.reasoningOptions)
? (def.reasoningOptions.find((r) => r.id === reasoning)?.id ?? null)
: null;
const agentOptions = { model: safeModel, reasoning: safeReasoning };
const send = (event, data) => {
persistRunEventToAssistantMessage(db, run, event, data);
design.runs.emit(run, event, data);
};
const mcpServers = buildLiveArtifactsMcpServersForAgent(def, {
enabled: Boolean(toolTokenGrant?.token),
command: process.execPath,
@ -10744,6 +10845,103 @@ export async function startServer({
const agentLaunch = resolveAgentLaunch(def, configuredAgentEnv);
const resolvedBin = agentLaunch.selectedPath;
// Hoisted above the AMR catalog preflight: the empty-catalog branch
// below calls `sendAmrAccountFailure(...)` to surface AMR_AUTH_REQUIRED
// for signed-out users, and a `const` declared later in the same outer
// function scope would hit a TDZ ReferenceError before initialization.
const sendAmrAccountFailure = (failure) => {
send('error', createSseErrorPayload(
failure.code,
failure.message,
{
retryable: true,
details: amrAccountFailureDetails(failure),
},
));
};
if (def.id === 'amr' && resolvedBin && agentLaunch.launchPath) {
const launchPath = agentLaunch.launchPath ?? resolvedBin;
const modelProbeEnv = launchPath
? applyAgentLaunchEnv(
spawnEnvForAgent(
def.id,
{
...createAgentRuntimeEnv(process.env, daemonUrl, toolTokenGrant),
...(def.env || {}),
},
configuredAgentEnv,
),
agentLaunch,
)
: null;
let liveModels = [];
try {
liveModels =
launchPath && typeof def.fetchModels === 'function'
? ((await def.fetchModels(launchPath, modelProbeEnv)) ?? [])
: [];
} catch {
liveModels = [];
}
rememberLiveModels(def.id, liveModels);
const liveModelIds = new Set(
liveModels.map((candidate) => candidate?.id).filter(Boolean),
);
if (liveModelIds.size === 0) {
// An empty AMR catalog usually means the user is signed out — `vela
// models` returns 401 and the catch above leaves `liveModels` empty.
// Surface AMR_AUTH_REQUIRED first so the chat shows the relogin
// affordance; otherwise the user sees a misleading "choose a model"
// when the real fix is to sign in.
if (def.id === 'amr') {
const loginStatus = readVelaLoginStatus(
modelProbeEnv ?? process.env,
configuredAgentEnv,
);
if (!loginStatus.loggedIn) {
sendAmrAccountFailure({
code: 'AMR_AUTH_REQUIRED',
message:
'AMR sign-in is required. Sign in to AMR Cloud again, then retry this run.',
action: 'relogin',
});
return design.runs.finish(run, 'failed', 1, null);
}
}
send('error', createAmrModelUnavailablePayload(safeModel, {
reason: 'model_catalog_unavailable',
}));
return design.runs.finish(run, 'failed', 1, null);
}
// `safeModel` was pre-resolved via the agent-wide cached model order,
// so a request that came in as 'default' (or empty) is already a
// concrete id by this point — `safeModel === 'default'` is rarely true.
// If the user actually asked for the agent default and the cached id no
// longer appears in the FRESH catalog (e.g. the AMR Link catalog rolled
// since `/api/agents` last responded), fall back to `liveModels[0]` from
// the fresh probe instead of rejecting their run as `AMR_MODEL_UNAVAILABLE`.
const userAskedForDefault =
typeof model !== 'string' ||
!model.trim() ||
model.trim().toLowerCase() === 'default';
if (
!safeModel ||
safeModel === 'default' ||
(userAskedForDefault && !liveModelIds.has(safeModel))
) {
safeModel = liveModels[0]?.id ?? null;
agentOptions.model = safeModel;
}
if (!safeModel || !liveModelIds.has(safeModel)) {
send('error', createAmrModelUnavailablePayload(
typeof model === 'string' && model.trim() ? model : safeModel,
{ availableModels: [...liveModelIds] },
));
return design.runs.finish(run, 'failed', 1, null);
}
}
const args = def.buildArgs(
composed,
safeImages,
@ -10807,10 +11005,11 @@ export async function startServer({
return design.runs.finish(run, 'failed', 1, null);
}
const send = (event, data) => {
persistRunEventToAssistantMessage(db, run, event, data);
design.runs.emit(run, event, data);
};
// `runStartTimeMs` is consumed by the run-end artifact-manifest
// reconciler (#2893 / #3110) to skip artifacts whose mtime predates
// this run. The original main-side hunk also re-declared `const send`
// here; on this branch `send` was hoisted into the AMR preflight
// earlier, so we keep only the new `runStartTimeMs` declaration.
const runStartTimeMs = Date.now();
const inactivityTimeoutMs = resolveChatRunInactivityTimeoutMs();
const artifactQuietPeriodMs = resolveChatRunArtifactQuietPeriodMs();
@ -10963,6 +11162,27 @@ export async function startServer({
));
return design.runs.finish(run, 'failed', 1, null);
}
const agentSpawnEnv = spawnEnvForAgent(
def.id,
{
...createAgentRuntimeEnv(process.env, daemonUrl, toolTokenGrant),
...(def.env || {}),
},
configuredAgentEnv,
);
if (def.id === 'amr') {
const loginStatus = readVelaLoginStatus(agentSpawnEnv, configuredAgentEnv);
if (!loginStatus.loggedIn) {
revokeToolToken('child_exit');
unregisterChatAgentEventSink();
sendAmrAccountFailure({
code: 'AMR_AUTH_REQUIRED',
message: 'AMR sign-in is required. Sign in to AMR Cloud again, then retry this run.',
action: 'relogin',
});
return design.runs.finish(run, 'failed', 1, null);
}
}
const odMediaEnv = {
OD_BIN,
OD_NODE_BIN,
@ -11009,14 +11229,7 @@ export async function startServer({
? 'pipe'
: 'ignore';
const env = applyAgentLaunchEnv({
...spawnEnvForAgent(
def.id,
{
...createAgentRuntimeEnv(process.env, daemonUrl, toolTokenGrant),
...(def.env || {}),
},
configuredAgentEnv,
),
...agentSpawnEnv,
...odMediaEnv,
// OpenCode external-MCP injection (issue #2142). Layered AFTER
// spawnEnvForAgent / odMediaEnv / configuredAgentEnv so the
@ -11323,15 +11536,13 @@ export async function startServer({
if (agentStreamError) return;
agentStreamError = String(ev.message || 'Agent stream error');
clearInactivityWatchdog();
const authFailure = classifyAgentAuthFailure(
agentId,
[
const failureText = [
agentStreamError,
typeof ev.raw === 'string' ? ev.raw : '',
agentStdoutTail,
agentStderrTail,
].join('\n'),
);
].join('\n');
const authFailure = classifyAgentAuthFailure(agentId, failureText);
if (authFailure?.status === 'missing') {
send('error', createSseErrorPayload(
'AGENT_AUTH_REQUIRED',
@ -11340,6 +11551,18 @@ export async function startServer({
));
return;
}
// Recover the specific model-service failure class (auth / quota /
// upstream) for agents without a tailored probe (Claude Code, codex,
// …), so the chat shows an accurate reason instead of the generic
// execution-failed bucket.
const serviceCode = classifyAgentServiceFailure(failureText);
if (serviceCode) {
send('error', createSseErrorPayload(serviceCode, agentStreamError, {
details: ev.raw ? { raw: ev.raw } : undefined,
retryable: true,
}));
return;
}
send('error', createSseErrorPayload('AGENT_EXECUTION_FAILED', agentStreamError, {
details: ev.raw ? { raw: ev.raw } : undefined,
retryable: false,
@ -11472,8 +11695,24 @@ export async function startServer({
cwd: effectiveCwd,
model: safeModel,
mcpServers,
...(def.id === 'amr' ? { modelUnavailableErrorCode: 'AMR_MODEL_UNAVAILABLE' } : {}),
send: (event, data) => {
noteAgentActivity();
if (def.id === 'amr' && event === 'error') {
const failure = classifyAmrAccountFailure(
[
typeof data?.message === 'string' ? data.message : '',
typeof data?.error?.message === 'string' ? data.error.message : '',
typeof data?.error?.code === 'string' ? data.error.code : '',
agentStdoutTail,
agentStderrTail,
].join('\n'),
);
if (failure) {
sendAmrAccountFailure(failure);
return;
}
}
send(event, data);
},
...(acpStageTimeoutMs !== undefined ? { stageTimeoutMs: acpStageTimeoutMs } : {}),
@ -11527,6 +11766,15 @@ export async function startServer({
code !== 0 &&
!run.cancelRequested
) {
if (def.id === 'amr') {
const amrFailure = classifyAmrAccountFailure(
`${agentStderrTail}\n${agentStdoutTail}`,
);
if (amrFailure) {
sendAmrAccountFailure(amrFailure);
return design.runs.finish(run, 'failed', code ?? 1, signal ?? null);
}
}
const authFailure = classifyAgentAuthFailure(
agentId,
`${agentStderrTail}\n${agentStdoutTail}`,
@ -11594,12 +11842,26 @@ export async function startServer({
stdoutTail: agentStdoutTail,
env: spawnedAgentEnv,
});
// A non-zero exit whose output reads as an auth / quota / upstream
// problem (typical of Claude Code, codex, …) gets the specific code
// rather than the generic execution-failed bucket; the human-readable
// message still prefers the richer CLI diagnostic when we have one.
const serviceCode = classifyAgentServiceFailure(
`${agentStderrTail}\n${agentStdoutTail}`,
);
if (diagnostic) {
send('error', createSseErrorPayload(
'AGENT_EXECUTION_FAILED',
serviceCode ?? 'AGENT_EXECUTION_FAILED',
diagnostic.message,
{ retryable: diagnostic.retryable, details: { detail: diagnostic.detail } },
));
} else if (serviceCode) {
const detail = (agentStderrTail || agentStdoutTail || '').trim();
send('error', createSseErrorPayload(
serviceCode,
detail || 'The model service returned an error.',
{ retryable: true },
));
}
}
// Reconcile any HTML artifacts that were written during this run

View file

@ -0,0 +1,533 @@
/**
* Integration coverage for the AMR (vela) ACP runtime def.
*
* Spawns the fake vela stub at tests/fixtures/fake-vela.mjs (which speaks
* just enough ACP JSON-RPC to drive one turn) and verifies the daemon's
* `attachAcpSession` + `detectAcpModels` can walk through initialize
* session/new session/set_model session/prompt without hand-stubbing
* the child stream.
*
* The runtime def itself (apps/daemon/src/runtimes/defs/amr.ts) is a pure
* data record, so this test also pins the contract the def declares:
* - id, bin, streamFormat are stable for downstream consumers
* - buildArgs() emits the vela invocation shape the docs describe
* - AMR picker models come from `vela models`, not stale static ids.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { attachAcpSession, detectAcpModels } from '../src/acp.js';
import { classifyAmrAccountFailure } from '../src/integrations/vela-errors.js';
import {
amrAgentDef,
normalizeVelaModelId,
parseVelaModels,
} from '../src/runtimes/defs/amr.js';
import { getAgentDef } from '../src/runtimes/registry.js';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const FAKE_VELA = path.join(HERE, 'fixtures', 'fake-vela.mjs');
function spawnFakeVela(env: NodeJS.ProcessEnv = {}): ChildProcess {
return spawn(process.execPath, [FAKE_VELA], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, ...env },
});
}
function spawnFixtureScript(source: string): ChildProcess {
return spawn(process.execPath, ['-e', source], {
stdio: ['pipe', 'pipe', 'pipe'],
env: process.env,
});
}
async function waitForExit(child: ChildProcess): Promise<void> {
if (child.exitCode !== null) return;
await new Promise<void>((resolve) => {
child.once('close', () => resolve());
child.once('exit', () => resolve());
});
}
describe('AMR runtime def', () => {
it('is registered with the expected ACP wiring', () => {
const def = getAgentDef('amr');
expect(def).toBeTruthy();
expect(def?.id).toBe('amr');
expect(def?.name).toBe('AMR');
expect(def?.bin).toBe('vela');
expect(def?.streamFormat).toBe('acp-json-rpc');
});
it('builds the documented `vela agent run --runtime opencode` argv', () => {
expect(amrAgentDef.buildArgs()).toEqual([
'agent',
'run',
'--runtime',
'opencode',
]);
});
it('fails closed instead of exposing static stale fallback models', () => {
// Real vela rejects session/prompt without a prior session/set_model,
// and attachAcpSession skips set_model whenever model === 'default'.
// AMR must rely on the live `vela models` catalog so stale defaults like
// gpt-5.4-mini cannot be offered after link stops accepting them.
const ids = amrAgentDef.fallbackModels.map((m) => m.id);
expect(ids).not.toContain('default');
expect(ids).not.toContain('gpt-5.4-mini');
expect(ids).toEqual([]);
});
it('normalizes Vela public model ids to link-canonical ACP model ids', () => {
expect(normalizeVelaModelId('public_model_deepseek_v3_2')).toBe('deepseek-v3.2');
expect(normalizeVelaModelId('public_model_kimi_k2_6')).toBe('kimi-k2.6');
expect(normalizeVelaModelId('public_model_gemini_2_5_flash')).toBe('gemini-2.5-flash');
expect(normalizeVelaModelId('public_model_gemini_3_1_flash_lite_preview')).toBe(
'gemini-3.1-flash-lite-preview',
);
expect(normalizeVelaModelId('public_model_gemini_3_1_pro_preview')).toBe(
'gemini-3.1-pro-preview',
);
expect(normalizeVelaModelId('public_model_claude_haiku_4_5')).toBe('claude-haiku-4.5');
expect(normalizeVelaModelId('public_model_claude_opus_4_6')).toBe('claude-opus-4.6');
expect(normalizeVelaModelId('vela/claude-sonnet-4-7')).toBe('claude-sonnet-4.7');
expect(normalizeVelaModelId('public_model_gpt_5_4')).toBe('gpt-5.4');
expect(normalizeVelaModelId('public_model_gpt_5_4_mini')).toBe('gpt-5.4-mini');
expect(normalizeVelaModelId('public_model_minimax_m2_7')).toBe('minimax-m2.7');
expect(normalizeVelaModelId('public_model_glm_5_1')).toBe('glm-5.1');
expect(normalizeVelaModelId('public_model_glm_5')).toBe('glm-5');
expect(normalizeVelaModelId('public_model_qwen3_235b_a22b')).toBe('qwen3-235b-a22b');
expect(normalizeVelaModelId('deepseek-v3.2')).toBe('deepseek-v3.2');
expect(normalizeVelaModelId('vela/deepseek-v3.2')).toBe('deepseek-v3.2');
});
it('parses `vela models` output with fast chat defaults and plain canonical labels', () => {
const models = parseVelaModels([
'public_model_claude_opus_4_6 vela',
'public_model_deepseek_v3_2 vela',
'public_model_deepseek_v4_flash vela',
'public_model_glm_5_1 vela',
'public_model_claude_opus_4_6 vela',
'public_model_gpt_image_2 vela',
'vela/kimi-k2.6 vela',
'public_model_seedance_2 vela',
'public_model_deepseek_v3_2 vela',
'',
].join('\n'));
expect(models).toEqual([
{ id: 'deepseek-v4-flash', label: 'deepseek-v4-flash' },
{ id: 'deepseek-v3.2', label: 'deepseek-v3.2' },
{ id: 'glm-5.1', label: 'glm-5.1' },
{ id: 'claude-opus-4.6', label: 'claude-opus-4.6' },
{ id: 'kimi-k2.6', label: 'kimi-k2.6' },
]);
expect(models.every((model) => !model.label.includes('vela/'))).toBe(true);
expect(models.map((model) => model.id)).not.toContain('gpt-image-2');
expect(models.map((model) => model.id)).not.toContain('seedance-2');
});
it('fetches AMR picker models from `vela models`', async () => {
const models = await amrAgentDef.fetchModels?.(FAKE_VELA, process.env);
expect(models).toEqual([
{ id: 'deepseek-v4-flash', label: 'deepseek-v4-flash' },
{ id: 'deepseek-v3.2', label: 'deepseek-v3.2' },
{ id: 'glm-5.1', label: 'glm-5.1' },
{ id: 'gemini-2.5-flash', label: 'gemini-2.5-flash' },
{ id: 'deepseek-v4-pro', label: 'deepseek-v4-pro' },
{ id: 'gemini-3.1-flash-lite-preview', label: 'gemini-3.1-flash-lite-preview' },
{ id: 'gemini-3.1-pro-preview', label: 'gemini-3.1-pro-preview' },
{ id: 'gpt-5.4', label: 'gpt-5.4' },
{ id: 'gpt-5.4-mini', label: 'gpt-5.4-mini' },
{ id: 'glm-5', label: 'glm-5' },
{ id: 'kimi-k2.6', label: 'kimi-k2.6' },
{ id: 'minimax-m2.7', label: 'minimax-m2.7' },
{ id: 'qwen3-235b-a22b', label: 'qwen3-235b-a22b' },
]);
});
});
describe('AMR ACP transport — end-to-end against fake vela stub', () => {
it('drives a complete turn: initialize → session/new → session/set_model → session/prompt', async () => {
const child = spawnFakeVela({
FAKE_VELA_TEXT: 'Hello from AMR.',
FAKE_VELA_THOUGHT: 'thinking-chunk',
});
const events: Array<{ event: string; payload: unknown }> = [];
try {
const session = attachAcpSession({
child: child as never,
prompt: 'Say hello',
cwd: process.cwd(),
// Pass a real model id so attachAcpSession sends session/set_model
// before session/prompt, matching the real vela contract the AMR
// runtime def encodes.
model: 'deepseek-v3.2',
mcpServers: [],
send: (event, payload) => {
events.push({ event, payload });
},
});
// attachAcpSession owns the stdin lifecycle: it sends initialize on
// construction and ends stdin after session/prompt completes. We just
// wait for the child to exit on its own.
await waitForExit(child);
expect(session.hasFatalError()).toBe(false);
expect(session.completedSuccessfully()).toBe(true);
} finally {
if (child.exitCode === null) child.kill('SIGTERM');
}
const textDeltas = events
.filter((e) => {
const payload = e.payload as { type?: unknown };
return e.event === 'agent' && payload.type === 'text_delta';
})
.map((e) => (e.payload as { delta?: unknown }).delta);
expect(textDeltas.join('')).toBe('Hello from AMR.');
const thinkingDeltas = events
.filter((e) => {
const payload = e.payload as { type?: unknown };
return e.event === 'agent' && payload.type === 'thinking_delta';
})
.map((e) => (e.payload as { delta?: unknown }).delta);
expect(thinkingDeltas.join('')).toBe('thinking-chunk');
});
it('regression: stub mirrors real vela by rejecting session/prompt before session/set_model', async () => {
const child = spawnFakeVela({ FAKE_VELA_TEXT: 'unused' });
const errors: Array<{ event: string; payload: unknown }> = [];
try {
const session = attachAcpSession({
child: child as never,
prompt: 'Say hello',
cwd: process.cwd(),
// model === 'default' triggers the daemon to skip session/set_model.
// Against a vela-faithful stub that should surface as a fatal error,
// not a silent success — otherwise this same call path would also
// silently fail against a real vela in production.
model: 'default',
mcpServers: [],
send: (event, payload) => {
if (event === 'error') errors.push({ event, payload });
},
});
await waitForExit(child);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(session.hasFatalError()).toBe(true);
} finally {
if (child.exitCode === null) child.kill('SIGTERM');
}
expect(errors.length).toBeGreaterThan(0);
const message = String(
(errors[0]?.payload as { message?: unknown })?.message ?? '',
);
expect(message.toLowerCase()).toContain('session/set_model');
});
it('detectAcpModels surfaces availableModels from the vela ACP session/new response', async () => {
const result = await detectAcpModels({
bin: process.execPath,
args: [FAKE_VELA],
env: process.env,
timeoutMs: 10_000,
defaultModelOption: { id: 'deepseek-v3.2', label: 'deepseek-v3.2 (default)' },
});
const ids = (result || []).map((m) => m.id);
expect(ids).toContain('deepseek-v3.2');
expect(ids).toContain('openai/gpt-5.4-mini');
expect(ids).toContain('anthropic/claude-3.7-sonnet');
});
it('surfaces session/new JSON-RPC errors as fatal daemon events', async () => {
const child = spawnFakeVela({
FAKE_VELA_SESSION_NEW_ERROR: 'forced session/new failure',
});
const errors: Array<{ event: string; payload: unknown }> = [];
try {
const session = attachAcpSession({
child: child as never,
prompt: 'Say hello',
cwd: process.cwd(),
model: 'deepseek-v3.2',
mcpServers: [],
send: (event, payload) => {
if (event === 'error') errors.push({ event, payload });
},
});
await waitForExit(child);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(session.hasFatalError()).toBe(true);
expect(session.completedSuccessfully()).toBe(false);
} finally {
if (child.exitCode === null) child.kill('SIGTERM');
}
const message = String(
(errors[0]?.payload as { message?: unknown })?.message ?? '',
);
expect(message).toContain('forced session/new failure');
});
it('surfaces unrecoverable session/set_model failures as fatal daemon events', async () => {
const child = spawnFakeVela({
FAKE_VELA_SET_MODEL_ERROR: 'forced session/set_model failure',
});
const errors: Array<{ event: string; payload: unknown }> = [];
try {
const session = attachAcpSession({
child: child as never,
prompt: 'Say hello',
cwd: process.cwd(),
model: 'deepseek-v3.2',
mcpServers: [],
send: (event, payload) => {
if (event === 'error') errors.push({ event, payload });
},
});
await waitForExit(child);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(session.hasFatalError()).toBe(true);
expect(session.completedSuccessfully()).toBe(false);
} finally {
if (child.exitCode === null) child.kill('SIGTERM');
}
const message = String(
(errors[0]?.payload as { message?: unknown })?.message ?? '',
);
expect(message).toContain('forced session/set_model failure');
});
it('surfaces session/prompt JSON-RPC errors as fatal daemon events', async () => {
const child = spawnFakeVela({
FAKE_VELA_PROMPT_ERROR: 'forced session/prompt failure',
});
const errors: Array<{ event: string; payload: unknown }> = [];
try {
const session = attachAcpSession({
child: child as never,
prompt: 'Say hello',
cwd: process.cwd(),
model: 'deepseek-v3.2',
mcpServers: [],
send: (event, payload) => {
if (event === 'error') errors.push({ event, payload });
},
});
await waitForExit(child);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(session.hasFatalError()).toBe(true);
expect(session.completedSuccessfully()).toBe(false);
} finally {
if (child.exitCode === null) child.kill('SIGTERM');
}
const message = String(
(errors[0]?.payload as { message?: unknown })?.message ?? '',
);
expect(message).toContain('forced session/prompt failure');
});
it('maps ACP model-not-found prompt errors to AMR_MODEL_UNAVAILABLE', async () => {
const child = spawnFakeVela({
FAKE_VELA_PROMPT_ERROR: 'Model not found: vela/gpt-5.4-mini.',
});
const errors: Array<{ event: string; payload: unknown }> = [];
try {
const session = attachAcpSession({
child: child as never,
prompt: 'Say hello',
cwd: process.cwd(),
model: 'gpt-5.4-mini',
mcpServers: [],
modelUnavailableErrorCode: 'AMR_MODEL_UNAVAILABLE',
send: (event, payload) => {
if (event === 'error') errors.push({ event, payload });
},
});
await waitForExit(child);
expect(session.hasFatalError()).toBe(true);
expect(session.completedSuccessfully()).toBe(false);
} finally {
if (child.exitCode === null) child.kill('SIGTERM');
}
const payload = errors[0]?.payload as {
message?: unknown;
error?: { code?: unknown };
};
expect(String(payload?.message ?? '')).toContain('Model not found');
expect(payload?.error?.code).toBe('AMR_MODEL_UNAVAILABLE');
});
it('keeps ACP insufficient-balance prompt errors classifiable as AMR recharge failures', async () => {
const child = spawnFakeVela({
FAKE_VELA_PROMPT_ERROR:
'HTTP 429: {"error":{"code":"insufficient_balance","message":"insufficient wallet balance"}}',
});
const errors: Array<{ event: string; payload: unknown }> = [];
try {
const session = attachAcpSession({
child: child as never,
prompt: 'Say hello',
cwd: process.cwd(),
model: 'claude-opus-4-6',
mcpServers: [],
send: (event, payload) => {
if (event === 'error') errors.push({ event, payload });
},
});
await waitForExit(child);
expect(session.hasFatalError()).toBe(true);
expect(session.completedSuccessfully()).toBe(false);
} finally {
if (child.exitCode === null) child.kill('SIGTERM');
}
const message = String(
(errors[0]?.payload as { message?: unknown })?.message ?? '',
);
expect(message).toContain('insufficient_balance');
expect(classifyAmrAccountFailure(message)).toMatchObject({
code: 'AMR_INSUFFICIENT_BALANCE',
action: 'recharge',
actionUrl: 'https://open-design.ai/amr/wallet',
});
});
it('allows non-AMR ACP completions that produce no assistant text', async () => {
const child = spawnFakeVela({ FAKE_VELA_TEXT: '' });
const errors: Array<{ event: string; payload: unknown }> = [];
try {
const session = attachAcpSession({
child: child as never,
prompt: 'Say hello',
cwd: process.cwd(),
model: 'glm-5',
mcpServers: [],
send: (event, payload) => {
if (event === 'error') errors.push({ event, payload });
},
});
await waitForExit(child);
expect(session.hasFatalError()).toBe(false);
expect(session.completedSuccessfully()).toBe(true);
} finally {
if (child.exitCode === null) child.kill('SIGTERM');
}
expect(errors).toHaveLength(0);
});
it('maps AMR empty-text completions to AMR_MODEL_UNAVAILABLE', async () => {
const child = spawnFakeVela({ FAKE_VELA_TEXT: '' });
const errors: Array<{ event: string; payload: unknown }> = [];
try {
const session = attachAcpSession({
child: child as never,
prompt: 'Say hello',
cwd: process.cwd(),
model: 'glm-5',
mcpServers: [],
modelUnavailableErrorCode: 'AMR_MODEL_UNAVAILABLE',
send: (event, payload) => {
if (event === 'error') errors.push({ event, payload });
},
});
await waitForExit(child);
expect(session.hasFatalError()).toBe(true);
expect(session.completedSuccessfully()).toBe(false);
} finally {
if (child.exitCode === null) child.kill('SIGTERM');
}
const payload = errors[0]?.payload as {
message?: unknown;
error?: { code?: unknown };
};
const message = String(
payload?.message ?? '',
);
expect(message).toContain('without producing any assistant text');
expect(payload?.error?.code).toBe('AMR_MODEL_UNAVAILABLE');
});
it('surfaces an actionable error when the ACP child exits before initialize completes', async () => {
const child = spawnFixtureScript(
"process.stdout.write('not-json\\n'); setTimeout(() => process.exit(0), 20);",
);
const errors: Array<{ event: string; payload: unknown }> = [];
try {
const session = attachAcpSession({
child: child as never,
prompt: 'Say hello',
cwd: process.cwd(),
model: 'deepseek-v3.2',
mcpServers: [],
send: (event, payload) => {
if (event === 'error') errors.push({ event, payload });
},
});
await waitForExit(child);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(session.hasFatalError()).toBe(true);
expect(session.completedSuccessfully()).toBe(false);
} finally {
if (child.exitCode === null) child.kill('SIGTERM');
}
const message = String(
(errors[0]?.payload as { message?: unknown })?.message ?? '',
);
expect(message).toContain('ACP session exited before completion');
});
it('times out silent ACP children instead of hanging forever', async () => {
const child = spawnFixtureScript(
'setTimeout(() => process.exit(0), 200);',
);
const errors: Array<{ event: string; payload: unknown }> = [];
try {
const session = attachAcpSession({
child: child as never,
prompt: 'Say hello',
cwd: process.cwd(),
model: 'deepseek-v3.2',
mcpServers: [],
stageTimeoutMs: 25,
send: (event, payload) => {
if (event === 'error') errors.push({ event, payload });
},
});
await waitForExit(child);
expect(session.hasFatalError()).toBe(true);
expect(session.completedSuccessfully()).toBe(false);
} finally {
if (child.exitCode === null) child.kill('SIGTERM');
}
const message = String(
(errors[0]?.payload as { message?: unknown })?.message ?? '',
);
expect(message).toContain('timed out');
});
});

View file

@ -317,6 +317,12 @@ describe('app-config', () => {
CODEX_BIN: '~/bin/codex-next',
OPENAI_API_KEY: ' sk-proxy-openai ',
},
amr: {
VELA_BIN: '~/bin/vela',
OPEN_DESIGN_AMR_PROFILE: ' local ',
OPENCODE_TEST_HOME: ' ~/.open-design-amr-opencode ',
HOME: 'should-not-persist',
},
'trae-cli': {
TRAE_CLI_BIN: ' ~/bin/traecli-public ',
},
@ -334,6 +340,11 @@ describe('app-config', () => {
expect(cfg.agentCliEnv).toEqual({
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2', ANTHROPIC_API_KEY: 'sk-proxy-anthropic' },
codex: { CODEX_HOME: '~/.codex-alt', CODEX_BIN: '~/bin/codex-next', OPENAI_API_KEY: 'sk-proxy-openai' },
amr: {
VELA_BIN: '~/bin/vela',
OPEN_DESIGN_AMR_PROFILE: 'local',
OPENCODE_TEST_HOME: '~/.open-design-amr-opencode',
},
'trae-cli': { TRAE_CLI_BIN: '~/bin/traecli-public' },
});
});

View file

@ -39,6 +39,10 @@ function mockResponse(): MockResponse {
return res;
}
interface DiagnosticsManifestFile {
name: string;
}
describe('diagnostics export handler — non-sidecar launch', () => {
// Reviewer-requested regression spec: `runDaemonCliStartup()` calls
// `startDaemonRuntime()` without a runtime context, so plain `od` users
@ -58,9 +62,18 @@ describe('diagnostics export handler — non-sidecar launch', () => {
expect(res.capturedPayload).toBeInstanceOf(Buffer);
const zip = await JSZip.loadAsync(res.capturedPayload!);
const manifestRaw = await zip.file('summary/manifest.json')!.async('string');
const manifest = JSON.parse(manifestRaw) as { warnings: string[]; files: unknown[] };
const manifest = JSON.parse(manifestRaw) as {
warnings: string[];
files: DiagnosticsManifestFile[];
};
expect(manifest.warnings).toContain(STANDALONE_LAUNCH_WARNING);
expect(manifest.files).toEqual([]);
// Standalone launches intentionally omit sidecar-managed daemon/web/desktop
// log files, but real developer machines may still contribute matching
// macOS crash reports from /Library/Logs/DiagnosticReports. Keep the test
// focused on the contract that no sidecar log files are bundled.
expect(
manifest.files.filter((file) => file.name.startsWith('logs/')),
).toEqual([]);
});
});

306
apps/daemon/tests/fixtures/fake-vela.mjs vendored Executable file
View file

@ -0,0 +1,306 @@
#!/usr/bin/env node
/**
* Fake vela CLI used by AMR integration tests. Routes by the first argv:
*
* `vela models` prints the live link model catalog
* in the same tabular shape as Vela
* 0.0.1.
*
* `vela login` writes ~/.amr/config.json (the
* active VELA_PROFILE only) and
* exits 0. Mirrors the real
* device-authorization flow's
* on-disk side-effect without the
* interactive browser approval
* tests for Open Design's daemon
* login route only care that the
* config file appears.
*
* `vela models` prints production-shaped public
* model ids from the Vela catalog.
*
* `vela agent run --runtime opencode` ACP stdio runtime. Speaks just
* enough of the protocol to drive
* Open Design's `detectAcpModels`
* and `attachAcpSession` through a
* complete turn:
*
* initialize { protocolVersion, agentCapabilities, models }
* session/new { sessionId, models: { currentModelId, availableModels } }
* session/set_model {}
* session/prompt emits session/update notifications, then
* { stopReason: 'end_turn', usage }
*
* Behaviour can be tweaked through env vars set by the test:
* FAKE_VELA_SESSION_ID session id returned by session/new
* FAKE_VELA_TEXT assistant text streamed back to the host
* FAKE_VELA_THOUGHT optional thought chunk streamed before text
* FAKE_VELA_LOGIN_DELAY_MS delay before writing config.json on `login`
* so tests can observe the in-flight state
* FAKE_VELA_LOGIN_USER_EMAIL email written into the saved profile
* FAKE_VELA_LOGIN_USER_PLAN plan written into the saved profile
* FAKE_VELA_SESSION_NEW_ERROR when set, session/new returns a JSON-RPC error
* FAKE_VELA_SET_MODEL_ERROR when set, session/set_model returns a JSON-RPC error
* FAKE_VELA_PROMPT_ERROR when set, session/prompt returns a JSON-RPC error
* FAKE_VELA_MODELS newline-separated `vela models` stdout
* FAKE_VELA_REQUIRE_SET_MODEL strict gate (default on); set to '0' to
* accept session/prompt without prior
* session/set_model (legacy behaviour)
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { argv, stdin, stdout, stderr, env, exit } from 'node:process';
const SESSION_ID = env.FAKE_VELA_SESSION_ID || 'fake-vela-session-1';
const ASSISTANT_TEXT = Object.prototype.hasOwnProperty.call(env, 'FAKE_VELA_TEXT')
? env.FAKE_VELA_TEXT
: 'Hello from fake vela.';
const THOUGHT_TEXT = env.FAKE_VELA_THOUGHT || '';
const SESSION_NEW_ERROR = env.FAKE_VELA_SESSION_NEW_ERROR || '';
const SET_MODEL_ERROR = env.FAKE_VELA_SET_MODEL_ERROR || '';
const PROMPT_ERROR = env.FAKE_VELA_PROMPT_ERROR || '';
const AVAILABLE_MODELS = [
{ modelId: 'openai/gpt-5.4-mini', name: 'gpt-5.4-mini' },
{ modelId: 'anthropic/claude-3.7-sonnet', name: 'claude-3.7-sonnet' },
];
const DEFAULT_MODELS_STDOUT = [
'public_model_deepseek_v3_2 vela',
'public_model_deepseek_v4_flash vela',
'public_model_deepseek_v4_pro vela',
'public_model_gemini_2_5_flash vela',
'public_model_gemini_3_1_flash_lite_preview vela',
'public_model_gemini_3_1_pro_preview vela',
'public_model_gpt_5_4 vela',
'public_model_gpt_5_4_mini vela',
'public_model_glm_5 vela',
'public_model_glm_5_1 vela',
'public_model_gpt_image_2 vela',
'public_model_kimi_k2_6 vela',
'public_model_minimax_m2_7 vela',
'public_model_qwen3_235b_a22b vela',
'public_model_seedance_2 vela',
].join('\n');
// Real `vela agent run --runtime opencode` rejects session/prompt until
// session/set_model has been called for the current session — see the
// AMR runtime def docblock and the integration test for the negative case.
// The stub mirrors that contract so a regression in attachAcpSession that
// silently skips set_model for AMR turns is caught here, not in production.
let currentModelId = null;
const sessionsWithModel = new Set();
const STRICT_SET_MODEL = process.env.FAKE_VELA_REQUIRE_SET_MODEL !== '0';
function writeMessage(obj) {
stdout.write(`${JSON.stringify(obj)}\n`);
}
function writeResult(id, result) {
writeMessage({ jsonrpc: '2.0', id, result });
}
function writeNotification(method, params) {
writeMessage({ jsonrpc: '2.0', method, params });
}
function writeError(id, message, code = -32603) {
writeMessage({
jsonrpc: '2.0',
id,
error: { code, message },
});
}
function logDiag(line) {
stderr.write(`[fake-vela] ${line}\n`);
}
function emitSessionUpdates(sessionId) {
if (THOUGHT_TEXT) {
writeNotification('session/update', {
sessionId,
update: {
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: THOUGHT_TEXT },
},
});
}
const chunks = ASSISTANT_TEXT.match(/.{1,16}/gs) || [ASSISTANT_TEXT];
for (const chunk of chunks) {
writeNotification('session/update', {
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: chunk },
},
});
}
}
function handleMessage(msg) {
if (!msg || typeof msg !== 'object') return;
const { id, method, params } = msg;
switch (method) {
case 'initialize':
writeResult(id, {
protocolVersion: 1,
agentCapabilities: { promptCapabilities: { embeddedContext: false } },
models: {
currentModelId,
availableModels: AVAILABLE_MODELS,
},
});
return;
case 'session/new':
if (SESSION_NEW_ERROR) {
writeError(id, SESSION_NEW_ERROR);
return;
}
writeResult(id, {
sessionId: SESSION_ID,
models: {
currentModelId,
availableModels: AVAILABLE_MODELS,
},
});
return;
case 'session/set_model': {
if (SET_MODEL_ERROR) {
writeError(id, SET_MODEL_ERROR, -32099);
return;
}
const next = typeof params?.modelId === 'string' ? params.modelId.trim() : '';
const sessionId = typeof params?.sessionId === 'string' ? params.sessionId : SESSION_ID;
if (next) currentModelId = next;
sessionsWithModel.add(sessionId);
writeResult(id, {});
return;
}
case 'session/set_config_option': {
const sessionId = typeof params?.sessionId === 'string' ? params.sessionId : SESSION_ID;
// Treat config-option model selection as set_model for the purposes of
// the strict-set_model gate so adapters that go through the
// configOptions branch are not penalized.
sessionsWithModel.add(sessionId);
writeResult(id, {});
return;
}
case 'session/prompt': {
if (PROMPT_ERROR) {
writeError(id, PROMPT_ERROR, -32602);
return;
}
const sessionId = typeof params?.sessionId === 'string' ? params.sessionId : SESSION_ID;
if (STRICT_SET_MODEL && !sessionsWithModel.has(sessionId)) {
writeError(id, 'session/set_model must be called before session/prompt', -32602);
return;
}
emitSessionUpdates(sessionId);
writeResult(id, {
stopReason: 'end_turn',
usage: { inputTokens: 12, outputTokens: 7, totalTokens: 19 },
});
return;
}
case 'session/cancel':
logDiag('session/cancel received');
return;
default:
if (typeof id !== 'undefined') {
writeMessage({
jsonrpc: '2.0',
id,
error: { code: -32601, message: `unknown method: ${method}` },
});
}
return;
}
}
let buffer = '';
stdin.setEncoding('utf8');
stdin.on('data', (chunk) => {
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const raw of lines) {
const line = raw.trim();
if (!line) continue;
let parsed;
try {
parsed = JSON.parse(line);
} catch (err) {
logDiag(`bad json on stdin: ${err instanceof Error ? err.message : String(err)}`);
continue;
}
handleMessage(parsed);
}
});
stdin.on('end', () => {
if (argv[2] === 'login') return;
stdout.end();
// Mirror real ACP runtimes that exit on EOF so the host's child.on('close')
// fires promptly and the chat run can finalize.
process.exit(0);
});
// `vela login`: the daemon's /api/integrations/vela/login route spawns this
// without expecting any ACP traffic. Real vela goes through a device-auth
// loop and writes ~/.amr/config.json on success; the stub skips the loop
// and just writes the file so Open Design's status reader and AmrLoginPill
// poller see the same on-disk projection production produces. The stdin EOF
// handler above ignores login mode so delayed login tests can keep this
// process alive without opening the ACP stdio bridge.
function loginAndExit() {
if (env.FAKE_VELA_LOGIN_FAIL) {
stderr.write(`${env.FAKE_VELA_LOGIN_FAIL}\n`);
exit(1);
}
const profile = (env.VELA_PROFILE || 'prod').trim() || 'prod';
const allowed = new Set(['prod', 'test', 'local']);
if (!allowed.has(profile)) {
stderr.write(`[fake-vela] unknown profile ${profile}; defaulting to prod\n`);
}
const profileName = allowed.has(profile) ? profile : 'prod';
const delayMs = Number(env.FAKE_VELA_LOGIN_DELAY_MS) || 0;
const userEmail = env.FAKE_VELA_LOGIN_USER_EMAIL || 'fake-user@example.com';
const userPlan = env.FAKE_VELA_LOGIN_USER_PLAN || 'free';
const finish = () => {
const file = join(homedir(), '.amr', 'config.json');
mkdirSync(dirname(file), { recursive: true });
const payload = {
profiles: {
[profileName]: {
controlKey: 'fake-control-key-0000000000000000000000',
runtimeKey: 'fake-runtime-key-0000000000000000000000',
apiUrl:
profileName === 'local' ? 'http://localhost:18080' : '',
linkUrl:
profileName === 'local' ? 'http://localhost:18081' : '',
user: {
id: 'fake-user-id',
email: userEmail,
name: 'Fake User',
plan: userPlan,
},
},
},
};
writeFileSync(file, JSON.stringify(payload, null, 2), 'utf8');
stdout.write(`Login successful for ${userEmail}.\n`);
exit(0);
};
if (delayMs > 0) setTimeout(finish, delayMs);
else finish();
}
if (argv[2] === 'login') {
loginAndExit();
}
if (argv[2] === 'models') {
stdout.write(`${env.FAKE_VELA_MODELS || DEFAULT_MODELS_STDOUT}\n`);
exit(0);
}

View file

@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_AMR_RECHARGE_URL,
amrAccountFailureDetails,
classifyAmrAccountFailure,
} from '../../src/integrations/vela-errors.js';
describe('AMR account failure classification', () => {
it('classifies insufficient_balance JSON-RPC failures as rechargeable AMR balance errors', () => {
const failure = classifyAmrAccountFailure(
'JSON-RPC error -32000: {"code":"insufficient_balance","message":"insufficient balance"}',
);
expect(failure).toMatchObject({
code: 'AMR_INSUFFICIENT_BALANCE',
action: 'recharge',
actionUrl: DEFAULT_AMR_RECHARGE_URL,
});
expect(failure?.message).toContain(DEFAULT_AMR_RECHARGE_URL);
expect(amrAccountFailureDetails(failure!)).toEqual({
kind: 'amr_account',
action: 'recharge',
actionUrl: DEFAULT_AMR_RECHARGE_URL,
});
});
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',
);
expect(failure).toMatchObject({
code: 'AMR_INSUFFICIENT_BALANCE',
action: 'recharge',
});
});
it('does not classify non-billing throttling as AMR balance errors', () => {
expect(classifyAmrAccountFailure('HTTP 429 rate limit reached')).toBeNull();
expect(classifyAmrAccountFailure('quota exceeded')).toBeNull();
expect(classifyAmrAccountFailure('temporary wallet balance lookup outage')).toBeNull();
});
it('classifies expired token, invalid session, and missing login text as AMR auth errors', () => {
for (const text of [
'Your token has expired. Please sign in again.',
'invalid session for AMR profile',
'login missing for runtime account',
'authentication required',
]) {
expect(classifyAmrAccountFailure(text)).toMatchObject({
code: 'AMR_AUTH_REQUIRED',
action: 'relogin',
});
}
});
it('does not classify unrelated ACP failures as AMR account failures', () => {
expect(classifyAmrAccountFailure('session/prompt failed: model returned malformed output')).toBeNull();
});
it('does not tell env-auth users to relogin for bad API key failures', () => {
expect(classifyAmrAccountFailure('OpenRouter returned invalid api key')).toBeNull();
expect(classifyAmrAccountFailure('provider error: forbidden_api_key')).toBeNull();
});
});

View file

@ -0,0 +1,672 @@
// HTTP-level coverage for the AMR (vela) integration routes.
//
// Boots the real daemon Express app on a random port (same shape as
// memory-config-route.test.ts) and exercises the three endpoints from the
// outside — `/api/integrations/vela/{status,login,logout}` — so the Settings
// AmrLoginPill provider helpers, the spawn lifecycle, and the
// ~/.amr/config.json projection all stay in lockstep.
//
// HOME is redirected to a tmpdir per test so the suite never touches the
// developer's real `~/.amr/config.json`. VELA_BIN points at the
// `tests/fixtures/fake-vela.mjs` stub, which handles the `login` argv by
// writing the config file with the active VELA_PROFILE and exiting 0 —
// mirroring real vela's on-disk side-effect without the device-auth loop.
import { mkdtempSync, existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type http from 'node:http';
import { fileURLToPath } from 'node:url';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { startServer } from '../../src/server.js';
import { readAppConfig, writeAppConfig } from '../../src/app-config.js';
interface StartedServer {
url: string;
server: http.Server;
}
const HERE = path.dirname(fileURLToPath(import.meta.url));
const FAKE_VELA = path.resolve(HERE, '..', 'fixtures', 'fake-vela.mjs');
let baseUrl: string;
let server: http.Server;
let originalHome: string | undefined;
let tmpHome: string;
async function getJson<T = unknown>(url: string): Promise<{ status: number; body: T }> {
const resp = await fetch(url);
const body = (await resp.json()) as T;
return { status: resp.status, body };
}
async function postJson<T = unknown>(url: string): Promise<{ status: number; body: T }> {
const resp = await fetch(url, { method: 'POST' });
const body = (await resp.json()) as T;
return { status: resp.status, body };
}
function configPath(): string {
return path.join(tmpHome, '.amr', 'config.json');
}
function legacyVelaConfigPath(): string {
return path.join(tmpHome, '.vela', 'config.json');
}
function seedLogin(profile: string, payload: Record<string, unknown> = {}): void {
const dir = path.dirname(configPath());
mkdirSync(dir, { recursive: true });
const full = {
profiles: {
[profile]: {
runtimeKey: 'rt-seeded-key',
controlKey: 'ck-seeded-key',
apiUrl: 'http://localhost:18080',
linkUrl: 'http://localhost:18081',
user: {
id: 'user-seed',
email: 'seed@example.com',
plan: 'free',
...((payload.user as Record<string, unknown>) ?? {}),
},
...payload,
},
},
};
writeFileSync(configPath(), JSON.stringify(full, null, 2), 'utf8');
}
beforeAll(async () => {
// The login route resolves the vela binary through the daemon's
// `agentCliEnvForAgent` projection of `app-config.json` (NOT process.env),
// so we have to persist the fake binary path through the app-config file
// before any test calls /login. Without this the route would fall through
// to `resolveOnPath('vela')` and spawn the developer's real vela.
const dataDir = process.env.OD_DATA_DIR as string;
const config = await readAppConfig(dataDir);
await writeAppConfig(dataDir, {
...config,
agentCliEnv: {
...(config.agentCliEnv ?? {}),
amr: {
...((config.agentCliEnv?.amr as Record<string, string>) ?? {}),
VELA_BIN: FAKE_VELA,
},
},
});
const started = (await startServer({ port: 0, returnServer: true })) as StartedServer;
baseUrl = started.url;
server = started.server;
});
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
beforeEach(() => {
originalHome = process.env.HOME;
tmpHome = mkdtempSync(path.join(tmpdir(), 'od-vela-routes-'));
process.env.HOME = tmpHome;
process.env.OPEN_DESIGN_AMR_PROFILE = 'local';
process.env.VELA_PROFILE = 'prod';
});
afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
delete process.env.OPEN_DESIGN_AMR_PROFILE;
delete process.env.VELA_PROFILE;
delete process.env.FAKE_VELA_LOGIN_DELAY_MS;
delete process.env.FAKE_VELA_LOGIN_FAIL;
delete process.env.FAKE_VELA_LOGIN_USER_EMAIL;
delete process.env.FAKE_VELA_LOGIN_USER_PLAN;
delete process.env.VELA_RUNTIME_KEY;
delete process.env.VELA_LINK_URL;
rmSync(tmpHome, { recursive: true, force: true });
});
describe('GET /api/integrations/vela/status', () => {
it('reports loggedIn=false when ~/.amr/config.json is absent', async () => {
const { status, body } = await getJson<{
loggedIn: boolean;
loginInFlight: boolean;
profile: string;
user: { email?: string } | null;
configPath: string;
}>(`${baseUrl}/api/integrations/vela/status`);
expect(status).toBe(200);
expect(body.loggedIn).toBe(false);
expect(body.loginInFlight).toBe(false);
expect(body.profile).toBe('local');
expect(body.user).toBeNull();
// configPath must point inside the temp HOME so the suite never leaks
// into the developer's real config file.
expect(body.configPath.startsWith(tmpHome)).toBe(true);
expect(body.configPath).toContain('/.amr/');
});
it('ignores legacy ~/.vela/config.json when reporting AMR status', async () => {
const legacyPath = legacyVelaConfigPath();
mkdirSync(path.dirname(legacyPath), { recursive: true });
writeFileSync(
legacyPath,
JSON.stringify({
profiles: {
local: {
runtimeKey: 'rt-legacy',
user: { id: 'legacy-user', email: 'legacy@example.com' },
},
},
}),
'utf8',
);
const { status, body } = await getJson<{
loggedIn: boolean;
user: { email?: string } | null;
configPath: string;
}>(`${baseUrl}/api/integrations/vela/status`);
expect(status).toBe(200);
expect(body.loggedIn).toBe(false);
expect(body.user).toBeNull();
expect(body.configPath).toContain('/.amr/');
});
it('reports Settings-configured AMR env credentials as logged in', async () => {
const dataDir = process.env.OD_DATA_DIR as string;
const previous = await readAppConfig(dataDir);
await writeAppConfig(dataDir, {
...previous,
agentCliEnv: {
...(previous.agentCliEnv ?? {}),
amr: {
...((previous.agentCliEnv?.amr as Record<string, string>) ?? {}),
VELA_BIN: FAKE_VELA,
VELA_RUNTIME_KEY: 'rt-env-secret',
VELA_LINK_URL: 'https://openrouter.example/v1',
},
},
});
try {
const { status, body } = await getJson<{
loggedIn: boolean;
user: { email?: string } | null;
}>(`${baseUrl}/api/integrations/vela/status`);
expect(status).toBe(200);
expect(body.loggedIn).toBe(true);
expect(body.user).toBeNull();
expect(JSON.stringify(body)).not.toContain('rt-env-secret');
} finally {
await writeAppConfig(dataDir, previous as unknown as Record<string, unknown>);
}
});
it('reports daemon-process AMR env credentials as logged in', async () => {
process.env.VELA_RUNTIME_KEY = 'rt-process-secret';
process.env.VELA_LINK_URL = 'https://openrouter.example/v1';
const { status, body } = await getJson<{
loggedIn: boolean;
user: { email?: string } | null;
}>(`${baseUrl}/api/integrations/vela/status`);
expect(status).toBe(200);
expect(body.loggedIn).toBe(true);
expect(body.user).toBeNull();
expect(JSON.stringify(body)).not.toContain('rt-process-secret');
});
it('reports status for the Settings-configured AMR profile', async () => {
const dataDir = process.env.OD_DATA_DIR as string;
const previous = await readAppConfig(dataDir);
seedLogin('local', {
user: { id: 'local-user', email: 'settings-local@example.com' },
});
const cfg = JSON.parse(readFileSync(configPath(), 'utf8'));
cfg.profiles.prod = {};
writeFileSync(configPath(), JSON.stringify(cfg, null, 2), 'utf8');
process.env.OPEN_DESIGN_AMR_PROFILE = 'prod';
await writeAppConfig(dataDir, {
...previous,
agentCliEnv: {
...(previous.agentCliEnv ?? {}),
amr: {
...((previous.agentCliEnv?.amr as Record<string, string>) ?? {}),
VELA_BIN: FAKE_VELA,
OPEN_DESIGN_AMR_PROFILE: 'local',
},
},
});
try {
const { status, body } = await getJson<{
loggedIn: boolean;
profile: string;
user: { email?: string } | null;
}>(`${baseUrl}/api/integrations/vela/status`);
expect(status).toBe(200);
expect(body.loggedIn).toBe(true);
expect(body.profile).toBe('local');
expect(body.user?.email).toBe('settings-local@example.com');
} finally {
await writeAppConfig(dataDir, previous as unknown as Record<string, unknown>);
}
});
it('reports loggedIn=true with the surfaced user fields when the active profile has a runtimeKey', async () => {
seedLogin('local', {
user: {
id: 'u1',
email: 'leaf@example.com',
name: '杨瑾龙',
plan: 'free',
},
});
const { body } = await getJson<{
loggedIn: boolean;
user: { email?: string; plan?: string; name?: string } | null;
}>(`${baseUrl}/api/integrations/vela/status`);
expect(body.loggedIn).toBe(true);
expect(body.user?.email).toBe('leaf@example.com');
expect(body.user?.plan).toBe('free');
expect(body.user?.name).toBe('杨瑾龙');
});
it('never leaks the runtimeKey or controlKey in the status payload', async () => {
seedLogin('local', {
runtimeKey: 'rt-very-secret-do-not-leak',
controlKey: 'ck-also-secret',
});
const resp = await fetch(`${baseUrl}/api/integrations/vela/status`);
const text = await resp.text();
expect(text).not.toContain('rt-very-secret-do-not-leak');
expect(text).not.toContain('ck-also-secret');
});
});
describe('POST /api/integrations/vela/login', () => {
it('spawns the configured vela binary and surfaces a pid + startedAt + profile', async () => {
process.env.FAKE_VELA_LOGIN_USER_EMAIL = 'login-route@example.com';
const { status, body } = await postJson<{
pid: number;
startedAt: string;
profile: string;
}>(`${baseUrl}/api/integrations/vela/login`);
expect(status).toBe(202);
expect(typeof body.pid).toBe('number');
expect(body.pid).toBeGreaterThan(0);
expect(body.profile).toBe('local');
expect(body.startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
// The fake vela writes ~/.amr/config.json synchronously before exit.
// Wait briefly for the child to finish so the next status read sees
// the on-disk projection production produces.
for (let i = 0; i < 50; i += 1) {
if (existsSync(configPath())) break;
await new Promise((resolve) => setTimeout(resolve, 100));
}
expect(existsSync(configPath())).toBe(true);
const cfg = JSON.parse(readFileSync(configPath(), 'utf8'));
expect(cfg?.profiles?.local?.user?.email).toBe('login-route@example.com');
expect(cfg?.profiles?.prod).toBeUndefined();
});
it('passes the resolved AMR profile to vela login even when VELA_PROFILE is set differently', async () => {
process.env.OPEN_DESIGN_AMR_PROFILE = 'test';
process.env.VELA_PROFILE = 'local';
process.env.FAKE_VELA_LOGIN_USER_EMAIL = 'login-test@example.com';
const { status, body } = await postJson<{
pid: number;
profile: string;
}>(`${baseUrl}/api/integrations/vela/login`);
expect(status).toBe(202);
expect(body.profile).toBe('test');
for (let i = 0; i < 50; i += 1) {
if (existsSync(configPath())) break;
await new Promise((resolve) => setTimeout(resolve, 100));
}
const cfg = JSON.parse(readFileSync(configPath(), 'utf8'));
expect(cfg?.profiles?.test?.user?.email).toBe('login-test@example.com');
expect(cfg?.profiles?.local).toBeUndefined();
});
it('passes the Settings-configured AMR profile to vela login', async () => {
const dataDir = process.env.OD_DATA_DIR as string;
const previous = await readAppConfig(dataDir);
process.env.OPEN_DESIGN_AMR_PROFILE = 'prod';
process.env.VELA_PROFILE = 'prod';
process.env.FAKE_VELA_LOGIN_USER_EMAIL = 'settings-login@example.com';
await writeAppConfig(dataDir, {
...previous,
agentCliEnv: {
...(previous.agentCliEnv ?? {}),
amr: {
...((previous.agentCliEnv?.amr as Record<string, string>) ?? {}),
VELA_BIN: FAKE_VELA,
OPEN_DESIGN_AMR_PROFILE: 'local',
},
},
});
try {
const { status, body } = await postJson<{
pid: number;
profile: string;
}>(`${baseUrl}/api/integrations/vela/login`);
expect(status).toBe(202);
expect(body.profile).toBe('local');
for (let i = 0; i < 50; i += 1) {
if (existsSync(configPath())) break;
await new Promise((resolve) => setTimeout(resolve, 100));
}
const cfg = JSON.parse(readFileSync(configPath(), 'utf8'));
expect(cfg?.profiles?.local?.user?.email).toBe('settings-login@example.com');
expect(cfg?.profiles?.prod).toBeUndefined();
} finally {
await writeAppConfig(dataDir, previous as unknown as Record<string, unknown>);
}
});
it('returns 409 when a login subprocess is already in flight', async () => {
// Use the stub's delay knob so the first login is still running when
// the second request arrives; without this the first exits before the
// route's `isVelaLoginInFlight` guard sees it.
process.env.FAKE_VELA_LOGIN_DELAY_MS = '2000';
const first = await postJson(`${baseUrl}/api/integrations/vela/login`);
expect(first.status).toBe(202);
const second = await postJson<{ error?: string }>(
`${baseUrl}/api/integrations/vela/login`,
);
expect(second.status).toBe(409);
expect(String(second.body.error || '')).toMatch(/already running/i);
delete process.env.FAKE_VELA_LOGIN_DELAY_MS;
// Let the first login finish so the next test starts from a clean slate.
for (let i = 0; i < 50; i += 1) {
if (existsSync(configPath())) break;
await new Promise((resolve) => setTimeout(resolve, 100));
}
});
it('returns an error when the login subprocess exits immediately with stderr', async () => {
process.env.FAKE_VELA_LOGIN_FAIL =
'profile "prod" api URL: is not configured';
const { status, body } = await postJson<{ error?: string }>(
`${baseUrl}/api/integrations/vela/login`,
);
expect(status).toBe(500);
expect(body.error).toContain('profile "prod" api URL: is not configured');
});
it('surfaces and cancels a delayed login subprocess', async () => {
process.env.FAKE_VELA_LOGIN_DELAY_MS = '30000';
const login = await postJson(`${baseUrl}/api/integrations/vela/login`);
expect(login.status).toBe(202);
const during = await getJson<{ loggedIn: boolean; loginInFlight: boolean }>(
`${baseUrl}/api/integrations/vela/status`,
);
expect(during.body.loggedIn).toBe(false);
expect(during.body.loginInFlight).toBe(true);
const cancel = await postJson<{ canceled: boolean; pids: number[] }>(
`${baseUrl}/api/integrations/vela/login/cancel`,
);
expect(cancel.status).toBe(200);
expect(cancel.body.canceled).toBe(true);
expect(cancel.body.pids.length).toBeGreaterThan(0);
for (let i = 0; i < 50; i += 1) {
const next = await getJson<{ loginInFlight: boolean }>(
`${baseUrl}/api/integrations/vela/status`,
);
if (!next.body.loginInFlight) break;
await new Promise((resolve) => setTimeout(resolve, 100));
}
const after = await getJson<{ loggedIn: boolean; loginInFlight: boolean }>(
`${baseUrl}/api/integrations/vela/status`,
);
expect(after.body.loggedIn).toBe(false);
expect(after.body.loginInFlight).toBe(false);
expect(existsSync(configPath())).toBe(false);
});
});
describe('POST /api/integrations/vela/logout', () => {
it('removes only resolved profile credentials so the next login can reuse endpoint config', async () => {
seedLogin('local');
const cfg = JSON.parse(readFileSync(configPath(), 'utf8'));
cfg.profiles.prod = {
runtimeKey: 'rt-prod',
user: { id: 'prod-user', email: 'prod@example.com' },
};
writeFileSync(configPath(), JSON.stringify(cfg, null, 2), 'utf8');
expect(existsSync(configPath())).toBe(true);
const { status, body } = await postJson<{ ok?: boolean }>(
`${baseUrl}/api/integrations/vela/logout`,
);
expect(status).toBe(200);
expect(body.ok).toBe(true);
expect(existsSync(configPath())).toBe(true);
const next = JSON.parse(readFileSync(configPath(), 'utf8'));
expect(next.profiles.local.runtimeKey).toBeUndefined();
expect(next.profiles.local.controlKey).toBeUndefined();
expect(next.profiles.local.user).toBeUndefined();
expect(next.profiles.local.apiUrl).toBe('http://localhost:18080');
expect(next.profiles.local.linkUrl).toBe('http://localhost:18081');
expect(next.profiles.prod.runtimeKey).toBe('rt-prod');
const after = await getJson<{ loggedIn: boolean }>(
`${baseUrl}/api/integrations/vela/status`,
);
expect(after.body.loggedIn).toBe(false);
});
it('is a no-op when there is no config file (idempotent / safe to spam from UI)', async () => {
expect(existsSync(configPath())).toBe(false);
const { status, body } = await postJson<{ ok?: boolean }>(
`${baseUrl}/api/integrations/vela/logout`,
);
expect(status).toBe(200);
expect(body.ok).toBe(true);
});
it('clears Settings-backed AMR auth env while preserving executable config', async () => {
const dataDir = process.env.OD_DATA_DIR as string;
const previous = await readAppConfig(dataDir);
await writeAppConfig(dataDir, {
agentCliEnv: {
...(previous.agentCliEnv ?? {}),
amr: {
...((previous.agentCliEnv?.amr as Record<string, string>) ?? {}),
VELA_BIN: FAKE_VELA,
VELA_OPENCODE_BIN: '/tmp/opencode',
VELA_RUNTIME_KEY: 'rt-env-secret',
VELA_LINK_URL: 'https://openrouter.example/v1',
},
},
});
try {
const before = await getJson<{ loggedIn: boolean }>(
`${baseUrl}/api/integrations/vela/status`,
);
expect(before.body.loggedIn).toBe(true);
const { status, body } = await postJson<{ ok?: boolean }>(
`${baseUrl}/api/integrations/vela/logout`,
);
expect(status).toBe(200);
expect(body.ok).toBe(true);
const after = await getJson<{ loggedIn: boolean }>(
`${baseUrl}/api/integrations/vela/status`,
);
expect(after.body.loggedIn).toBe(false);
const next = await readAppConfig(dataDir);
expect(next.agentCliEnv?.amr?.VELA_BIN).toBe(FAKE_VELA);
expect(next.agentCliEnv?.amr?.VELA_OPENCODE_BIN).toBe('/tmp/opencode');
expect(next.agentCliEnv?.amr?.VELA_RUNTIME_KEY).toBeUndefined();
expect(next.agentCliEnv?.amr?.VELA_LINK_URL).toBeUndefined();
} finally {
await writeAppConfig(dataDir, previous as unknown as Record<string, unknown>);
}
});
it('clears both Settings-backed AMR env credentials and same-profile ~/.amr credentials on logout', async () => {
const dataDir = process.env.OD_DATA_DIR as string;
const previous = await readAppConfig(dataDir);
seedLogin('local', {
user: { id: 'local-user', email: 'local@example.com' },
});
await writeAppConfig(dataDir, {
...previous,
agentCliEnv: {
...(previous.agentCliEnv ?? {}),
amr: {
...((previous.agentCliEnv?.amr as Record<string, string>) ?? {}),
VELA_BIN: FAKE_VELA,
VELA_OPENCODE_BIN: '/tmp/opencode',
OPEN_DESIGN_AMR_PROFILE: 'local',
VELA_RUNTIME_KEY: 'rt-env-secret',
VELA_LINK_URL: 'https://openrouter.example/v1',
},
},
});
try {
const before = await getJson<{ loggedIn: boolean }>(
`${baseUrl}/api/integrations/vela/status`,
);
expect(before.body.loggedIn).toBe(true);
const { status, body } = await postJson<{ ok?: boolean }>(
`${baseUrl}/api/integrations/vela/logout`,
);
expect(status).toBe(200);
expect(body.ok).toBe(true);
const after = await getJson<{ loggedIn: boolean }>(
`${baseUrl}/api/integrations/vela/status`,
);
expect(after.body.loggedIn).toBe(false);
const nextConfig = await readAppConfig(dataDir);
expect(nextConfig.agentCliEnv?.amr?.VELA_RUNTIME_KEY).toBeUndefined();
expect(nextConfig.agentCliEnv?.amr?.VELA_LINK_URL).toBeUndefined();
const nextAmrConfig = JSON.parse(readFileSync(configPath(), 'utf8'));
expect(nextAmrConfig.profiles.local.runtimeKey).toBeUndefined();
expect(nextAmrConfig.profiles.local.user).toBeUndefined();
expect(nextAmrConfig.profiles.local.linkUrl).toBe('http://localhost:18081');
} finally {
await writeAppConfig(dataDir, previous as unknown as Record<string, unknown>);
}
});
it('logs out the Settings-configured AMR profile from the AMR config file', async () => {
const dataDir = process.env.OD_DATA_DIR as string;
const previous = await readAppConfig(dataDir);
seedLogin('local');
const cfg = JSON.parse(readFileSync(configPath(), 'utf8'));
cfg.profiles.prod = {
runtimeKey: 'rt-prod',
user: { id: 'prod-user', email: 'prod@example.com' },
};
writeFileSync(configPath(), JSON.stringify(cfg, null, 2), 'utf8');
process.env.OPEN_DESIGN_AMR_PROFILE = 'prod';
await writeAppConfig(dataDir, {
...previous,
agentCliEnv: {
...(previous.agentCliEnv ?? {}),
amr: {
...((previous.agentCliEnv?.amr as Record<string, string>) ?? {}),
VELA_BIN: FAKE_VELA,
OPEN_DESIGN_AMR_PROFILE: 'local',
},
},
});
try {
const { status, body } = await postJson<{ ok?: boolean }>(
`${baseUrl}/api/integrations/vela/logout`,
);
expect(status).toBe(200);
expect(body.ok).toBe(true);
const next = JSON.parse(readFileSync(configPath(), 'utf8'));
expect(next.profiles.local.runtimeKey).toBeUndefined();
expect(next.profiles.prod.runtimeKey).toBe('rt-prod');
} finally {
await writeAppConfig(dataDir, previous as unknown as Record<string, unknown>);
}
});
it('clears daemon-process AMR auth env for the current daemon session', async () => {
process.env.VELA_RUNTIME_KEY = 'rt-process-secret';
process.env.VELA_LINK_URL = 'https://openrouter.example/v1';
const before = await getJson<{ loggedIn: boolean }>(
`${baseUrl}/api/integrations/vela/status`,
);
expect(before.body.loggedIn).toBe(true);
const { status, body } = await postJson<{ ok?: boolean }>(
`${baseUrl}/api/integrations/vela/logout`,
);
expect(status).toBe(200);
expect(body.ok).toBe(true);
expect(process.env.VELA_RUNTIME_KEY).toBeUndefined();
expect(process.env.VELA_LINK_URL).toBeUndefined();
const after = await getJson<{ loggedIn: boolean }>(
`${baseUrl}/api/integrations/vela/status`,
);
expect(after.body.loggedIn).toBe(false);
});
});
describe('login → status round-trip (E2E across the three routes)', () => {
it('flips loggedIn=false → loggedIn=true after a successful login subprocess', async () => {
process.env.FAKE_VELA_LOGIN_USER_EMAIL = 'round-trip@example.com';
process.env.FAKE_VELA_LOGIN_USER_PLAN = 'pro';
const before = await getJson<{ loggedIn: boolean }>(
`${baseUrl}/api/integrations/vela/status`,
);
expect(before.body.loggedIn).toBe(false);
const login = await postJson(`${baseUrl}/api/integrations/vela/login`);
expect(login.status).toBe(202);
// Poll until the subprocess writes the config file (production AmrLoginPill
// polls /status every 2s; here we cap at 5s).
for (let i = 0; i < 50; i += 1) {
if (existsSync(configPath())) break;
await new Promise((resolve) => setTimeout(resolve, 100));
}
expect(existsSync(configPath())).toBe(true);
const after = await getJson<{
loggedIn: boolean;
user: { email?: string; plan?: string } | null;
}>(`${baseUrl}/api/integrations/vela/status`);
expect(after.body.loggedIn).toBe(true);
expect(after.body.user?.email).toBe('round-trip@example.com');
expect(after.body.user?.plan).toBe('pro');
delete process.env.FAKE_VELA_LOGIN_USER_EMAIL;
delete process.env.FAKE_VELA_LOGIN_USER_PLAN;
});
});

View file

@ -0,0 +1,409 @@
/**
* Coverage for `apps/daemon/src/integrations/vela.ts` the read-side of
* the AMR (vela) login integration. The spawn path is exercised by
* `tests/amr-acp-integration.test.ts` (which uses the fake-vela stub); here
* we focus on the status reader that drives the Settings UI.
*
* `~/.amr/config.json` is the source of truth vela CLI writes it on
* successful `vela login` and Open Design just surfaces a small projection.
* Tests redirect HOME via env so we never touch the real user file.
*/
import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
forgetVelaLogin,
readVelaLoginStatus,
resolveAmrProfile,
spawnVelaLogin,
amrConfigPath,
} from '../../src/integrations/vela.js';
let originalHome: string | undefined;
let tmpHome: string;
const HERE = path.dirname(fileURLToPath(import.meta.url));
const FAKE_VELA = path.resolve(HERE, '..', 'fixtures', 'fake-vela.mjs');
function writeConfig(payload: unknown): string {
const dir = path.join(tmpHome, '.amr');
mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'config.json');
writeFileSync(file, JSON.stringify(payload), 'utf8');
return file;
}
function writeLegacyVelaConfig(payload: unknown): string {
const dir = path.join(tmpHome, '.vela');
mkdirSync(dir, { recursive: true });
const file = path.join(dir, 'config.json');
writeFileSync(file, JSON.stringify(payload), 'utf8');
return file;
}
beforeEach(() => {
originalHome = process.env.HOME;
tmpHome = mkdtempSync(path.join(tmpdir(), 'od-vela-test-'));
process.env.HOME = tmpHome;
delete process.env.OPEN_DESIGN_AMR_PROFILE;
delete process.env.VELA_PROFILE;
});
afterEach(() => {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
rmSync(tmpHome, { recursive: true, force: true });
});
describe('resolveAmrProfile', () => {
it('defaults to "prod" when OPEN_DESIGN_AMR_PROFILE is unset or empty', () => {
expect(resolveAmrProfile({})).toBe('prod');
expect(resolveAmrProfile({ OPEN_DESIGN_AMR_PROFILE: ' ' })).toBe('prod');
});
it('honors OPEN_DESIGN_AMR_PROFILE when set to a known profile', () => {
expect(resolveAmrProfile({ OPEN_DESIGN_AMR_PROFILE: 'prod' })).toBe('prod');
expect(resolveAmrProfile({ OPEN_DESIGN_AMR_PROFILE: 'local' })).toBe('local');
expect(resolveAmrProfile({ OPEN_DESIGN_AMR_PROFILE: 'test' })).toBe('test');
});
it('ignores lower-priority VELA_PROFILE values', () => {
expect(resolveAmrProfile({ VELA_PROFILE: 'local' })).toBe('prod');
expect(
resolveAmrProfile({
OPEN_DESIGN_AMR_PROFILE: 'test',
VELA_PROFILE: 'local',
}),
).toBe('test');
});
it('warns for unknown OPEN_DESIGN_AMR_PROFILE values and falls back to prod', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(resolveAmrProfile({ OPEN_DESIGN_AMR_PROFILE: 'evil' })).toBe('prod');
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('OPEN_DESIGN_AMR_PROFILE'),
);
warn.mockRestore();
});
});
describe('readVelaLoginStatus', () => {
it('returns loggedIn=false when ~/.amr/config.json is absent', () => {
const status = readVelaLoginStatus({ OPEN_DESIGN_AMR_PROFILE: 'local' });
expect(status.loggedIn).toBe(false);
expect(status.user).toBeNull();
expect(status.profile).toBe('local');
expect(status.configPath).toBe(amrConfigPath());
});
it('ignores legacy ~/.vela/config.json when ~/.amr/config.json is absent', () => {
writeLegacyVelaConfig({
profiles: {
local: {
runtimeKey: 'rt-legacy',
user: { id: 'legacy-user', email: 'legacy@example.com' },
},
},
});
const status = readVelaLoginStatus({ OPEN_DESIGN_AMR_PROFILE: 'local' });
expect(status.loggedIn).toBe(false);
expect(status.user).toBeNull();
expect(status.configPath).toBe(amrConfigPath());
});
it('treats configured AMR env credentials as logged in without an AMR config file', () => {
const status = readVelaLoginStatus(
{ OPEN_DESIGN_AMR_PROFILE: 'local' },
{
VELA_RUNTIME_KEY: 'rt-env-secret',
VELA_LINK_URL: 'https://openrouter.example/v1',
},
);
expect(status.loggedIn).toBe(true);
expect(status.user).toBeNull();
expect(status.profile).toBe('local');
expect(JSON.stringify(status)).not.toContain('rt-env-secret');
});
it('prefers configured AMR env credentials over an incomplete ~/.amr active profile', () => {
writeConfig({
profiles: {
local: {
apiUrl: 'http://localhost:18080',
user: { id: 'stale-user', email: 'stale@example.com' },
},
},
});
const status = readVelaLoginStatus(
{ OPEN_DESIGN_AMR_PROFILE: 'local' },
{
VELA_RUNTIME_KEY: 'rt-env-secret',
VELA_LINK_URL: 'https://openrouter.example/v1',
},
);
expect(status.loggedIn).toBe(true);
expect(status.profile).toBe('local');
expect(status.user).toBeNull();
expect(JSON.stringify(status)).not.toContain('rt-env-secret');
expect(JSON.stringify(status)).not.toContain('stale@example.com');
});
it('uses the Settings-configured AMR profile when reading status', () => {
writeConfig({
profiles: {
prod: {},
local: { runtimeKey: 'rt-local', user: { id: 'u', email: 'local@example.com' } },
},
});
const status = readVelaLoginStatus(
{ OPEN_DESIGN_AMR_PROFILE: 'prod' },
{ OPEN_DESIGN_AMR_PROFILE: 'local' },
);
expect(status.loggedIn).toBe(true);
expect(status.profile).toBe('local');
expect(status.user?.email).toBe('local@example.com');
});
it('treats daemon process AMR env credentials as logged in without an AMR config file', () => {
const status = readVelaLoginStatus({
OPEN_DESIGN_AMR_PROFILE: 'local',
VELA_RUNTIME_KEY: 'rt-process-secret',
VELA_LINK_URL: 'https://openrouter.example/v1',
});
expect(status.loggedIn).toBe(true);
expect(status.user).toBeNull();
expect(status.profile).toBe('local');
expect(JSON.stringify(status)).not.toContain('rt-process-secret');
});
it('requires both env runtime key and link URL before reporting env-only login', () => {
expect(
readVelaLoginStatus(
{ OPEN_DESIGN_AMR_PROFILE: 'local' },
{ VELA_RUNTIME_KEY: 'rt-env-secret' },
).loggedIn,
).toBe(false);
expect(
readVelaLoginStatus(
{ OPEN_DESIGN_AMR_PROFILE: 'local' },
{ VELA_LINK_URL: 'https://openrouter.example/v1' },
).loggedIn,
).toBe(false);
});
it('returns loggedIn=true with user info when the active profile has a runtimeKey', () => {
writeConfig({
profiles: {
local: {
runtimeKey: 'rt-secret-abc',
controlKey: 'ck-secret',
apiUrl: 'http://localhost:18080',
linkUrl: 'http://localhost:18081',
user: {
id: 'user_1',
email: 'leaf@example.com',
name: '杨瑾龙',
image: 'https://example.com/avatar.png',
plan: 'free',
},
},
},
});
const status = readVelaLoginStatus({ OPEN_DESIGN_AMR_PROFILE: 'local' });
expect(status.loggedIn).toBe(true);
expect(status.profile).toBe('local');
expect(status.user?.email).toBe('leaf@example.com');
expect(status.user?.plan).toBe('free');
// The secrets in the file are intentionally NOT surfaced through the
// status projection — the UI never needs them and we don't want them
// showing up in HTTP responses to the local web.
expect(JSON.stringify(status)).not.toContain('rt-secret-abc');
expect(JSON.stringify(status)).not.toContain('ck-secret');
});
it('returns loggedIn=false when the active profile is present but lacks runtimeKey', () => {
writeConfig({
profiles: {
local: { apiUrl: 'http://localhost:18080', user: { id: 'u', email: 'e' } },
},
});
const status = readVelaLoginStatus({ OPEN_DESIGN_AMR_PROFILE: 'local' });
expect(status.loggedIn).toBe(false);
});
it('isolates profiles — a logged-in "local" does not imply logged-in "prod"', () => {
writeConfig({
profiles: {
local: { runtimeKey: 'rt-local', user: { id: 'u', email: 'leaf@example.com' } },
},
});
expect(readVelaLoginStatus({ OPEN_DESIGN_AMR_PROFILE: 'local' }).loggedIn).toBe(true);
expect(readVelaLoginStatus({ OPEN_DESIGN_AMR_PROFILE: 'prod' }).loggedIn).toBe(false);
});
it('does not let VELA_PROFILE select the active status profile', () => {
writeConfig({
profiles: {
local: { runtimeKey: 'rt-local', user: { id: 'u', email: 'leaf@example.com' } },
},
});
expect(
readVelaLoginStatus({
OPEN_DESIGN_AMR_PROFILE: 'prod',
VELA_PROFILE: 'local',
}).loggedIn,
).toBe(false);
});
it('treats malformed JSON as logged-out rather than crashing', () => {
const file = path.join(tmpHome, '.amr', 'config.json');
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, '{not json', 'utf8');
expect(readVelaLoginStatus({ OPEN_DESIGN_AMR_PROFILE: 'local' }).loggedIn).toBe(false);
});
it('treats the local runtimeKey as the source of truth even when user fields are missing', () => {
writeConfig({
profiles: {
local: {
runtimeKey: 'rt-local',
user: { email: 42, plan: ['pro'] },
},
},
});
const status = readVelaLoginStatus({ OPEN_DESIGN_AMR_PROFILE: 'local' });
expect(status.loggedIn).toBe(true);
expect(status.user?.id).toBe('');
expect(status.user?.email).toBe('');
expect(status.user?.plan).toBeUndefined();
});
});
describe('forgetVelaLogin', () => {
it('removes only the resolved profile credentials and preserves the rest of the config', () => {
const file = writeConfig({
version: 1,
profiles: {
local: {
runtimeKey: 'rt',
controlKey: 'ck',
apiUrl: 'http://localhost:18080',
linkUrl: 'http://localhost:18081',
user: { id: 'u', email: 'e' },
},
prod: { runtimeKey: 'rt-prod', user: { id: 'p', email: 'prod@example.com' } },
},
otherTopLevel: true,
});
expect(readVelaLoginStatus({ OPEN_DESIGN_AMR_PROFILE: 'local' }).loggedIn).toBe(true);
forgetVelaLogin({ OPEN_DESIGN_AMR_PROFILE: 'local' });
expect(readVelaLoginStatus({ OPEN_DESIGN_AMR_PROFILE: 'local' }).loggedIn).toBe(false);
expect(readVelaLoginStatus({ OPEN_DESIGN_AMR_PROFILE: 'prod' }).loggedIn).toBe(true);
const next = JSON.parse(readFileSync(file, 'utf8'));
expect(next.otherTopLevel).toBe(true);
expect(next.profiles.local.runtimeKey).toBeUndefined();
expect(next.profiles.local.controlKey).toBeUndefined();
expect(next.profiles.local.user).toBeUndefined();
expect(next.profiles.local.apiUrl).toBe('http://localhost:18080');
expect(next.profiles.local.linkUrl).toBe('http://localhost:18081');
expect(next.profiles.prod.runtimeKey).toBe('rt-prod');
});
it('is a no-op when the resolved profile does not exist', () => {
const file = writeConfig({
profiles: {
prod: { runtimeKey: 'rt-prod', user: { id: 'p', email: 'prod@example.com' } },
},
});
expect(() => forgetVelaLogin({ OPEN_DESIGN_AMR_PROFILE: 'local' })).not.toThrow();
const next = JSON.parse(readFileSync(file, 'utf8'));
expect(next.profiles.prod.runtimeKey).toBe('rt-prod');
});
it('is a no-op when the config file does not exist (idempotent)', () => {
expect(() => forgetVelaLogin()).not.toThrow();
});
});
describe('spawnVelaLogin', () => {
it('returns an actionable error when no vela binary can be resolved', async () => {
const originalPath = process.env.PATH;
const originalResourceRoot = process.env.OD_RESOURCE_ROOT;
try {
process.env.PATH = '';
delete process.env.OD_RESOURCE_ROOT;
await expect(
spawnVelaLogin({
baseEnv: { ...process.env, HOME: tmpHome },
configuredEnv: {},
}),
).rejects.toThrow('vela binary not found');
} finally {
if (originalPath === undefined) delete process.env.PATH;
else process.env.PATH = originalPath;
if (originalResourceRoot === undefined) delete process.env.OD_RESOURCE_ROOT;
else process.env.OD_RESOURCE_ROOT = originalResourceRoot;
}
});
it('spawns the configured vela binary and writes only the resolved AMR profile', async () => {
const result = await spawnVelaLogin({
baseEnv: {
...process.env,
HOME: tmpHome,
OPEN_DESIGN_AMR_PROFILE: 'test',
VELA_PROFILE: 'prod',
FAKE_VELA_LOGIN_USER_EMAIL: 'spawn-login@example.com',
},
configuredEnv: {
VELA_BIN: FAKE_VELA,
},
});
expect(result.pid).toBeGreaterThan(0);
expect(result.profile).toBe('test');
const file = path.join(tmpHome, '.amr', 'config.json');
for (let i = 0; i < 20; i += 1) {
if (existsSync(file)) break;
await new Promise((resolve) => setTimeout(resolve, 25));
}
const next = JSON.parse(readFileSync(file, 'utf8'));
expect(next.profiles.test.user.email).toBe('spawn-login@example.com');
expect(next.profiles.prod).toBeUndefined();
});
it('spawns login with the Settings-configured AMR profile over daemon env', async () => {
const result = await spawnVelaLogin({
baseEnv: {
...process.env,
HOME: tmpHome,
OPEN_DESIGN_AMR_PROFILE: 'prod',
VELA_PROFILE: 'prod',
FAKE_VELA_LOGIN_USER_EMAIL: 'settings-profile@example.com',
},
configuredEnv: {
VELA_BIN: FAKE_VELA,
OPEN_DESIGN_AMR_PROFILE: 'local',
},
});
expect(result.pid).toBeGreaterThan(0);
expect(result.profile).toBe('local');
const file = path.join(tmpHome, '.amr', 'config.json');
for (let i = 0; i < 20; i += 1) {
if (existsSync(file)) break;
await new Promise((resolve) => setTimeout(resolve, 25));
}
const next = JSON.parse(readFileSync(file, 'utf8'));
expect(next.profiles.local.user.email).toBe('settings-profile@example.com');
expect(next.profiles.prod).toBeUndefined();
});
});

View file

@ -58,6 +58,13 @@ describe('discovery.ts task-type form (single-shot brief)', () => {
);
});
it('forbids pairing a tailored discovery form with the default Quick brief in one turn', () => {
expect(DISCOVERY_AND_PHILOSOPHY).toContain('Emit exactly ONE `<question-form>` in this turn.');
expect(DISCOVERY_AND_PHILOSOPHY).toContain(
'that tailored form replaces the default "Quick brief — 30 seconds" form; never output both.',
);
});
it('teaches RULE 2 to accept the task-type answer marker alongside discovery', () => {
// RULE 2's first sentence enumerates the answer markers it routes on. The
// single-shot brief means `[form answers — task-type]` must be a valid

View file

@ -65,6 +65,84 @@ test('spawnEnvForAgent expands configured env home paths', () => {
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent injects the resolved AMR profile after configured env', () => {
const env = spawnEnvForAgent(
'amr',
{
OPEN_DESIGN_AMR_PROFILE: 'test',
VELA_PROFILE: 'prod',
PATH: '/usr/bin',
},
{
VELA_PROFILE: 'local',
},
);
assert.equal(env.VELA_PROFILE, 'test');
assert.equal(env.OPEN_DESIGN_AMR_PROFILE, 'test');
assert.equal(env.PATH, '/usr/bin');
});
test('spawnEnvForAgent gives AMR a stable OpenCode home under OD_DATA_DIR', () => {
const dataDir = mkdtempSync(join(tmpdir(), 'od-amr-data-'));
try {
const env = spawnEnvForAgent('amr', {
OD_DATA_DIR: dataDir,
PATH: '/usr/bin',
});
assert.equal(
env.OPENCODE_TEST_HOME,
join(dataDir, 'amr', 'opencode-home'),
);
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
});
test('spawnEnvForAgent preserves a configured AMR OpenCode home override', () => {
const dataDir = mkdtempSync(join(tmpdir(), 'od-amr-data-'));
try {
const configuredHome = join(dataDir, 'custom-opencode-home');
const env = spawnEnvForAgent(
'amr',
{
OD_DATA_DIR: dataDir,
PATH: '/usr/bin',
},
{
OPENCODE_TEST_HOME: configuredHome,
},
);
assert.equal(env.OPENCODE_TEST_HOME, configuredHome);
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
});
fsTest('spawnEnvForAgent gives AMR a discovered OpenCode binary under a minimal child PATH', () => {
const dir = mkdtempSync(join(tmpdir(), 'od-amr-opencode-home-'));
try {
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME'], () => {
const opencodeBinDir = join(dir, '.opencode', 'bin');
const opencodeBin = join(opencodeBinDir, 'opencode');
mkdirSync(opencodeBinDir, { recursive: true });
writeFileSync(opencodeBin, '#!/bin/sh\nexit 0\n');
chmodSync(opencodeBin, 0o755);
process.env.PATH = '/usr/bin';
process.env.OD_AGENT_HOME = dir;
const env = spawnEnvForAgent('amr', { PATH: '/usr/bin' });
assert.equal(env.PATH, '/usr/bin');
assert.equal(env.VELA_OPENCODE_BIN, opencodeBin);
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('resolveAgentExecutable prefers a configured CODEX_BIN override over PATH resolution', () => {
const dir = mkdtempSync(join(tmpdir(), 'od-codex-bin-'));
try {
@ -250,6 +328,75 @@ fsTest('detectAgents marks Codex available when nvm exposes a node shim but laun
}
});
fsTest('detectAgents keeps packaged built-in AMR unavailable when OpenCode cannot be resolved', async () => {
const root = mkdtempSync(join(tmpdir(), 'od-detect-amr-built-in-'));
try {
return await withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'OD_RESOURCE_ROOT', 'VELA_OPENCODE_BIN'], async () => {
const resourceRoot = join(root, 'resources', 'open-design');
const builtInVela = join(resourceRoot, 'bin', 'vela');
mkdirSync(join(resourceRoot, 'bin'), { recursive: true });
writeFileSync(
builtInVela,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "vela manual-amr"; exit 0; fi\nexit 0\n',
);
chmodSync(builtInVela, 0o755);
process.env.PATH = '';
process.env.OD_AGENT_HOME = join(root, 'empty-home');
process.env.OD_RESOURCE_ROOT = resourceRoot;
delete process.env.VELA_OPENCODE_BIN;
const agents = await detectAgents();
const amrAgent = agents.find((agent) => agent.id === 'amr');
assert.ok(amrAgent);
assert.equal(amrAgent.available, false);
assert.equal(amrAgent.path, undefined);
assert.equal(amrAgent.version, undefined);
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
fsTest('detectAgents marks AMR available from packaged built-in Vela with the bundled OpenCode companion tree', async () => {
const root = mkdtempSync(join(tmpdir(), 'od-detect-amr-built-in-'));
try {
return await withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'OD_RESOURCE_ROOT', 'VELA_OPENCODE_BIN'], async () => {
const resourceRoot = join(root, 'resources', 'open-design');
const builtInVela = join(resourceRoot, 'bin', 'vela');
const companionTree = join(resourceRoot, 'bin', 'libexec', 'opencode');
mkdirSync(join(resourceRoot, 'bin'), { recursive: true });
mkdirSync(companionTree, { recursive: true });
writeFileSync(
builtInVela,
'#!/bin/sh\nif [ "$1" = "--version" ]; then echo "vela manual-amr"; exit 0; fi\nexit 0\n',
);
chmodSync(builtInVela, 0o755);
// The companion tree is only "valid" when an actual `opencode`
// executable lives inside — directory-only checks were treating an
// empty/partial copy as available and the first real run had nothing
// to launch. Match the resources.test.ts packaging contract.
const companionExe = join(companionTree, 'opencode');
writeFileSync(companionExe, '#!/bin/sh\nexit 0\n');
chmodSync(companionExe, 0o755);
process.env.PATH = '';
process.env.OD_AGENT_HOME = join(root, 'empty-home');
process.env.OD_RESOURCE_ROOT = resourceRoot;
delete process.env.VELA_OPENCODE_BIN;
const agents = await detectAgents();
const amrAgent = agents.find((agent) => agent.id === 'amr');
assert.ok(amrAgent);
assert.equal(amrAgent.available, true);
assert.equal(amrAgent.path, builtInVela);
assert.equal(amrAgent.version, 'vela manual-amr');
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
function codexNativeTargetTriple(): string {
if (process.platform === 'darwin' && process.arch === 'arm64') return 'aarch64-apple-darwin';
if (process.platform === 'darwin' && process.arch === 'x64') return 'x86_64-apple-darwin';

View file

@ -1,6 +1,6 @@
import { test } from 'vitest';
import {
assert, chmodSync, claude, deepseek, gemini, join, minimalAgentDef, mkdirSync, mkdtempSync, resolveAgentExecutable, rmSync, tmpdir, withPlatform, writeFileSync,
assert, chmodSync, claude, deepseek, gemini, join, minimalAgentDef, mkdirSync, mkdtempSync, resolveAgentExecutable, rmSync, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
} from './helpers/test-helpers.js';
const fsTest = process.platform === 'win32' ? test.skip : test;
@ -40,6 +40,124 @@ test('deepseek entry declares codewhale as a fallback bin (issue #2983)', () =>
// files don't carry. Skip the filesystem-backed cases there — the
// declarative `fallbackBins`-on-claude assertion above still runs on
// every platform and is what catches regressions in the AGENT_DEF.
fsTest(
'resolveAgentExecutable uses packaged built-in Vela for AMR with the bundled OpenCode companion tree',
() => {
const root = mkdtempSync(join(tmpdir(), 'od-amr-built-in-'));
try {
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'OD_RESOURCE_ROOT', 'VELA_OPENCODE_BIN'], () => {
const resourceRoot = join(root, 'resources', 'open-design');
const builtInVela = join(resourceRoot, 'bin', 'vela');
const companionTree = join(resourceRoot, 'bin', 'libexec', 'opencode');
mkdirSync(join(resourceRoot, 'bin'), { recursive: true });
mkdirSync(companionTree, { recursive: true });
writeFileSync(builtInVela, '#!/bin/sh\nexit 0\n');
chmodSync(builtInVela, 0o755);
// Match the resources.test.ts packaging contract: the companion tree
// is only valid when `<libexec>/opencode/opencode` actually exists +
// is executable. Directory-only checks were producing a false-positive
// availability path.
const companionExe = join(companionTree, 'opencode');
writeFileSync(companionExe, '#!/bin/sh\nexit 0\n');
chmodSync(companionExe, 0o755);
process.env.PATH = '';
process.env.OD_AGENT_HOME = join(root, 'empty-home');
process.env.OD_RESOURCE_ROOT = resourceRoot;
delete process.env.VELA_OPENCODE_BIN;
const resolved = resolveAgentExecutable(minimalAgentDef({ id: 'amr', bin: 'vela' }));
assert.equal(resolved, builtInVela);
});
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
fsTest(
'resolveAgentExecutable does not select packaged built-in Vela when OpenCode is missing',
() => {
const root = mkdtempSync(join(tmpdir(), 'od-amr-built-in-no-opencode-'));
try {
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'OD_RESOURCE_ROOT', 'VELA_OPENCODE_BIN'], () => {
const resourceRoot = join(root, 'resources', 'open-design');
const builtInVela = join(resourceRoot, 'bin', 'vela');
mkdirSync(join(resourceRoot, 'bin'), { recursive: true });
writeFileSync(builtInVela, '#!/bin/sh\nexit 0\n');
chmodSync(builtInVela, 0o755);
process.env.PATH = '';
process.env.OD_AGENT_HOME = join(root, 'empty-home');
process.env.OD_RESOURCE_ROOT = resourceRoot;
delete process.env.VELA_OPENCODE_BIN;
const resolved = resolveAgentExecutable(minimalAgentDef({ id: 'amr', bin: 'vela' }));
assert.equal(resolved, null);
});
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
fsTest(
'resolveAgentExecutable prefers configured VELA_BIN over packaged built-in Vela',
() => {
const root = mkdtempSync(join(tmpdir(), 'od-amr-built-in-precedence-'));
try {
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'OD_RESOURCE_ROOT'], () => {
const resourceRoot = join(root, 'resources', 'open-design');
const builtInVela = join(resourceRoot, 'bin', 'vela');
const configuredVela = join(root, 'configured', 'vela');
mkdirSync(join(resourceRoot, 'bin'), { recursive: true });
mkdirSync(join(root, 'configured'), { recursive: true });
writeFileSync(builtInVela, '#!/bin/sh\nexit 0\n');
writeFileSync(configuredVela, '#!/bin/sh\nexit 0\n');
chmodSync(builtInVela, 0o755);
chmodSync(configuredVela, 0o755);
process.env.PATH = '';
process.env.OD_AGENT_HOME = join(root, 'empty-home');
process.env.OD_RESOURCE_ROOT = resourceRoot;
const resolved = resolveAgentExecutable(
minimalAgentDef({ id: 'amr', bin: 'vela' }),
{ VELA_BIN: configuredVela },
);
assert.equal(resolved, configuredVela);
});
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
fsTest(
'resolveAgentExecutable falls back to PATH Vela when packaged built-in Vela is absent',
() => {
const root = mkdtempSync(join(tmpdir(), 'od-amr-path-fallback-'));
try {
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'OD_RESOURCE_ROOT'], () => {
const pathBin = join(root, 'path-bin');
const pathVela = join(pathBin, 'vela');
mkdirSync(pathBin, { recursive: true });
writeFileSync(pathVela, '#!/bin/sh\nexit 0\n');
chmodSync(pathVela, 0o755);
process.env.PATH = pathBin;
process.env.OD_AGENT_HOME = join(root, 'empty-home');
process.env.OD_RESOURCE_ROOT = join(root, 'resources', 'open-design');
const resolved = resolveAgentExecutable(minimalAgentDef({ id: 'amr', bin: 'vela' }));
assert.equal(resolved, pathVela);
});
} finally {
rmSync(root, { recursive: true, force: true });
}
},
);
fsTest(
'resolveAgentExecutable prefers def.bin over fallbackBins when bin is on PATH',
() => {

View file

@ -8,6 +8,7 @@ import {
codex,
mkdirSync,
mkdtempSync,
minimalAgentDef,
resolveAgentLaunch,
rmSync,
tmpdir,
@ -112,6 +113,44 @@ fsTest('resolveAgentLaunch selects nvm-installed codex under a minimal PATH and
}
});
fsTest('resolveAgentLaunch uses packaged built-in Vela for AMR and prepends its dirname', () => {
const root = mkdtempSync(join(tmpdir(), 'od-launch-amr-built-in-'));
try {
return withEnvSnapshot(['PATH', 'OD_AGENT_HOME', 'OD_RESOURCE_ROOT', 'VELA_OPENCODE_BIN'], () => {
const resourceRoot = join(root, 'resources', 'open-design');
const builtInDir = join(resourceRoot, 'bin');
const builtInVela = join(builtInDir, 'vela');
const companionTree = join(builtInDir, 'libexec', 'opencode');
const companionExe = join(
companionTree,
process.platform === 'win32' ? 'opencode.exe' : 'opencode',
);
mkdirSync(builtInDir, { recursive: true });
mkdirSync(companionTree, { recursive: true });
writeFileSync(builtInVela, '#!/bin/sh\nexit 0\n');
chmodSync(builtInVela, 0o755);
// packagedVelaOpenCodeCompanionTree now verifies the inner opencode
// executable, not just the directory — see #3148. Fixture must match.
writeFileSync(companionExe, '#!/bin/sh\nexit 0\n');
chmodSync(companionExe, 0o755);
process.env.PATH = '';
process.env.OD_AGENT_HOME = join(root, 'empty-home');
process.env.OD_RESOURCE_ROOT = resourceRoot;
delete process.env.VELA_OPENCODE_BIN;
const launch = resolveAgentLaunch(minimalAgentDef({ id: 'amr', bin: 'vela' }));
assert.equal(launch.selectedPath, builtInVela);
assert.equal(launch.launchPath, builtInVela);
assert.equal(launch.launchKind, 'selected');
assert.deepEqual(launch.childPathPrepend, [builtInDir]);
assert.equal(launch.diagnostic, null);
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
fsTest('resolveAgentLaunch resolves a Codex npm wrapper to the native packaged binary', () => {
const root = mkdtempSync(join(tmpdir(), 'od-launch-codex-wrapper-'));
try {

View file

@ -0,0 +1,127 @@
/**
* 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).
*
* 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`.
*/
import { describe, expect, it } from 'vitest';
import {
rememberLiveModels,
resolveModelForAgent,
} from '../../src/runtimes/models.js';
import type { RuntimeAgentDef } from '../../src/runtimes/types.js';
function defWith(fallbackIds: string[]): RuntimeAgentDef {
return {
id: 'test',
name: 'Test',
bin: 'test',
versionArgs: ['--version'],
fallbackModels: fallbackIds.map((id) => ({ id, label: id })),
buildArgs: () => [],
streamFormat: 'acp-json-rpc',
};
}
function defWithId(id: string, fallbackIds: string[]): RuntimeAgentDef {
return {
...defWith(fallbackIds),
id,
};
}
describe('resolveModelForAgent', () => {
it('substitutes the first concrete fallback when the resolved model is null and the def has no "default" option', () => {
const def = defWith(['gpt-5.4-mini', 'gpt-5.4']);
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"', () => {
const def = defWith(['gpt-5.4-mini', 'gpt-5.4']);
expect(resolveModelForAgent(def, 'default')).toBe('gpt-5.4-mini');
});
it('prefers the first remembered live model when the def cannot accept the synthetic default model', () => {
const def = defWithId('live-default-test', []);
rememberLiveModels(def.id, [
{ id: 'deepseek-v3.2', label: 'deepseek-v3.2' },
{ id: 'glm-5.1', label: 'glm-5.1' },
]);
expect(resolveModelForAgent(def, null)).toBe('deepseek-v3.2');
expect(resolveModelForAgent(def, 'default')).toBe('deepseek-v3.2');
});
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, [
{ id: 'deepseek-v3.2', label: 'deepseek-v3.2' },
]);
expect(resolveModelForAgent(def, null)).toBe(null);
expect(resolveModelForAgent(def, 'default')).toBe('default');
});
it('leaves the resolved model alone when the def lists "default" itself (the common case for hermes/devin/kimi)', () => {
const def = defWith(['default', 'sonnet']);
expect(resolveModelForAgent(def, 'default')).toBe('default');
expect(resolveModelForAgent(def, null)).toBe(null);
});
it('leaves real model ids untouched even when the def omits "default"', () => {
const def = defWith(['gpt-5.4-mini']);
expect(resolveModelForAgent(def, 'gpt-5.4')).toBe('gpt-5.4');
});
it('returns the original value when fallbackModels is empty (no substitution possible)', () => {
const def = defWith([]);
expect(resolveModelForAgent(def, null)).toBe(null);
expect(resolveModelForAgent(def, 'default')).toBe('default');
});
it('honors defaultModelEnvVar over the hardcoded fallback when the env var is set', () => {
const def: RuntimeAgentDef = {
...defWith(['gpt-5.4-mini']),
defaultModelEnvVar: 'VELA_DEFAULT_MODEL',
};
expect(
resolveModelForAgent(def, null, { VELA_DEFAULT_MODEL: 'gpt-5.5' }),
).toBe('gpt-5.5');
expect(
resolveModelForAgent(def, 'default', { VELA_DEFAULT_MODEL: 'gpt-5.5' }),
).toBe('gpt-5.5');
});
it('falls back to the static list when defaultModelEnvVar is set but the env var is empty / missing', () => {
const def: RuntimeAgentDef = {
...defWith(['gpt-5.4-mini']),
defaultModelEnvVar: 'VELA_DEFAULT_MODEL',
};
expect(resolveModelForAgent(def, null, {})).toBe('gpt-5.4-mini');
expect(
resolveModelForAgent(def, null, { VELA_DEFAULT_MODEL: ' ' }),
).toBe('gpt-5.4-mini');
});
it('does NOT use the env override when the user already picked a real model', () => {
const def: RuntimeAgentDef = {
...defWith(['gpt-5.4-mini']),
defaultModelEnvVar: 'VELA_DEFAULT_MODEL',
};
expect(
resolveModelForAgent(def, 'gpt-5.4-fast', { VELA_DEFAULT_MODEL: 'gpt-5.5' }),
).toBe('gpt-5.4-fast');
});
});

View file

@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { classifyAgentServiceFailure } from '../../src/runtimes/auth.js';
describe('classifyAgentServiceFailure', () => {
it('classifies auth failures (Claude Code / codex style)', () => {
for (const text of [
'Error: 401 {"type":"authentication_error","message":"invalid x-api-key"}',
'Incorrect API key provided: sk-***. ',
'Please run /login to authenticate.',
'Unauthorized: OAuth token has expired',
]) {
expect(classifyAgentServiceFailure(text)).toBe('AGENT_AUTH_REQUIRED');
}
});
it('classifies quota / rate-limit / balance failures', () => {
for (const text of [
'Error: 429 Too Many Requests',
'rate_limit_error: rate limit exceeded',
'You exceeded your current quota, please check your plan and billing details.',
'Your credit balance is too low to access the Anthropic API.',
'insufficient_quota',
]) {
expect(classifyAgentServiceFailure(text)).toBe('RATE_LIMITED');
}
});
it('classifies upstream/provider failures', () => {
for (const text of [
'Error: 529 {"type":"overloaded_error"}',
'Service temporarily unavailable (503)',
'Bad gateway',
'The model is currently overloaded. Please try again later.',
]) {
expect(classifyAgentServiceFailure(text)).toBe('UPSTREAM_UNAVAILABLE');
}
});
it('classifies a 5xx only with status context, not a bare number', () => {
for (const text of [
'HTTP 500 from provider',
'status 503',
'server error 502',
'502 Bad Gateway',
]) {
expect(classifyAgentServiceFailure(text)).toBe('UPSTREAM_UNAVAILABLE');
}
});
it('requires status context for auth/rate numbers too', () => {
expect(classifyAgentServiceFailure('HTTP 401 Unauthorized')).toBe('AGENT_AUTH_REQUIRED');
expect(classifyAgentServiceFailure('status code 429')).toBe('RATE_LIMITED');
});
it('checks auth before rate/upstream so a 401 is never misread', () => {
expect(
classifyAgentServiceFailure('401 unauthorized — also saw a 503 earlier'),
).toBe('AGENT_AUTH_REQUIRED');
});
it('returns null for ordinary process failures and empty text', () => {
expect(classifyAgentServiceFailure('')).toBeNull();
expect(classifyAgentServiceFailure('spawn ENOENT')).toBeNull();
expect(
classifyAgentServiceFailure('Segmentation fault (core dumped)'),
).toBeNull();
expect(
classifyAgentServiceFailure('TypeError: cannot read properties of undefined'),
).toBeNull();
});
it('does not misread unrelated numbers (line/size/duration) as a provider outage', () => {
for (const text of [
'Compiled 500 modules in 503ms; read 502 bytes at line 529',
'Build failed at line 500 (exit code 1)',
'Processed 4290 rows, 401 skipped, took 4290ms',
'wrote 502 files',
]) {
expect(classifyAgentServiceFailure(text)).toBeNull();
}
});
it('does not treat a process exit code as an HTTP status', () => {
for (const text of [
'exit code 401',
'process exited with code 429',
'command failed: exit code 503',
'child process exited with code 500',
]) {
expect(classifyAgentServiceFailure(text)).toBeNull();
}
});
});

View file

@ -1,6 +1,6 @@
{
"name": "@open-design/desktop",
"version": "0.8.0",
"version": "0.8.1",
"private": true,
"type": "module",
"main": "./dist/main/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@open-design/landing-page",
"version": "0.8.0",
"version": "0.8.1",
"private": true,
"type": "module",
"scripts": {

View file

@ -1,6 +1,6 @@
{
"name": "@open-design/packaged",
"version": "0.8.0",
"version": "0.8.1",
"private": true,
"type": "module",
"main": "./dist/index.mjs",

View file

@ -19,8 +19,10 @@ export const PACKAGED_WEB_STANDALONE_ROOT_ENV = "OD_WEB_STANDALONE_ROOT";
export const PACKAGED_WEB_OUTPUT_MODE_ENV = "OD_WEB_OUTPUT_MODE";
export type PackagedWebOutputMode = "server" | "standalone";
export type PackagedAmrProfile = "prod" | "test" | "local";
export type RawPackagedConfig = {
amrProfile?: string;
appVersion?: string;
daemonCliEntryRelative?: string;
daemonSidecarEntryRelative?: string;
@ -45,6 +47,7 @@ export type RawPackagedConfig = {
};
export type PackagedConfig = {
amrProfile: PackagedAmrProfile | null;
appVersion: string | null;
daemonCliEntry: string | null;
daemonSidecarEntry: string | null;
@ -112,6 +115,13 @@ function resolvePackagedWebOutputMode(value: string | undefined): PackagedWebOut
throw new Error(`unsupported packaged web output mode: ${value}`);
}
function resolvePackagedAmrProfile(value: string | undefined): PackagedAmrProfile | null {
const cleaned = cleanOptionalString(value);
if (cleaned == null) return null;
if (cleaned === "prod" || cleaned === "test" || cleaned === "local") return cleaned;
throw new Error(`unsupported packaged AMR profile: ${value}`);
}
function isTruthyEnv(value: string | undefined): boolean {
return value === "1" || value === "true" || value === "yes";
}
@ -168,6 +178,7 @@ export async function readPackagedConfig(): Promise<PackagedConfig> {
const webSidecarEntry = await resolvePackagedRelativeEntry(raw.webSidecarEntryRelative);
return {
amrProfile: resolvePackagedAmrProfile(raw.amrProfile),
appVersion: cleanOptionalString(raw.appVersion),
daemonCliEntry,
daemonSidecarEntry,

View file

@ -35,6 +35,13 @@ function resolveHeadlessNamespaceBaseRoot(): string {
return join(dataBase, "open-design", "namespaces");
}
function resolveHeadlessAmrProfile(): PackagedConfig["amrProfile"] {
const value = process.env.OPEN_DESIGN_AMR_PROFILE?.trim();
if (value == null || value.length === 0) return null;
if (value === "prod" || value === "test" || value === "local") return value;
throw new Error(`unsupported packaged AMR profile: ${value}`);
}
function resolveHeadlessConfig(): PackagedConfig {
const namespace =
OPEN_DESIGN_SIDECAR_CONTRACT.normalizeNamespace(
@ -51,6 +58,7 @@ function resolveHeadlessConfig(): PackagedConfig {
join(__dirname, "..", "..", "..", "open-design");
return {
amrProfile: resolveHeadlessAmrProfile(),
appVersion: null,
daemonCliEntry: null,
daemonSidecarEntry: null,
@ -110,6 +118,7 @@ async function main(): Promise<void> {
const sidecars = await startPackagedSidecars(runtime, paths, {
appVersion: config.appVersion,
amrProfile: config.amrProfile,
daemonCliEntry: config.daemonCliEntry,
daemonSidecarEntry: config.daemonSidecarEntry,
nodeCommand: config.nodeCommand,

View file

@ -102,6 +102,7 @@ async function main(): Promise<void> {
const sidecars = await startPackagedSidecars(runtime, paths, {
appVersion: config.appVersion,
amrProfile: config.amrProfile,
daemonCliEntry: config.daemonCliEntry,
daemonSidecarEntry: config.daemonSidecarEntry,
nodeCommand: config.nodeCommand,

View file

@ -1,5 +1,5 @@
import { spawn, type ChildProcess } from "node:child_process";
import { mkdir, open, type FileHandle } from "node:fs/promises";
import { access, mkdir, open, type FileHandle } from "node:fs/promises";
import { createRequire } from "node:module";
import { delimiter, dirname, join } from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
@ -95,6 +95,43 @@ function logPathFor(paths: PackagedNamespacePaths, app: AppKey): string {
return join(paths.logsRoot, app, "latest.log");
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
export async function resolvePackagedElectronNodeCommand(
execPath = process.execPath,
platform = process.platform,
): Promise<string> {
if (platform !== "darwin") return execPath;
const executableName = execPath.split("/").pop();
if (executableName == null || executableName.length === 0) return execPath;
const marker = "/Contents/MacOS/";
const markerIndex = execPath.lastIndexOf(marker);
if (markerIndex === -1) return execPath;
const appPath = execPath.slice(0, markerIndex);
const helperName = `${executableName} Helper`;
const helperPath = join(
appPath,
"Contents",
"Frameworks",
`${helperName}.app`,
"Contents",
"MacOS",
helperName,
);
return (await pathExists(helperPath)) ? helperPath : execPath;
}
async function openLog(path: string): Promise<FileHandle> {
await mkdir(dirname(path), { recursive: true });
return await open(path, "w");
@ -233,6 +270,7 @@ function createPackagedDaemonManagedPathEnv(
export type PackagedDaemonSpawnEnvOptions = {
appVersion: string | null;
amrProfile?: string | null;
daemonCliEntry: string | null;
/**
* PR #974 round-5 (lefarcen P2): only pin the daemon's import-folder
@ -276,6 +314,9 @@ export function buildPackagedDaemonSpawnEnv(
// fallback, but packaged runtime must not rely on path inference from
// Electron userData, bundle names, or ports.
...createPackagedDaemonManagedPathEnv(paths),
...(options.amrProfile == null || options.amrProfile.length === 0
? {}
: { OPEN_DESIGN_AMR_PROFILE: options.amrProfile }),
...(options.appVersion == null ? {} : { OD_APP_VERSION: options.appVersion }),
...(options.telemetryRelayUrl == null || options.telemetryRelayUrl.length === 0
? {}
@ -336,7 +377,7 @@ async function spawnSidecarChild(options: {
},
stamp,
});
const command = options.nodeCommand ?? process.execPath;
const command = options.nodeCommand ?? (await resolvePackagedElectronNodeCommand());
const child = spawn(
command,
[options.entryPath, ...createProcessStampArgs(stamp, OPEN_DESIGN_SIDECAR_CONTRACT)],
@ -375,6 +416,7 @@ export async function startPackagedSidecars(
paths: PackagedNamespacePaths,
options: {
appVersion: string | null;
amrProfile: string | null;
daemonCliEntry: string | null;
daemonSidecarEntry: string | null;
nodeCommand: string | null;
@ -414,6 +456,7 @@ export async function startPackagedSidecars(
entryPath: options.daemonSidecarEntry ?? resolveSidecarEntry("@open-design/daemon", "sidecar"),
env: buildPackagedDaemonSpawnEnv(paths, {
appVersion: options.appVersion,
amrProfile: options.amrProfile,
daemonCliEntry: options.daemonCliEntry,
legacyDataDir: process.env.OD_LEGACY_DATA_DIR ?? null,
requireDesktopAuth: options.requireDesktopAuth,

View file

@ -16,6 +16,7 @@ function stubPlatform(value: NodeJS.Platform): () => void {
function fakeConfig(): PackagedConfig {
return {
amrProfile: null,
appVersion: null,
daemonCliEntry: null,
daemonSidecarEntry: null,
@ -55,6 +56,7 @@ describe("resolvePackagedNamespacePaths", () => {
it("rejects namespace overrides that would escape the namespace base root", () => {
const config: PackagedConfig = {
amrProfile: null,
appVersion: "1.2.3",
daemonCliEntry: null,
daemonSidecarEntry: null,

View file

@ -16,15 +16,16 @@
* @see https://github.com/nexu-io/open-design/issues/710
*/
import { EventEmitter } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { delimiter, join } from 'node:path';
import { delimiter, dirname, join } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
buildPackagedDaemonSpawnEnv,
resolveDaemonStatusTimeoutMs,
resolvePackagedChildBaseEnv,
resolvePackagedElectronNodeCommand,
resolvePackagedPathEnv,
waitForStatus,
} from '../src/sidecars.js';
@ -124,6 +125,53 @@ describe('packaged child Vite+ environment forwarding', () => {
});
});
describe('resolvePackagedElectronNodeCommand', () => {
it('uses the hidden Electron helper as the macOS Electron-as-Node command when available', async () => {
const root = mkdtempSync(join(tmpdir(), 'od-packaged-electron-helper-'));
try {
const appPath = join(root, 'Open Design.app');
const execPath = join(appPath, 'Contents', 'MacOS', 'Open Design');
const helperPath = join(
appPath,
'Contents',
'Frameworks',
'Open Design Helper.app',
'Contents',
'MacOS',
'Open Design Helper',
);
mkdirSync(join(appPath, 'Contents', 'MacOS'), { recursive: true });
mkdirSync(dirname(helperPath), { recursive: true });
writeFileSync(execPath, '#!/bin/sh\n', 'utf8');
writeFileSync(helperPath, '#!/bin/sh\n', 'utf8');
await expect(resolvePackagedElectronNodeCommand(execPath, 'darwin')).resolves.toBe(helperPath);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('falls back to the main executable when the macOS helper is unavailable', async () => {
const root = mkdtempSync(join(tmpdir(), 'od-packaged-no-electron-helper-'));
try {
const execPath = join(root, 'Open Design.app', 'Contents', 'MacOS', 'Open Design');
mkdirSync(dirname(execPath), { recursive: true });
writeFileSync(execPath, '#!/bin/sh\n', 'utf8');
await expect(resolvePackagedElectronNodeCommand(execPath, 'darwin')).resolves.toBe(execPath);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it('keeps the main executable on non-macOS platforms', async () => {
const execPath = '/opt/Open Design/open-design';
await expect(resolvePackagedElectronNodeCommand(execPath, 'linux')).resolves.toBe(execPath);
});
});
/**
* Build a child-process stand-in that satisfies the `watch.child`
* shape `waitForStatus` consumes. We only use `once('exit')`,
@ -252,6 +300,17 @@ describe('buildPackagedDaemonSpawnEnv', () => {
);
});
it('forwards the packaged AMR profile to the daemon when configured', () => {
const env = buildPackagedDaemonSpawnEnv(fakePaths(), {
appVersion: null,
amrProfile: 'test',
daemonCliEntry: null,
legacyDataDir: null,
requireDesktopAuth: true,
});
expect(env.OPEN_DESIGN_AMR_PROFILE).toBe('test');
});
it('forwards POSTHOG_KEY/POSTHOG_HOST to the daemon spawn env when baked into the bundle', () => {
const env = buildPackagedDaemonSpawnEnv(fakePaths(), {
appVersion: null,

View file

@ -1,6 +1,6 @@
{
"name": "@open-design/web",
"version": "0.8.0",
"version": "0.8.1",
"private": true,
"type": "module",
"exports": {

View file

@ -0,0 +1,8 @@
<svg width="61" height="61" viewBox="0 0 61 61" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M59.8711 30.5C59.8711 56.0969 55.968 60 30.3711 60C4.7742 60 0.871094 56.0969 0.871094 30.5C0.871094 4.9031 4.7742 1 30.3711 1C55.968 1 59.8711 4.9031 59.8711 30.5Z" fill="#87EA5C"/>
<path d="M34.5271 26.9915C34.4226 27.6369 33.6061 27.8574 33.1978 27.3504L20.4888 11.5716C19.9658 10.9223 20.7318 10.0242 21.4533 10.4408L30.951 15.9243L36.3612 6.55362C36.7806 5.82706 37.889 6.21903 37.7551 7.04658L34.5271 26.9915Z" fill="#237F00"/>
<path d="M34.9157 33.3782C34.2696 33.4783 33.8076 32.7699 34.1636 32.2249L45.2428 15.262C45.6988 14.564 46.7896 15.0149 46.6164 15.8299L44.3362 26.5572L54.9201 28.8069C55.7407 28.9813 55.7104 30.1565 54.882 30.2849L34.9157 33.3782Z" fill="#237F00"/>
<path d="M28.9603 35.7227C28.6654 35.1391 29.1964 34.4809 29.8248 34.651L49.3811 39.9461C50.1858 40.1641 50.094 41.3408 49.2654 41.4279L38.3585 42.5743L39.4896 53.3353C39.5773 54.1697 38.4502 54.504 38.0721 53.7558L28.9603 35.7227Z" fill="#237F00"/>
<path d="M24.8915 30.7822C25.3554 30.3214 26.1455 30.623 26.1779 31.2732L27.1852 51.5086C27.2266 52.3413 26.079 52.6177 25.7402 51.8565L21.2795 41.8377L11.3946 46.2387C10.6282 46.5799 9.96197 45.6114 10.5567 45.0206L24.8915 30.7822Z" fill="#237F00"/>
<path d="M28.3305 25.3842C28.9121 25.683 28.8694 26.5277 28.261 26.7594L9.32726 33.9705C8.54806 34.2672 7.93066 33.2612 8.54982 32.7037L16.6999 25.3653L9.45967 17.3243C8.8983 16.7008 9.61357 15.7679 10.3592 16.1509L28.3305 25.3842Z" fill="#237F00"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,48 @@
<svg width="184" height="64" viewBox="0 0 184 64" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="官方角标">
<defs>
<linearGradient id="pillFill" x1="28" y1="6" x2="166" y2="56" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#5B3728"/>
<stop offset="0.48" stop-color="#3C2A24"/>
<stop offset="1" stop-color="#241D1B"/>
</linearGradient>
<linearGradient id="pillStroke" x1="22" y1="4" x2="168" y2="60" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#D48A5B"/>
<stop offset="0.45" stop-color="#8C543A"/>
<stop offset="1" stop-color="#3A2620"/>
</linearGradient>
<linearGradient id="shieldFill" x1="27" y1="11" x2="57" y2="50" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FFD19A"/>
<stop offset="0.35" stop-color="#D9784C"/>
<stop offset="1" stop-color="#8B412E"/>
</linearGradient>
<linearGradient id="shieldStroke" x1="25" y1="8" x2="59" y2="52" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FFE0B8"/>
<stop offset="1" stop-color="#9B5137"/>
</linearGradient>
<linearGradient id="textFill" x1="80" y1="19" x2="80" y2="45" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FFE4C7"/>
<stop offset="0.42" stop-color="#F8A56E"/>
<stop offset="1" stop-color="#D6754E"/>
</linearGradient>
<filter id="softShadow" x="0" y="0" width="184" height="64" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="6" stdDeviation="8" flood-color="#000000" flood-opacity="0.28"/>
</filter>
<filter id="innerGlow" x="18" y="8" width="48" height="48" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy="0" stdDeviation="2" flood-color="#FFD09A" flood-opacity="0.45"/>
</filter>
</defs>
<g filter="url(#softShadow)">
<rect x="23" y="8" width="148" height="48" rx="24" fill="url(#pillFill)" stroke="url(#pillStroke)" stroke-width="2"/>
<rect x="27" y="12" width="140" height="40" rx="20" stroke="#FFFFFF" stroke-opacity="0.06"/>
<g filter="url(#innerGlow)">
<path d="M42 11.5C42 11.5 29 15.4 29 17.7V30.5C29 40.8 37.2 47.6 42 49.7C46.8 47.6 55 40.8 55 30.5V17.7C55 15.4 42 11.5 42 11.5Z" fill="url(#shieldFill)" stroke="url(#shieldStroke)" stroke-width="2"/>
<path d="M36.2 30.9L40.1 34.8L48.8 25.2" stroke="#FFF3E0" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M36.2 30.9L40.1 34.8L48.8 25.2" stroke="#7A3527" stroke-opacity="0.18" stroke-width="5.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M36.2 30.9L40.1 34.8L48.8 25.2" stroke="#FFF3E0" stroke-width="4.2" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<text x="72" y="40" font-family="-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'PingFang SC', 'Microsoft YaHei', sans-serif" font-size="26" font-weight="800" letter-spacing="1.5" fill="url(#textFill)">官方</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3 KiB

View file

@ -32,6 +32,7 @@ import {
switchApiProtocolConfig,
updateCurrentApiProtocolConfig,
type SettingsSection,
type SettingsHighlight,
} from './components/SettingsDialog';
import { PrivacyConsentModal } from './components/PrivacyConsentModal';
import {
@ -193,6 +194,7 @@ export function App() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsWelcome, setSettingsWelcome] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSection>('execution');
const [settingsHighlight, setSettingsHighlight] = useState<SettingsHighlight>(null);
const [integrationInitialTab, setIntegrationInitialTab] = useState<IntegrationTab>('mcp');
const [daemonLive, setDaemonLive] = useState(false);
const [agents, setAgents] = useState<AgentInfo[]>([]);
@ -1160,7 +1162,10 @@ export function App() {
};
}, [route, activeProject, projects, daemonLive]);
const openSettings = useCallback((section: SettingsSection = 'execution') => {
const openSettings = useCallback((
section: SettingsSection = 'execution',
opts?: { highlight?: SettingsHighlight },
) => {
if (section === 'composio' || section === 'mcpClient' || section === 'integrations') {
setIntegrationInitialTab(
section === 'composio'
@ -1174,9 +1179,17 @@ export function App() {
}
setSettingsWelcome(false);
setSettingsInitialSection(section);
setSettingsHighlight(opts?.highlight ?? null);
setSettingsOpen(true);
}, []);
// Entry point from the failed-run AMR nudge: open Settings on the execution
// section and flag the AMR agent card for a one-shot scroll-into-view +
// highlight (and a sign-in coachmark when not yet authorized).
const openAmrSettings = useCallback(() => {
openSettings('execution', { highlight: 'amr' });
}, [openSettings]);
const openPetSettings = useCallback(() => {
setSettingsWelcome(false);
setSettingsInitialSection('pet');
@ -1379,6 +1392,7 @@ export function App() {
onAgentModelChange={handleAgentModelChange}
onRefreshAgents={refreshAgents}
onOpenSettings={openSettings}
onOpenAmrSettings={openAmrSettings}
onOpenMcpSettings={openMcpSettings}
onAdoptPetInline={handleAdoptPet}
onTogglePet={handleTogglePet}
@ -1495,10 +1509,12 @@ export function App() {
<SettingsDialog
initial={config}
agents={agents}
agentsLoading={agentsLoading}
daemonLive={daemonLive}
appVersionInfo={appVersionInfo}
welcome={settingsWelcome}
initialSection={settingsInitialSection}
initialHighlight={settingsHighlight}
composioConfigLoading={composioConfigLoading}
onPersist={handleConfigPersist}
onPersistComposioKey={handleConfigPersistComposioKey}
@ -1516,6 +1532,7 @@ export function App() {
setConfig(next);
}
setSettingsOpen(false);
setSettingsHighlight(null);
}}
onRefreshAgents={refreshAgents}
onSkillsRefresh={refreshSkills}

View file

@ -48,6 +48,8 @@ import type {
IntegrationsSkillsTabClickProps,
IntegrationsUseEverywhereTabClickProps,
ChatPanelClickProps,
RunFailedToastClickProps,
RunFailedToastSurfaceViewProps,
ChatPanelResourcesPopoverClickProps,
FileManagerClickProps,
ArtifactToolbarClickProps,
@ -160,6 +162,20 @@ export function trackAssistantFeedbackReasonPanelSurfaceView(
send(track, 'surface_view', props);
}
export function trackRunFailedToastSurfaceView(
track: Track,
props: RunFailedToastSurfaceViewProps,
): void {
send(track, 'surface_view', props);
}
export function trackRunFailedToastGoAmrClick(
track: Track,
props: RunFailedToastClickProps,
): void {
send(track, 'ui_click', props);
}
// ---- ui_click (home) -----------------------------------------------------
export function trackHomeNavClick(

View file

@ -12,6 +12,7 @@ interface Props {
// only ships a rasterised icon on devin.ai). New brand: drop the optimised
// file in that folder and add the id here.
const ICON_EXT: Record<string, 'svg' | 'png'> = {
amr: 'svg',
claude: 'svg',
codex: 'svg',
gemini: 'svg',

View file

@ -0,0 +1,96 @@
import { useEffect, useRef } from 'react';
import { useT } from '../i18n';
import { useAnalytics } from '../analytics/provider';
import {
trackRunFailedToastGoAmrClick,
trackRunFailedToastSurfaceView,
} from '../analytics/events';
import type { TrackingProjectKind } from '@open-design/contracts/analytics';
export interface AmrGuidanceProps {
errorCode: string;
projectId: string;
projectKind: TrackingProjectKind | null;
conversationId: string | null;
assistantMessageId: string;
runId: string | null;
// Switch the run to AMR and retry. The `ui_click` analytics event is fired
// here first; the host performs the switch + arms the auto-retry.
onActivate: () => void;
}
// Theme-color promotion card under a failed run's gray error card, shown when a
// non-AMR agent hits a model/auth/quota wall. Offers a one-click switch to
// Open Design's hosted AMR with auto-retry. Fires `surface_view`
// (element=run_failed_toast) once on mount and `ui_click` (element=go_amr) on
// the action. `useAnalytics()` returns a no-op stub outside the provider, so
// this is safe in isolated tests.
export function AmrGuidance({
errorCode,
projectId,
projectKind,
conversationId,
assistantMessageId,
runId,
onActivate,
}: AmrGuidanceProps) {
const t = useT();
const analytics = useAnalytics();
const firedRef = useRef(false);
useEffect(() => {
if (firedRef.current) return;
firedRef.current = true;
trackRunFailedToastSurfaceView(analytics.track, {
page_name: 'chat_panel',
area: 'chat_panel',
element: 'run_failed_toast',
error_code: errorCode,
project_id: projectId,
project_kind: projectKind,
conversation_id: conversationId,
assistant_message_id: assistantMessageId,
run_id: runId,
});
}, [
analytics.track,
errorCode,
projectId,
projectKind,
conversationId,
assistantMessageId,
runId,
]);
return (
<div className="amr-card amr-card--switch" data-testid="amr-guidance">
<div className="amr-card__head">
<span className="amr-card__icon" aria-hidden="true">
!
</span>
<strong className="amr-card__title">{t('chat.amrCard.switchTitle')}</strong>
</div>
<p className="amr-card__body">{t('chat.amrCard.switchBody')}</p>
<div className="amr-card__chips" aria-hidden="true">
<span className="amr-card__chip">{t('chat.amrCard.chipOfficial')}</span>
<span className="amr-card__chip">{t('chat.amrCard.chipNoKey')}</span>
<span className="amr-card__chip">{t('chat.amrCard.chipAutoRetry')}</span>
</div>
<div className="amr-card__actions">
<button
type="button"
className="amr-card__cta"
onClick={() => {
trackRunFailedToastGoAmrClick(analytics.track, {
page_name: 'chat_panel',
area: 'chat_panel',
element: 'go_amr',
});
onActivate();
}}
>
{t('chat.amrCard.switchCta')}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,450 @@
import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react';
import {
cancelVelaLogin,
fetchVelaLoginStatus,
startVelaLogin,
velaLogout,
type VelaLoginStatus,
} from '../providers/daemon';
import { useI18n } from '../i18n';
import {
AMR_LOGIN_STATUS_EVENT,
AMR_LOGIN_POLL_INTERVAL_MS,
AMR_LOGIN_STARTUP_SETTLE_MS,
amrLoginPollOutcome,
amrLoginStatusEventReason,
notifyAmrLoginStatusChanged,
} from './amrLoginPolling';
interface AmrLoginPillProps {
className?: string;
hideSignedOutStatus?: boolean;
hideSignedInStatus?: boolean;
initialStatus?: VelaLoginStatus | null;
skipInitialRefresh?: boolean;
signInLabel?: string;
revealPendingCancelAction?: boolean;
onStatusChange?: (status: VelaLoginStatus | null) => void;
}
export type AmrAccountControlStatus =
| 'signed-out'
| 'signing-in'
| 'canceled'
| 'signed-in'
| 'error';
export interface AmrAccountControlProps {
status: AmrAccountControlStatus;
className?: string;
compact?: boolean;
email?: string;
errorMessage?: string | null;
profile?: string;
showProfileBadge?: boolean;
showSignInAction?: boolean;
hideSignedOutStatus?: boolean;
hideSignedInStatus?: boolean;
signInLabel?: string;
showCancelSignInAction?: boolean;
onSignIn?: (event: MouseEvent<HTMLButtonElement>) => void;
onSignOut?: (event: MouseEvent<HTMLButtonElement>) => void;
onCancelSignIn?: (event: MouseEvent<HTMLButtonElement>) => void;
signInDisabled?: boolean;
signOutDisabled?: boolean;
cancelSignInDisabled?: boolean;
}
const AMR_CANCELED_RESET_MS = 1500;
function closeAmrActivationWindowBestEffort(): boolean {
if (typeof window === 'undefined') return false;
if (window.opener == null) return false;
try {
window.close();
return true;
} catch {
return false;
}
}
function profileBadgeLabel(profile: string | undefined): string | null {
if (profile === 'test') return 'TEST';
if (profile === 'local') return 'LOCAL';
return null;
}
function classNames(...names: Array<string | false | null | undefined>): string {
return names.filter(Boolean).join(' ');
}
export function AmrAccountControl({
status,
className,
compact = false,
email = '',
profile,
showProfileBadge = false,
showSignInAction = true,
hideSignedOutStatus = false,
hideSignedInStatus = false,
signInLabel,
showCancelSignInAction = false,
onSignIn,
onSignOut,
onCancelSignIn,
signInDisabled = false,
signOutDisabled = false,
cancelSignInDisabled = false,
}: AmrAccountControlProps) {
const { t } = useI18n();
const badgeLabel = showProfileBadge ? profileBadgeLabel(profile) : null;
const isSignedIn = status === 'signed-in';
const isSigningIn = status === 'signing-in';
const isCanceled = status === 'canceled';
const hasError = status === 'error';
const statusText = isSignedIn
? hideSignedInStatus
? ''
: email || t('settings.amrSignedIn')
: isSigningIn
? t('settings.amrSigningIn')
: isCanceled
? t('designs.status.canceled')
: hideSignedOutStatus
? ''
: t('settings.amrNotSignedIn');
const canSignIn = showSignInAction && (status === 'signed-out' || hasError);
return (
<div
className={classNames(
'amr-account-control',
compact && 'amr-account-control--compact',
`amr-account-control--${status}`,
className,
)}
role="group"
aria-label={t('settings.amrAccountStatus')}
>
{statusText ? (
<span className="amr-account-control__status">{statusText}</span>
) : null}
{isSignedIn && onSignOut ? (
<button
type="button"
className="amr-account-control__action"
disabled={signOutDisabled}
onClick={onSignOut}
title={email || undefined}
aria-label={t('settings.amrLogout')}
>
{signOutDisabled ? t('settings.amrLoggingOut') : t('settings.amrLogout')}
</button>
) : null}
{isSigningIn && showCancelSignInAction && onCancelSignIn ? (
<button
type="button"
className="amr-account-control__action"
disabled={cancelSignInDisabled}
onClick={onCancelSignIn}
aria-label={t('common.cancel')}
>
{t('common.cancel')}
</button>
) : null}
{canSignIn ? (
<button
type="button"
className="amr-account-control__action"
disabled={signInDisabled}
onClick={onSignIn}
>
{signInLabel ?? t('settings.amrSignIn')}
</button>
) : null}
{badgeLabel ? (
<span className="amr-login-pill-badge">{badgeLabel}</span>
) : null}
{hasError ? (
<span className="amr-account-control__error" role="alert">
{t('settings.amrLoginErrorCompact')}
</span>
) : null}
</div>
);
}
// AMR-specific login pill that lives as a sibling inside the installed
// agent card. The pill polls `/api/integrations/vela/status` after a Sign-in
// click until the daemon reports loggedIn=true.
export function AmrLoginPill({
className,
hideSignedOutStatus = false,
hideSignedInStatus = false,
initialStatus = null,
skipInitialRefresh = false,
signInLabel,
revealPendingCancelAction = false,
onStatusChange,
}: AmrLoginPillProps) {
const { t } = useI18n();
const [status, setStatus] = useState<VelaLoginStatus | null>(initialStatus);
const [pending, setPending] = useState<null | 'login' | 'logout' | 'cancel'>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [canceledVisible, setCanceledVisible] = useState(false);
const pollRef = useRef<number | null>(null);
const loginStartedAtRef = useRef<number | null>(null);
const loginPendingRef = useRef(false);
const stopPolling = useCallback(() => {
if (pollRef.current !== null) {
window.clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
const refresh = useCallback(async () => {
const next = await fetchVelaLoginStatus();
if (next) setStatus(next);
return next;
}, []);
useEffect(() => {
if (!skipInitialRefresh) void refresh();
return () => {
loginPendingRef.current = false;
loginStartedAtRef.current = null;
stopPolling();
};
}, [refresh, skipInitialRefresh, stopPolling]);
useEffect(() => {
setStatus(initialStatus);
}, [initialStatus]);
useEffect(() => {
if (!canceledVisible) return;
const timeout = window.setTimeout(() => {
setCanceledVisible(false);
}, AMR_CANCELED_RESET_MS);
return () => window.clearTimeout(timeout);
}, [canceledVisible]);
useEffect(() => {
onStatusChange?.(status);
}, [onStatusChange, status]);
const startPolling = useCallback((startedAt = Date.now()) => {
stopPolling();
loginStartedAtRef.current = startedAt;
const tick = async () => {
const next = await refresh();
const outcome = amrLoginPollOutcome(next, startedAt);
if (outcome === 'signed-in') {
stopPolling();
loginStartedAtRef.current = null;
loginPendingRef.current = false;
setPending(null);
return;
}
if (outcome === 'stopped' || outcome === 'timed-out') {
stopPolling();
if (outcome === 'timed-out') {
void cancelVelaLogin().then(() =>
notifyAmrLoginStatusChanged('login-canceled'),
);
}
loginStartedAtRef.current = null;
loginPendingRef.current = false;
setPending(null);
setErrorMessage(t('settings.amrLoginErrorCompact'));
}
};
pollRef.current = window.setInterval(() => {
void tick();
}, AMR_LOGIN_POLL_INTERVAL_MS);
}, [refresh, stopPolling, t]);
useEffect(() => {
const onStatusChange = (event: Event) => {
const reason = amrLoginStatusEventReason(event);
if (reason === 'login-started') {
const startedAt = Date.now();
loginStartedAtRef.current = startedAt;
setErrorMessage(null);
setPending('login');
startPolling(startedAt);
} else if (reason === 'login-canceled') {
loginStartedAtRef.current = null;
loginPendingRef.current = false;
stopPolling();
setPending(null);
// Skip the daemon refresh below. `cancelVelaLogin()` only sends
// SIGTERM (escalating to SIGKILL after 2s) and keeps the child
// in `activeLoginProcs` until it actually exits, so an
// immediate `/api/integrations/vela/status` read can legally
// still return `loginInFlight: true`. Falling through to the
// refresh + restart-polling branch below would bounce the pill
// back into 'Signing in…' and could surface the timeout/error
// path even though the user already canceled. Trust the cancel
// locally on every subscribed pill instance instead — the next
// explicit refresh (mount, user interaction, or a
// `status-changed` event) will pick up the daemon's confirmed
// state once the child has actually exited.
setStatus((current) => (
current ? { ...current, loginInFlight: false } : current
));
return;
}
void refresh().then((next) => {
if (!next) return;
if (next.loggedIn) {
stopPolling();
loginStartedAtRef.current = null;
loginPendingRef.current = false;
setPending(null);
setCanceledVisible(false);
setErrorMessage(null);
return;
}
if (next.loginInFlight) {
setErrorMessage(null);
setPending('login');
startPolling();
return;
}
const pendingStartup =
loginStartedAtRef.current !== null &&
Date.now() - loginStartedAtRef.current < AMR_LOGIN_STARTUP_SETTLE_MS;
if (!pendingStartup) {
loginStartedAtRef.current = null;
loginPendingRef.current = false;
setPending(null);
}
});
};
window.addEventListener(AMR_LOGIN_STATUS_EVENT, onStatusChange);
return () => {
window.removeEventListener(AMR_LOGIN_STATUS_EVENT, onStatusChange);
};
}, [refresh, startPolling, stopPolling]);
const handleLogin = useCallback(
async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (loginPendingRef.current) return;
loginPendingRef.current = true;
const startedAt = Date.now();
loginStartedAtRef.current = startedAt;
setErrorMessage(null);
setPending('login');
const result = await startVelaLogin();
if (!result.ok && !result.alreadyRunning) {
loginStartedAtRef.current = null;
loginPendingRef.current = false;
setPending(null);
setErrorMessage(result.error || t('settings.amrLoginErrorCompact'));
return;
}
notifyAmrLoginStatusChanged('login-started');
startPolling(startedAt);
},
[startPolling, t],
);
const handleCancelLogin = useCallback(
async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
stopPolling();
setErrorMessage(null);
setPending('cancel');
const result = await cancelVelaLogin();
closeAmrActivationWindowBestEffort();
loginStartedAtRef.current = null;
loginPendingRef.current = false;
if (!result.ok) {
setPending(null);
setErrorMessage(t('settings.amrLoginErrorCompact'));
return;
}
setStatus((current) => (
current
? { ...current, loggedIn: false, loginInFlight: false, user: null }
: {
loggedIn: false,
loginInFlight: false,
profile: 'default',
user: null,
configPath: '',
}
));
setPending(null);
setCanceledVisible(true);
notifyAmrLoginStatusChanged('login-canceled');
},
[stopPolling, t],
);
const handleLogout = useCallback(
async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
setErrorMessage(null);
setPending('logout');
const result = await velaLogout();
loginStartedAtRef.current = null;
loginPendingRef.current = false;
setPending(null);
if (!result.ok) {
setErrorMessage(t('settings.amrLoginErrorCompact'));
return;
}
await refresh();
notifyAmrLoginStatusChanged('status-changed');
},
[refresh, t],
);
const loggedIn = status?.loggedIn === true;
const userEmail = status?.user?.email ?? '';
const loginInFlight =
pending === 'login' || (status?.loggedIn !== true && status?.loginInFlight === true);
const logoutInFlight = pending === 'logout';
const cancelInFlight = pending === 'cancel';
const accountStatus: AmrAccountControlStatus = errorMessage
? 'error'
: loggedIn
? 'signed-in'
: canceledVisible
? 'canceled'
: loginInFlight
? 'signing-in'
: 'signed-out';
return (
<div
className={'amr-login-pill' + (className ? ' ' + className : '')}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
<AmrAccountControl
status={accountStatus}
compact
email={userEmail}
profile={status?.profile}
showProfileBadge
hideSignedOutStatus={hideSignedOutStatus}
hideSignedInStatus={hideSignedInStatus}
signInLabel={signInLabel}
signInDisabled={loginInFlight}
signOutDisabled={logoutInFlight}
showCancelSignInAction={revealPendingCancelAction && loginInFlight}
cancelSignInDisabled={cancelInFlight}
onSignIn={handleLogin}
onSignOut={handleLogout}
onCancelSignIn={handleCancelLogin}
className={loggedIn ? 'amr-login-pill-status' : undefined}
/>
</div>
);
}

View file

@ -297,6 +297,10 @@ interface Props {
// interactivity on this so older forms render as a locked "answered"
// capsule instead of being re-submittable.
isLast?: boolean;
// Assistant message id whose run-failure error is rendered as ChatPane's
// top-level error card; that message's per-message error pill is suppressed
// to avoid duplication. Other messages keep their error pill.
errorCardOwnerId?: string | null;
// The user message that immediately follows this assistant turn (if
// any). Used to detect that a form was already answered so we can
// render its locked state with the user's picks visible.
@ -332,6 +336,7 @@ export function AssistantMessage({
activePluginActionPaths = new Set(),
hiddenPluginActionPaths = new Set(),
isLast,
errorCardOwnerId = null,
nextUserContent,
onSubmitForm,
onContinueRemainingTasks,
@ -350,7 +355,9 @@ export function AssistantMessage({
// above the composer, so we strip any TodoWrite tool-groups out of the
// per-message flow to avoid the same task list rendering twice.
const blocks = stripTodoToolGroups(
suppressDuplicateQuestionForms(
suppressAskUserQuestionFallbackText(buildBlocks(events)),
),
);
const fileOps = useMemo(() => deriveFileOps(events), [events]);
const produced = message.producedFiles ?? [];
@ -568,8 +575,15 @@ export function AssistantMessage({
/>
);
}
if (b.kind === "status")
if (b.kind === "status") {
// Suppress this message's gray error pill ONLY when ChatPane is
// rendering the top-level error card for it (the last failed run).
// Other failed turns — older history, or once a follow-up makes
// this no longer the last assistant message — keep their pill so
// the error detail still survives reload / history review.
if (b.label === "error" && message.id === errorCardOwnerId) return null;
return <StatusPill key={i} label={b.label} detail={b.detail} />;
}
return null;
})}
{!streaming && displayedProduced.length > 0 && projectId ? (
@ -1821,11 +1835,52 @@ function StatusPill({
return (
<div className="status-pill">
<span className="status-label">{label}</span>
{detail ? <span className="status-detail">{detail}</span> : null}
{detail ? <span className="status-detail">{renderStatusDetail(detail)}</span> : null}
</div>
);
}
function renderStatusDetail(detail: string): ReactNode {
const segments: ReactNode[] = [];
const urlRe = /(https?:\/\/[^\s)<>]+)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
let key = 0;
while ((match = urlRe.exec(detail))) {
if (match.index > lastIndex) {
segments.push(detail.slice(lastIndex, match.index));
}
const [href, suffix] = splitStatusDetailUrlPunctuation(match[1]!);
segments.push(
<a
key={`url-${key++}`}
className="md-link md-link-bare"
href={href}
target="_blank"
rel="noreferrer noopener"
>
{href}
</a>,
);
if (suffix) segments.push(suffix);
lastIndex = urlRe.lastIndex;
}
if (lastIndex < detail.length) {
segments.push(detail.slice(lastIndex));
}
return <>{segments}</>;
}
function splitStatusDetailUrlPunctuation(url: string): [string, string] {
const match = /([.,!?;:,。!?;:、'"」』】》〉)]+)$/.exec(url);
if (!match?.[1]) return [url, ''];
const trimmed = url.slice(0, -match[1].length);
return trimmed ? [trimmed, match[1]] : [url, ''];
}
interface ToolItem {
use: Extract<AgentEvent, { kind: "tool_use" }>;
result?: Extract<AgentEvent, { kind: "tool_result" }>;
@ -2096,6 +2151,31 @@ function stripTodoToolGroups(blocks: Block[]): Block[] {
});
}
// The prompt asks for one discovery form and then a stop, but LLMs can still
// emit a tailored discovery form followed by the default Quick brief in the
// same assistant turn. Keep the first form for each id and drop later repeats.
function suppressDuplicateQuestionForms(blocks: Block[]): Block[] {
const seenFormIds = new Set<string>();
return blocks.map((block) => {
if (block.kind !== "text") return block;
const segments = splitOnQuestionForms(block.text);
let changed = false;
const nextText = segments
.map((segment) => {
if (segment.kind === "text") return segment.text;
const formKey = segment.form.id.trim().toLowerCase();
if (seenFormIds.has(formKey)) {
changed = true;
return "";
}
seenFormIds.add(formKey);
return segment.raw;
})
.join("");
return changed ? { ...block, text: nextText } : block;
});
}
// Hide text blocks that follow an `AskUserQuestion` tool use in the same
// assistant message. Claude tends to also write the same questions as
// markdown text alongside the tool call. The card already shows the

View file

@ -22,6 +22,10 @@ interface Props {
onBack?: () => void;
}
function displayAgentName(agent: Pick<AgentInfo, 'id' | 'name'>): string {
return agent.id === 'amr' ? 'Open Design AMR' : agent.name;
}
/**
* Compact settings control at the right of the project header. Click opens a dropdown
* with current execution mode, the agent picker (when in daemon mode), and
@ -115,7 +119,15 @@ export function AvatarMenu({
{config.mode === 'api'
? safeHost(config.baseUrl)
: currentAgent
? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}${currentModelLabel && currentModelId !== 'default' ? ` · ${currentModelLabel}` : ''}`
? `${displayAgentName(currentAgent)}${
currentAgent.id !== 'amr' && currentAgent.version
? ` · ${currentAgent.version}`
: ''
}${
currentModelLabel && currentModelId !== 'default'
? ` · ${currentModelLabel}`
: ''
}`
: t('avatar.noAgentSelected')}
</span>
</div>
@ -191,12 +203,12 @@ export function AvatarMenu({
}}
>
<AgentIcon id={a.id} size={18} />
<span>{a.name}</span>
<span>{displayAgentName(a)}</span>
{selected ? (
<span className="avatar-item-meta">
{t('avatar.metaSelected')}
</span>
) : a.version ? (
) : a.id !== 'amr' && a.version ? (
<span className="avatar-item-meta">{a.version}</span>
) : null}
{selected ? (

View file

@ -19,6 +19,8 @@ import type { AppConfig, ChatAttachment, ChatCommentAttachment, ChatMessage, Cha
import { dayKey, dayLabel, exactDateTime, messageTime, relativeTimeLong } from '../utils/chatTime';
import { commentTargetDisplayName, commentsToAttachments, simplePositionLabel } from '../comments';
import { AssistantMessage } from './AssistantMessage';
import { AmrGuidance } from './AmrGuidance';
import { AMR_RECHARGE_URL, resolveRunFailureUi } from '../runtime/amr-guidance';
import {
ChatComposer,
type ChatComposerHandle,
@ -277,6 +279,8 @@ interface Props {
// Composer settings/CLI button forwards to here. The dialog lives in App
// (it owns the AppConfig lifecycle) so we just pass the open trigger.
onOpenSettings?: (section?: SettingsSection) => void;
onOpenAmrSettings?: () => void;
onSwitchToAmrAndRetry?: (failedAssistant: ChatMessage) => void;
// Same dialog, but landing on the External MCP tab. Forwarded to the
// composer's `/mcp` slash and MCP picker button.
onOpenMcpSettings?: () => void;
@ -371,6 +375,8 @@ export function ChatPane({
onDeleteConversation,
onRenameConversation,
onOpenSettings,
onOpenAmrSettings,
onSwitchToAmrAndRetry,
onOpenMcpSettings,
connectRepoNeeded,
githubConnected,
@ -422,6 +428,45 @@ export function ChatPane({
(m) => m.role === 'assistant' && isActiveRunStatus(m.runStatus),
);
const retryAssistant = retryableAssistantMessage(messages, lastAssistantId, streaming);
// The failed run's error event lives on the (persisted) assistant message, so
// the error card + AMR card survive a reload — unlike the ephemeral global
// `error` state. Drive both off this event.
const failedRunErrorEvent = (() => {
const evs = retryAssistant?.events ?? [];
for (let i = evs.length - 1; i >= 0; i--) {
const ev = evs[i];
if (ev?.kind === 'status' && ev.label === 'error') return ev;
}
return null;
})();
// Per-case failure UI (button + copy + whether to promote AMR). Only
// meaningful for a failed run (retryAssistant present).
const runFailureUi = retryAssistant
? resolveRunFailureUi(failedRunErrorEvent?.code, retryAssistant.agentId)
: null;
// Prefer a case-specific message (AMR auth / balance) over the raw upstream
// string; fall back to the live global error (also covers conversation-load
// / audio errors) then the persisted run error so a reload still shows it.
const rawError = error ?? failedRunErrorEvent?.detail ?? null;
const displayError = runFailureUi?.messageKey ? t(runFailureUi.messageKey) : rawError;
// The failed run whose error this top-level card represents. AssistantMessage
// suppresses only THIS message's per-message error pill (to avoid the
// duplicate); other failed turns — older history, or once a follow-up makes
// this no longer the last assistant — keep their pill so the error survives.
const errorCardOwnerId =
retryAssistant && failedRunErrorEvent ? retryAssistant.id : null;
// AMR promotion card payload (only the non-AMR model/auth/quota case).
const amrSwitchPayload =
runFailureUi?.showSwitchCard && retryAssistant && failedRunErrorEvent?.code
? {
errorCode: failedRunErrorEvent.code,
projectId: projectId ?? '',
projectKind: projectKindForTracking,
conversationId: activeConversationId,
assistantMessageId: retryAssistant.id,
runId: retryAssistant.runId ?? null,
}
: null;
const composerDraftStorageKey = projectId && activeConversationId
? `od:chat-composer:draft:${projectId}:${activeConversationId}`
: undefined;
@ -1099,6 +1144,7 @@ export function ChatPane({
activePluginActionPaths={activePluginActionPaths}
hiddenPluginActionPaths={hiddenPluginActionPaths}
isLast={m.id === lastAssistantId}
errorCardOwnerId={errorCardOwnerId}
nextUserContent={nextUserContentByAssistantId.get(m.id)}
suppressDirectionForms={hasActiveDesignSystem}
hasDesignSystemContext={hasActiveDesignSystem || !!activeDesignSystem}
@ -1122,10 +1168,37 @@ export function ChatPane({
</Fragment>
);
})}
{error ? (
{displayError ? (
<div className="msg error">
<span className="chat-error-text">{error}</span>
{retryAssistant && onRetry ? (
<span className="chat-error-text">{displayError}</span>
{retryAssistant && onRetry && runFailureUi ? (
<div className="chat-error-actions">
{runFailureUi.primaryAction === 'authorize' ? (
<button
type="button"
className="chat-error-action"
onClick={() => {
if (onSwitchToAmrAndRetry) {
onSwitchToAmrAndRetry(retryAssistant);
} else {
onOpenAmrSettings?.();
}
}}
>
{t('chat.amrError.authorizeCta')}
</button>
) : runFailureUi.primaryAction === 'recharge' ? (
<button
type="button"
className="chat-error-action"
onClick={() =>
window.open(AMR_RECHARGE_URL, '_blank', 'noopener,noreferrer')
}
>
{t('chat.amrError.rechargeCta')}
</button>
) : null}
{runFailureUi.primaryAction === 'retry' || runFailureUi.secondaryRetry ? (
<button
type="button"
className="ghost chat-error-retry"
@ -1137,6 +1210,20 @@ export function ChatPane({
</div>
) : null}
</div>
) : null}
{amrSwitchPayload ? (
<AmrGuidance
{...amrSwitchPayload}
onActivate={() => {
if (retryAssistant && onSwitchToAmrAndRetry) {
onSwitchToAmrAndRetry(retryAssistant);
} else {
onOpenAmrSettings?.();
}
}}
/>
) : null}
</div>
{/* Always mounted so the CSS transition can play in both
directions; the `chat-jump-btn-active` class flips the
slide + opacity, and `aria-hidden` + `tabIndex={-1}`

View file

@ -8,7 +8,14 @@
// can be rebased without touching this file. `EntryView` becomes a
// thin wrapper that passes data and callbacks through to this shell.
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import {
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from 'react';
import {
defaultScenarioPluginIdForProjectMetadata,
type ConnectorDetail,
@ -96,6 +103,17 @@ import { KNOWN_PROVIDERS } from '../state/config';
import type { KnownProvider } from '../state/config';
import { testApiProvider } from '../providers/connection-test';
import { fetchProviderModels } from '../providers/provider-models';
import {
cancelVelaLogin,
fetchVelaLoginStatus,
startVelaLogin,
type VelaLoginStatus,
} from '../providers/daemon';
import { AmrAccountControl } from './AmrLoginPill';
import {
AMR_LOGIN_POLL_INTERVAL_MS,
amrLoginPollOutcome,
} from './amrLoginPolling';
// The topbar chips (GitHub star, model switcher, Use everywhere)
// collapse into the settings dropdown when the viewport gets
@ -771,10 +789,13 @@ function OnboardingView({
const t = useT();
const analytics = useAnalytics();
const [step, setStep] = useState(0);
const [runtime, setRuntime] = useState<'local' | 'byok' | null>(null);
const [runtime, setRuntime] = useState<'amr' | 'local' | 'byok' | null>(null);
const [designSource, setDesignSource] = useState<'github' | 'upload' | 'prompt' | null>(null);
const [apiKeyVisible, setApiKeyVisible] = useState(false);
const [cliScanStatus, setCliScanStatus] = useState<'idle' | 'scanning' | 'done'>('idle');
const [amrStatus, setAmrStatus] = useState<VelaLoginStatus | null>(null);
const [amrLoginPending, setAmrLoginPending] = useState(false);
const [amrLoginError, setAmrLoginError] = useState(false);
const [visibleAgentIds, setVisibleAgentIds] = useState<string[]>([]);
const [providerTestState, setProviderTestState] = useState<
| { status: 'idle' }
@ -809,6 +830,8 @@ function OnboardingView({
}, [profile]);
const agentRevealTimersRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const cliScanTokenRef = useRef(0);
const amrLoginPollCancelledRef = useRef(false);
const amrAgentRefreshAttemptedRef = useRef(false);
const apiProtocol = config.apiProtocol ?? 'anthropic';
const providerTestInputKey = [
apiProtocol,
@ -849,18 +872,53 @@ function OnboardingView({
provider.baseUrl === (config.apiProviderBaseUrl ?? config.baseUrl),
) ?? null;
const visibleAgents = agents.filter(
(agent) => agent.available && visibleAgentIds.includes(agent.id),
(agent) => agent.available && agent.id !== 'amr' && visibleAgentIds.includes(agent.id),
);
const amrAgent = agents.find((agent) => agent.id === 'amr' && agent.available) ?? null;
const showAmrCloudOption = amrAgent !== null || agents.length === 0;
const amrSignedIn = amrStatus?.loggedIn === true;
const amrSelectedAndSignedOut = runtime === 'amr' && !amrSignedIn;
const selectedAgent = visibleAgents.find((agent) => agent.id === config.agentId) ?? null;
const selectedAgentChoice = selectedAgent ? (config.agentModels?.[selectedAgent.id] ?? {}) : {};
useEffect(() => {
return () => {
amrLoginPollCancelledRef.current = true;
agentRevealTimersRef.current.forEach((timer) => clearTimeout(timer));
agentRevealTimersRef.current = [];
};
}, []);
useEffect(() => {
if (!amrAgent || runtime !== null) return;
setRuntime('amr');
onModeChange('daemon');
onAgentChange('amr');
}, [amrAgent, onAgentChange, onModeChange, runtime]);
useEffect(() => {
if (amrAgent || amrAgentRefreshAttemptedRef.current) return;
amrAgentRefreshAttemptedRef.current = true;
void Promise.resolve(onRefreshAgents()).catch(() => undefined);
}, [amrAgent, onRefreshAgents]);
useEffect(() => {
if (!amrAgent) return;
let cancelled = false;
void fetchVelaLoginStatus().then((next) => {
if (!cancelled && next) setAmrStatus(next);
});
return () => {
cancelled = true;
};
}, [amrAgent]);
useEffect(() => {
if (runtime === 'amr') return;
amrLoginPollCancelledRef.current = true;
setAmrLoginPending(false);
}, [runtime]);
// Onboarding step exposure. Design-system intake used to live here
// as step 3, but it is temporarily removed from first-run
// onboarding and remains available from the app surfaces.
@ -911,6 +969,7 @@ function OnboardingView({
const onboardingStartedAtRef = useRef<number>(Date.now());
const lifecycleReportedRef = useRef(false);
function currentRuntimeType(): TrackingOnboardingRuntimeType {
if (runtime === 'amr') return 'amr_cloud';
if (runtime === 'local') return 'local_cli';
if (runtime === 'byok') return 'byok';
return 'none';
@ -1230,6 +1289,10 @@ function OnboardingView({
setStep((current) => current - 1);
}
function handlePrimaryAction() {
if (step === 0 && amrSelectedAndSignedOut) {
void handleAmrSignInToContinue();
return;
}
if (isLastStep) {
// Emit the About-you survey snapshot FIRST, before the
// continue/complete pair. This is the bombproof carrier for the
@ -1256,6 +1319,51 @@ function OnboardingView({
setStep((current) => current + 1);
}
async function handleAmrSignInToContinue() {
if (amrLoginPending) return;
amrLoginPollCancelledRef.current = false;
setAmrLoginError(false);
setAmrLoginPending(true);
try {
const currentStatus = await fetchVelaLoginStatus();
if (currentStatus) setAmrStatus(currentStatus);
if (currentStatus?.loggedIn) {
setStep((current) => current + 1);
return;
}
const loginResult = await startVelaLogin();
if (!loginResult.ok && !loginResult.alreadyRunning) {
setAmrLoginError(true);
return;
}
if (await pollAmrLoginCompletion()) {
setStep((current) => current + 1);
}
} finally {
setAmrLoginPending(false);
}
}
async function pollAmrLoginCompletion(): Promise<boolean> {
const startedAt = Date.now();
while (!amrLoginPollCancelledRef.current) {
await new Promise((resolve) =>
window.setTimeout(resolve, AMR_LOGIN_POLL_INTERVAL_MS),
);
if (amrLoginPollCancelledRef.current) return false;
const nextStatus = await fetchVelaLoginStatus();
if (nextStatus) setAmrStatus(nextStatus);
const outcome = amrLoginPollOutcome(nextStatus, startedAt);
if (outcome === 'signed-in') return true;
if (outcome === 'stopped' || outcome === 'timed-out') {
if (outcome === 'timed-out') void cancelVelaLogin();
setAmrLoginError(true);
return false;
}
}
return false;
}
// Survey snapshot. Reads `profileRef.current` rather than `profile`
// because Finish-setup may fire within the same render commit as the
// user's last dropdown pick, before React has rebound the closure to
@ -1308,7 +1416,16 @@ function OnboardingView({
try {
const nextAgents = await onRefreshAgents();
if (cliScanTokenRef.current !== scanToken) return;
const availableAgents = nextAgents.filter((agent) => agent.available);
const availableAgents = nextAgents.filter((agent) => agent.available && agent.id !== 'amr');
// If the user previously had AMR selected (e.g. it was auto-picked once
// we detected vela) and they have now chosen the Local CLI path, the
// persisted agentId is still 'amr' and would survive Continue without
// an explicit click on a local agent card. Switch the selection to the
// first available local agent as soon as we have one, so the runtime
// and the persisted agent always agree.
if (config.agentId === 'amr' && availableAgents[0]) {
onAgentChange(availableAgents[0].id);
}
// Scan-result semantics: zero available CLIs is a `failed` outcome
// because the user's runtime path is blocked, even though the
// detect call itself returned successfully. `detected_cli_count`
@ -1433,7 +1550,11 @@ function OnboardingView({
}
}
const primaryActionLabel = isLastStep
const primaryActionLabel = step === 0 && amrSelectedAndSignedOut
? t('settings.amrSignInToContinue')
: step === 1
? t('settings.onboardingContinue')
: isLastStep
? t('settings.onboardingFinish')
: t('settings.onboardingContinue');
@ -1465,6 +1586,54 @@ function OnboardingView({
body={t('settings.onboardingConnectBody')}
/>
<div className="onboarding-view__runtime-stack">
{showAmrCloudOption ? (
<div className="onboarding-view__amr-cloud-card">
<OnboardingChoiceCard
icon="orbit"
agentIconId="amr"
title={t('settings.amrCloud')}
body={t('settings.onboardingExecutionBody')}
benefits={[
t('settings.onboardingAmrCloudBenefitOfficial'),
t('settings.onboardingAmrCloudBenefitReady'),
t('settings.onboardingAmrCloudBenefitModels'),
t('settings.onboardingAmrCloudBenefitPricing'),
]}
badge={t('settings.onboardingRecommended')}
officialLabel={t('settings.onboardingAmrCloudOfficialBadge')}
statusSlot={
runtime === 'amr' ? (
<AmrAccountControl
status={
amrLoginError
? 'error'
: amrSignedIn
? 'signed-in'
: amrLoginPending
? 'signing-in'
: 'signed-out'
}
compact
email={
amrSignedIn
? amrStatus?.user?.email || t('settings.amrSignedIn')
: ''
}
showSignInAction={false}
signInDisabled={amrLoginPending}
/>
) : null
}
featured
selected={runtime === 'amr'}
onClick={() => {
setRuntime('amr');
onModeChange('daemon');
onAgentChange('amr');
}}
/>
</div>
) : null}
<div className="onboarding-view__alternatives">
{runtimeItems.map((item) => (
<OnboardingChoiceCard
@ -1733,9 +1902,9 @@ function OnboardingView({
type="button"
className="onboarding-view__primary"
onClick={handlePrimaryAction}
disabled={amrLoginPending}
>
<span>{primaryActionLabel}</span>
<Icon name={isLastStep ? 'check' : 'chevron-right'} size={16} />
</button>
</div>
)}
@ -2265,48 +2434,98 @@ function OnboardingDropdown(props: OnboardingDropdownProps) {
function OnboardingChoiceCard({
icon,
agentIconId,
title,
body,
benefits,
actionLabel,
selected,
badge,
officialLabel,
statusSlot,
featured,
onClick,
}: {
icon: 'orbit' | 'hammer' | 'sliders' | 'github' | 'upload' | 'sparkles';
agentIconId?: string;
title: string;
body: string;
benefits?: string[];
actionLabel?: string;
selected: boolean;
badge?: string;
officialLabel?: string;
statusSlot?: ReactNode;
featured?: boolean;
onClick: () => void;
}) {
function handleKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (event.target !== event.currentTarget) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onClick();
}
return (
<button
type="button"
<div
role="button"
tabIndex={0}
className={`onboarding-view__card${selected ? ' is-selected' : ''}${
featured ? ' onboarding-view__card--featured' : ''
}`}
}${officialLabel ? ' onboarding-view__card--official' : ''}`}
onClick={onClick}
onKeyDown={handleKeyDown}
aria-pressed={selected}
>
<span className="onboarding-view__icon">
{officialLabel ? (
<span className="onboarding-view__official-tag">
<img src="/official_badge.svg" alt={officialLabel} draggable={false} />
</span>
) : null}
<span
className={
'onboarding-view__icon' +
(agentIconId ? ' onboarding-view__icon--asset' : '')
}
>
{agentIconId ? (
<AgentIcon
id={agentIconId}
size={featured ? 52 : 40}
className="onboarding-view__agent-logo"
/>
) : (
<Icon name={icon} size={18} />
)}
</span>
<span className="onboarding-view__card-copy">
<span className="onboarding-view__card-top">
<strong>{title}</strong>
{badge ? <span className="onboarding-view__badge">{badge}</span> : null}
</span>
<small>{body}</small>
{benefits && benefits.length > 0 ? (
<span className="onboarding-view__benefits">
{benefits.map((item) => (
<span key={item} className="onboarding-view__benefit">
{item}
</span>
))}
</span>
) : (
<small>{body}</small>
)}
</span>
{statusSlot ? (
<span className="onboarding-view__card-status">
{statusSlot}
</span>
) : null}
{actionLabel ? <span className="onboarding-view__card-action">{actionLabel}</span> : null}
{selected ? (
<span className="onboarding-view__check">
<Icon name="check" size={14} />
</span>
) : null}
</button>
</div>
);
}

View file

@ -8,13 +8,28 @@
// upward through the same callbacks `AvatarMenu` already uses, so the
// switcher inherits autosave + daemon sync without re-implementing it.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useT } from '../i18n';
import { KNOWN_PROVIDERS } from '../state/config';
import {
cancelVelaLogin,
fetchVelaLoginStatus,
startVelaLogin,
type VelaLoginStatus,
} from '../providers/daemon';
import type { AgentInfo, ApiProtocol, AppConfig, ExecMode } from '../types';
import { apiProtocolLabel } from '../utils/apiProtocol';
import { AgentIcon } from './AgentIcon';
import { Icon } from './Icon';
import {
AMR_LOGIN_STATUS_EVENT,
AMR_LOGIN_POLL_INTERVAL_MS,
AMR_LOGIN_STARTUP_SETTLE_MS,
amrLoginPollOutcome,
amrLoginStatusEventReason,
notifyAmrLoginStatusChanged,
} from './amrLoginPolling';
import { normalizeAgentModelChoice } from './agentModelSelection';
import { renderModelOptions } from './modelOptions';
interface Props {
@ -49,6 +64,41 @@ const API_PROTOCOL_TABS: Array<{ id: ApiProtocol; title: string }> = [
{ id: 'google', title: 'Google' },
];
const AMR_REMINDER_SEEN_KEY = 'open-design:inline-amr-cli-reminder-seen:v2';
let amrReminderSeenFallback = false;
function readAmrReminderSeen(): boolean {
if (typeof window === 'undefined') return true;
try {
return window.localStorage
? window.localStorage.getItem(AMR_REMINDER_SEEN_KEY) === '1'
: amrReminderSeenFallback;
} catch {
return amrReminderSeenFallback;
}
}
function markAmrReminderSeen(): void {
if (typeof window === 'undefined') return;
try {
if (window.localStorage) {
window.localStorage.setItem(AMR_REMINDER_SEEN_KEY, '1');
return;
}
} catch {
// Ignore storage failures; the reminder is purely advisory UI.
}
amrReminderSeenFallback = true;
}
function displayAgentName(agent: Pick<AgentInfo, 'id' | 'name'>): string {
return agent.id === 'amr' ? 'Open Design AMR' : agent.name;
}
function displayAgentChipName(agent: Pick<AgentInfo, 'id' | 'name'>): string {
return agent.id === 'amr' ? 'AMR' : displayAgentName(agent);
}
export function InlineModelSwitcher({
config,
agents,
@ -63,6 +113,117 @@ export function InlineModelSwitcher({
const t = useT();
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement | null>(null);
const [amrStatus, setAmrStatus] = useState<VelaLoginStatus | null>(null);
const [amrLoginPending, setAmrLoginPending] = useState(false);
const [amrLoginError, setAmrLoginError] = useState(false);
const [amrReminderSeen, setAmrReminderSeen] = useState(readAmrReminderSeen);
const [showAmrReminderInPopover, setShowAmrReminderInPopover] =
useState(false);
const amrPollRef = useRef<number | null>(null);
const amrLoginStartedAtRef = useRef<number | null>(null);
const stopAmrPolling = useCallback(() => {
if (amrPollRef.current !== null) {
window.clearInterval(amrPollRef.current);
amrPollRef.current = null;
}
}, []);
const refreshAmrStatus = useCallback(async () => {
const next = await fetchVelaLoginStatus();
if (next) {
setAmrStatus(next);
const pendingStartup =
amrLoginStartedAtRef.current !== null &&
Date.now() - amrLoginStartedAtRef.current < AMR_LOGIN_STARTUP_SETTLE_MS;
if (next.loggedIn) {
amrLoginStartedAtRef.current = null;
setAmrLoginPending(false);
} else if (next.loginInFlight) {
setAmrLoginPending(true);
} else if (!pendingStartup) {
amrLoginStartedAtRef.current = null;
setAmrLoginPending(false);
}
}
return next;
}, []);
const startAmrPolling = useCallback((startedAt = Date.now()) => {
stopAmrPolling();
amrLoginStartedAtRef.current = startedAt;
const tick = async () => {
const next = await refreshAmrStatus();
const outcome = amrLoginPollOutcome(next, startedAt);
if (outcome === 'signed-in') {
stopAmrPolling();
amrLoginStartedAtRef.current = null;
setAmrLoginPending(false);
return;
}
if (outcome === 'stopped' || outcome === 'timed-out') {
stopAmrPolling();
if (outcome === 'timed-out') {
void cancelVelaLogin().then(() =>
notifyAmrLoginStatusChanged('login-canceled'),
);
}
amrLoginStartedAtRef.current = null;
setAmrLoginPending(false);
setAmrLoginError(true);
}
};
amrPollRef.current = window.setInterval(() => {
void tick();
}, AMR_LOGIN_POLL_INTERVAL_MS);
}, [refreshAmrStatus, stopAmrPolling]);
const handleAmrSignIn = useCallback(async () => {
const startedAt = Date.now();
amrLoginStartedAtRef.current = startedAt;
setAmrLoginError(false);
setAmrLoginPending(true);
const result = await startVelaLogin();
if (!result.ok && !result.alreadyRunning) {
amrLoginStartedAtRef.current = null;
setAmrLoginPending(false);
setAmrLoginError(true);
return;
}
notifyAmrLoginStatusChanged('login-started');
startAmrPolling(startedAt);
}, [startAmrPolling]);
const handleAmrCancelLogin = useCallback(async () => {
stopAmrPolling();
amrLoginStartedAtRef.current = null;
setAmrLoginError(false);
setAmrLoginPending(false);
await cancelVelaLogin();
notifyAmrLoginStatusChanged('login-canceled');
await refreshAmrStatus();
}, [refreshAmrStatus, stopAmrPolling]);
const handleAgentButtonClick = useCallback(
async (agentId: string) => {
onAgentChange?.(agentId);
if (agentId !== 'amr') return;
if (amrLoginPending) {
await handleAmrCancelLogin();
return;
}
const latest = await refreshAmrStatus();
if (latest?.loggedIn) return;
await handleAmrSignIn();
},
[
amrLoginPending,
handleAmrCancelLogin,
handleAmrSignIn,
onAgentChange,
refreshAmrStatus,
],
);
useEffect(() => {
if (!open) return;
@ -81,6 +242,42 @@ export function InlineModelSwitcher({
};
}, [open]);
useEffect(() => {
if (open && agents.some((agent) => agent.id === 'amr' && agent.available)) {
void refreshAmrStatus();
}
return () => stopAmrPolling();
}, [agents, open, refreshAmrStatus, stopAmrPolling]);
useEffect(() => {
const onStatusChange = (event: Event) => {
const reason = amrLoginStatusEventReason(event);
if (reason === 'login-started') {
const startedAt = Date.now();
amrLoginStartedAtRef.current = startedAt;
setAmrLoginError(false);
setAmrLoginPending(true);
startAmrPolling(startedAt);
} else if (reason === 'login-canceled') {
amrLoginStartedAtRef.current = null;
stopAmrPolling();
setAmrLoginPending(false);
}
void refreshAmrStatus().then((next) => {
if (next?.loggedIn) {
amrLoginStartedAtRef.current = null;
stopAmrPolling();
return;
}
if (next?.loginInFlight) startAmrPolling();
});
};
window.addEventListener(AMR_LOGIN_STATUS_EVENT, onStatusChange);
return () => {
window.removeEventListener(AMR_LOGIN_STATUS_EVENT, onStatusChange);
};
}, [refreshAmrStatus, startAmrPolling, stopAmrPolling]);
const installedAgents = useMemo(
() => agents.filter((a) => a.available),
[agents],
@ -89,13 +286,66 @@ export function InlineModelSwitcher({
() => agents.find((a) => a.id === config.agentId) ?? null,
[agents, config.agentId],
);
const amrInstalled = installedAgents.some((a) => a.id === 'amr');
const shouldOfferAmrReminder =
config.mode === 'daemon' && config.agentId !== 'amr' && amrInstalled;
const showAmrReminder = shouldOfferAmrReminder && !amrReminderSeen;
const currentChoice =
(config.agentId && config.agentModels?.[config.agentId]) || {};
const normalizedCurrentChoice = normalizeAgentModelChoice(
currentAgent,
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
: null;
const currentModelId =
currentChoice.model ?? currentAgent?.models?.[0]?.id ?? null;
currentAgent?.id === 'amr' &&
configuredModelId &&
!currentAgentModelIds.includes(configuredModelId)
? currentAgent?.models?.[0]?.id ?? null
: configuredModelId ?? currentAgent?.models?.[0]?.id ?? null;
useEffect(() => {
if (!currentAgentId || !normalizedCurrentModelId) return;
onAgentModelChange(currentAgentId, {
model: normalizedCurrentModelId,
reasoning: normalizedCurrentReasoning,
});
}, [
currentAgentId,
normalizedCurrentModelId,
normalizedCurrentReasoning,
onAgentModelChange,
]);
const currentModelLabel =
currentAgent?.models?.find((m) => m.id === currentModelId)?.label ?? null;
const amrLoggedIn = amrStatus?.loggedIn === true;
const amrActionLabel = amrLoginPending
? t('settings.amrSigningIn')
: amrLoggedIn
? t('settings.amrSignedIn')
: t('settings.amrSignIn');
const amrPendingHoverLabel = t('settings.amrCancelSignIn');
const amrInlineStatus = amrLoginError
? t('settings.amrLoginErrorCompact')
: amrLoggedIn
? t('settings.amrSignedIn')
: amrLoginPending
? t('settings.amrSigningIn')
: t('settings.amrSignIn');
const amrStatusIconName = amrLoggedIn
? 'check'
: amrLoginPending
? 'spinner'
: null;
const apiProtocol = config.apiProtocol ?? 'anthropic';
const providerForProtocol = useMemo(
@ -119,7 +369,9 @@ export function InlineModelSwitcher({
: t('inlineSwitcher.chipByok');
const chipPrimary =
config.mode === 'daemon'
? currentAgent?.name ?? t('inlineSwitcher.noAgent')
? currentAgent
? displayAgentChipName(currentAgent)
: t('inlineSwitcher.noAgent')
: apiProtocolLabel(apiProtocol);
const chipModel =
config.mode === 'daemon'
@ -128,6 +380,24 @@ export function InlineModelSwitcher({
: t('inlineSwitcher.modelDefault')
: config.model.trim() || t('inlineSwitcher.modelDefault');
const handleChipClick = useCallback(() => {
const nextOpen = !open;
if (nextOpen && showAmrReminder) {
setShowAmrReminderInPopover(true);
setAmrReminderSeen(true);
markAmrReminderSeen();
} else if (!nextOpen) {
setShowAmrReminderInPopover(false);
}
setOpen(nextOpen);
}, [open, showAmrReminder]);
useEffect(() => {
if (!open || config.mode !== 'daemon' || config.agentId === 'amr') {
setShowAmrReminderInPopover(false);
}
}, [config.agentId, config.mode, open]);
return (
<div
className="inline-switcher"
@ -136,13 +406,23 @@ export function InlineModelSwitcher({
>
<button
type="button"
className="inline-switcher__chip"
className={
'inline-switcher__chip' +
(showAmrReminder ? ' has-amr-reminder' : '')
}
data-testid="inline-model-switcher-chip"
onClick={() => setOpen((v) => !v)}
onClick={handleChipClick}
aria-haspopup="menu"
aria-expanded={open}
title={t('inlineSwitcher.chipTitle')}
>
{showAmrReminder ? (
<span
className="inline-switcher__amr-reminder-dot inline-switcher__amr-reminder-dot--chip"
data-testid="inline-model-switcher-amr-reminder"
aria-hidden="true"
/>
) : null}
<span className="inline-switcher__chip-icon" aria-hidden="true">
{config.mode === 'daemon' && currentAgent ? (
<AgentIcon id={currentAgent.id} size={18} />
@ -244,25 +524,87 @@ export function InlineModelSwitcher({
>
{installedAgents.map((a) => {
const active = config.agentId === a.id;
const agentName = displayAgentChipName(a);
const showAgentReminder =
a.id === 'amr' &&
showAmrReminderInPopover &&
config.agentId !== 'amr';
return (
<button
<div
key={a.id}
className="inline-switcher__agent-row"
>
<button
type="button"
role="radio"
aria-checked={active}
aria-label={
a.id === 'amr'
? `${agentName} ${amrInlineStatus}`
: agentName
}
className={
'inline-switcher__agent' +
(active ? ' is-active' : '')
(active ? ' is-active' : '') +
(showAgentReminder ? ' has-amr-reminder' : '')
}
data-testid={`inline-model-switcher-agent-${a.id}`}
onClick={() => onAgentChange?.(a.id)}
title={a.version ? `${a.name} · ${a.version}` : a.name}
onClick={() => void handleAgentButtonClick(a.id)}
title={
a.id === 'amr' && amrLoginPending
? amrPendingHoverLabel
: a.id !== 'amr' && a.version
? `${agentName} · ${a.version}`
: agentName
}
>
<AgentIcon id={a.id} size={20} />
{showAgentReminder ? (
<span
className="inline-switcher__amr-reminder-dot inline-switcher__amr-reminder-dot--agent"
data-testid="inline-model-switcher-agent-amr-reminder"
aria-hidden="true"
/>
) : null}
<span className="inline-switcher__agent-name">
{a.name}
{agentName}
</span>
{a.id === 'amr' ? (
<span className="inline-switcher__agent-status">
{amrStatusIconName ? (
<span
className={
'inline-switcher__agent-status-icon' +
(amrLoginPending ? ' is-pending' : '') +
(amrLoggedIn ? ' is-signed-in' : '') +
(!amrLoginPending && !amrLoggedIn ? ' is-signed-out' : '')
}
>
<Icon name={amrStatusIconName} size={13} />
</span>
) : null}
<span
className={
'inline-switcher__agent-action-label' +
(amrLoginPending ? ' is-cancelable' : '')
}
>
<span className="inline-switcher__agent-action-default">
{amrActionLabel}
</span>
{amrLoginPending ? (
<span
className="inline-switcher__agent-action-hover"
aria-hidden="true"
>
{amrPendingHoverLabel}
</span>
) : null}
</span>
</span>
) : null}
</button>
</div>
);
})}
</div>
@ -287,7 +629,8 @@ export function InlineModelSwitcher({
}
>
{renderModelOptions(currentAgent.models)}
{currentModelId &&
{currentAgent.id !== 'amr' &&
currentModelId &&
!currentAgent.models.some((m) => m.id === currentModelId) ? (
<option value={currentModelId}>
{currentModelId} {t('inlineSwitcher.customSuffix')}

View file

@ -17,6 +17,7 @@ import { useI18n } from '../i18n';
import { streamMessage } from '../providers/anthropic';
import {
fetchChatRunStatus,
fetchVelaLoginStatus,
listActiveChatRuns,
listProjectRuns,
reattachDaemonRun,
@ -164,11 +165,14 @@ import { useTerminalLaunch } from '../hooks/useTerminalLaunch';
import { buildContinueInCliToast } from '../lib/build-continue-in-cli-toast';
import { buildClipboardPrompt } from '../lib/build-clipboard-prompt';
import { copyToClipboard } from '../lib/copy-to-clipboard';
import { effectiveMaxTokens } from '../state/maxTokens';
import { effectiveAgentModelChoice } from './agentModelSelection';
import {
buildFinalizeCredentialsMissingToast,
buildFinalizeRequest,
} from '../lib/resolve-finalize-request';
type ProjectChatSendMeta = ChatSendMeta & {
retryOfAssistantId?: string;
};
@ -211,6 +215,7 @@ interface Props {
) => void;
onRefreshAgents: () => void;
onOpenSettings: (section?: SettingsSection) => void;
onOpenAmrSettings?: () => void;
onOpenMcpSettings?: () => void;
// Pet wiring forwarded to the chat composer so users can adopt /
// wake / tuck a pet without leaving the project view.
@ -493,6 +498,7 @@ export function ProjectView({
onAgentModelChange,
onRefreshAgents,
onOpenSettings,
onOpenAmrSettings,
onOpenMcpSettings,
onAdoptPetInline,
onTogglePet,
@ -1671,12 +1677,15 @@ export function ProjectView({
[updateMessageById, activeConversationId, project.id],
);
// `code` is the structured API error code (e.g. AGENT_AUTH_REQUIRED); it
// rides along on the error status event so AssistantMessage can render the
// hosted-AMR nudge for model/auth/quota failures on non-AMR agents.
const appendAssistantErrorEvent = useCallback(
(messageId: string, message: string) => {
(messageId: string, message: string, code?: string) => {
if (!message) return;
updateMessageById(
messageId,
(prev) => appendErrorStatusEvent(prev, message),
(prev) => appendErrorStatusEvent(prev, message, code),
true,
);
},
@ -2072,14 +2081,19 @@ export function ProjectView({
onProjectsRefresh();
},
onError: (err) => {
const errorCode = (err as Error & { code?: string }).code;
textBuffer.flush();
textBuffer.cancel();
unregisterTextBuffer();
setError(err.message);
appendAssistantErrorEvent(message.id, err.message);
appendAssistantErrorEvent(message.id, err.message, errorCode);
updateMessageById(
message.id,
(prev) => ({ ...prev, runStatus: 'failed', endedAt: prev.endedAt ?? Date.now() }),
(prev) => ({
...prev,
runStatus: 'failed',
endedAt: prev.endedAt ?? Date.now(),
}),
true,
);
completedReattachRunsRef.current.add(runId);
@ -2255,6 +2269,10 @@ export function ProjectView({
config.mode === 'daemon' && config.agentId
? config.agentModels?.[config.agentId]
: undefined;
const effectiveSelectedAgentChoice = effectiveAgentModelChoice(
selectedAgent,
selectedAgentChoice,
);
const assistantAgentId =
config.mode === 'daemon'
? config.agentId ?? undefined
@ -2264,7 +2282,7 @@ export function ProjectView({
? agentModelDisplayName(
config.agentId,
selectedAgent?.name,
selectedAgentChoice?.model,
effectiveSelectedAgentChoice?.model,
)
: apiProtocolModelLabel(config.apiProtocol, config.model);
const preTurnFileNames = projectFiles.map((f) => f.name);
@ -2624,11 +2642,12 @@ export function ProjectView({
},
onError: (err: Error) => {
const endedAt = Date.now();
const errorCode = (err as Error & { code?: string }).code;
textBuffer.flush();
textBuffer.cancel();
cancelSendTextBuffer();
setError(err.message);
appendAssistantErrorEvent(assistantId, err.message);
appendAssistantErrorEvent(assistantId, err.message, errorCode);
updateAssistant((prev) => ({
...prev,
endedAt,
@ -2656,7 +2675,7 @@ export function ProjectView({
handlers.onError(new Error('Pick a local agent first (top bar).'));
return;
}
const choice = selectedAgentChoice;
const choice = effectiveSelectedAgentChoice;
// v2 analytics: when the active project is a DS workspace
// (created by `prepareCreatedDesignSystemProject`, identifiable
// by `metadata.importedFrom === 'design-system'`), every run
@ -2915,6 +2934,50 @@ export function ProjectView({
[currentConversationActionDisabled, handleSend],
);
// "Switch to AMR & retry" from the failed-run card: switch the run to AMR,
// open Settings on the AMR controls so the user can sign in / authorize /
// top up, and arm an auto-retry that fires once AMR is selected AND signed
// in (see the effect below).
const [pendingAmrRetry, setPendingAmrRetry] = useState<ChatMessage | null>(null);
const handleSwitchToAmrAndRetry = useCallback(
(failedAssistant: ChatMessage) => {
if (currentConversationActionDisabled) return;
onModeChange('daemon');
onAgentChange('amr');
onOpenAmrSettings?.();
setPendingAmrRetry(failedAssistant);
},
[currentConversationActionDisabled, onModeChange, onAgentChange, onOpenAmrSettings],
);
// Poll the AMR login status while a retry is armed, rather than only reacting
// to the AmrLoginPill's status event — the user may close Settings (which
// unmounts the pill and stops its polling) before finishing sign-in in the
// browser. Polling here keeps working regardless of the pill's lifecycle.
// Fires once AMR is the selected agent AND the account is signed in.
useEffect(() => {
if (!pendingAmrRetry) return;
let cancelled = false;
const tryRetry = async () => {
if (cancelled) return;
if (!(config.mode === 'daemon' && config.agentId === 'amr')) return;
const status = await fetchVelaLoginStatus().catch(() => null);
if (cancelled || status?.loggedIn !== true) return;
setPendingAmrRetry(null);
handleRetry(pendingAmrRetry);
};
void tryRetry();
const interval = setInterval(() => void tryRetry(), 2000);
// Give up after a few minutes so we never poll forever.
const stop = setTimeout(() => {
if (!cancelled) setPendingAmrRetry(null);
}, 5 * 60 * 1000);
return () => {
cancelled = true;
clearInterval(interval);
clearTimeout(stop);
};
}, [pendingAmrRetry, config.mode, config.agentId, handleRetry]);
useEffect(() => {
if (!autoAuditRepairSeed) return;
if (!activeConversationId) return;
@ -2971,12 +3034,16 @@ export function ProjectView({
config.mode === 'daemon' && config.agentId
? config.agentModels?.[config.agentId]
: undefined;
const effectiveSelectedPluginActionChoice = effectiveAgentModelChoice(
selectedPluginActionAgent,
selectedPluginActionChoice,
);
const pluginWorkflowAgentName =
config.mode === 'daemon'
? agentModelDisplayName(
config.agentId,
selectedPluginActionAgent?.name,
selectedPluginActionChoice?.model,
effectiveSelectedPluginActionChoice?.model,
)
: apiProtocolModelLabel(config.apiProtocol, config.model);
@ -4358,6 +4425,8 @@ export function ProjectView({
onDeleteConversation={handleDeleteConversation}
onRenameConversation={handleRenameConversation}
onOpenSettings={onOpenSettings}
onOpenAmrSettings={onOpenAmrSettings}
onSwitchToAmrAndRetry={handleSwitchToAmrAndRetry}
onOpenMcpSettings={onOpenMcpSettings}
connectRepoNeeded={connectRepoNeeded}
githubConnected={githubConnected}

View file

@ -27,6 +27,11 @@ import { LOCALE_LABEL, LOCALES, useI18n } from '../i18n';
import type { Locale } from '../i18n';
import type { Dict } from '../i18n/types';
import { AgentIcon } from './AgentIcon';
import { AmrLoginPill } from './AmrLoginPill';
import {
fetchVelaLoginStatus,
type VelaLoginStatus,
} from '../providers/daemon';
import { ExportDiagnosticsRow } from './ExportDiagnosticsButton';
import { Icon } from './Icon';
import {
@ -140,13 +145,20 @@ export type SettingsSection =
| 'library'
| 'about';
// One-shot focus hint when opening the dialog. `'amr'` scrolls the AMR agent
// card into view on the execution section and plays a highlight (plus a
// sign-in coachmark when the user has not authorized AMR yet).
export type SettingsHighlight = 'amr' | null;
interface Props {
initial: AppConfig;
agents: AgentInfo[];
agentsLoading?: boolean;
daemonLive: boolean;
appVersionInfo: AppVersionInfo | null;
welcome?: boolean;
initialSection?: SettingsSection;
initialHighlight?: SettingsHighlight;
/**
* Persist the current draft. Invoked by the dialog's autosave loop on
* every committed edit. Returns a promise that resolves once both
@ -428,6 +440,10 @@ function cleanAgentVersionLabel(
.trim();
}
function displayAgentName(agent: Pick<AgentInfo, 'id' | 'name'>): string {
return agent.id === 'amr' ? 'Open Design AMR' : agent.name;
}
export function mergeProviderModelOptions(
fetchedModels: readonly ProviderModelOption[],
suggestedModelIds: readonly string[],
@ -790,10 +806,12 @@ export function switchApiProtocolConfig(
export function SettingsDialog({
initial,
agents,
agentsLoading = false,
daemonLive,
appVersionInfo,
welcome,
initialSection = 'execution',
initialHighlight = null,
onPersist,
onPersistComposioKey,
composioConfigLoading = false,
@ -845,15 +863,50 @@ export function SettingsDialog({
// (About) keeps the previous scrollTop, so the new section's header
// can land out of view and the panel reads as half-loaded. Issue #634.
const settingsContentRef = useRef<HTMLDivElement | null>(null);
// AMR-card focus, driven by the failed-run nudge (`initialHighlight==='amr'`).
const amrCardRef = useRef<HTMLDivElement | null>(null);
// Card pulse: a brief attention flash that auto-clears after a few seconds.
const [amrHighlightActive, setAmrHighlightActive] = useState(false);
// Coachmark: persists (unlike the card pulse) until the real pointer reaches
// the authorize button — so it won't vanish while the user is still moving
// toward it.
const [amrCoachmarkArmed, setAmrCoachmarkArmed] = useState(false);
// The fake-cursor coachmark dismisses as soon as the real pointer reaches the
// authorize button — once the user has found it, the hint has done its job.
const [amrCoachmarkDismissed, setAmrCoachmarkDismissed] = useState(false);
const [agentRescanRunning, setAgentRescanRunning] = useState(false);
const [agentRescanNotice, setAgentRescanNotice] =
useState<RescanNotice | null>(null);
const [agentTestState, setAgentTestState] = useState<TestState>({
status: 'idle',
});
const [amrCardStatus, setAmrCardStatus] = useState<VelaLoginStatus | null>(null);
const [amrCardStatusReady, setAmrCardStatusReady] = useState(false);
const [hoveredAgentCardId, setHoveredAgentCardId] = useState<string | null>(null);
const [providerTestState, setProviderTestState] = useState<TestState>({
status: 'idle',
});
useEffect(() => {
const hasAmrAgent = agents.some((agent) => agent.id === 'amr' && agent.available);
if (!hasAmrAgent) {
setAmrCardStatus(null);
setAmrCardStatusReady(false);
setHoveredAgentCardId(null);
return;
}
let cancelled = false;
setAmrCardStatusReady(false);
void fetchVelaLoginStatus().then((next) => {
if (!cancelled) {
setAmrCardStatus(next);
setAmrCardStatusReady(true);
}
});
return () => {
cancelled = true;
};
}, [agents]);
const [byokPreconditionNotice, setByokPreconditionNotice] = useState<{
action: ByokPreconditionAction;
message: string;
@ -948,13 +1001,34 @@ export function SettingsDialog({
if (el) el.scrollTop = 0;
}, [activeSection]);
// Tests pin a result against the unsaved draft. Once the user edits any
// field that feeds into the test, the result is no longer trustworthy —
// clear it so we don't show a stale "Connected" line next to fresh input.
// If a test is already running, leave the running state visible and let the
// stale result be ignored when it returns; the button stays disabled so a
// new smoke test cannot overlap the old one.
const agentChoiceForTest = cfg.agentModels?.[cfg.agentId ?? ''];
// One-shot AMR-card focus from the failed-run nudge: scroll the card into
// view (on the next frame, so it wins over the section's scrollTop reset
// above) and play a brief highlight + arm the sign-in coachmark. The
// coachmark only actually shows when the AMR card reports a signed-out state
// (`amrCardStatus?.loggedIn === false`). If the execution pane is in API mode
// the AMR card is absent and this no-ops.
useEffect(() => {
if (initialHighlight !== 'amr' || activeSection !== 'execution') return;
let cancelled = false;
const raf = requestAnimationFrame(() => {
if (cancelled) return;
amrCardRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
setAmrCoachmarkDismissed(false);
setAmrHighlightActive(true);
setAmrCoachmarkArmed(true);
});
// Only the card pulse auto-clears; the coachmark persists until the pointer
// reaches the authorize button (or the user signs in).
const clear = setTimeout(() => {
if (!cancelled) setAmrHighlightActive(false);
}, 3200);
return () => {
cancelled = true;
cancelAnimationFrame(raf);
clearTimeout(clear);
};
}, [initialHighlight, activeSection]);
const selectedMemoryChatAgent =
cfg.mode === 'daemon' && cfg.agentId
? agents.find((agent) => agent.id === cfg.agentId) ?? null
@ -965,6 +1039,10 @@ export function SettingsDialog({
?? selectedMemoryChatAgent?.models?.[0]?.id
?? null
: null;
const agentChoiceForTest =
cfg.mode === 'daemon' && cfg.agentId
? cfg.agentModels?.[cfg.agentId]
: null;
useEffect(() => {
agentTestRevisionRef.current += 1;
setAgentTestState((state) =>
@ -1946,6 +2024,187 @@ export function SettingsDialog({
const activeHeader = sectionHeader[activeSection];
const installedAgents = agents.filter((a) => a.available);
const unavailableAgents = agents.filter((a) => !a.available);
const initialAgentScanRunning = agentsLoading && agents.length === 0;
const agentModelOptionLabel = (
model: ProviderModelOption | undefined,
fallback: string,
) => {
if (!model) return fallback;
const label = model.label?.trim();
const id = model.id.trim();
if (label && label !== id) {
return label.toLowerCase().includes(id.toLowerCase())
? label
: `${label} (${id})`;
}
return label || id;
};
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 ?? '';
if (!modelValue) return t('settings.modelCustom');
return agentModelOptionLabel(
agent.models.find((m) => m.id === modelValue),
modelValue,
);
};
const renderAgentModelConfig = (selected: AgentInfo) => {
const hasModels =
Array.isArray(selected.models) && selected.models.length > 0;
const hasReasoning =
Array.isArray(selected.reasoningOptions) &&
selected.reasoningOptions.length > 0;
if (!hasModels && !hasReasoning) return null;
const choice = cfg.agentModels?.[selected.id] ?? {};
const knownModelIds = selected.models?.map((m) => m.id) ?? [];
const allowCustomModel = selected.id !== 'amr';
const configuredModel =
typeof choice.model === 'string' && choice.model
? choice.model
: null;
const setChoice = (
next: { model?: string; reasoning?: string },
) => {
setCfg((c) => {
const prev = c.agentModels?.[selected.id] ?? {};
return {
...c,
agentModels: {
...(c.agentModels ?? {}),
[selected.id]: { ...prev, ...next },
},
};
});
};
const modelValue =
selected.id === 'amr' &&
configuredModel &&
!knownModelIds.includes(configuredModel)
? selected.models?.[0]?.id ?? ''
: configuredModel ?? selected.models?.[0]?.id ?? '';
const reasoningValue =
choice.reasoning ??
selected.reasoningOptions?.[0]?.id ?? '';
const customActive =
allowCustomModel &&
hasModels &&
shouldShowCustomModelInput(
modelValue,
knownModelIds,
agentCustomModelIds.has(selected.id),
);
const selectValue = customActive
? CUSTOM_MODEL_SENTINEL
: modelValue;
const modelSource = selected.modelsSource ?? 'fallback';
const modelSourceLabel =
modelSource === 'live'
? t('settings.modelSourceLive')
: t('settings.modelSourceFallback');
const modelSourceHint =
modelSource === 'live'
? t('settings.modelPickerLiveHint')
: t('settings.modelPickerFallbackHint');
return (
<div className="agent-card-config">
{hasModels ? (
<>
<label className="field">
<span className="field-label">
{t('settings.modelPicker')}
<span
className={`agent-model-source-badge ${modelSource}`}
aria-hidden="true"
>
{modelSourceLabel}
</span>
</span>
<div className="agent-model-select-wrap">
<select
value={selectValue}
onChange={(e) => {
if (e.target.value === CUSTOM_MODEL_SENTINEL) {
setAgentCustomModelIds((prev) => {
const next = new Set(prev);
next.add(selected.id);
return next;
});
setChoice({ model: '' });
} else {
setAgentCustomModelIds((prev) => {
if (!prev.has(selected.id)) return prev;
const next = new Set(prev);
next.delete(selected.id);
return next;
});
setChoice({ model: e.target.value });
}
}}
>
{renderModelOptions(selected.models!)}
{allowCustomModel ? (
<option value={CUSTOM_MODEL_SENTINEL}>
{t('settings.modelCustom')}
</option>
) : null}
</select>
<Icon
name="chevron-down"
size={12}
className="agent-model-select-chevron"
/>
</div>
</label>
<p className="hint agent-model-row-hint">
{modelSourceHint}
</p>
</>
) : null}
{customActive ? (
<label className="field">
<span className="field-label">
{t('settings.modelCustomLabel')}
</span>
<input
type="text"
value={modelValue}
placeholder={t('settings.modelCustomPlaceholder')}
onChange={(e) =>
setChoice({ model: e.target.value.trim() })
}
/>
</label>
) : null}
{hasReasoning ? (
<label className="field">
<span className="field-label">
{t('settings.reasoningPicker')}
</span>
<div className="agent-model-select-wrap">
<select
value={reasoningValue}
onChange={(e) =>
setChoice({ reasoning: e.target.value })
}
>
{selected.reasoningOptions!.map((r) => (
<option key={r.id} value={r.id}>
{r.label}
</option>
))}
</select>
<Icon
name="chevron-down"
size={12}
className="agent-model-select-chevron"
/>
</div>
</label>
) : null}
</div>
);
};
return (
<div className="modal-backdrop" onClick={onClose}>
@ -2287,7 +2546,23 @@ export function SettingsDialog({
<p className="hint">{t('settings.codeAgentHint')}</p>
</div>
</div>
{agents.length === 0 ? (
{initialAgentScanRunning ? (
<div className="agent-scan-card" role="status" aria-live="polite">
<div className="agent-scan-card__stage">
<span className="agent-scan-card__ring" aria-hidden />
<strong>{t('settings.rescanRunning')}</strong>
<span>{t('settings.codeAgentHint')}</span>
<div className="agent-scan-card__progress" aria-hidden>
<span />
</div>
</div>
<div className="agent-scan-card__rows" aria-hidden>
<span><i /><b /><em /></span>
<span><i /><b /><em /></span>
<span><i /><b /><em /></span>
</div>
</div>
) : agents.length === 0 ? (
<div className="empty-card">
{t('settings.noAgentsDetected')}
</div>
@ -2347,23 +2622,68 @@ export function SettingsDialog({
</div>
{installedAgents.length > 0 ? (
<div className="agent-grid agent-grid-installed">
{installedAgents.flatMap((a) => {
{installedAgents.map((a) => {
const active = cfg.agentId === a.id;
const running =
active && agentTestState.status === 'running';
const isAmrAgent = a.id === 'amr';
const description = AGENT_SHORT_DESCRIPTIONS[a.id];
const versionLabel = cleanAgentVersionLabel(
a.name,
a.version,
);
const agentName = displayAgentName(a);
const modelSummary = agentModelSummary(a);
const amrBenefits = [
t('settings.amrBenefitOfficial'),
t('settings.amrBenefitLowerPrice'),
t('settings.amrBenefitManyModels'),
];
const versionLabel =
isAmrAgent
? ''
: cleanAgentVersionLabel(a.name, a.version);
const metaLabel =
a.authStatus === 'missing'
? t('settings.agentAuthRequired')
: a.authStatus === 'unknown'
? t('settings.agentAuthUnknown')
: versionLabel
? versionLabel
: a.id === 'amr'
? ''
: t('common.installed');
const metaTitle =
a.authStatus === 'missing' ||
a.authStatus === 'unknown'
? (a.authMessage ?? a.path ?? '')
: (a.path ?? '');
const amrHighlighted = isAmrAgent && amrHighlightActive;
const amrCardEmail =
isAmrAgent && active && amrCardStatus?.loggedIn
? amrCardStatus.user?.email || t('settings.amrSignedIn')
: '';
const amrRevealPendingCancelAction =
isAmrAgent &&
active &&
hoveredAgentCardId === a.id &&
amrCardStatus?.loggedIn !== true &&
amrCardStatus?.loginInFlight === true;
const cardEl = (
<div
key={a.id}
ref={isAmrAgent ? amrCardRef : undefined}
className={
'agent-card agent-card-installed' +
(active ? ' active' : '')
(active ? ' active' : '') +
(amrHighlighted ? ' agent-card--amr-highlight' : '')
}
onMouseEnter={() => {
if (!isAmrAgent || !active) return;
setHoveredAgentCardId(a.id);
}}
onMouseLeave={() => {
if (hoveredAgentCardId !== a.id) return;
setHoveredAgentCardId(null);
}}
>
<div className="agent-card-main">
<button
type="button"
className="agent-card-select"
@ -2381,9 +2701,32 @@ export function SettingsDialog({
>
<AgentIcon id={a.id} size={32} />
<div className="agent-card-body">
<div className="agent-card-name">
<span>{a.name}</span>
{description ? (
<div
className={
'agent-card-name' +
(isAmrAgent
? ' agent-card-name--amr'
: '')
}
>
<span className="agent-card-title">
{agentName}
</span>
{isAmrAgent ? (
<span
className="agent-card-benefits"
aria-hidden="true"
>
{amrBenefits.map((benefit) => (
<span
key={benefit}
className="agent-card-benefit"
>
{benefit}
</span>
))}
</span>
) : description ? (
<>
<span
className="agent-card-name-divider"
@ -2397,28 +2740,75 @@ export function SettingsDialog({
</>
) : null}
</div>
{metaLabel ? (
<div className="agent-card-meta">
{a.authStatus === 'missing' ? (
<span title={a.authMessage ?? a.path ?? ''}>
{t('settings.agentAuthRequired')}
<span title={metaTitle}>
{metaLabel}
</span>
) : a.authStatus === 'unknown' ? (
<span title={a.authMessage ?? a.path ?? ''}>
{t('settings.agentAuthUnknown')}
</span>
) : versionLabel ? (
<span title={a.path ?? ''}>
{versionLabel}
</span>
) : (
<span title={a.path ?? ''}>
{t('common.installed')}
</span>
)}
</div>
) : null}
{amrCardEmail ? (
<div className="agent-card-amr-email">
<span title={amrCardEmail}>
{amrCardEmail}
</span>
</div>
) : null}
{!active && modelSummary ? (
<div className="agent-card-model-summary">
<span>{t('settings.modelPicker')}</span>
<strong>{modelSummary}</strong>
</div>
) : null}
</div>
</button>
{active ? (
{isAmrAgent ? (
active && amrCardStatusReady ? (
<span
className="amr-auth-anchor"
onMouseEnter={() => setAmrCoachmarkDismissed(true)}
>
{amrCoachmarkArmed &&
amrCardStatus?.loggedIn === false &&
!amrCoachmarkDismissed ? (
<span className="amr-coachmark" aria-hidden="true">
<span className="amr-coachmark__ring" />
<svg
className="amr-coachmark__cursor"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M9.4 13V8a1.8 1.8 0 0 1 3.6 0v4.6c.35-.55 1-.95 1.75-.95.65 0 1.25.32 1.6.85.32-.5.9-.8 1.55-.8.8 0 1.5.5 1.78 1.2.35-.3.8-.5 1.3-.5 1.1 0 2 .9 2 2v3.05a5.6 5.6 0 0 1-5.6 5.6h-2.5a5 5 0 0 1-3.75-1.7l-4.2-4.75a1.85 1.85 0 0 1 2.65-2.6L9.4 16Z"
fill="#fff"
stroke="#1a1a1a"
strokeWidth="1.1"
strokeLinejoin="round"
/>
</svg>
</span>
) : null}
<AmrLoginPill
className="agent-card-amr-auth"
hideSignedOutStatus
hideSignedInStatus
initialStatus={amrCardStatus}
skipInitialRefresh
signInLabel={t('settings.amrAuthorize')}
revealPendingCancelAction={amrRevealPendingCancelAction}
onStatusChange={setAmrCardStatus}
/>
</span>
) : (
<div
className="agent-card-amr-auth agent-card-amr-auth--placeholder"
aria-hidden="true"
/>
)
) : null}
{active && !isAmrAgent ? (
<button
type="button"
className={
@ -2444,6 +2834,8 @@ export function SettingsDialog({
</button>
) : null}
</div>
{active ? renderAgentModelConfig(a) : null}
</div>
);
if (active && agentTestState.status !== 'idle') {
const resultRow = (
@ -2542,155 +2934,6 @@ export function SettingsDialog({
</div>
)}
</div>
{(() => {
const selected = agents.find(
(a) => a.id === cfg.agentId && a.available,
);
if (!selected) return null;
const hasModels =
Array.isArray(selected.models) && selected.models.length > 0;
const hasReasoning =
Array.isArray(selected.reasoningOptions) &&
selected.reasoningOptions.length > 0;
if (!hasModels && !hasReasoning) return null;
const choice = cfg.agentModels?.[selected.id] ?? {};
const setChoice = (
next: { model?: string; reasoning?: string },
) => {
setCfg((c) => {
const prev = c.agentModels?.[selected.id] ?? {};
return {
...c,
agentModels: {
...(c.agentModels ?? {}),
[selected.id]: { ...prev, ...next },
},
};
});
};
const modelValue =
choice.model ?? selected.models?.[0]?.id ?? '';
const reasoningValue =
choice.reasoning ??
selected.reasoningOptions?.[0]?.id ?? '';
const customActive =
hasModels &&
shouldShowCustomModelInput(
modelValue,
selected.models!.map((m) => m.id),
agentCustomModelIds.has(selected.id),
);
const selectValue = customActive
? CUSTOM_MODEL_SENTINEL
: modelValue;
const modelSource = selected.modelsSource ?? 'fallback';
const modelSourceLabel =
modelSource === 'live'
? t('settings.modelSourceLive')
: t('settings.modelSourceFallback');
const modelSourceHint =
modelSource === 'live'
? t('settings.modelPickerLiveHint')
: t('settings.modelPickerFallbackHint');
return (
<div className="agent-model-row">
<div className="agent-model-row-head">
{t('settings.agentModelHead')} <strong>{selected.name}</strong>
</div>
{hasModels ? (
<>
<label className="field">
<span className="field-label">
{t('settings.modelPicker')}
<span
className={`agent-model-source-badge ${modelSource}`}
>
{modelSourceLabel}
</span>
</span>
<div className="agent-model-select-wrap">
<select
value={selectValue}
onChange={(e) => {
if (e.target.value === CUSTOM_MODEL_SENTINEL) {
setAgentCustomModelIds((prev) => {
const next = new Set(prev);
next.add(selected.id);
return next;
});
setChoice({ model: '' });
} else {
setAgentCustomModelIds((prev) => {
if (!prev.has(selected.id)) return prev;
const next = new Set(prev);
next.delete(selected.id);
return next;
});
setChoice({ model: e.target.value });
}
}}
>
{renderModelOptions(selected.models!)}
<option value={CUSTOM_MODEL_SENTINEL}>
{t('settings.modelCustom')}
</option>
</select>
<Icon
name="chevron-down"
size={12}
className="agent-model-select-chevron"
/>
</div>
</label>
<p className="hint agent-model-row-hint">
{modelSourceHint}
</p>
</>
) : null}
{customActive ? (
<label className="field">
<span className="field-label">
{t('settings.modelCustomLabel')}
</span>
<input
type="text"
value={modelValue}
placeholder={t('settings.modelCustomPlaceholder')}
onChange={(e) =>
setChoice({ model: e.target.value.trim() })
}
/>
</label>
) : null}
{hasReasoning ? (
<label className="field">
<span className="field-label">
{t('settings.reasoningPicker')}
</span>
<div className="agent-model-select-wrap">
<select
value={reasoningValue}
onChange={(e) =>
setChoice({ reasoning: e.target.value })
}
>
{selected.reasoningOptions!.map((r) => (
<option key={r.id} value={r.id}>
{r.label}
</option>
))}
</select>
<Icon
name="chevron-down"
size={12}
className="agent-model-select-chevron"
/>
</div>
</label>
) : null}
</div>
);
})()}
{unavailableAgents.length > 0 ? (
<details
className="agent-install-collapse"
@ -2709,7 +2952,8 @@ export function SettingsDialog({
const docsUrl = sanitizeHttpsUrl(a.docsUrl);
const hasLinks = Boolean(installUrl || docsUrl);
const description = AGENT_SHORT_DESCRIPTIONS[a.id];
const cardLabel = `${a.name} · ${t('common.notInstalled')}`;
const agentName = displayAgentName(a);
const cardLabel = `${agentName} · ${t('common.notInstalled')}`;
return (
<div
key={a.id}
@ -2719,7 +2963,7 @@ export function SettingsDialog({
>
<AgentIcon id={a.id} size={40} />
<div className="agent-card-body">
<div className="agent-card-name">{a.name}</div>
<div className="agent-card-name">{agentName}</div>
{description ? (
<div className="agent-card-description">
{description}
@ -2795,8 +3039,17 @@ export function SettingsDialog({
const hasModels =
Array.isArray(selected.models) && selected.models.length > 0;
const choice = cfg.agentModels?.[selected.id] ?? {};
const knownModelIds = selected.models?.map((m) => m.id) ?? [];
const configuredModel =
typeof choice.model === 'string' && choice.model
? choice.model
: null;
const modelValue =
choice.model ?? selected.models?.[0]?.id ?? '';
selected.id === 'amr' &&
configuredModel &&
!knownModelIds.includes(configuredModel)
? selected.models?.[0]?.id ?? ''
: configuredModel ?? selected.models?.[0]?.id ?? '';
return (
<details className="agent-cli-env settings-memory-advanced">
<summary className="agent-cli-env-summary">

View file

@ -0,0 +1,29 @@
import type { AgentInfo, AgentModelChoice } from '../types';
type AgentModelSource = Pick<AgentInfo, 'id' | 'models'> | null | undefined;
export function normalizeAgentModelChoice(
agent: AgentModelSource,
choice: AgentModelChoice | undefined,
): AgentModelChoice | null {
const configuredModel =
typeof choice?.model === 'string' && choice.model ? choice.model : null;
if (agent?.id !== 'amr' || !configuredModel) return null;
const modelIds = agent.models?.map((model) => model.id) ?? [];
if (modelIds.length === 0 || modelIds.includes(configuredModel)) {
return null;
}
return {
...choice,
model: modelIds[0],
};
}
export function effectiveAgentModelChoice(
agent: AgentModelSource,
choice: AgentModelChoice | undefined,
): AgentModelChoice | undefined {
return normalizeAgentModelChoice(agent, choice) ?? choice;
}

View file

@ -0,0 +1,52 @@
import type { VelaLoginStatus } from '../providers/daemon';
export const AMR_LOGIN_POLL_INTERVAL_MS = 2000;
export const AMR_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
export const AMR_LOGIN_STARTUP_SETTLE_MS = 3000;
export const AMR_LOGIN_STATUS_EVENT = 'od:amr-login-status-change';
export type AmrLoginPollOutcome = 'pending' | 'signed-in' | 'stopped' | 'timed-out';
export type AmrLoginStatusEventReason =
| 'login-started'
| 'login-canceled'
| 'status-changed';
export function amrLoginPollOutcome(
status: VelaLoginStatus | null,
startedAt: number,
now: number = Date.now(),
): AmrLoginPollOutcome {
if (status?.loggedIn) return 'signed-in';
if (
status?.loginInFlight === false &&
now - startedAt >= AMR_LOGIN_STARTUP_SETTLE_MS
) {
return 'stopped';
}
if (now - startedAt >= AMR_LOGIN_TIMEOUT_MS) return 'timed-out';
return 'pending';
}
export function notifyAmrLoginStatusChanged(
reason: AmrLoginStatusEventReason = 'status-changed',
) {
window.dispatchEvent(
new CustomEvent(AMR_LOGIN_STATUS_EVENT, { detail: { reason } }),
);
}
export function amrLoginStatusEventReason(
event: Event,
): AmrLoginStatusEventReason {
if (event instanceof CustomEvent) {
const reason = (event.detail as { reason?: unknown } | null)?.reason;
if (
reason === 'login-started' ||
reason === 'login-canceled' ||
reason === 'status-changed'
) {
return reason;
}
}
return 'status-changed';
}

View file

@ -3,6 +3,16 @@ import type { Dict } from '../types';
export const ar: Dict = {
...en,
'chat.amrCard.switchTitle': 'فشل استدعاء النموذج — تم إيقاف هذه المهمة مؤقتًا',
'chat.amrCard.switchBody': 'بدِّل إلى خدمة نماذج AMR الرسمية من Open Design — لا حاجة لإعداد مفتاح API. بعد تسجيل الدخول والتفويض والشحن، ستُعاد محاولة هذه المهمة تلقائيًا.',
'chat.amrCard.chipOfficial': 'استضافة رسمية',
'chat.amrCard.chipNoKey': 'بدون مفتاح API',
'chat.amrCard.chipAutoRetry': 'إعادة المحاولة تلقائيًا بعد تسجيل الدخول',
'chat.amrCard.switchCta': 'التبديل إلى AMR وإعادة المحاولة',
'chat.amrError.authMessage': 'حساب AMR الخاص بك لم يتم تفويضه بعد. فوِّضه وستُعاد محاولة هذه المهمة تلقائيًا.',
'chat.amrError.balanceMessage': 'نفد رصيد AMR الخاص بك. اشحن للاستمرار في هذه المهمة.',
'chat.amrError.authorizeCta': 'تفويض وإعادة المحاولة',
'chat.amrError.rechargeCta': 'شحن AMR',
'plugins.actions.copyInstallCommand': 'نسخ أمر التثبيت',
'plugins.actions.copyPluginId': 'نسخ معرّف الإضافة',
'plugins.actions.copyReadmeBadge': 'نسخ شارة README',
@ -97,7 +107,13 @@ export const ar: Dict = {
'Pick or create a brand system so generated work follows real colors, typography, and product language.',
'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody':
'Use a local CLI agent or connect your own API key. You can change this any time in Settings.',
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'صيانة رسمية',
'settings.onboardingAmrCloudBenefitReady': 'جاهز للاستخدام',
'settings.onboardingAmrCloudBenefitModels': 'نماذج عديدة',
'settings.onboardingAmrCloudBenefitPricing': 'أسعار أفضل',
'settings.onboardingAmrCloudAuthorizeAction': 'تخويل AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'تم التخويل',
'settings.onboardingStepConnect': "Connect",
'settings.onboardingStepDesignSystem': "Design system",
'settings.onboardingStepProfile': "About you",
@ -225,6 +241,28 @@ export const ar: Dict = {
'settings.agentInstallGroup': 'متاحة للتثبيت ({count})',
'settings.agentAuthRequired': 'المصادقة مطلوبة',
'settings.agentAuthUnknown': 'حالة المصادقة غير معروفة',
'settings.amrLogin': 'Sign in',
'settings.amrLogout': 'Sign out',
'settings.amrLoggingIn': 'Signing in…',
'settings.amrLoggingOut': 'Signing out…',
'settings.amrLoggedInAs': 'Signed in as {email}',
'settings.amrLoggedInWithPlan': 'Signed in as {email} · {plan}',
'settings.amrLoggedInPill': 'Signed in',
'settings.amrNotLoggedIn': 'Not signed in',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': 'Authorize',
'settings.amrBenefitOfficial': 'Officially maintained',
'settings.amrBenefitLowerPrice': 'Lower price',
'settings.amrBenefitManyModels': 'Many models',
'settings.amrPromoBonus': 'Limited bonus: +100%',
'settings.amrSignInToContinue': 'Sign in to continue',
'settings.amrSignIn': 'Sign in',
'settings.amrSignedIn': 'Signed in',
'settings.amrNotSignedIn': 'Not signed in',
'settings.amrSigningIn': 'Signing in…',
'settings.amrCancelSignIn': 'Cancel sign-in',
'settings.amrAccountStatus': 'AMR account status',
'settings.amrLoginErrorCompact': 'AMR sign-in failed.',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'ملء المزوّد سريعًا',
'settings.customProvider': 'مزوّد مخصص',

View file

@ -3,6 +3,16 @@ import { en } from './en';
export const de: Dict = {
...en,
'chat.amrCard.switchTitle': 'Modellaufruf fehlgeschlagen — dieser Lauf ist pausiert',
'chat.amrCard.switchBody': 'Wechsle zum offiziellen AMR-Modelldienst von Open Design — kein API-Key-Setup nötig. Nach Anmeldung, Autorisierung und Aufladung wird dieser Lauf automatisch wiederholt.',
'chat.amrCard.chipOfficial': 'Offizielles Hosting',
'chat.amrCard.chipNoKey': 'Kein API-Key',
'chat.amrCard.chipAutoRetry': 'Auto-Wiederholung nach Anmeldung',
'chat.amrCard.switchCta': 'Zu AMR wechseln und wiederholen',
'chat.amrError.authMessage': 'Dein AMR-Konto ist noch nicht autorisiert. Autorisiere es, und dieser Lauf wird automatisch wiederholt.',
'chat.amrError.balanceMessage': 'Dein AMR-Guthaben ist aufgebraucht. Lade auf, um diesen Lauf fortzusetzen.',
'chat.amrError.authorizeCta': 'Autorisieren und wiederholen',
'chat.amrError.rechargeCta': 'AMR aufladen',
'plugins.actions.copyInstallCommand': 'Installationsbefehl kopieren',
'plugins.actions.copyPluginId': 'Plugin-ID kopieren',
'plugins.actions.copyReadmeBadge': 'README-Badge kopieren',
@ -97,7 +107,13 @@ export const de: Dict = {
'Pick or create a brand system so generated work follows real colors, typography, and product language.',
'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody':
'Use a local CLI agent or connect your own API key. You can change this any time in Settings.',
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Offiziell gepflegt',
'settings.onboardingAmrCloudBenefitReady': 'Sofort einsatzbereit',
'settings.onboardingAmrCloudBenefitModels': 'Viele Modelle',
'settings.onboardingAmrCloudBenefitPricing': 'Günstigere Preise',
'settings.onboardingAmrCloudAuthorizeAction': 'AMR autorisieren',
'settings.onboardingAmrCloudAuthorizedAction': 'Autorisiert',
'settings.onboardingStepConnect': "Connect",
'settings.onboardingStepDesignSystem': "Design system",
'settings.onboardingStepProfile': "About you",
@ -225,6 +241,28 @@ export const de: Dict = {
'settings.agentInstallGroup': 'Zur Installation verfügbar ({count})',
'settings.agentAuthRequired': 'Authentifizierung erforderlich',
'settings.agentAuthUnknown': 'Authentifizierungsstatus unbekannt',
'settings.amrLogin': 'Sign in',
'settings.amrLogout': 'Sign out',
'settings.amrLoggingIn': 'Signing in…',
'settings.amrLoggingOut': 'Signing out…',
'settings.amrLoggedInAs': 'Signed in as {email}',
'settings.amrLoggedInWithPlan': 'Signed in as {email} · {plan}',
'settings.amrLoggedInPill': 'Signed in',
'settings.amrNotLoggedIn': 'Not signed in',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': 'Authorize',
'settings.amrBenefitOfficial': 'Officially maintained',
'settings.amrBenefitLowerPrice': 'Lower price',
'settings.amrBenefitManyModels': 'Many models',
'settings.amrPromoBonus': 'Limited bonus: +100%',
'settings.amrSignInToContinue': 'Sign in to continue',
'settings.amrSignIn': 'Sign in',
'settings.amrSignedIn': 'Signed in',
'settings.amrNotSignedIn': 'Not signed in',
'settings.amrSigningIn': 'Signing in…',
'settings.amrCancelSignIn': 'Cancel sign-in',
'settings.amrAccountStatus': 'AMR account status',
'settings.amrLoginErrorCompact': 'AMR sign-in failed.',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'Anbieter schnell ausfüllen',
'settings.customProvider': 'Benutzerdefinierter Anbieter',

View file

@ -1,6 +1,16 @@
import type { Dict } from '../types';
export const en: Dict = {
'chat.amrCard.switchTitle': "Model call failed — this run is paused",
'chat.amrCard.switchBody': "Switch to Open Design's official AMR model service — no API key setup needed. After you sign in, authorize, and top up, this run retries automatically.",
'chat.amrCard.chipOfficial': "Official hosting",
'chat.amrCard.chipNoKey': "No API key",
'chat.amrCard.chipAutoRetry': "Auto-retry after sign-in",
'chat.amrCard.switchCta': "Switch to AMR & retry",
'chat.amrError.authMessage': "Your AMR account isn't authorized yet. Authorize it and this run retries automatically.",
'chat.amrError.balanceMessage': "Your AMR balance has run out. Top up to keep this run going.",
'chat.amrError.authorizeCta': "Authorize & retry",
'chat.amrError.rechargeCta': "Top up AMR",
'common.cancel': 'Cancel',
'common.save': 'Save',
'common.close': 'Close',
@ -84,13 +94,20 @@ export const en: Dict = {
'Pick or create a brand system so generated work follows real colors, typography, and product language.',
'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody':
'Use a local CLI agent or connect your own API key. You can change this any time in Settings.',
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Officially maintained',
'settings.onboardingAmrCloudBenefitReady': 'Ready to use',
'settings.onboardingAmrCloudBenefitModels': 'Many models',
'settings.onboardingAmrCloudBenefitPricing': 'Better pricing',
'settings.onboardingAmrCloudAuthorizeAction': 'Authorize AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Authorized',
'settings.onboardingStepConnect': "Connect",
'settings.onboardingStepDesignSystem': "Design system",
'settings.onboardingStepProfile': "About you",
'settings.onboardingConnectTitle': "Choose a runtime",
'settings.onboardingConnectBody': "",
'settings.onboardingRecommended': "Recommended",
'settings.onboardingAmrCloudOfficialBadge': "Official",
'settings.onboardingLocalTitle': "Local coding agent",
'settings.onboardingLocalBody': "Use an installed CLI such as Claude Code, Codex, Cursor, Gemini, or OpenCode.",
'settings.onboardingLocalAction': "Open CLI settings",
@ -216,7 +233,29 @@ export const en: Dict = {
'settings.agentInstallGroup': 'Available to install ({count})',
'settings.agentAuthRequired': 'Authentication required',
'settings.agentAuthUnknown': 'Auth status unknown',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': 'Authorize',
'settings.amrBenefitOfficial': 'Official',
'settings.amrBenefitLowerPrice': 'Lower cost',
'settings.amrBenefitManyModels': 'Many models',
'settings.amrPromoBonus': 'Limited bonus: +100%',
'settings.amrSignInToContinue': 'Sign in to continue',
'settings.amrSignIn': 'Sign in',
'settings.amrSignedIn': 'Signed in',
'settings.amrNotSignedIn': 'Not signed in',
'settings.amrSigningIn': 'Signing in…',
'settings.amrCancelSignIn': 'Cancel sign-in',
'settings.amrAccountStatus': 'AMR account status',
'settings.amrLoginErrorCompact': 'AMR sign-in failed.',
'settings.advanced': 'Advanced',
'settings.amrLogin': 'Sign in',
'settings.amrLogout': 'Sign out',
'settings.amrLoggingIn': 'Signing in…',
'settings.amrLoggingOut': 'Signing out…',
'settings.amrLoggedInAs': 'Signed in as {email}',
'settings.amrLoggedInWithPlan': 'Signed in as {email} · {plan}',
'settings.amrLoggedInPill': 'Signed in',
'settings.amrNotLoggedIn': 'Not signed in',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'Quick fill provider',
'settings.customProvider': 'Custom provider',
@ -282,7 +321,7 @@ export const en: Dict = {
'settings.modelPickerHint':
'Default uses the CLIs own config. Custom… lets you type any model id.',
'settings.modelPickerLiveHint':
'Models were refreshed from the installed CLI. Default still uses the CLIs own config.',
"Model list comes from this CLI. Default uses the CLI's own config.",
'settings.modelPickerFallbackHint':
'Showing built-in defaults. Click Rescan to pull live models from the CLI.',
'settings.cliEnvTitle': 'Advanced: proxy & custom paths',

View file

@ -3,6 +3,16 @@ import { en } from './en';
export const esES: Dict = {
...en,
'chat.amrCard.switchTitle': 'Falló la llamada al modelo — esta ejecución está en pausa',
'chat.amrCard.switchBody': 'Cambia al servicio oficial de modelos AMR de Open Design — sin configurar API Key. Tras iniciar sesión, autorizar y recargar, esta ejecución se reintentará automáticamente.',
'chat.amrCard.chipOfficial': 'Alojamiento oficial',
'chat.amrCard.chipNoKey': 'Sin API Key',
'chat.amrCard.chipAutoRetry': 'Reintento automático tras iniciar sesión',
'chat.amrCard.switchCta': 'Cambiar a AMR y reintentar',
'chat.amrError.authMessage': 'Tu cuenta de AMR aún no está autorizada. Autorízala y esta ejecución se reintentará automáticamente.',
'chat.amrError.balanceMessage': 'Tu saldo de AMR se ha agotado. Recarga para continuar esta ejecución.',
'chat.amrError.authorizeCta': 'Autorizar y reintentar',
'chat.amrError.rechargeCta': 'Recargar AMR',
'plugins.actions.copyInstallCommand': 'Copiar comando de instalación',
'plugins.actions.copyPluginId': 'Copiar ID del plugin',
'plugins.actions.copyReadmeBadge': 'Copiar insignia README',
@ -97,7 +107,13 @@ export const esES: Dict = {
'Pick or create a brand system so generated work follows real colors, typography, and product language.',
'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody':
'Use a local CLI agent or connect your own API key. You can change this any time in Settings.',
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Mantenimiento oficial',
'settings.onboardingAmrCloudBenefitReady': 'Listo para usar',
'settings.onboardingAmrCloudBenefitModels': 'Muchos modelos',
'settings.onboardingAmrCloudBenefitPricing': 'Mejor precio',
'settings.onboardingAmrCloudAuthorizeAction': 'Autorizar AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Autorizado',
'settings.onboardingStepConnect': "Connect",
'settings.onboardingStepDesignSystem': "Design system",
'settings.onboardingStepProfile': "About you",
@ -225,6 +241,28 @@ export const esES: Dict = {
'settings.agentInstallGroup': 'Disponibles para instalar ({count})',
'settings.agentAuthRequired': 'Autenticación requerida',
'settings.agentAuthUnknown': 'Estado de autenticación desconocido',
'settings.amrLogin': 'Sign in',
'settings.amrLogout': 'Sign out',
'settings.amrLoggingIn': 'Signing in…',
'settings.amrLoggingOut': 'Signing out…',
'settings.amrLoggedInAs': 'Signed in as {email}',
'settings.amrLoggedInWithPlan': 'Signed in as {email} · {plan}',
'settings.amrLoggedInPill': 'Signed in',
'settings.amrNotLoggedIn': 'Not signed in',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': 'Authorize',
'settings.amrBenefitOfficial': 'Officially maintained',
'settings.amrBenefitLowerPrice': 'Lower price',
'settings.amrBenefitManyModels': 'Many models',
'settings.amrPromoBonus': 'Limited bonus: +100%',
'settings.amrSignInToContinue': 'Sign in to continue',
'settings.amrSignIn': 'Sign in',
'settings.amrSignedIn': 'Signed in',
'settings.amrNotSignedIn': 'Not signed in',
'settings.amrSigningIn': 'Signing in…',
'settings.amrCancelSignIn': 'Cancel sign-in',
'settings.amrAccountStatus': 'AMR account status',
'settings.amrLoginErrorCompact': 'AMR sign-in failed.',
'settings.apiSection': 'API de Anthropic',
'settings.quickFillProvider': 'Rellenar proveedor',
'settings.customProvider': 'Proveedor personalizado',

View file

@ -3,6 +3,16 @@ import { en } from './en';
export const fa: Dict = {
...en,
'chat.amrCard.switchTitle': 'فراخوانی مدل ناموفق بود — این اجرا متوقف شد',
'chat.amrCard.switchBody': 'به سرویس رسمی مدل AMR از Open Design سوئیچ کنید — بدون نیاز به تنظیم کلید API. پس از ورود، اعطای دسترسی و شارژ، این اجرا به‌طور خودکار دوباره انجام می‌شود.',
'chat.amrCard.chipOfficial': 'میزبانی رسمی',
'chat.amrCard.chipNoKey': 'بدون کلید API',
'chat.amrCard.chipAutoRetry': 'تلاش مجدد خودکار پس از ورود',
'chat.amrCard.switchCta': 'سوئیچ به AMR و تلاش مجدد',
'chat.amrError.authMessage': 'حساب AMR شما هنوز مجاز نشده است. آن را مجاز کنید تا این اجرا به‌طور خودکار دوباره انجام شود.',
'chat.amrError.balanceMessage': 'موجودی AMR شما تمام شده است. برای ادامه این اجرا شارژ کنید.',
'chat.amrError.authorizeCta': 'اعطای دسترسی و تلاش مجدد',
'chat.amrError.rechargeCta': 'شارژ AMR',
'plugins.actions.copyInstallCommand': 'کپی دستور نصب',
'plugins.actions.copyPluginId': 'کپی شناسهٔ افزونه',
'plugins.actions.copyReadmeBadge': 'کپی نشان README',
@ -97,7 +107,13 @@ export const fa: Dict = {
'Pick or create a brand system so generated work follows real colors, typography, and product language.',
'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody':
'Use a local CLI agent or connect your own API key. You can change this any time in Settings.',
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'نگهداری رسمی',
'settings.onboardingAmrCloudBenefitReady': 'آماده استفاده',
'settings.onboardingAmrCloudBenefitModels': 'مدل‌های فراوان',
'settings.onboardingAmrCloudBenefitPricing': 'قیمت بهتر',
'settings.onboardingAmrCloudAuthorizeAction': 'مجوزدهی AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'مجوز داده شد',
'settings.onboardingStepConnect': "Connect",
'settings.onboardingStepDesignSystem': "Design system",
'settings.onboardingStepProfile': "About you",
@ -225,6 +241,28 @@ export const fa: Dict = {
'settings.agentInstallGroup': 'آماده نصب ({count})',
'settings.agentAuthRequired': 'احراز هویت لازم است',
'settings.agentAuthUnknown': 'وضعیت احراز هویت نامشخص است',
'settings.amrLogin': 'Sign in',
'settings.amrLogout': 'Sign out',
'settings.amrLoggingIn': 'Signing in…',
'settings.amrLoggingOut': 'Signing out…',
'settings.amrLoggedInAs': 'Signed in as {email}',
'settings.amrLoggedInWithPlan': 'Signed in as {email} · {plan}',
'settings.amrLoggedInPill': 'Signed in',
'settings.amrNotLoggedIn': 'Not signed in',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': 'Authorize',
'settings.amrBenefitOfficial': 'Officially maintained',
'settings.amrBenefitLowerPrice': 'Lower price',
'settings.amrBenefitManyModels': 'Many models',
'settings.amrPromoBonus': 'Limited bonus: +100%',
'settings.amrSignInToContinue': 'Sign in to continue',
'settings.amrSignIn': 'Sign in',
'settings.amrSignedIn': 'Signed in',
'settings.amrNotSignedIn': 'Not signed in',
'settings.amrSigningIn': 'Signing in…',
'settings.amrCancelSignIn': 'Cancel sign-in',
'settings.amrAccountStatus': 'AMR account status',
'settings.amrLoginErrorCompact': 'AMR sign-in failed.',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'پر کردن سریع ارائه‌دهنده',
'settings.customProvider': 'ارائه‌دهنده سفارشی',

View file

@ -3,6 +3,16 @@ import type { Dict } from '../types';
export const fr: Dict = {
...en,
'chat.amrCard.switchTitle': 'Échec de l\'appel du modèle — cette exécution est en pause',
'chat.amrCard.switchBody': 'Passez au service de modèles AMR officiel d\'Open Design — aucune clé API à configurer. Après connexion, autorisation et recharge, cette exécution sera relancée automatiquement.',
'chat.amrCard.chipOfficial': 'Hébergement officiel',
'chat.amrCard.chipNoKey': 'Sans clé API',
'chat.amrCard.chipAutoRetry': 'Reprise automatique après connexion',
'chat.amrCard.switchCta': 'Passer à AMR et relancer',
'chat.amrError.authMessage': 'Votre compte AMR n\'est pas encore autorisé. Autorisez-le et cette exécution sera relancée automatiquement.',
'chat.amrError.balanceMessage': 'Votre solde AMR est épuisé. Rechargez pour poursuivre cette exécution.',
'chat.amrError.authorizeCta': 'Autoriser et relancer',
'chat.amrError.rechargeCta': 'Recharger AMR',
'plugins.actions.copyInstallCommand': 'Copier la commande dinstallation',
'plugins.actions.copyPluginId': 'Copier lID du plugin',
'plugins.actions.copyReadmeBadge': 'Copier le badge README',
@ -97,7 +107,13 @@ export const fr: Dict = {
'Pick or create a brand system so generated work follows real colors, typography, and product language.',
'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody':
'Use a local CLI agent or connect your own API key. You can change this any time in Settings.',
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Maintenance officielle',
'settings.onboardingAmrCloudBenefitReady': 'Prêt à lemploi',
'settings.onboardingAmrCloudBenefitModels': 'Nombreux modèles',
'settings.onboardingAmrCloudBenefitPricing': 'Prix avantageux',
'settings.onboardingAmrCloudAuthorizeAction': 'Autoriser AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Autorisé',
'settings.onboardingStepConnect': "Connect",
'settings.onboardingStepDesignSystem': "Design system",
'settings.onboardingStepProfile': "About you",
@ -225,6 +241,28 @@ export const fr: Dict = {
'settings.agentInstallGroup': 'Disponibles à installer ({count})',
'settings.agentAuthRequired': 'Authentification requise',
'settings.agentAuthUnknown': 'Statut dauthentification inconnu',
'settings.amrLogin': 'Sign in',
'settings.amrLogout': 'Sign out',
'settings.amrLoggingIn': 'Signing in…',
'settings.amrLoggingOut': 'Signing out…',
'settings.amrLoggedInAs': 'Signed in as {email}',
'settings.amrLoggedInWithPlan': 'Signed in as {email} · {plan}',
'settings.amrLoggedInPill': 'Signed in',
'settings.amrNotLoggedIn': 'Not signed in',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': 'Authorize',
'settings.amrBenefitOfficial': 'Officially maintained',
'settings.amrBenefitLowerPrice': 'Lower price',
'settings.amrBenefitManyModels': 'Many models',
'settings.amrPromoBonus': 'Limited bonus: +100%',
'settings.amrSignInToContinue': 'Sign in to continue',
'settings.amrSignIn': 'Sign in',
'settings.amrSignedIn': 'Signed in',
'settings.amrNotSignedIn': 'Not signed in',
'settings.amrSigningIn': 'Signing in…',
'settings.amrCancelSignIn': 'Cancel sign-in',
'settings.amrAccountStatus': 'AMR account status',
'settings.amrLoginErrorCompact': 'AMR sign-in failed.',
'settings.apiSection': 'API Anthropic',
'settings.quickFillProvider': 'Remplissage rapide du fournisseur',
'settings.customProvider': 'Fournisseur personnalisé',

View file

@ -3,6 +3,16 @@ import type { Dict } from '../types';
export const hu: Dict = {
...en,
'chat.amrCard.switchTitle': 'Sikertelen modellhívás — ez a futtatás szünetel',
'chat.amrCard.switchBody': 'Válts az Open Design hivatalos AMR modellszolgáltatására — nincs szükség API-kulcs beállítására. Bejelentkezés, engedélyezés és feltöltés után ez a futtatás automatikusan újraindul.',
'chat.amrCard.chipOfficial': 'Hivatalos szolgáltatás',
'chat.amrCard.chipNoKey': 'Nincs API-kulcs',
'chat.amrCard.chipAutoRetry': 'Automatikus újrapróbálkozás bejelentkezés után',
'chat.amrCard.switchCta': 'Váltás AMR-re és újrapróbálkozás',
'chat.amrError.authMessage': 'Az AMR-fiókod még nincs engedélyezve. Engedélyezd, és ez a futtatás automatikusan újraindul.',
'chat.amrError.balanceMessage': 'Az AMR-egyenleged elfogyott. Tölts fel a futtatás folytatásához.',
'chat.amrError.authorizeCta': 'Engedélyezés és újrapróbálkozás',
'chat.amrError.rechargeCta': 'AMR feltöltése',
'plugins.actions.copyInstallCommand': 'Telepítési parancs másolása',
'plugins.actions.copyPluginId': 'Pluginazonosító másolása',
'plugins.actions.copyReadmeBadge': 'README jelvény másolása',
@ -97,7 +107,13 @@ export const hu: Dict = {
'Pick or create a brand system so generated work follows real colors, typography, and product language.',
'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody':
'Use a local CLI agent or connect your own API key. You can change this any time in Settings.',
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Hivatalosan karbantartott',
'settings.onboardingAmrCloudBenefitReady': 'Azonnal használható',
'settings.onboardingAmrCloudBenefitModels': 'Sok modell',
'settings.onboardingAmrCloudBenefitPricing': 'Kedvezőbb ár',
'settings.onboardingAmrCloudAuthorizeAction': 'AMR engedélyezése',
'settings.onboardingAmrCloudAuthorizedAction': 'Engedélyezve',
'settings.onboardingStepConnect': "Connect",
'settings.onboardingStepDesignSystem': "Design system",
'settings.onboardingStepProfile': "About you",
@ -225,6 +241,28 @@ export const hu: Dict = {
'settings.agentInstallGroup': 'Telepíthető ({count})',
'settings.agentAuthRequired': 'Hitelesítés szükséges',
'settings.agentAuthUnknown': 'A hitelesítési állapot ismeretlen',
'settings.amrLogin': 'Sign in',
'settings.amrLogout': 'Sign out',
'settings.amrLoggingIn': 'Signing in…',
'settings.amrLoggingOut': 'Signing out…',
'settings.amrLoggedInAs': 'Signed in as {email}',
'settings.amrLoggedInWithPlan': 'Signed in as {email} · {plan}',
'settings.amrLoggedInPill': 'Signed in',
'settings.amrNotLoggedIn': 'Not signed in',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': 'Authorize',
'settings.amrBenefitOfficial': 'Officially maintained',
'settings.amrBenefitLowerPrice': 'Lower price',
'settings.amrBenefitManyModels': 'Many models',
'settings.amrPromoBonus': 'Limited bonus: +100%',
'settings.amrSignInToContinue': 'Sign in to continue',
'settings.amrSignIn': 'Sign in',
'settings.amrSignedIn': 'Signed in',
'settings.amrNotSignedIn': 'Not signed in',
'settings.amrSigningIn': 'Signing in…',
'settings.amrCancelSignIn': 'Cancel sign-in',
'settings.amrAccountStatus': 'AMR account status',
'settings.amrLoginErrorCompact': 'AMR sign-in failed.',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'Szolgáltató gyors kitöltése',
'settings.customProvider': 'Egyéni szolgáltató',

View file

@ -3,6 +3,16 @@ import { en } from './en';
export const id: Dict = {
...en,
'chat.amrCard.switchTitle': 'Panggilan model gagal — proses ini dijeda',
'chat.amrCard.switchBody': 'Beralih ke layanan model AMR resmi Open Design — tanpa perlu mengatur API Key. Setelah masuk, otorisasi, dan isi ulang, proses ini akan dicoba ulang otomatis.',
'chat.amrCard.chipOfficial': 'Hosting resmi',
'chat.amrCard.chipNoKey': 'Tanpa API Key',
'chat.amrCard.chipAutoRetry': 'Coba ulang otomatis setelah masuk',
'chat.amrCard.switchCta': 'Beralih ke AMR & coba lagi',
'chat.amrError.authMessage': 'Akun AMR Anda belum diotorisasi. Otorisasi sekarang dan proses ini akan dicoba ulang otomatis.',
'chat.amrError.balanceMessage': 'Saldo AMR Anda habis. Isi ulang untuk melanjutkan proses ini.',
'chat.amrError.authorizeCta': 'Otorisasi & coba lagi',
'chat.amrError.rechargeCta': 'Isi ulang AMR',
'plugins.actions.copyInstallCommand': 'Salin perintah instal',
'plugins.actions.copyPluginId': 'Salin ID plugin',
'plugins.actions.copyReadmeBadge': 'Salin lencana README',
@ -97,7 +107,13 @@ export const id: Dict = {
'Pick or create a brand system so generated work follows real colors, typography, and product language.',
'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody':
'Use a local CLI agent or connect your own API key. You can change this any time in Settings.',
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Dikelola resmi',
'settings.onboardingAmrCloudBenefitReady': 'Siap pakai',
'settings.onboardingAmrCloudBenefitModels': 'Banyak model',
'settings.onboardingAmrCloudBenefitPricing': 'Harga lebih hemat',
'settings.onboardingAmrCloudAuthorizeAction': 'Otorisasi AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Diotorisasi',
'settings.onboardingStepConnect': "Connect",
'settings.onboardingStepDesignSystem': "Design system",
'settings.onboardingStepProfile': "About you",
@ -225,6 +241,28 @@ export const id: Dict = {
'settings.agentInstallGroup': 'Tersedia untuk dipasang ({count})',
'settings.agentAuthRequired': 'Autentikasi diperlukan',
'settings.agentAuthUnknown': 'Status autentikasi tidak diketahui',
'settings.amrLogin': 'Sign in',
'settings.amrLogout': 'Sign out',
'settings.amrLoggingIn': 'Signing in…',
'settings.amrLoggingOut': 'Signing out…',
'settings.amrLoggedInAs': 'Signed in as {email}',
'settings.amrLoggedInWithPlan': 'Signed in as {email} · {plan}',
'settings.amrLoggedInPill': 'Signed in',
'settings.amrNotLoggedIn': 'Not signed in',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': 'Authorize',
'settings.amrBenefitOfficial': 'Officially maintained',
'settings.amrBenefitLowerPrice': 'Lower price',
'settings.amrBenefitManyModels': 'Many models',
'settings.amrPromoBonus': 'Limited bonus: +100%',
'settings.amrSignInToContinue': 'Sign in to continue',
'settings.amrSignIn': 'Sign in',
'settings.amrSignedIn': 'Signed in',
'settings.amrNotSignedIn': 'Not signed in',
'settings.amrSigningIn': 'Signing in…',
'settings.amrCancelSignIn': 'Cancel sign-in',
'settings.amrAccountStatus': 'AMR account status',
'settings.amrLoginErrorCompact': 'AMR sign-in failed.',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'Provider isi cepat',
'settings.customProvider': 'Provider kustom',

View file

@ -3,6 +3,16 @@ import type { Dict } from '../types';
export const it: Dict = {
...en,
'chat.amrCard.switchTitle': 'Chiamata al modello fallita — questa esecuzione è in pausa',
'chat.amrCard.switchBody': 'Passa al servizio modelli AMR ufficiale di Open Design — nessuna chiave API da configurare. Dopo accesso, autorizzazione e ricarica, questa esecuzione verrà ritentata automaticamente.',
'chat.amrCard.chipOfficial': 'Hosting ufficiale',
'chat.amrCard.chipNoKey': 'Senza chiave API',
'chat.amrCard.chipAutoRetry': 'Ritenta automatico dopo l\'accesso',
'chat.amrCard.switchCta': 'Passa ad AMR e riprova',
'chat.amrError.authMessage': 'Il tuo account AMR non è ancora autorizzato. Autorizzalo e questa esecuzione verrà ritentata automaticamente.',
'chat.amrError.balanceMessage': 'Il tuo saldo AMR è esaurito. Ricarica per continuare questa esecuzione.',
'chat.amrError.authorizeCta': 'Autorizza e riprova',
'chat.amrError.rechargeCta': 'Ricarica AMR',
'plugins.actions.copyInstallCommand': 'Copia comando di installazione',
'plugins.actions.copyPluginId': 'Copia ID plugin',
'plugins.actions.copyReadmeBadge': 'Copia badge README',
@ -96,7 +106,13 @@ export const it: Dict = {
'Pick or create a brand system so generated work follows real colors, typography, and product language.',
'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody':
'Use a local CLI agent or connect your own API key. You can change this any time in Settings.',
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Manutenzione ufficiale',
'settings.onboardingAmrCloudBenefitReady': 'Pronto alluso',
'settings.onboardingAmrCloudBenefitModels': 'Molti modelli',
'settings.onboardingAmrCloudBenefitPricing': 'Prezzi migliori',
'settings.onboardingAmrCloudAuthorizeAction': 'Autorizza AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Autorizzato',
'settings.onboardingStepConnect': "Connect",
'settings.onboardingStepDesignSystem': "Design system",
'settings.onboardingStepProfile': "About you",
@ -222,6 +238,28 @@ export const it: Dict = {
'Nessun agente rilevato per ora. Installa Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen o GitHub Copilot CLI, poi clicca su Rianalizza.',
'settings.agentInstalledGroup': 'Le tue CLI ({count})',
'settings.agentInstallGroup': 'Disponibili per l\'installazione ({count})',
'settings.amrLogin': 'Sign in',
'settings.amrLogout': 'Sign out',
'settings.amrLoggingIn': 'Signing in…',
'settings.amrLoggingOut': 'Signing out…',
'settings.amrLoggedInAs': 'Signed in as {email}',
'settings.amrLoggedInWithPlan': 'Signed in as {email} · {plan}',
'settings.amrLoggedInPill': 'Signed in',
'settings.amrNotLoggedIn': 'Not signed in',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': 'Authorize',
'settings.amrBenefitOfficial': 'Officially maintained',
'settings.amrBenefitLowerPrice': 'Lower price',
'settings.amrBenefitManyModels': 'Many models',
'settings.amrPromoBonus': 'Limited bonus: +100%',
'settings.amrSignInToContinue': 'Sign in to continue',
'settings.amrSignIn': 'Sign in',
'settings.amrSignedIn': 'Signed in',
'settings.amrNotSignedIn': 'Not signed in',
'settings.amrSigningIn': 'Signing in…',
'settings.amrCancelSignIn': 'Cancel sign-in',
'settings.amrAccountStatus': 'AMR account status',
'settings.amrLoginErrorCompact': 'AMR sign-in failed.',
'settings.apiSection': 'API Anthropic',
'settings.quickFillProvider': 'Compilazione rapida del provider',
'settings.customProvider': 'Provider personalizzato',

View file

@ -3,6 +3,16 @@ import { en } from './en';
export const ja: Dict = {
...en,
'chat.amrCard.switchTitle': 'モデル呼び出しに失敗しました — このタスクは一時停止中です',
'chat.amrCard.switchBody': 'Open Design 公式の AMR モデルサービスに切り替えてください — API キーの設定は不要です。サインイン・認可・チャージが完了すると、このタスクは自動で再試行されます。',
'chat.amrCard.chipOfficial': '公式ホスティング',
'chat.amrCard.chipNoKey': 'API キー不要',
'chat.amrCard.chipAutoRetry': 'サインイン後に自動再試行',
'chat.amrCard.switchCta': 'AMR に切り替えて再試行',
'chat.amrError.authMessage': 'AMR アカウントがまだ認可されていません。認可するとこのタスクは自動で再試行されます。',
'chat.amrError.balanceMessage': 'AMR の残高が不足しています。チャージしてこのタスクを続行してください。',
'chat.amrError.authorizeCta': '認可して再試行',
'chat.amrError.rechargeCta': 'AMR にチャージ',
'plugins.actions.copyInstallCommand': 'インストールコマンドをコピー',
'plugins.actions.copyPluginId': 'プラグイン ID をコピー',
'plugins.actions.copyReadmeBadge': 'README バッジをコピー',
@ -97,7 +107,13 @@ export const ja: Dict = {
'Pick or create a brand system so generated work follows real colors, typography, and product language.',
'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody':
'Use a local CLI agent or connect your own API key. You can change this any time in Settings.',
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': '公式メンテナンス',
'settings.onboardingAmrCloudBenefitReady': 'すぐ使える',
'settings.onboardingAmrCloudBenefitModels': '多数のモデル',
'settings.onboardingAmrCloudBenefitPricing': 'お得な価格',
'settings.onboardingAmrCloudAuthorizeAction': 'AMR を認証',
'settings.onboardingAmrCloudAuthorizedAction': '認証済み',
'settings.onboardingStepConnect': "Connect",
'settings.onboardingStepDesignSystem': "Design system",
'settings.onboardingStepProfile': "About you",
@ -225,6 +241,28 @@ export const ja: Dict = {
'settings.agentInstallGroup': 'インストール可能({count}',
'settings.agentAuthRequired': '認証が必要です',
'settings.agentAuthUnknown': '認証状態は不明です',
'settings.amrLogin': 'Sign in',
'settings.amrLogout': 'Sign out',
'settings.amrLoggingIn': 'Signing in…',
'settings.amrLoggingOut': 'Signing out…',
'settings.amrLoggedInAs': 'Signed in as {email}',
'settings.amrLoggedInWithPlan': 'Signed in as {email} · {plan}',
'settings.amrLoggedInPill': 'Signed in',
'settings.amrNotLoggedIn': 'Not signed in',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': 'Authorize',
'settings.amrBenefitOfficial': 'Officially maintained',
'settings.amrBenefitLowerPrice': 'Lower price',
'settings.amrBenefitManyModels': 'Many models',
'settings.amrPromoBonus': 'Limited bonus: +100%',
'settings.amrSignInToContinue': 'Sign in to continue',
'settings.amrSignIn': 'Sign in',
'settings.amrSignedIn': 'Signed in',
'settings.amrNotSignedIn': 'Not signed in',
'settings.amrSigningIn': 'Signing in…',
'settings.amrCancelSignIn': 'Cancel sign-in',
'settings.amrAccountStatus': 'AMR account status',
'settings.amrLoginErrorCompact': 'AMR sign-in failed.',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'プロバイダーをクイック入力',
'settings.customProvider': 'カスタムプロバイダー',

View file

@ -3,6 +3,16 @@ import type { Dict } from '../types';
export const ko: Dict = {
...en,
'chat.amrCard.switchTitle': '모델 호출 실패 — 이 작업이 일시중지되었습니다',
'chat.amrCard.switchBody': 'Open Design 공식 AMR 모델 서비스로 전환하세요 — API 키 설정이 필요 없습니다. 로그인・인증・충전이 완료되면 이 작업이 자동으로 재시도됩니다.',
'chat.amrCard.chipOfficial': '공식 호스팅',
'chat.amrCard.chipNoKey': 'API 키 불필요',
'chat.amrCard.chipAutoRetry': '로그인 후 자동 재시도',
'chat.amrCard.switchCta': 'AMR로 전환하고 재시도',
'chat.amrError.authMessage': 'AMR 계정이 아직 인증되지 않았습니다. 인증하면 이 작업이 자동으로 재시도됩니다.',
'chat.amrError.balanceMessage': 'AMR 잔액이 부족합니다. 충전하여 이 작업을 계속 진행하세요.',
'chat.amrError.authorizeCta': '인증하고 재시도',
'chat.amrError.rechargeCta': 'AMR 충전',
'plugins.actions.copyInstallCommand': '설치 명령 복사',
'plugins.actions.copyPluginId': '플러그인 ID 복사',
'plugins.actions.copyReadmeBadge': 'README 배지 복사',
@ -97,7 +107,13 @@ export const ko: Dict = {
'Pick or create a brand system so generated work follows real colors, typography, and product language.',
'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody':
'Use a local CLI agent or connect your own API key. You can change this any time in Settings.',
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': '공식 관리',
'settings.onboardingAmrCloudBenefitReady': '바로 사용',
'settings.onboardingAmrCloudBenefitModels': '다양한 모델',
'settings.onboardingAmrCloudBenefitPricing': '더 나은 가격',
'settings.onboardingAmrCloudAuthorizeAction': 'AMR 인증',
'settings.onboardingAmrCloudAuthorizedAction': '인증됨',
'settings.onboardingStepConnect': "Connect",
'settings.onboardingStepDesignSystem': "Design system",
'settings.onboardingStepProfile': "About you",
@ -225,6 +241,28 @@ export const ko: Dict = {
'settings.agentInstallGroup': '설치 가능 ({count})',
'settings.agentAuthRequired': '인증 필요',
'settings.agentAuthUnknown': '인증 상태를 알 수 없음',
'settings.amrLogin': 'Sign in',
'settings.amrLogout': 'Sign out',
'settings.amrLoggingIn': 'Signing in…',
'settings.amrLoggingOut': 'Signing out…',
'settings.amrLoggedInAs': 'Signed in as {email}',
'settings.amrLoggedInWithPlan': 'Signed in as {email} · {plan}',
'settings.amrLoggedInPill': 'Signed in',
'settings.amrNotLoggedIn': 'Not signed in',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': 'Authorize',
'settings.amrBenefitOfficial': 'Officially maintained',
'settings.amrBenefitLowerPrice': 'Lower price',
'settings.amrBenefitManyModels': 'Many models',
'settings.amrPromoBonus': 'Limited bonus: +100%',
'settings.amrSignInToContinue': 'Sign in to continue',
'settings.amrSignIn': 'Sign in',
'settings.amrSignedIn': 'Signed in',
'settings.amrNotSignedIn': 'Not signed in',
'settings.amrSigningIn': 'Signing in…',
'settings.amrCancelSignIn': 'Cancel sign-in',
'settings.amrAccountStatus': 'AMR account status',
'settings.amrLoginErrorCompact': 'AMR sign-in failed.',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': '제공자 빠른 입력',
'settings.customProvider': '사용자 지정 제공자',

View file

@ -3,6 +3,16 @@ import type { Dict } from '../types';
export const pl: Dict = {
...en,
'chat.amrCard.switchTitle': 'Wywołanie modelu nieudane — to zadanie jest wstrzymane',
'chat.amrCard.switchBody': 'Przełącz się na oficjalną usługę modeli AMR od Open Design — bez konfigurowania klucza API. Po zalogowaniu, autoryzacji i doładowaniu zadanie zostanie automatycznie ponowione.',
'chat.amrCard.chipOfficial': 'Oficjalny hosting',
'chat.amrCard.chipNoKey': 'Bez klucza API',
'chat.amrCard.chipAutoRetry': 'Automatyczne ponowienie po zalogowaniu',
'chat.amrCard.switchCta': 'Przełącz na AMR i ponów',
'chat.amrError.authMessage': 'Twoje konto AMR nie zostało jeszcze autoryzowane. Autoryzuj je, a zadanie zostanie automatycznie ponowione.',
'chat.amrError.balanceMessage': 'Saldo AMR zostało wyczerpane. Doładuj, aby kontynuować zadanie.',
'chat.amrError.authorizeCta': 'Autoryzuj i ponów',
'chat.amrError.rechargeCta': 'Doładuj AMR',
'plugins.actions.copyInstallCommand': 'Kopiuj polecenie instalacji',
'plugins.actions.copyPluginId': 'Kopiuj ID wtyczki',
'plugins.actions.copyReadmeBadge': 'Kopiuj odznakę README',
@ -97,7 +107,13 @@ export const pl: Dict = {
'Pick or create a brand system so generated work follows real colors, typography, and product language.',
'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody':
'Use a local CLI agent or connect your own API key. You can change this any time in Settings.',
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Oficjalnie utrzymane',
'settings.onboardingAmrCloudBenefitReady': 'Gotowe do użycia',
'settings.onboardingAmrCloudBenefitModels': 'Wiele modeli',
'settings.onboardingAmrCloudBenefitPricing': 'Lepsza cena',
'settings.onboardingAmrCloudAuthorizeAction': 'Autoryzuj AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Autoryzowano',
'settings.onboardingStepConnect': "Connect",
'settings.onboardingStepDesignSystem': "Design system",
'settings.onboardingStepProfile': "About you",
@ -225,6 +241,28 @@ export const pl: Dict = {
'settings.agentInstallGroup': 'Dostępne do instalacji ({count})',
'settings.agentAuthRequired': 'Wymagane uwierzytelnienie',
'settings.agentAuthUnknown': 'Stan uwierzytelnienia nieznany',
'settings.amrLogin': 'Sign in',
'settings.amrLogout': 'Sign out',
'settings.amrLoggingIn': 'Signing in…',
'settings.amrLoggingOut': 'Signing out…',
'settings.amrLoggedInAs': 'Signed in as {email}',
'settings.amrLoggedInWithPlan': 'Signed in as {email} · {plan}',
'settings.amrLoggedInPill': 'Signed in',
'settings.amrNotLoggedIn': 'Not signed in',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': 'Authorize',
'settings.amrBenefitOfficial': 'Officially maintained',
'settings.amrBenefitLowerPrice': 'Lower price',
'settings.amrBenefitManyModels': 'Many models',
'settings.amrPromoBonus': 'Limited bonus: +100%',
'settings.amrSignInToContinue': 'Sign in to continue',
'settings.amrSignIn': 'Sign in',
'settings.amrSignedIn': 'Signed in',
'settings.amrNotSignedIn': 'Not signed in',
'settings.amrSigningIn': 'Signing in…',
'settings.amrCancelSignIn': 'Cancel sign-in',
'settings.amrAccountStatus': 'AMR account status',
'settings.amrLoginErrorCompact': 'AMR sign-in failed.',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'Szybkie wypełnienie dostawcy',
'settings.customProvider': 'Niestandardowy dostawca',

View file

@ -3,6 +3,16 @@ import { en } from './en';
export const ptBR: Dict = {
...en,
'chat.amrCard.switchTitle': 'Falha ao chamar o modelo — esta execução está pausada',
'chat.amrCard.switchBody': 'Mude para o serviço oficial de modelos AMR do Open Design — sem precisar configurar API Key. Após entrar, autorizar e recarregar, esta execução será repetida automaticamente.',
'chat.amrCard.chipOfficial': 'Hospedagem oficial',
'chat.amrCard.chipNoKey': 'Sem API Key',
'chat.amrCard.chipAutoRetry': 'Nova tentativa automática após entrar',
'chat.amrCard.switchCta': 'Mudar para AMR e tentar novamente',
'chat.amrError.authMessage': 'Sua conta AMR ainda não está autorizada. Autorize-a e esta execução será repetida automaticamente.',
'chat.amrError.balanceMessage': 'Seu saldo AMR acabou. Recarregue para continuar esta execução.',
'chat.amrError.authorizeCta': 'Autorizar e tentar novamente',
'chat.amrError.rechargeCta': 'Recarregar AMR',
'plugins.actions.copyInstallCommand': 'Copiar comando de instalação',
'plugins.actions.copyPluginId': 'Copiar ID do plugin',
'plugins.actions.copyReadmeBadge': 'Copiar selo do README',
@ -97,7 +107,13 @@ export const ptBR: Dict = {
'Pick or create a brand system so generated work follows real colors, typography, and product language.',
'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody':
'Use a local CLI agent or connect your own API key. You can change this any time in Settings.',
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Mantido oficialmente',
'settings.onboardingAmrCloudBenefitReady': 'Pronto para usar',
'settings.onboardingAmrCloudBenefitModels': 'Muitos modelos',
'settings.onboardingAmrCloudBenefitPricing': 'Preço melhor',
'settings.onboardingAmrCloudAuthorizeAction': 'Autorizar AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Autorizado',
'settings.onboardingStepConnect': "Connect",
'settings.onboardingStepDesignSystem': "Design system",
'settings.onboardingStepProfile': "About you",
@ -225,6 +241,28 @@ export const ptBR: Dict = {
'settings.agentInstallGroup': 'Disponíveis para instalar ({count})',
'settings.agentAuthRequired': 'Autenticação necessária',
'settings.agentAuthUnknown': 'Status de autenticação desconhecido',
'settings.amrLogin': 'Sign in',
'settings.amrLogout': 'Sign out',
'settings.amrLoggingIn': 'Signing in…',
'settings.amrLoggingOut': 'Signing out…',
'settings.amrLoggedInAs': 'Signed in as {email}',
'settings.amrLoggedInWithPlan': 'Signed in as {email} · {plan}',
'settings.amrLoggedInPill': 'Signed in',
'settings.amrNotLoggedIn': 'Not signed in',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': 'Authorize',
'settings.amrBenefitOfficial': 'Officially maintained',
'settings.amrBenefitLowerPrice': 'Lower price',
'settings.amrBenefitManyModels': 'Many models',
'settings.amrPromoBonus': 'Limited bonus: +100%',
'settings.amrSignInToContinue': 'Sign in to continue',
'settings.amrSignIn': 'Sign in',
'settings.amrSignedIn': 'Signed in',
'settings.amrNotSignedIn': 'Not signed in',
'settings.amrSigningIn': 'Signing in…',
'settings.amrCancelSignIn': 'Cancel sign-in',
'settings.amrAccountStatus': 'AMR account status',
'settings.amrLoginErrorCompact': 'AMR sign-in failed.',
'settings.apiSection': 'API da Anthropic',
'settings.quickFillProvider': 'Preencher provedor',
'settings.customProvider': 'Provedor personalizado',

View file

@ -3,6 +3,16 @@ import { en } from './en';
export const ru: Dict = {
...en,
'chat.amrCard.switchTitle': 'Не удалось вызвать модель — это выполнение приостановлено',
'chat.amrCard.switchBody': 'Переключитесь на официальный сервис моделей AMR от Open Design — без настройки API-ключа. После входа, авторизации и пополнения это выполнение будет автоматически повторено.',
'chat.amrCard.chipOfficial': 'Официальный хостинг',
'chat.amrCard.chipNoKey': 'Без API-ключа',
'chat.amrCard.chipAutoRetry': 'Авто-повтор после входа',
'chat.amrCard.switchCta': 'Переключиться на AMR и повторить',
'chat.amrError.authMessage': 'Ваш аккаунт AMR ещё не авторизован. Авторизуйте его, и это выполнение будет автоматически повторено.',
'chat.amrError.balanceMessage': 'Баланс AMR исчерпан. Пополните, чтобы продолжить это выполнение.',
'chat.amrError.authorizeCta': 'Авторизовать и повторить',
'chat.amrError.rechargeCta': 'Пополнить AMR',
'plugins.actions.copyInstallCommand': 'Скопировать команду установки',
'plugins.actions.copyPluginId': 'Скопировать ID плагина',
'plugins.actions.copyReadmeBadge': 'Скопировать бейдж README',
@ -97,7 +107,13 @@ export const ru: Dict = {
'Pick or create a brand system so generated work follows real colors, typography, and product language.',
'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody':
'Use a local CLI agent or connect your own API key. You can change this any time in Settings.',
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Официальная поддержка',
'settings.onboardingAmrCloudBenefitReady': 'Готово к работе',
'settings.onboardingAmrCloudBenefitModels': 'Много моделей',
'settings.onboardingAmrCloudBenefitPricing': 'Выгодная цена',
'settings.onboardingAmrCloudAuthorizeAction': 'Авторизовать AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Авторизовано',
'settings.onboardingStepConnect': "Connect",
'settings.onboardingStepDesignSystem': "Design system",
'settings.onboardingStepProfile': "About you",
@ -225,6 +241,28 @@ export const ru: Dict = {
'settings.agentInstallGroup': 'Доступно для установки ({count})',
'settings.agentAuthRequired': 'Требуется аутентификация',
'settings.agentAuthUnknown': 'Статус аутентификации неизвестен',
'settings.amrLogin': 'Sign in',
'settings.amrLogout': 'Sign out',
'settings.amrLoggingIn': 'Signing in…',
'settings.amrLoggingOut': 'Signing out…',
'settings.amrLoggedInAs': 'Signed in as {email}',
'settings.amrLoggedInWithPlan': 'Signed in as {email} · {plan}',
'settings.amrLoggedInPill': 'Signed in',
'settings.amrNotLoggedIn': 'Not signed in',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': 'Authorize',
'settings.amrBenefitOfficial': 'Officially maintained',
'settings.amrBenefitLowerPrice': 'Lower price',
'settings.amrBenefitManyModels': 'Many models',
'settings.amrPromoBonus': 'Limited bonus: +100%',
'settings.amrSignInToContinue': 'Sign in to continue',
'settings.amrSignIn': 'Sign in',
'settings.amrSignedIn': 'Signed in',
'settings.amrNotSignedIn': 'Not signed in',
'settings.amrSigningIn': 'Signing in…',
'settings.amrCancelSignIn': 'Cancel sign-in',
'settings.amrAccountStatus': 'AMR account status',
'settings.amrLoginErrorCompact': 'AMR sign-in failed.',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'Быстро заполнить провайдера',
'settings.customProvider': 'Пользовательский провайдер',

View file

@ -3,6 +3,16 @@ import type { Dict } from '../types';
export const th: Dict = {
...en,
'chat.amrCard.switchTitle': 'เรียกใช้โมเดลล้มเหลว — งานนี้ถูกหยุดชั่วคราว',
'chat.amrCard.switchBody': 'สลับไปยังบริการโมเดล AMR อย่างเป็นทางการของ Open Design — ไม่ต้องตั้งค่า API Key หลังจากเข้าสู่ระบบ ให้สิทธิ์ และเติมเงินแล้ว งานนี้จะถูกลองใหม่โดยอัตโนมัติ',
'chat.amrCard.chipOfficial': 'โฮสติ้งอย่างเป็นทางการ',
'chat.amrCard.chipNoKey': 'ไม่ต้องใช้ API Key',
'chat.amrCard.chipAutoRetry': 'ลองใหม่อัตโนมัติหลังเข้าสู่ระบบ',
'chat.amrCard.switchCta': 'สลับไปยัง AMR และลองใหม่',
'chat.amrError.authMessage': 'บัญชี AMR ของคุณยังไม่ได้รับอนุญาต ให้สิทธิ์แล้วงานนี้จะถูกลองใหม่โดยอัตโนมัติ',
'chat.amrError.balanceMessage': 'ยอดเงิน AMR ของคุณหมดแล้ว เติมเงินเพื่อดำเนินงานนี้ต่อ',
'chat.amrError.authorizeCta': 'ให้สิทธิ์และลองใหม่',
'chat.amrError.rechargeCta': 'เติมเงิน AMR',
'plugins.actions.copyInstallCommand': 'คัดลอกคำสั่งติดตั้ง',
'plugins.actions.copyPluginId': 'คัดลอก ID ปลั๊กอิน',
'plugins.actions.copyReadmeBadge': 'คัดลอกแบดจ์ README',
@ -97,7 +107,13 @@ export const th: Dict = {
'Pick or create a brand system so generated work follows real colors, typography, and product language.',
'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody':
'Use a local CLI agent or connect your own API key. You can change this any time in Settings.',
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'ดูแลอย่างเป็นทางการ',
'settings.onboardingAmrCloudBenefitReady': 'พร้อมใช้งาน',
'settings.onboardingAmrCloudBenefitModels': 'มีโมเดลให้เลือกมาก',
'settings.onboardingAmrCloudBenefitPricing': 'ราคาดีกว่า',
'settings.onboardingAmrCloudAuthorizeAction': 'อนุญาต AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'อนุญาตแล้ว',
'settings.onboardingStepConnect': "Connect",
'settings.onboardingStepDesignSystem': "Design system",
'settings.onboardingStepProfile': "About you",
@ -223,6 +239,28 @@ export const th: Dict = {
'settings.agentInstalledGroup': 'CLI ของคุณ ({count})',
'settings.agentInstallGroup': 'พร้อมให้ติดตั้ง ({count})',
'settings.agentAuthUnknown': 'ไม่ทราบสถานะการยืนยันตัวตน',
'settings.amrLogin': 'Sign in',
'settings.amrLogout': 'Sign out',
'settings.amrLoggingIn': 'Signing in…',
'settings.amrLoggingOut': 'Signing out…',
'settings.amrLoggedInAs': 'Signed in as {email}',
'settings.amrLoggedInWithPlan': 'Signed in as {email} · {plan}',
'settings.amrLoggedInPill': 'Signed in',
'settings.amrNotLoggedIn': 'Not signed in',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': 'Authorize',
'settings.amrBenefitOfficial': 'Officially maintained',
'settings.amrBenefitLowerPrice': 'Lower price',
'settings.amrBenefitManyModels': 'Many models',
'settings.amrPromoBonus': 'Limited bonus: +100%',
'settings.amrSignInToContinue': 'Sign in to continue',
'settings.amrSignIn': 'Sign in',
'settings.amrSignedIn': 'Signed in',
'settings.amrNotSignedIn': 'Not signed in',
'settings.amrSigningIn': 'Signing in…',
'settings.amrCancelSignIn': 'Cancel sign-in',
'settings.amrAccountStatus': 'AMR account status',
'settings.amrLoginErrorCompact': 'AMR sign-in failed.',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'เลือกผู้ให้บริการอย่างรวดเร็ว',
'settings.customProvider': 'กำหนดผู้ให้บริการเอง',

View file

@ -3,6 +3,16 @@ import type { Dict } from '../types';
export const tr: Dict = {
...en,
'chat.amrCard.switchTitle': 'Model çağrısı başarısız oldu — bu çalıştırma duraklatıldı',
'chat.amrCard.switchBody': 'Open Design\'ın resmi AMR model hizmetine geçin — API anahtarı yapılandırması gerekmez. Oturum açma, yetkilendirme ve bakiye yükleme sonrası bu çalıştırma otomatik olarak yeniden denenir.',
'chat.amrCard.chipOfficial': 'Resmi hizmet',
'chat.amrCard.chipNoKey': 'API anahtarı gerekmez',
'chat.amrCard.chipAutoRetry': 'Giriş sonrası otomatik yeniden deneme',
'chat.amrCard.switchCta': 'AMR\'ye geç ve yeniden dene',
'chat.amrError.authMessage': 'AMR hesabınız henüz yetkilendirilmedi. Yetkilendirin ve bu çalıştırma otomatik olarak yeniden denensin.',
'chat.amrError.balanceMessage': 'AMR bakiyeniz bitti. Çalıştırmaya devam etmek için bakiye yükleyin.',
'chat.amrError.authorizeCta': 'Yetkilendir ve yeniden dene',
'chat.amrError.rechargeCta': 'AMR bakiyesi yükle',
'plugins.actions.copyInstallCommand': 'Kurulum komutunu kopyala',
'plugins.actions.copyPluginId': 'Eklenti IDsini kopyala',
'plugins.actions.copyReadmeBadge': 'README rozetini kopyala',
@ -97,7 +107,13 @@ export const tr: Dict = {
'Pick or create a brand system so generated work follows real colors, typography, and product language.',
'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody':
'Use a local CLI agent or connect your own API key. You can change this any time in Settings.',
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Resmi bakım',
'settings.onboardingAmrCloudBenefitReady': 'Kullanıma hazır',
'settings.onboardingAmrCloudBenefitModels': 'Çok model seçeneği',
'settings.onboardingAmrCloudBenefitPricing': 'Daha uygun fiyat',
'settings.onboardingAmrCloudAuthorizeAction': 'AMR yetkilendir',
'settings.onboardingAmrCloudAuthorizedAction': 'Yetkilendirildi',
'settings.onboardingStepConnect': "Connect",
'settings.onboardingStepDesignSystem': "Design system",
'settings.onboardingStepProfile': "About you",
@ -225,6 +241,28 @@ export const tr: Dict = {
'settings.agentInstallGroup': 'Kurulabilir ({count})',
'settings.agentAuthRequired': 'Kimlik doğrulama gerekli',
'settings.agentAuthUnknown': 'Kimlik doğrulama durumu bilinmiyor',
'settings.amrLogin': 'Sign in',
'settings.amrLogout': 'Sign out',
'settings.amrLoggingIn': 'Signing in…',
'settings.amrLoggingOut': 'Signing out…',
'settings.amrLoggedInAs': 'Signed in as {email}',
'settings.amrLoggedInWithPlan': 'Signed in as {email} · {plan}',
'settings.amrLoggedInPill': 'Signed in',
'settings.amrNotLoggedIn': 'Not signed in',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': 'Authorize',
'settings.amrBenefitOfficial': 'Officially maintained',
'settings.amrBenefitLowerPrice': 'Lower price',
'settings.amrBenefitManyModels': 'Many models',
'settings.amrPromoBonus': 'Limited bonus: +100%',
'settings.amrSignInToContinue': 'Sign in to continue',
'settings.amrSignIn': 'Sign in',
'settings.amrSignedIn': 'Signed in',
'settings.amrNotSignedIn': 'Not signed in',
'settings.amrSigningIn': 'Signing in…',
'settings.amrCancelSignIn': 'Cancel sign-in',
'settings.amrAccountStatus': 'AMR account status',
'settings.amrLoginErrorCompact': 'AMR sign-in failed.',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'Sağlayıcıyı hızlı doldur',
'settings.customProvider': 'Özel sağlayıcı',

View file

@ -3,6 +3,16 @@ import { en } from './en';
export const uk: Dict = {
...en,
'chat.amrCard.switchTitle': 'Не вдалося викликати модель — це виконання призупинено',
'chat.amrCard.switchBody': 'Перейдіть на офіційний сервіс моделей AMR від Open Design — без налаштування API-ключа. Після входу, авторизації та поповнення це виконання буде повторено автоматично.',
'chat.amrCard.chipOfficial': 'Офіційний хостинг',
'chat.amrCard.chipNoKey': 'Без API-ключа',
'chat.amrCard.chipAutoRetry': 'Авто-повтор після входу',
'chat.amrCard.switchCta': 'Перейти на AMR і повторити',
'chat.amrError.authMessage': 'Ваш обліковий запис AMR ще не авторизовано. Авторизуйте, і це виконання буде повторено автоматично.',
'chat.amrError.balanceMessage': 'Баланс AMR вичерпано. Поповніть, щоб продовжити це виконання.',
'chat.amrError.authorizeCta': 'Авторизувати та повторити',
'chat.amrError.rechargeCta': 'Поповнити AMR',
'plugins.actions.copyInstallCommand': 'Скопіювати команду встановлення',
'plugins.actions.copyPluginId': 'Скопіювати ID плагіна',
'plugins.actions.copyReadmeBadge': 'Скопіювати бейдж README',
@ -97,7 +107,13 @@ export const uk: Dict = {
'Pick or create a brand system so generated work follows real colors, typography, and product language.',
'settings.onboardingExecutionTitle': 'Choose how generation runs',
'settings.onboardingExecutionBody':
'Use a local CLI agent or connect your own API key. You can change this any time in Settings.',
'Official CLI with one-click setup and ready-to-use defaults. Use one key to choose from many models with better pricing.',
'settings.onboardingAmrCloudBenefitOfficial': 'Офіційна підтримка',
'settings.onboardingAmrCloudBenefitReady': 'Готово до роботи',
'settings.onboardingAmrCloudBenefitModels': 'Багато моделей',
'settings.onboardingAmrCloudBenefitPricing': 'Вигідніша ціна',
'settings.onboardingAmrCloudAuthorizeAction': 'Авторизувати AMR',
'settings.onboardingAmrCloudAuthorizedAction': 'Авторизовано',
'settings.onboardingStepConnect': "Connect",
'settings.onboardingStepDesignSystem': "Design system",
'settings.onboardingStepProfile': "About you",
@ -226,6 +242,28 @@ export const uk: Dict = {
'settings.agentInstallGroup': 'Доступні для встановлення ({count})',
'settings.agentAuthRequired': 'Потрібна автентифікація',
'settings.agentAuthUnknown': 'Стан автентифікації невідомий',
'settings.amrLogin': 'Sign in',
'settings.amrLogout': 'Sign out',
'settings.amrLoggingIn': 'Signing in…',
'settings.amrLoggingOut': 'Signing out…',
'settings.amrLoggedInAs': 'Signed in as {email}',
'settings.amrLoggedInWithPlan': 'Signed in as {email} · {plan}',
'settings.amrLoggedInPill': 'Signed in',
'settings.amrNotLoggedIn': 'Not signed in',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': 'Authorize',
'settings.amrBenefitOfficial': 'Officially maintained',
'settings.amrBenefitLowerPrice': 'Lower price',
'settings.amrBenefitManyModels': 'Many models',
'settings.amrPromoBonus': 'Limited bonus: +100%',
'settings.amrSignInToContinue': 'Sign in to continue',
'settings.amrSignIn': 'Sign in',
'settings.amrSignedIn': 'Signed in',
'settings.amrNotSignedIn': 'Not signed in',
'settings.amrSigningIn': 'Signing in…',
'settings.amrCancelSignIn': 'Cancel sign-in',
'settings.amrAccountStatus': 'AMR account status',
'settings.amrLoginErrorCompact': 'AMR sign-in failed.',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': 'Швидко заповнити провайдера',
'settings.customProvider': 'Власний провайдер',

View file

@ -1,6 +1,16 @@
import type { Dict } from '../types';
export const zhCN: Dict = {
'chat.amrCard.switchTitle': "模型调用失败,当前任务已暂停",
'chat.amrCard.switchBody': "可切换到 Open Design 官方 AMR 模型服务,无需配置 API Key。完成登录、授权和充值后将自动重试当前任务。",
'chat.amrCard.chipOfficial': "官方托管",
'chat.amrCard.chipNoKey': "无需 API Key",
'chat.amrCard.chipAutoRetry': "授权后自动重试",
'chat.amrCard.switchCta': "切换到 AMR 并重试",
'chat.amrError.authMessage': "AMR 账号尚未授权。完成授权后将自动重试当前任务。",
'chat.amrError.balanceMessage': "AMR 账户余额不足。充值后可继续运行当前任务。",
'chat.amrError.authorizeCta': "授权并重试",
'chat.amrError.rechargeCta': "前往充值",
'common.cancel': '取消',
'common.save': '保存',
'common.close': '关闭',
@ -84,13 +94,20 @@ export const zhCN: Dict = {
'选择或创建品牌系统,让生成结果跟随真实的颜色、字体和产品语言。',
'settings.onboardingExecutionTitle': '选择生成方式',
'settings.onboardingExecutionBody':
'使用本机 CLI Agent或连接自己的 API Key。后续可以随时在设置里修改。',
'官方 CLI一键配置、开箱即用。一个 Key 自由选择海量模型,价格更优惠。',
'settings.onboardingAmrCloudBenefitOfficial': '官方维护',
'settings.onboardingAmrCloudBenefitReady': '开箱即用',
'settings.onboardingAmrCloudBenefitModels': '海量模型可选',
'settings.onboardingAmrCloudBenefitPricing': '价格优惠',
'settings.onboardingAmrCloudAuthorizeAction': '授权使用',
'settings.onboardingAmrCloudAuthorizedAction': '已授权',
'settings.onboardingStepConnect': "连接",
'settings.onboardingStepDesignSystem': "设计系统",
'settings.onboardingStepProfile': "关于你",
'settings.onboardingConnectTitle': "选择运行方式",
'settings.onboardingConnectBody': "",
'settings.onboardingRecommended': "推荐",
'settings.onboardingAmrCloudOfficialBadge': "官方",
'settings.onboardingLocalTitle': "本地 Coding Agent",
'settings.onboardingLocalBody': "使用已安装的 CLI如 Claude Code、Codex、Cursor、Gemini 或 OpenCode。",
'settings.onboardingLocalAction': "打开 CLI 设置",
@ -216,7 +233,29 @@ export const zhCN: Dict = {
'settings.agentInstallGroup': '可安装({count}',
'settings.agentAuthRequired': '需要认证',
'settings.agentAuthUnknown': '认证状态未知',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': '授权',
'settings.amrBenefitOfficial': '官方维护',
'settings.amrBenefitLowerPrice': '价格更低',
'settings.amrBenefitManyModels': '海量模型',
'settings.amrPromoBonus': '限时充值赠 100%',
'settings.amrSignInToContinue': '授权后继续',
'settings.amrSignIn': '登录',
'settings.amrSignedIn': '已登录',
'settings.amrNotSignedIn': '未授权',
'settings.amrSigningIn': '登录中…',
'settings.amrCancelSignIn': '取消登录',
'settings.amrAccountStatus': 'AMR 账户状态',
'settings.amrLoginErrorCompact': 'AMR 登录失败。',
'settings.advanced': '高级设置',
'settings.amrLogin': '登录',
'settings.amrLogout': '登出',
'settings.amrLoggingIn': '登录中…',
'settings.amrLoggingOut': '退出中…',
'settings.amrLoggedInAs': '已登录 {email}',
'settings.amrLoggedInWithPlan': '已登录 {email} · {plan}',
'settings.amrLoggedInPill': '已登录',
'settings.amrNotLoggedIn': '未授权',
'settings.apiSection': 'Anthropic API',
'settings.quickFillProvider': '快速填充提供方',
'settings.customProvider': '自定义提供方',
@ -282,7 +321,7 @@ export const zhCN: Dict = {
'settings.modelPickerHint':
'当 CLI 提供 `models` 命令时会自动拉取。选择「默认」则沿用 CLI 自身的配置;选择「自定义」可手动输入任何 CLI 支持的模型 id。',
'settings.modelPickerLiveHint':
'已从已安装的 CLI 刷新模型。“默认”仍使用 CLI 自身配置。',
'模型列表来自这个 CLI选“默认”会沿用 CLI 自己的设置。',
'settings.modelPickerFallbackHint':
'正在显示内置默认值。点击“重新扫描”可从 CLI 拉取实时模型。',
'settings.cliEnvTitle': 'CLI 配置位置',

View file

@ -3,6 +3,16 @@ import { en } from './en';
export const zhTW: Dict = {
...en,
'chat.amrCard.switchTitle': "模型呼叫失敗,目前任務已暫停",
'chat.amrCard.switchBody': "可切換到 Open Design 官方 AMR 模型服務,無需設定 API Key。完成登入、授權與儲值後將自動重試目前任務。",
'chat.amrCard.chipOfficial': "官方代管",
'chat.amrCard.chipNoKey': "無需 API Key",
'chat.amrCard.chipAutoRetry': "授權後自動重試",
'chat.amrCard.switchCta': "切換到 AMR 並重試",
'chat.amrError.authMessage': "AMR 帳號尚未授權。完成授權後將自動重試目前任務。",
'chat.amrError.balanceMessage': "AMR 帳戶餘額不足。儲值後即可繼續執行目前任務。",
'chat.amrError.authorizeCta': "授權並重試",
'chat.amrError.rechargeCta': "前往儲值",
'plugins.actions.copyInstallCommand': '複製安裝命令',
'plugins.actions.copyPluginId': '複製外掛 ID',
'plugins.actions.copyReadmeBadge': '複製 README 徽章',
@ -100,13 +110,20 @@ export const zhTW: Dict = {
'選擇或建立品牌系統,讓生成結果跟隨真實的顏色、字體和產品語言。',
'settings.onboardingExecutionTitle': '選擇生成方式',
'settings.onboardingExecutionBody':
'使用本機 CLI Agent或連接自己的 API Key。後續可以隨時在設定裡修改。',
'官方 CLI一鍵配置、開箱即用。一個 Key 自由選擇海量模型,價格更優惠。',
'settings.onboardingAmrCloudBenefitOfficial': '官方維護',
'settings.onboardingAmrCloudBenefitReady': '開箱即用',
'settings.onboardingAmrCloudBenefitModels': '海量模型可選',
'settings.onboardingAmrCloudBenefitPricing': '價格優惠',
'settings.onboardingAmrCloudAuthorizeAction': '授權使用',
'settings.onboardingAmrCloudAuthorizedAction': '已授權',
'settings.onboardingStepConnect': "連接",
'settings.onboardingStepDesignSystem': "設計系統",
'settings.onboardingStepProfile': "關於你",
'settings.onboardingConnectTitle': "選擇執行方式",
'settings.onboardingConnectBody': "",
'settings.onboardingRecommended': "推荐",
'settings.onboardingAmrCloudOfficialBadge': "官方",
'settings.onboardingLocalTitle': "本地 Coding Agent",
'settings.onboardingLocalBody': "使用已安裝的 CLI如 Claude Code、Codex、Cursor、Gemini 或 OpenCode。",
'settings.onboardingLocalAction': "開啟 CLI 設定",
@ -227,6 +244,28 @@ export const zhTW: Dict = {
'settings.agentInstallGroup': '可安裝({count}',
'settings.agentAuthRequired': '需要認證',
'settings.agentAuthUnknown': '認證狀態未知',
'settings.amrLogin': '登入',
'settings.amrLogout': '登出',
'settings.amrLoggingIn': '登入中…',
'settings.amrLoggingOut': '登出中…',
'settings.amrLoggedInAs': '已登入 {email}',
'settings.amrLoggedInWithPlan': '已登入 {email} · {plan}',
'settings.amrLoggedInPill': '已登入',
'settings.amrNotLoggedIn': '未授權',
'settings.amrCloud': 'Open Design AMR',
'settings.amrAuthorize': '授權',
'settings.amrBenefitOfficial': '官方維護',
'settings.amrBenefitLowerPrice': '價格更低',
'settings.amrBenefitManyModels': '海量模型',
'settings.amrPromoBonus': '限時儲值贈 100%',
'settings.amrSignInToContinue': '授權後繼續',
'settings.amrSignIn': '登入',
'settings.amrSignedIn': '已登入',
'settings.amrNotSignedIn': '未授權',
'settings.amrSigningIn': '登入中…',
'settings.amrCancelSignIn': '取消登入',
'settings.amrAccountStatus': 'AMR 帳戶狀態',
'settings.amrLoginErrorCompact': 'AMR 登入失敗。',
'settings.apiSection': 'API 設定',
'settings.quickFillProvider': '快速填入提供方',
'settings.customProvider': '自訂提供方',
@ -274,7 +313,7 @@ export const zhTW: Dict = {
'settings.modelPickerHint':
'當 CLI 提供 `models` 命令時會自動拉取。選擇「預設」則沿用 CLI 自身的設定;選擇「自訂」可手動輸入任何 CLI 支援的模型 id。',
'settings.modelPickerLiveHint':
'已從已安裝的 CLI 重新整理模型。「預設」仍使用 CLI 自身設定。',
'模型列表來自這個 CLI選「預設」會沿用 CLI 自己的設定。',
'settings.modelPickerFallbackHint':
'正在顯示內建預設值。點擊「重新掃描」可從 CLI 拉取即時模型。',
'settings.cliEnvTitle': 'CLI 設定位置',

View file

@ -114,12 +114,19 @@ export interface Dict {
'settings.onboardingSystemsBody': string;
'settings.onboardingExecutionTitle': string;
'settings.onboardingExecutionBody': string;
'settings.onboardingAmrCloudBenefitOfficial': string;
'settings.onboardingAmrCloudBenefitReady': string;
'settings.onboardingAmrCloudBenefitModels': string;
'settings.onboardingAmrCloudBenefitPricing': string;
'settings.onboardingAmrCloudAuthorizeAction': string;
'settings.onboardingAmrCloudAuthorizedAction': string;
'settings.onboardingStepConnect': string;
'settings.onboardingStepDesignSystem': string;
'settings.onboardingStepProfile': string;
'settings.onboardingConnectTitle': string;
'settings.onboardingConnectBody': string;
'settings.onboardingRecommended': string;
'settings.onboardingAmrCloudOfficialBadge': string;
'settings.onboardingLocalTitle': string;
'settings.onboardingLocalBody': string;
'settings.onboardingLocalAction': string;
@ -241,6 +248,28 @@ export interface Dict {
'settings.agentAuthRequired': string;
'settings.agentAuthUnknown': string;
'settings.advanced': string;
'settings.amrLogin': string;
'settings.amrLogout': string;
'settings.amrLoggingIn': string;
'settings.amrLoggingOut': string;
'settings.amrLoggedInAs': string;
'settings.amrLoggedInWithPlan': string;
'settings.amrLoggedInPill': string;
'settings.amrNotLoggedIn': string;
'settings.amrCloud': string;
'settings.amrAuthorize': string;
'settings.amrBenefitOfficial': string;
'settings.amrBenefitLowerPrice': string;
'settings.amrBenefitManyModels': string;
'settings.amrPromoBonus': string;
'settings.amrSignInToContinue': string;
'settings.amrSignIn': string;
'settings.amrSignedIn': string;
'settings.amrNotSignedIn': string;
'settings.amrSigningIn': string;
'settings.amrCancelSignIn': string;
'settings.amrAccountStatus': string;
'settings.amrLoginErrorCompact': string;
'settings.apiSection': string;
'settings.quickFillProvider': string;
'settings.customProvider': string;
@ -1649,6 +1678,16 @@ export interface Dict {
'project.instructionsActive': string;
'project.resizeChatPanel': string;
'chat.tabChat': string;
'chat.amrCard.switchTitle': string;
'chat.amrCard.switchBody': string;
'chat.amrCard.chipOfficial': string;
'chat.amrCard.chipNoKey': string;
'chat.amrCard.chipAutoRetry': string;
'chat.amrCard.switchCta': string;
'chat.amrError.authMessage': string;
'chat.amrError.balanceMessage': string;
'chat.amrError.authorizeCta': string;
'chat.amrError.rechargeCta': string;
'chat.tabComments': string;
'chat.commentsSoon': string;
'chat.comments.attached': string;

View file

@ -225,6 +225,31 @@ function daemonSseErrorMessage(data: SseErrorPayload): string {
return `${message}\n${detail}`;
}
function daemonSseError(data: SseErrorPayload): Error {
const error = new Error(daemonSseErrorMessage(data)) as Error & {
code?: string;
details?: unknown;
};
if (data.error?.code) error.code = data.error.code;
if (data.error?.details !== undefined) error.details = data.error.details;
return error;
}
function shouldSuppressLifecycleExitFallback(
agentId: string | undefined,
exitCode: number | null,
exitSignal: string | null,
stderrTail: string,
): boolean {
if (exitCode !== 130 || exitSignal) return false;
if (agentId === 'amr') return true;
const normalizedStderr = stderrTail.toLowerCase();
return (
normalizedStderr.includes('opencode server listening') ||
normalizedStderr.includes('opencode_server_password')
);
}
export async function streamViaDaemon({
agentId,
history,
@ -317,6 +342,7 @@ export async function streamViaDaemon({
notifyRunsChanged();
emitRunStatus('queued');
await consumeDaemonRun({
agentId,
runId,
signal,
cancelSignal,
@ -376,6 +402,85 @@ export async function submitChatRunToolResult(
}
}
export interface VelaUser {
id: string;
email: string;
name?: string;
image?: string | null;
plan?: string;
}
export interface VelaLoginStatus {
loggedIn: boolean;
loginInFlight?: boolean;
profile: string;
user: VelaUser | null;
configPath: string;
}
// AMR (vela) login surfaces three thin endpoints on the daemon:
// GET /api/integrations/vela/status — read ~/.amr/config.json projection
// POST /api/integrations/vela/login — spawn `vela login` (vela opens browser itself)
// 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<VelaLoginStatus | null> {
try {
const resp = await fetch('/api/integrations/vela/status');
if (!resp.ok) return null;
return (await resp.json()) as VelaLoginStatus;
} catch {
return null;
}
}
export interface StartVelaLoginResult {
ok: boolean;
status: number;
pid?: number;
alreadyRunning?: boolean;
error?: string;
}
export async function startVelaLogin(): Promise<StartVelaLoginResult> {
try {
const resp = await fetch('/api/integrations/vela/login', { method: 'POST' });
if (resp.ok) {
const body = (await resp.json()) as { pid?: number };
return { ok: true, status: resp.status, pid: body.pid };
}
const body = (await resp.json().catch(() => null)) as { error?: string } | null;
return {
ok: false,
status: resp.status,
alreadyRunning: resp.status === 409,
error: body?.error ?? '',
};
} catch (err) {
return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
}
}
export async function cancelVelaLogin(): Promise<{ ok: boolean; canceled?: boolean }> {
try {
const resp = await fetch('/api/integrations/vela/login/cancel', { method: 'POST' });
if (!resp.ok) return { ok: false };
const body = (await resp.json().catch(() => null)) as { canceled?: boolean } | null;
return { ok: true, canceled: body?.canceled };
} catch {
return { ok: false };
}
}
export async function velaLogout(): Promise<{ ok: boolean }> {
try {
const resp = await fetch('/api/integrations/vela/logout', { method: 'POST' });
return { ok: resp.ok };
} catch {
return { ok: false };
}
}
// Forwards the user's assistant-turn rating to the daemon so it can emit
// a Langfuse `score-create`. Fire-and-forget — failures are not surfaced
// to the UI (the rating is already persisted on the message itself via
@ -428,6 +533,7 @@ export async function listProjectRuns(): Promise<ChatRunStatusResponse[]> {
}
async function consumeDaemonRun({
agentId,
runId,
signal,
cancelSignal,
@ -435,7 +541,7 @@ async function consumeDaemonRun({
initialLastEventId,
onRunStatus,
onRunEventId,
}: DaemonReattachOptions): Promise<void> {
}: DaemonReattachOptions & { agentId?: string }): Promise<void> {
let acc = '';
let stderrBuf = '';
let exitCode: number | null = null;
@ -553,7 +659,7 @@ async function consumeDaemonRun({
if (event.event === 'error') {
onRunStatus?.('failed');
const data = event.data as SseErrorPayload;
handlers.onError(new Error(daemonSseErrorMessage(data)));
handlers.onError(daemonSseError(data));
return;
}
@ -612,6 +718,10 @@ async function consumeDaemonRun({
(!serverDeclaredSuccess &&
(exitSignal || (exitCode !== null && exitCode !== 0)));
if (looksLikeFailure) {
if (shouldSuppressLifecycleExitFallback(agentId, exitCode, exitSignal, stderrBuf)) {
handlers.onDone(acc);
return;
}
const tail = stderrBuf.trim().slice(-400);
handlers.onError(
new Error(`agent exited with ${exitSignal ? `signal ${exitSignal}` : `code ${exitCode}`}${tail ? `\n${tail}` : ''}`),

View file

@ -87,7 +87,7 @@ function deployProviderQuery(providerId?: WebDeployProviderId): string {
export async function fetchAgents(options?: { throwOnError?: boolean }): Promise<AgentInfo[]> {
try {
const resp = await fetch('/api/agents');
const resp = await fetch('/api/agents', { cache: 'no-store' });
if (!resp.ok) {
if (options?.throwOnError) throw new Error(`agents ${resp.status}`);
return [];

View file

@ -0,0 +1,87 @@
// Shared logic that maps a failed run's error code + agent into the failure
// UI: which contextual button the gray error card shows, whether to override
// the error text, and whether to show the AMR promotion card below. Kept in
// its own module so ChatPane / ProjectView / AssistantMessage can import it
// without a circular dependency.
// AMR model-gateway console wallet (recharge).
export const AMR_RECHARGE_URL = 'https://open-design.ai/amr/wallet';
// Codes that mean a non-AMR agent hit "the model service rejected or could not
// serve the run" — auth missing/invalid, quota/rate exhausted, or the upstream
// model endpoint was unavailable. These are the failures worth promoting AMR
// for. Generic process failures (AGENT_EXECUTION_FAILED) and missing binaries
// (AGENT_UNAVAILABLE) are excluded.
const PROMOTE_AMR_CODES = new Set<string>([
'AGENT_AUTH_REQUIRED',
'UNAUTHORIZED',
'RATE_LIMITED',
'UPSTREAM_UNAVAILABLE',
]);
// Primary action offered in the gray error card.
// - 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).
export type RunFailurePrimaryAction = 'retry' | 'authorize' | 'recharge';
// i18n keys for the gray-card text override (null = show the raw error).
export type RunFailureMessageKey =
| 'chat.amrError.authMessage'
| 'chat.amrError.balanceMessage'
| null;
export interface RunFailureUi {
primaryAction: RunFailurePrimaryAction;
// Override the gray error card's text (e.g. AMR auth / balance get a clearer
// explanation than the raw upstream string).
messageKey: RunFailureMessageKey;
// Show a secondary plain "retry" button alongside the primary action (used
// by the recharge case, where retry is manual after topping up).
secondaryRetry: boolean;
// Show the AMR promotion card under the gray error card.
showSwitchCard: boolean;
}
// Resolve the failure UI for a failed run:
// - AMR agent, auth required → authorize-and-retry button, clearer copy
// - AMR agent, insufficient funds → recharge button + manual retry, clearer copy
// - AMR agent, anything else → plain retry
// - non-AMR agent, model/auth/quota error → plain retry + promotion card
// - non-AMR agent, generic failure → plain retry
export function resolveRunFailureUi(
code: string | null | undefined,
agentId: string | null | undefined,
): RunFailureUi {
if (agentId === 'amr') {
if (code === 'AMR_AUTH_REQUIRED') {
return {
primaryAction: 'authorize',
messageKey: 'chat.amrError.authMessage',
secondaryRetry: false,
showSwitchCard: false,
};
}
if (code === 'AMR_INSUFFICIENT_BALANCE') {
return {
primaryAction: 'recharge',
messageKey: 'chat.amrError.balanceMessage',
secondaryRetry: true,
showSwitchCard: false,
};
}
return {
primaryAction: 'retry',
messageKey: null,
secondaryRetry: false,
showSwitchCard: false,
};
}
const promote = typeof code === 'string' && PROMOTE_AMR_CODES.has(code);
return {
primaryAction: 'retry',
messageKey: null,
secondaryRetry: false,
showSwitchCard: promote,
};
}

View file

@ -1,6 +1,10 @@
import type { ChatMessage } from '../types';
export function appendErrorStatusEvent(message: ChatMessage, detail: string): ChatMessage {
export function appendErrorStatusEvent(
message: ChatMessage,
detail: string,
code?: string,
): ChatMessage {
if (!detail) return message;
const events = message.events ?? [];
const last = events[events.length - 1];
@ -12,6 +16,6 @@ export function appendErrorStatusEvent(message: ChatMessage, detail: string): Ch
}
return {
...message,
events: [...events, { kind: 'status', label: 'error', detail }],
events: [...events, { kind: 'status', label: 'error', detail, ...(code ? { code } : {}) }],
};
}

View file

@ -323,10 +323,12 @@
color: white;
border-radius: 4px;
}
/* Run-failure error card. Neutral gray (not red): the actionable next step
lives in the AMR card below it; this card just states what happened. */
.msg.error {
border: 1px solid var(--red-border);
background: var(--red-bg);
color: var(--red);
border: 1px solid var(--border);
background: var(--bg-subtle);
color: var(--text-muted);
padding: 10px 12px;
border-radius: var(--radius-sm);
display: flex;
@ -335,12 +337,112 @@
gap: 10px;
}
.chat-error-text { min-width: 0; }
.chat-error-actions {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 8px;
}
.chat-error-retry {
flex: 0 0 auto;
border-color: var(--red-border);
color: var(--red);
padding: 4px 10px;
}
/* Contextual primary action in the gray error card (Authorize & retry / Top up). */
.chat-error-action {
flex: 0 0 auto;
appearance: none;
cursor: pointer;
border: 1px solid var(--accent);
background: var(--accent);
color: white;
font: inherit;
font-weight: 600;
padding: 4px 12px;
border-radius: var(--radius-sm);
transition: background 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.chat-error-action:hover {
background: var(--accent-hover, var(--accent));
}
/* AMR guidance card under the gray error card. Theme-color tinted; two
content variants (switch / recharge) share the shell. */
.amr-card {
margin-top: 8px;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--border));
background: color-mix(in srgb, var(--accent) 7%, var(--bg-panel));
border-radius: var(--radius-sm);
display: flex;
flex-direction: column;
gap: 8px;
}
.amr-card__head {
display: flex;
align-items: center;
gap: 8px;
}
.amr-card__icon {
flex: 0 0 auto;
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--accent);
font-weight: 700;
font-size: 13px;
}
.amr-card__title {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.amr-card__body {
margin: 0;
font-size: 13px;
line-height: 1.55;
color: var(--text-muted);
}
.amr-card__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.amr-card__chip {
font-size: 11px;
line-height: 1.4;
padding: 3px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--accent);
border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
}
.amr-card__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 2px;
}
.amr-card__cta {
appearance: none;
cursor: pointer;
border: 1px solid var(--accent);
background: var(--accent);
color: white;
font: inherit;
font-weight: 600;
font-size: 13px;
padding: 7px 14px;
border-radius: var(--radius-sm);
transition:
background 140ms cubic-bezier(0.23, 1, 0.32, 1),
border-color 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.amr-card__cta:hover {
background: var(--accent-hover, var(--accent));
}
/* -------- Composer -------------------------------------------------- */
.composer {

View file

@ -692,6 +692,7 @@
}
.inline-switcher__chip {
appearance: none;
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
@ -777,6 +778,22 @@
color: var(--text-faint);
flex: 0 0 auto;
}
.inline-switcher__amr-reminder-dot {
position: absolute;
z-index: 1;
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--danger, #dc2626);
box-shadow:
0 0 0 2px var(--bg-panel),
0 0 0 4px color-mix(in srgb, var(--danger, #dc2626) 12%, transparent);
pointer-events: none;
}
.inline-switcher__amr-reminder-dot--chip {
top: -2px;
right: -2px;
}
/* Popover */
.inline-switcher__popover {
@ -852,8 +869,13 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.inline-switcher__agent-row {
display: flex;
min-width: 0;
}
.inline-switcher__agent {
appearance: none;
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
@ -865,6 +887,7 @@
font-size: 12px;
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease;
width: 100%;
min-width: 0;
}
.inline-switcher__agent:hover {
@ -875,14 +898,92 @@
background: var(--accent-tint);
color: var(--text-strong);
}
.inline-switcher__amr-reminder-dot--agent {
top: 6px;
left: 26px;
}
.inline-switcher__agent-name {
flex: 1;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.inline-switcher__agent-status {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex: 0 0 auto;
min-width: 0;
max-width: 76px;
height: 18px;
margin-left: auto;
}
.inline-switcher__agent-status-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 18px;
height: 18px;
border-radius: 999px;
color: var(--accent-strong);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.inline-switcher__agent-status-icon.is-error {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, transparent);
}
.inline-switcher__agent-status-icon.is-signed-out {
color: var(--text-muted);
background: var(--bg-subtle);
}
.inline-switcher__agent-status-icon.is-pending svg {
animation: spin 0.8s linear infinite;
}
.inline-switcher__agent-action-label {
position: relative;
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
color: var(--text-strong);
text-align: right;
}
.inline-switcher__agent-action-default,
.inline-switcher__agent-action-hover {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: opacity 120ms ease, transform 120ms ease;
}
.inline-switcher__agent-action-hover {
position: absolute;
inset: 0;
opacity: 0;
color: var(--danger);
transform: translateY(3px);
}
.inline-switcher__agent-action-label.is-cancelable {
cursor: pointer;
}
.inline-switcher__agent:hover .inline-switcher__agent-action-label.is-cancelable
.inline-switcher__agent-action-default,
.inline-switcher__agent:focus-visible .inline-switcher__agent-action-label.is-cancelable
.inline-switcher__agent-action-default {
opacity: 0;
transform: translateY(-3px);
}
.inline-switcher__agent:hover .inline-switcher__agent-action-label.is-cancelable
.inline-switcher__agent-action-hover,
.inline-switcher__agent:focus-visible .inline-switcher__agent-action-label.is-cancelable
.inline-switcher__agent-action-hover {
opacity: 1;
transform: translateY(0);
}
/* Provider chips (BYOK mode) */
.inline-switcher__chips {
@ -1038,7 +1139,7 @@
.onboarding-view__steps {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
width: min(860px, 100%);
margin: 0;
@ -1129,7 +1230,7 @@
.onboarding-view__content {
display: grid;
gap: 18px;
width: min(800px, 100%);
width: min(860px, 100%);
min-width: 0;
}
@ -1314,6 +1415,13 @@
transform: translateY(-1px);
}
.onboarding-view__primary:hover:not(:disabled) {
color: var(--accent-contrast, #fff);
background: var(--accent-hover);
border-color: var(--accent-hover);
box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 24%, transparent);
}
.onboarding-view__primary:focus-visible,
.onboarding-view__secondary:focus-visible,
.onboarding-view__ghost:focus-visible,
@ -1654,6 +1762,7 @@
grid-template-columns: 44px minmax(0, 1fr);
gap: 14px;
align-items: center;
width: 100%;
min-height: 112px;
padding: 16px 18px;
border-radius: 8px;
@ -1681,6 +1790,14 @@
box-shadow: 0 18px 40px color-mix(in srgb, var(--accent) 10%, transparent);
}
.onboarding-view__card--featured.onboarding-view__card--official {
grid-template-columns: 56px minmax(0, 1fr);
align-items: center;
min-height: 148px;
padding: 50px 24px 36px;
overflow: hidden;
}
.onboarding-view__card.is-selected {
border-color: color-mix(in srgb, var(--accent) 52%, var(--border));
background: color-mix(in srgb, var(--bg-panel) 94%, var(--accent) 6%);
@ -1695,16 +1812,47 @@
);
}
.onboarding-view__card--featured:not(.is-selected) {
border-color: var(--border);
background: var(--bg-panel);
box-shadow: var(--shadow-xs);
}
.onboarding-view__card:hover {
border-color: color-mix(in srgb, var(--accent) 38%, var(--border));
background: color-mix(in srgb, var(--bg-panel) 97%, var(--accent) 3%);
transform: translateY(-1px);
}
.onboarding-view__official-tag {
position: absolute;
z-index: 2;
top: 0;
left: 0;
width: 88px;
height: 30px;
display: block;
overflow: hidden;
border-top-left-radius: inherit;
pointer-events: none;
user-select: none;
}
.onboarding-view__official-tag img {
position: absolute;
top: -4px;
left: -12px;
width: 92px;
height: 32px;
display: block;
max-width: none;
}
.onboarding-view__card-top {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
min-height: 26px;
margin-bottom: 0;
}
@ -1727,6 +1875,19 @@
background: color-mix(in srgb, var(--accent) 12%, var(--bg-panel));
}
.onboarding-view__icon.onboarding-view__icon--asset,
.onboarding-view__card--featured .onboarding-view__icon.onboarding-view__icon--asset {
overflow: hidden;
border-color: transparent;
background: transparent;
}
.onboarding-view__icon--asset .onboarding-view__agent-logo {
display: block;
flex: 0 0 auto;
border-radius: inherit;
}
.onboarding-view__badge {
display: inline-flex;
align-items: center;
@ -1749,6 +1910,12 @@
min-width: 0;
}
.onboarding-view__card--official .onboarding-view__card-copy {
align-self: center;
gap: 6px;
padding-right: 40px;
}
.onboarding-view__card strong {
display: block;
color: var(--text-strong);
@ -1774,6 +1941,99 @@
font-size: 13px;
}
.onboarding-view__benefits {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 0;
}
.onboarding-view__benefit {
display: inline-flex;
align-items: center;
min-height: 20px;
padding: 0 7px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--accent-strong);
background: color-mix(in srgb, var(--accent) 8%, var(--bg-panel));
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 8%, transparent);
font-size: 11px;
font-weight: 680;
line-height: 1;
white-space: nowrap;
}
.onboarding-view__card-status {
position: absolute;
left: 24px;
bottom: 16px;
z-index: 1;
display: inline-flex;
max-width: min(260px, calc(100% - 72px));
color: var(--text-muted);
pointer-events: none;
}
.onboarding-view__card-status .amr-account-control {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 0;
max-width: 100%;
min-height: 20px;
gap: 6px;
padding: 0 8px;
border: 1px solid color-mix(in srgb, var(--accent) 14%, var(--border));
border-radius: 999px;
background: color-mix(in srgb, var(--bg-panel) 90%, var(--accent) 10%);
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 8%, transparent);
color: currentColor;
font-size: 11px;
font-weight: 650;
line-height: 1;
}
.onboarding-view__card-status .amr-account-control__status {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.onboarding-view__card-status .amr-account-control__status::before {
content: '';
flex: 0 0 auto;
width: 6px;
height: 6px;
border-radius: 999px;
background: currentColor;
opacity: 0.7;
}
.onboarding-view__card-status .amr-account-control--signed-in {
color: var(--success-text, #15803d);
border-color: color-mix(in srgb, var(--success-text, #15803d) 18%, var(--border));
background: color-mix(in srgb, var(--bg-panel) 88%, var(--success-text, #15803d) 12%);
}
.onboarding-view__card-status .amr-account-control--signing-in {
color: var(--accent-strong);
}
.onboarding-view__card-status .amr-account-control--error {
color: var(--danger-text, #991b1b);
border-color: color-mix(in srgb, var(--danger-text, #991b1b) 18%, var(--border));
background: color-mix(in srgb, var(--bg-panel) 88%, var(--danger-text, #991b1b) 12%);
}
.onboarding-view__card-status .amr-account-control__error {
display: none;
}
.onboarding-view__card-action {
grid-column: 2;
justify-self: start;
@ -2909,3 +3169,22 @@
justify-self: start;
}
}
@media (max-width: 760px) {
.onboarding-view__card--featured.onboarding-view__card--official {
grid-template-columns: 56px minmax(0, 1fr);
gap: 14px;
align-items: center;
padding: 50px 18px 36px;
}
.onboarding-view__card--official .onboarding-view__card-copy {
padding-right: 40px;
}
.onboarding-view__card-status {
left: 18px;
bottom: 16px;
max-width: min(240px, calc(100% - 72px));
}
}

View file

@ -669,6 +669,198 @@
background: var(--bg-subtle);
}
.agent-scan-card {
position: relative;
display: grid;
align-content: center;
gap: 20px;
overflow: hidden;
min-height: 296px;
padding: 30px 34px;
border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border));
border-radius: 14px;
background:
radial-gradient(circle at 50% 38%, color-mix(in srgb, var(--accent) 13%, transparent), transparent 34%),
linear-gradient(180deg, color-mix(in srgb, var(--bg-panel) 90%, var(--accent) 10%), var(--bg-subtle));
box-shadow:
inset 0 1px 0 color-mix(in srgb, #fff 6%, transparent),
0 18px 42px color-mix(in srgb, #000 18%, transparent);
}
.agent-scan-card::before {
content: '';
position: absolute;
inset: 18px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--accent) 8%, transparent);
pointer-events: none;
}
.agent-scan-card__stage {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.agent-scan-card__ring {
position: relative;
display: block;
width: 46px;
height: 46px;
margin-bottom: 14px;
border-radius: 999px;
background:
radial-gradient(circle at center, var(--bg-subtle) 50%, transparent 52%),
conic-gradient(from 0deg, color-mix(in srgb, var(--accent) 92%, #fff) 0 84deg, color-mix(in srgb, var(--accent) 22%, transparent) 84deg 180deg, transparent 180deg 360deg);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent),
0 0 32px color-mix(in srgb, var(--accent) 18%, transparent);
animation: agent-scan-ring 1.15s linear infinite;
}
.agent-scan-card__ring::after {
content: '';
position: absolute;
inset: 15px;
border-radius: inherit;
background: var(--accent);
box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 42%, transparent);
}
.agent-scan-card__stage strong {
color: var(--text);
font-size: 15px;
font-weight: 700;
line-height: 1.25;
}
.agent-scan-card__stage > span:not(.agent-scan-card__ring) {
margin-top: 6px;
color: var(--text-muted);
font-size: 12px;
line-height: 1.45;
}
.agent-scan-card__progress {
width: min(240px, 70%);
height: 2px;
margin-top: 18px;
overflow: hidden;
border-radius: 999px;
background: color-mix(in srgb, var(--text) 8%, transparent);
}
.agent-scan-card__progress span {
display: block;
width: 44%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
animation: agent-scan-progress 1.35s cubic-bezier(0.23, 1, 0.32, 1) infinite;
}
.agent-scan-card__rows {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
width: min(640px, 100%);
justify-self: center;
}
.agent-scan-card__rows span {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
grid-template-rows: 10px 8px;
gap: 8px 10px;
align-items: center;
min-height: 54px;
padding: 12px;
border: 1px solid color-mix(in srgb, var(--border) 72%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-panel) 72%, transparent);
opacity: 0.7;
}
.agent-scan-card__rows i,
.agent-scan-card__rows b,
.agent-scan-card__rows em {
display: block;
overflow: hidden;
border-radius: 999px;
background:
linear-gradient(90deg, transparent, color-mix(in srgb, var(--text) 9%, transparent), transparent),
color-mix(in srgb, var(--text) 9%, transparent);
background-size: 220% 100%, 100% 100%;
animation: agent-scan-row 1.45s cubic-bezier(0.23, 1, 0.32, 1) infinite;
}
.agent-scan-card__rows i {
grid-row: 1 / span 2;
width: 28px;
height: 28px;
border-radius: 8px;
}
.agent-scan-card__rows b {
width: 82%;
height: 10px;
}
.agent-scan-card__rows em {
width: 54%;
height: 8px;
}
.agent-scan-card__rows span:nth-child(2) {
opacity: 0.58;
}
.agent-scan-card__rows span:nth-child(3) {
opacity: 0.46;
}
.agent-scan-card__rows span:nth-child(2) i,
.agent-scan-card__rows span:nth-child(2) b,
.agent-scan-card__rows span:nth-child(2) em {
animation-delay: 120ms;
}
.agent-scan-card__rows span:nth-child(3) i,
.agent-scan-card__rows span:nth-child(3) b,
.agent-scan-card__rows span:nth-child(3) em {
animation-delay: 240ms;
}
@keyframes agent-scan-ring {
to {
transform: rotate(360deg);
}
}
@keyframes agent-scan-progress {
from {
transform: translateX(-100%);
}
to {
transform: translateX(230%);
}
}
@keyframes agent-scan-row {
from {
background-position: 120% 0, 0 0;
}
to {
background-position: -120% 0, 0 0;
}
}
.agent-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@ -776,10 +968,96 @@
transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease;
}
.agent-card-installed {
display: block;
padding: 0;
min-height: 72px;
overflow: hidden;
}
/* Failed-run AMR nudge: when the chat error card's "Try Open Design AMR" link
opens Settings, the AMR card scrolls into view and pulses a couple of times.
The card's own box-shadow is not clipped by `overflow: hidden`, so the glow
reads fully even though inner content is clipped. */
.agent-card--amr-highlight {
border-color: var(--accent);
animation: amr-card-pulse 1s cubic-bezier(0.23, 1, 0.32, 1) 2;
}
@keyframes amr-card-pulse {
0%,
100% {
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent);
}
35% {
box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent) 34%, transparent);
}
}
/* Authorize-button coachmark: a fake cursor that fades + slides in from the
lower-right, landing with its tip on the AMR authorize button, inside a
pulsing ring. Anchored to the button (`.amr-auth-anchor`) so it always lines
up. `pointer-events: none` keeps the real button clickable; it dismisses the
moment the real pointer reaches the button (handled in JS). */
.amr-auth-anchor {
position: relative;
display: inline-flex;
}
.amr-coachmark {
position: absolute;
/* Horizontally centered on the button, sitting low the hand's fingertip
(top of the glyph) then points up into the button. `margin-left` centers
the box at `left: 50%` so `transform` stays free for the entrance. */
left: 50%;
margin-left: -12px;
bottom: 7px;
width: 24px;
height: 24px;
pointer-events: none;
z-index: 4;
animation: amr-coachmark-in 460ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
/* Fade + rise straight up into place (bottom → up). */
@keyframes amr-coachmark-in {
0% {
opacity: 0;
transform: translateY(16px) scale(0.8);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.amr-coachmark__ring {
position: absolute;
inset: -3px;
border-radius: 50%;
border: 2px solid var(--accent);
opacity: 0;
/* Start pulsing only after the cursor has settled in. */
animation: amr-coachmark-ring 1.5s cubic-bezier(0.23, 1, 0.32, 1) 460ms infinite;
}
@keyframes amr-coachmark-ring {
0% {
transform: scale(0.55);
opacity: 0;
}
30% {
opacity: 0.65;
}
100% {
transform: scale(1.25);
opacity: 0;
}
}
.amr-coachmark__cursor {
position: absolute;
inset: 0;
filter: drop-shadow(0 1px 2px color-mix(in srgb, var(--text-muted) 45%, transparent));
}
.agent-card-main {
display: flex;
align-items: stretch;
min-width: 0;
}
.agent-card-select {
flex: 1;
min-width: 0;
@ -815,16 +1093,43 @@
border-color: var(--border-strong);
}
.agent-card.active {
border-color: var(--border);
background: color-mix(in srgb, var(--accent-tint) 54%, var(--bg-panel));
box-shadow: none;
border-color: color-mix(in srgb, var(--accent) 36%, var(--border));
background: var(--bg-panel);
box-shadow:
0 1px 0 color-mix(in srgb, var(--bg-panel) 88%, transparent) inset,
0 10px 28px color-mix(in srgb, var(--text) 9%, transparent);
}
.agent-card.active::before {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: var(--accent);
inset: 12px auto 12px 0;
width: 4px;
border-radius: 0 999px 999px 0;
background: linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 56%, var(--selected)));
}
.agent-card-installed.active .agent-card-main {
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--accent-tint) 32%, var(--bg-panel)),
color-mix(in srgb, var(--accent-tint) 10%, var(--bg-panel))
);
}
.agent-card-installed.active .agent-card-select {
min-height: 78px;
padding: 14px 18px 12px 22px;
}
.agent-card-installed.active .agent-icon {
border-radius: 11px;
box-shadow:
0 0 0 1px color-mix(in srgb, var(--accent) 16%, var(--border-soft)),
0 8px 18px color-mix(in srgb, var(--accent) 16%, transparent);
}
.agent-card-installed.active .agent-card-name {
font-size: 13px;
}
.agent-card-installed.active .agent-card-meta {
margin-top: 1px;
}
.agent-card.disabled {
cursor: not-allowed;
@ -926,6 +1231,38 @@
.agent-card-name > span:first-child {
flex: 0 1 auto;
}
.agent-card-title {
min-width: 0;
}
.agent-card-name--amr {
align-items: center;
gap: 8px;
}
.agent-card-name--amr .agent-card-title {
flex: 0 0 auto;
}
.agent-card-benefits {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
overflow: hidden;
}
.agent-card-benefit {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
min-height: 20px;
padding: 0 7px;
border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border-soft));
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 8%, var(--bg-panel));
color: color-mix(in srgb, var(--accent-strong) 82%, var(--text-strong));
font-size: 10.5px;
font-weight: 680;
line-height: 1;
white-space: nowrap;
}
.agent-card-name-divider,
.agent-card-tagline {
color: var(--text-muted);
@ -949,6 +1286,102 @@
line-height: 1.35;
}
.agent-card-meta .muted { color: var(--text-soft); font-style: italic; }
.agent-card-amr-email {
margin-top: 4px;
color: var(--text-muted);
font-size: 11px;
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-card-amr-email span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-card-model-summary {
display: flex;
align-items: baseline;
gap: 6px;
margin-top: 4px;
min-width: 0;
color: var(--text-muted);
font-size: 11px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-card-model-summary span {
flex: 0 0 auto;
}
.agent-card-model-summary strong {
min-width: 0;
color: var(--text);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-card-amr-auth {
align-self: stretch;
flex: 0 1 auto;
min-width: 112px;
max-width: 280px;
display: flex;
align-items: center;
justify-content: center;
border-left: 1px solid var(--border-soft);
}
.agent-card-amr-auth--placeholder {
visibility: hidden;
pointer-events: none;
}
.agent-card-amr-auth .amr-account-control {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
gap: 8px;
padding: 0 14px;
}
.agent-card-amr-auth .amr-account-control__status {
max-width: 150px;
overflow: hidden;
color: var(--text-muted);
font-size: 11px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-card-amr-auth .amr-account-control__action {
min-height: 36px;
padding: 0 18px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
border-radius: 10px;
background: color-mix(in srgb, var(--accent-tint) 62%, var(--bg-panel));
color: var(--text);
font-size: 13px;
font-weight: 680;
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease;
}
.agent-card-amr-auth .amr-account-control__action:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
background: color-mix(in srgb, var(--accent-tint) 76%, var(--bg-panel));
color: var(--accent-strong);
transform: translateY(-1px);
}
.agent-card-amr-auth .amr-account-control__action:disabled {
cursor: default;
opacity: 0.62;
}
.agent-card-amr-auth .amr-account-control__error,
.agent-card-amr-auth .amr-login-pill-badge {
display: none;
}
.agent-model-row-head {
font-size: 12px;
color: var(--text);
@ -965,6 +1398,20 @@
border-radius: var(--radius-sm);
background: var(--bg-panel);
}
.agent-card-config {
display: grid;
gap: 9px;
padding: 16px 24px 18px;
border-top: 1px solid color-mix(in srgb, var(--accent) 14%, var(--border-soft));
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--bg-subtle) 52%, var(--bg-panel)),
color-mix(in srgb, var(--bg-panel) 92%, var(--bg-subtle))
);
}
.agent-card-config select,
.agent-card-config input,
.agent-model-row select,
.agent-model-row input,
.agent-cli-env select,
@ -986,7 +1433,9 @@
border-radius: var(--radius-sm);
background: var(--bg-panel);
}
.agent-card-config .field,
.agent-model-row .field { gap: 4px; }
.agent-card-config .field-label,
.agent-model-row .field-label {
display: inline-flex;
align-items: center;
@ -997,6 +1446,11 @@
letter-spacing: 0.04em;
color: var(--text-muted);
}
.agent-card-config .field-label {
margin-bottom: 2px;
font-size: 11px;
letter-spacing: 0.06em;
}
.agent-model-source-badge {
display: inline-flex;
align-items: center;
@ -1032,7 +1486,12 @@
margin-right: 6px;
color: var(--text-muted);
}
.agent-card-config .hint,
.agent-model-row .hint { margin: 0; font-size: 11.5px; }
.agent-card-config .hint {
color: var(--text-muted);
line-height: 1.35;
}
.agent-model-select-wrap {
position: relative;
}
@ -1043,6 +1502,14 @@
padding-right: 28px;
width: 100%;
}
.agent-card-config .agent-model-select-wrap select {
min-height: 36px;
border-color: color-mix(in srgb, var(--border) 78%, transparent);
background: color-mix(in srgb, var(--bg-subtle) 82%, var(--bg-panel));
}
.agent-card-config .agent-model-select-wrap select:hover {
border-color: var(--border-strong);
}
.agent-model-select-chevron {
position: absolute;
right: 8px;
@ -2176,4 +2643,3 @@
font-weight: 500;
color: var(--accent);
}

View file

@ -72,6 +72,19 @@ describe('AgentIcon', () => {
expect(markup).not.toContain('agent-icon-mono');
});
it('renders AMR as the bundled color SVG instead of the fallback initial', () => {
const amrSvg = readFileSync(
new URL('../../public/agent-icons/amr.svg', import.meta.url),
'utf8',
);
const markup = renderToStaticMarkup(<AgentIcon id="amr" size={24} />);
expect(amrSvg).toMatch(/^<svg\b/);
expect(amrSvg).toContain('fill="#87EA5C"');
expect(markup).toContain('src="/agent-icons/amr.svg"');
expect(markup).not.toContain('agent-icon-fallback');
});
it('renders monochrome SVGs as a CSS-masked <span> so they pick up theme color', () => {
// cursor-agent.svg ships with `fill="currentColor"` and would lose its
// ink under a dark theme if loaded through `<img>` (which would make

View file

@ -0,0 +1,95 @@
// @vitest-environment jsdom
/**
* Analytics + behaviour coverage for the hosted-AMR nudge component. It fires
* `surface_view` (element=run_failed_toast) on mount with the full business
* prop set, and `ui_click` (element=go_amr) + `onActivate` on the link.
* (Gating which agents/codes get the nudge is covered by the
* `resolveRunFailureUi` resolver test.)
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/analytics/events', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/analytics/events')>();
return {
...actual,
trackRunFailedToastSurfaceView: vi.fn(),
trackRunFailedToastGoAmrClick: vi.fn(),
};
});
import { AmrGuidance } from '../../src/components/AmrGuidance';
import {
trackRunFailedToastGoAmrClick,
trackRunFailedToastSurfaceView,
} from '../../src/analytics/events';
beforeAll(() => {
const store = new Map<string, string>();
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
clear: () => store.clear(),
getItem: (key: string) => store.get(key) ?? null,
removeItem: (key: string) => store.delete(key),
setItem: (key: string, value: string) => store.set(key, value),
},
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
});
function renderGuidance(onActivate = vi.fn()) {
render(
<AmrGuidance
errorCode="AGENT_AUTH_REQUIRED"
projectId="proj-1"
projectKind="prototype"
conversationId="conv-1"
assistantMessageId="msg-amr"
runId="run-9"
onActivate={onActivate}
/>,
);
return onActivate;
}
describe('AmrGuidance', () => {
it('fires surface_view once on mount with the full prop set', () => {
renderGuidance();
expect(screen.getByTestId('amr-guidance')).toBeTruthy();
expect(trackRunFailedToastSurfaceView).toHaveBeenCalledTimes(1);
expect(vi.mocked(trackRunFailedToastSurfaceView).mock.calls[0]![1]).toMatchObject({
page_name: 'chat_panel',
area: 'chat_panel',
element: 'run_failed_toast',
error_code: 'AGENT_AUTH_REQUIRED',
project_id: 'proj-1',
project_kind: 'prototype',
conversation_id: 'conv-1',
assistant_message_id: 'msg-amr',
run_id: 'run-9',
});
});
it('fires ui_click go_amr and calls onActivate on click', () => {
const onActivate = renderGuidance();
fireEvent.click(screen.getByRole('button'));
expect(trackRunFailedToastGoAmrClick).toHaveBeenCalledTimes(1);
expect(vi.mocked(trackRunFailedToastGoAmrClick).mock.calls[0]![1]).toMatchObject({
page_name: 'chat_panel',
area: 'chat_panel',
element: 'go_amr',
});
expect(onActivate).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,537 @@
// @vitest-environment jsdom
/**
* Coverage for the AMR Settings login pill. The pill is a sibling of the
* Test button inside the installed-agent card and intentionally stops
* click/key event propagation so a Sign-in / Sign-out click does NOT
* also re-select the agent card.
*
* The component polls `/api/integrations/vela/status` to keep up with
* subprocess-driven login completion vela CLI owns the
* device-authorization UX, so we just kick `vela login` off and wait.
*/
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
AmrAccountControl,
AmrLoginPill,
} from '../../src/components/AmrLoginPill';
import { AMR_LOGIN_TIMEOUT_MS } from '../../src/components/amrLoginPolling';
import { I18nProvider } from '../../src/i18n';
interface StubbedResponse {
status?: number;
body: unknown;
}
function jsonResponse({ status = 200, body }: StubbedResponse): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
const originalFetch = globalThis.fetch;
afterEach(() => {
cleanup();
globalThis.fetch = originalFetch;
vi.useRealTimers();
});
beforeEach(() => {
globalThis.fetch = originalFetch;
});
function renderPill() {
return render(
<I18nProvider initial="en">
<AmrLoginPill />
</I18nProvider>,
);
}
function renderAccountControl(
props: ComponentProps<typeof AmrAccountControl>,
) {
return render(
<I18nProvider initial="en">
<AmrAccountControl {...props} />
</I18nProvider>,
);
}
describe('AmrAccountControl', () => {
it('renders the compact signed-out status and sign-in action', () => {
const onSignIn = vi.fn();
renderAccountControl({
status: 'signed-out',
compact: true,
onSignIn,
});
expect(
screen.getByRole('group', { name: 'AMR account status' }),
).toBeTruthy();
expect(screen.getByText('Not signed in')).toBeTruthy();
const signIn = screen.getByRole('button', { name: 'Sign in' });
expect(signIn).toBeTruthy();
fireEvent.click(signIn);
expect(onSignIn).toHaveBeenCalledTimes(1);
});
it('renders the signing-in state without exposing a second action', () => {
renderAccountControl({
status: 'signing-in',
compact: true,
onSignIn: vi.fn(),
});
expect(screen.getByText('Signing in…')).toBeTruthy();
expect(screen.queryByRole('button')).toBeNull();
});
it('renders the signed-in email without profile fallback details', () => {
renderAccountControl({
status: 'signed-in',
email: 'leaf@example.com',
compact: true,
profile: 'local',
});
expect(screen.getByText('leaf@example.com')).toBeTruthy();
expect(screen.queryByText('LOCAL')).toBeNull();
expect(screen.queryByText('local')).toBeNull();
});
it('renders compact login errors with AMR-labeled text', () => {
renderAccountControl({
status: 'error',
compact: true,
errorMessage: 'command failed',
onSignIn: vi.fn(),
});
expect(screen.getByText('AMR sign-in failed.')).toBeTruthy();
expect(screen.queryByText('command failed')).toBeNull();
expect(screen.getByRole('button', { name: 'Sign in' })).toBeTruthy();
});
});
describe('AmrLoginPill', () => {
it('renders a Sign-in button when /status reports loggedIn=false', async () => {
globalThis.fetch = vi.fn(async (input) => {
const url = typeof input === 'string' ? input : (input as URL).toString();
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
body: { loggedIn: false, profile: 'prod', user: null, configPath: '/x' },
});
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
renderPill();
expect(await screen.findByRole('button', { name: 'Sign in' })).toBeTruthy();
expect(screen.queryByText('TEST')).toBeNull();
expect(screen.queryByText('LOCAL')).toBeNull();
});
it('renders a TEST badge next to the signed-out action for the test profile', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({
body: { loggedIn: false, profile: 'test', user: null, configPath: '/x' },
}),
) as typeof fetch;
renderPill();
expect(await screen.findByRole('button', { name: 'Sign in' })).toBeTruthy();
expect(screen.getByText('TEST')).toBeTruthy();
});
it('renders daemon-reported in-flight login attempts as signing-in', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({
body: {
loggedIn: false,
loginInFlight: true,
profile: 'prod',
user: null,
configPath: '/x',
},
}),
) as typeof fetch;
renderPill();
expect(await screen.findByText('Signing in…')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Sign in' })).toBeNull();
});
it('renders a LOCAL badge next to the signed-out action for the local profile', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({
body: { loggedIn: false, profile: 'local', user: null, configPath: '/x' },
}),
) as typeof fetch;
renderPill();
expect(await screen.findByRole('button', { name: 'Sign in' })).toBeTruthy();
expect(screen.getByText('LOCAL')).toBeTruthy();
});
it('renders a "Signed in" pill (with the Sign-out aria-label) when /status reports a logged-in user', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({
body: {
loggedIn: true,
profile: 'local',
configPath: '/x',
user: { id: 'u', email: 'leaf@example.com', plan: 'free' },
},
}),
) as typeof fetch;
renderPill();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Sign out' })).toBeTruthy();
});
expect(screen.getByText('leaf@example.com')).toBeTruthy();
expect(screen.getByText('LOCAL')).toBeTruthy();
});
it('stops click propagation so the Sign-in button never bubbles up to the agent-card-select sibling', async () => {
const fetchMock = vi.fn(async (input, init) => {
const url = typeof input === 'string' ? input : (input as URL).toString();
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
body: { loggedIn: false, profile: 'local', user: null, configPath: '/x' },
});
}
if (
url.endsWith('/api/integrations/vela/login') &&
init?.method === 'POST'
) {
return jsonResponse({ status: 202, body: { pid: 4242 } });
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
const cardSelect = vi.fn();
render(
<I18nProvider initial="en">
<div
role="group"
onClick={cardSelect}
onKeyDown={cardSelect}
>
<AmrLoginPill />
</div>
</I18nProvider>,
);
const signInBtn = await screen.findByRole('button', { name: 'Sign in' });
fireEvent.click(signInBtn);
expect(cardSelect).not.toHaveBeenCalled();
await waitFor(() => {
expect(
fetchMock.mock.calls.some(
([url, init]) =>
String(url).endsWith('/api/integrations/vela/login') &&
(init as RequestInit | undefined)?.method === 'POST',
),
).toBe(true);
});
});
it('shows an AMR error instead of staying in signing-in state when login fails immediately', async () => {
const fetchMock = vi.fn(async (input, init) => {
const url = typeof input === 'string' ? input : (input as URL).toString();
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
body: { loggedIn: false, profile: 'prod', user: null, configPath: '/x' },
});
}
if (
url.endsWith('/api/integrations/vela/login') &&
init?.method === 'POST'
) {
return jsonResponse({
status: 500,
body: { error: 'profile "prod" api URL: is not configured' },
});
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
renderPill();
fireEvent.click(await screen.findByRole('button', { name: 'Sign in' }));
await waitFor(() => {
expect(screen.getByRole('alert')).toBeTruthy();
});
expect(screen.getByText('AMR sign-in failed.')).toBeTruthy();
expect(screen.queryByText('Signing in…')).toBeNull();
});
it('does not POST /login twice while sign-in polling is already pending', async () => {
let loginCalls = 0;
const fetchMock = vi.fn(async (input, init) => {
const url = typeof input === 'string' ? input : (input as URL).toString();
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
body: { loggedIn: false, profile: 'prod', user: null, configPath: '/x' },
});
}
if (
url.endsWith('/api/integrations/vela/login') &&
init?.method === 'POST'
) {
loginCalls += 1;
return jsonResponse({ status: 202, body: { pid: 4242 } });
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
renderPill();
const signIn = await screen.findByRole('button', { name: 'Sign in' });
fireEvent.click(signIn);
fireEvent.click(signIn);
await waitFor(() => {
expect(loginCalls).toBe(1);
});
expect(await screen.findByText('Signing in…')).toBeTruthy();
});
it('recovers from transient /status failures and still flips to signed-in when polling succeeds later', async () => {
let statusCalls = 0;
const fetchMock = vi.fn(async (input, init) => {
const url = typeof input === 'string' ? input : (input as URL).toString();
if (url.endsWith('/api/integrations/vela/status')) {
statusCalls += 1;
if (statusCalls === 2) {
throw new Error('temporary network failure');
}
return jsonResponse({
body:
statusCalls >= 3
? {
loggedIn: true,
profile: 'local',
configPath: '/x',
user: { id: 'u', email: 'leaf@example.com', plan: 'free' },
}
: { loggedIn: false, profile: 'local', user: null, configPath: '/x' },
});
}
if (
url.endsWith('/api/integrations/vela/login') &&
init?.method === 'POST'
) {
return jsonResponse({ status: 202, body: { pid: 4242 } });
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
renderPill();
fireEvent.click(await screen.findByRole('button', { name: 'Sign in' }));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 2100));
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 2100));
});
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Sign out' })).toBeTruthy();
});
expect(screen.getByText('leaf@example.com')).toBeTruthy();
}, 10_000);
it('cancels a timed-out login attempt and restores the Sign-in action', async () => {
let loginStarted = false;
const fetchMock = vi.fn(async (input, init) => {
const url = typeof input === 'string' ? input : (input as URL).toString();
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
body: {
loggedIn: false,
loginInFlight: loginStarted,
profile: 'prod',
user: null,
configPath: '/x',
},
});
}
if (
url.endsWith('/api/integrations/vela/login') &&
init?.method === 'POST'
) {
loginStarted = true;
return jsonResponse({ status: 202, body: { pid: 4242 } });
}
if (
url.endsWith('/api/integrations/vela/login/cancel') &&
init?.method === 'POST'
) {
loginStarted = false;
return jsonResponse({ body: { canceled: true, pids: [4242] } });
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
renderPill();
const signIn = await screen.findByRole('button', { name: 'Sign in' });
vi.useFakeTimers();
fireEvent.click(signIn);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByText('Signing in…')).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(AMR_LOGIN_TIMEOUT_MS);
});
expect(
fetchMock.mock.calls.some(
([url, init]) =>
String(url).endsWith('/api/integrations/vela/login/cancel') &&
(init as RequestInit | undefined)?.method === 'POST',
),
).toBe(true);
expect(screen.getByText('AMR sign-in failed.')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Sign in' })).toBeTruthy();
expect(screen.queryByText('Signing in…')).toBeNull();
});
it('logout POSTs /logout and flips the pill back to Sign-in', async () => {
let loggedIn = true;
const fetchMock = vi.fn(async (input, init) => {
const url = typeof input === 'string' ? input : (input as URL).toString();
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
body: loggedIn
? {
loggedIn: true,
profile: 'local',
configPath: '/x',
user: { id: 'u', email: 'leaf@example.com', plan: 'free' },
}
: { loggedIn: false, profile: 'local', user: null, configPath: '/x' },
});
}
if (
url.endsWith('/api/integrations/vela/logout') &&
init?.method === 'POST'
) {
loggedIn = false;
return jsonResponse({ body: { ok: true } });
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
renderPill();
const logoutBtn = await screen.findByRole('button', { name: 'Sign out' });
fireEvent.click(logoutBtn);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Sign in' })).toBeTruthy();
});
});
it('converges a stale signed-in snapshot back to Sign-in when a later status read reports loggedOut', async () => {
let readCount = 0;
globalThis.fetch = vi.fn(async () => {
readCount += 1;
return jsonResponse({
body:
readCount === 1
? {
loggedIn: true,
profile: 'local',
configPath: '/x',
user: { id: 'u', email: 'leaf@example.com', plan: 'free' },
}
: { loggedIn: false, profile: 'local', user: null, configPath: '/x' },
});
}) as typeof fetch;
const first = renderPill();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Sign out' })).toBeTruthy();
});
first.unmount();
renderPill();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Sign in' })).toBeTruthy();
});
expect(screen.queryByRole('button', { name: 'Sign out' })).toBeNull();
});
it('does not silently auto-recover to signed-in after a local logout completes', async () => {
let loggedIn = true;
let statusCalls = 0;
const fetchMock = vi.fn(async (input, init) => {
const url = typeof input === 'string' ? input : (input as URL).toString();
if (url.endsWith('/api/integrations/vela/status')) {
statusCalls += 1;
return jsonResponse({
body: loggedIn
? {
loggedIn: true,
profile: 'local',
configPath: '/x',
user: { id: 'u', email: 'leaf@example.com', plan: 'free' },
}
: { loggedIn: false, profile: 'local', user: null, configPath: '/x' },
});
}
if (
url.endsWith('/api/integrations/vela/logout') &&
init?.method === 'POST'
) {
loggedIn = false;
return jsonResponse({ body: { ok: true } });
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
renderPill();
const logoutBtn = await screen.findByRole('button', { name: 'Sign out' });
fireEvent.click(logoutBtn);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Sign in' })).toBeTruthy();
});
const callsAfterLogout = statusCalls;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
expect(screen.getByRole('button', { name: 'Sign in' })).toBeTruthy();
expect(statusCalls).toBe(callsAfterLogout);
});
});

View file

@ -0,0 +1,86 @@
// @vitest-environment jsdom
/**
* The per-message gray "error" status pill is suppressed ONLY for the failed
* run that ChatPane renders its top-level error card for (errorCardOwnerId).
* Other failed turns older history, or once a follow-up makes this no longer
* the last assistant message must keep their pill so the error detail still
* survives reload / history review (regression: #3083 review).
*/
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { AssistantMessage } from '../../src/components/AssistantMessage';
import type { ChatMessage } from '../../src/types';
beforeAll(() => {
const store = new Map<string, string>();
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
clear: () => store.clear(),
getItem: (k: string) => store.get(k) ?? null,
removeItem: (k: string) => store.delete(k),
setItem: (k: string, v: string) => store.set(k, v),
},
});
});
afterEach(cleanup);
function failedMessage(): ChatMessage {
return {
id: 'msg-failed',
role: 'assistant',
content: '',
runStatus: 'failed',
startedAt: 1700000000,
endedAt: 1700000005,
events: [
{ kind: 'status', label: 'error', detail: 'boom-401' },
] as ChatMessage['events'],
producedFiles: [],
} as ChatMessage;
}
describe('AssistantMessage error-pill suppression', () => {
it('keeps the error pill when this message does NOT own the top-level card', () => {
render(
<AssistantMessage
message={failedMessage()}
streaming={false}
projectId="p1"
errorCardOwnerId={null}
onFeedback={vi.fn()}
/>,
);
expect(screen.getByText('boom-401')).toBeTruthy();
});
it('keeps the pill for a non-last failed run even when another message owns the card', () => {
render(
<AssistantMessage
message={failedMessage()}
streaming={false}
projectId="p1"
errorCardOwnerId="some-other-message"
onFeedback={vi.fn()}
/>,
);
expect(screen.getByText('boom-401')).toBeTruthy();
});
it('suppresses the pill only for the message that owns the top-level card', () => {
render(
<AssistantMessage
message={failedMessage()}
streaming={false}
projectId="p1"
errorCardOwnerId="msg-failed"
onFeedback={vi.fn()}
/>,
);
expect(screen.queryByText('boom-401')).toBeNull();
});
});

View file

@ -13,7 +13,6 @@ import { AssistantMessage } from '../../src/components/AssistantMessage';
import type { ChatMessage, ProjectFile } from '../../src/types';
beforeAll(() => {
if (window.localStorage) return;
const store = new Map<string, string>();
Object.defineProperty(window, 'localStorage', {
configurable: true,
@ -192,6 +191,85 @@ describe('AssistantMessage status badge updates (Bug A)', () => {
const matches = screen.queryAllByText('claude-opus-4-7-max');
expect(matches.length).toBe(1);
});
it('renders bare URLs in status details as links', () => {
render(
<AssistantMessage
message={baseMessage({
runStatus: 'failed',
events: [
{
kind: 'status',
label: 'error',
detail:
'AMR Cloud reported insufficient balance. Recharge at https://open-design.ai/amr/wallet, then retry.',
} as ChatMessage['events'][number],
],
})}
streaming={false}
projectId="proj-1"
onFeedback={vi.fn()}
/>,
);
const link = screen.getByRole('link', { name: 'https://open-design.ai/amr/wallet' });
expect(link.getAttribute('href')).toBe('https://open-design.ai/amr/wallet');
expect(link.classList.contains('md-link')).toBe(true);
});
});
describe('AssistantMessage question forms', () => {
it('renders only the first question form for a repeated form id in one assistant turn', () => {
const firstForm = [
'<question-form id="discovery" title="Quick brief — tailored">',
JSON.stringify({
questions: [
{
id: 'audience',
label: 'Who is this for?',
type: 'text',
},
],
}),
'</question-form>',
].join('\n');
const duplicateForm = [
'<question-form id="discovery" title="Quick brief — 30 seconds">',
JSON.stringify({
questions: [
{
id: 'output',
label: 'What are we making?',
type: 'radio',
required: true,
options: ['Slide deck / pitch', 'Dashboard / tool UI'],
},
],
}),
'</question-form>',
].join('\n');
render(
<AssistantMessage
message={baseMessage({
events: [
{
kind: 'text',
text: `${firstForm}\n\nFirst answer the tailored brief:\n\n${duplicateForm}`,
} as ChatMessage['events'][number],
],
})}
streaming={false}
projectId="proj-1"
isLast
/>,
);
expect(screen.getByText('Quick brief — tailored')).toBeTruthy();
expect(screen.getByText('Who is this for?')).toBeTruthy();
expect(screen.queryByText('Quick brief — 30 seconds')).toBeNull();
expect(screen.queryByText('What are we making?')).toBeNull();
});
});
describe('AssistantMessage recovered produced files', () => {

View file

@ -0,0 +1,390 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EntryShell } from '../../src/components/EntryShell';
import { AMR_LOGIN_TIMEOUT_MS } from '../../src/components/amrLoginPolling';
import { I18nProvider } from '../../src/i18n';
import type { AgentInfo, AppConfig } from '../../src/types';
const originalFetch = globalThis.fetch;
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function amrAgent(overrides: Partial<AgentInfo> = {}): AgentInfo {
return {
id: 'amr',
name: 'AMR',
bin: 'amr',
available: true,
models: [{ id: 'amr-model', label: 'AMR Model' }],
...overrides,
};
}
function cliAgent(overrides: Partial<AgentInfo> = {}): AgentInfo {
return {
id: 'claude-code',
name: 'Claude Code',
bin: 'claude',
available: true,
version: '1.0.0',
models: [{ id: 'sonnet', label: 'Sonnet' }],
...overrides,
};
}
function baseConfig(overrides: Partial<AppConfig> = {}): AppConfig {
return {
mode: 'daemon',
agentId: null,
agentModels: {},
apiProtocol: 'anthropic',
apiProtocolConfigs: {},
apiKey: '',
baseUrl: '',
model: '',
...overrides,
} as AppConfig;
}
function renderOnboarding(
overrides: Partial<React.ComponentProps<typeof EntryShell>> = {},
) {
window.history.replaceState(null, '', '/onboarding');
const props: React.ComponentProps<typeof EntryShell> = {
skills: [],
designTemplates: [],
designSystems: [],
projects: [],
templates: [],
promptTemplates: [],
defaultDesignSystemId: null,
connectors: [],
connectorsLoading: false,
config: baseConfig(),
agents: [amrAgent(), cliAgent()],
daemonLive: true,
onModeChange: vi.fn(),
onAgentChange: vi.fn(),
onAgentModelChange: vi.fn(),
onApiProtocolChange: vi.fn(),
onApiModelChange: vi.fn(),
onConfigPersist: vi.fn(),
onRefreshAgents: vi.fn(() => [amrAgent(), cliAgent()]),
onThemeChange: vi.fn(),
onCreateProject: vi.fn(),
onCreatePluginShareProject: vi.fn(),
onImportClaudeDesign: vi.fn(),
onOpenProject: vi.fn(),
onOpenLiveArtifact: vi.fn(),
onDeleteProject: vi.fn(),
onRenameProject: vi.fn(),
onChangeDefaultDesignSystem: vi.fn(),
onPersistComposioKey: vi.fn(),
onOpenSettings: vi.fn(),
onCompleteOnboarding: vi.fn(),
...overrides,
};
render(
<I18nProvider initial="en">
<EntryShell {...props} />
</I18nProvider>,
);
return props;
}
afterEach(() => {
cleanup();
globalThis.fetch = originalFetch;
vi.useRealTimers();
});
beforeEach(() => {
globalThis.fetch = originalFetch;
});
describe('EntryShell onboarding Open Design AMR runtime', () => {
it('does not auto-select Open Design AMR when the AMR runtime is unavailable', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' }),
) as typeof fetch;
const props = renderOnboarding({
agents: [cliAgent()],
onRefreshAgents: vi.fn(() => [cliAgent()]),
});
expect(screen.queryByRole('button', { name: /Open Design AMR/i })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /Local coding agent/i }));
await waitFor(() => {
expect(props.onAgentChange).not.toHaveBeenCalledWith('amr');
});
expect(screen.getByText('Local CLI')).toBeTruthy();
expect(screen.queryByText('Sign in to continue')).toBeNull();
});
it('shows Open Design AMR as the recommended default when AMR is available', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' }),
) as typeof fetch;
const props = renderOnboarding();
const amrCloud = screen.getByRole('button', { name: /Open Design AMR/i });
expect(amrCloud.getAttribute('aria-pressed')).toBe('true');
expect(amrCloud.textContent).toContain('Officially maintained');
expect(amrCloud.textContent).toContain('Ready to use');
expect(amrCloud.textContent).toContain('Many models');
expect(amrCloud.textContent).toContain('Better pricing');
expect(screen.queryByRole('link', { name: /Authorize AMR/i })).toBeNull();
expect(screen.getByRole('button', { name: /Sign in to continue/i })).toBeTruthy();
await screen.findByText('Not signed in');
expect(screen.queryByRole('button', { name: /^Sign in$/i })).toBeNull();
await waitFor(() => {
expect(props.onModeChange).toHaveBeenCalledWith('daemon');
expect(props.onAgentChange).toHaveBeenCalledWith('amr');
});
});
it('excludes AMR from the Local CLI agent list', async () => {
vi.useFakeTimers();
globalThis.fetch = vi.fn(async () =>
jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' }),
) as typeof fetch;
renderOnboarding();
fireEvent.click(screen.getByRole('button', { name: /Local coding agent/i }));
await vi.advanceTimersByTimeAsync(300);
const localPanel = screen.getByText('Local CLI').closest('.onboarding-view__setup-panel');
expect(localPanel?.textContent).toContain('Claude Code');
expect(localPanel?.textContent).not.toContain('AMR');
});
it('keeps AMR login pending while device authorization is waiting', async () => {
const fetchMock = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' });
}
if (url.endsWith('/api/integrations/vela/login') && init?.method === 'POST') {
return jsonResponse({ pid: 123 }, 202);
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
const props = renderOnboarding();
const signIn = await screen.findByRole('button', { name: /Sign in to continue/i });
vi.useFakeTimers();
fireEvent.click(signIn);
await act(async () => {});
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith('/api/integrations/vela/login', { method: 'POST' });
});
expect(screen.getByText('Signing in…')).toBeTruthy();
expect(screen.queryByText('Not signed in')).toBeNull();
expect(signIn.hasAttribute('disabled')).toBe(true);
await vi.advanceTimersByTimeAsync(2000);
expect(screen.getByText('Signing in…')).toBeTruthy();
expect(props.onCompleteOnboarding).not.toHaveBeenCalled();
expect(screen.getByText('Connect')).toBeTruthy();
});
it('clears AMR login pending when the user switches to another runtime', async () => {
const fetchMock = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' });
}
if (url.endsWith('/api/integrations/vela/login') && init?.method === 'POST') {
return jsonResponse({ pid: 123 }, 202);
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
renderOnboarding();
const signIn = await screen.findByRole('button', { name: /Sign in to continue/i });
vi.useFakeTimers();
fireEvent.click(signIn);
await act(async () => {});
expect(screen.getByText('Signing in…')).toBeTruthy();
expect(signIn.hasAttribute('disabled')).toBe(true);
fireEvent.click(screen.getByRole('button', { name: /Local coding agent/i }));
await act(async () => {});
expect(screen.queryByText('Signing in…')).toBeNull();
expect(screen.getByRole('button', { name: /^Continue$/i }).hasAttribute('disabled')).toBe(false);
});
it('cancels AMR login and re-enables onboarding after the login timeout', async () => {
let loginStarted = false;
const fetchMock = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
return jsonResponse({
loggedIn: false,
loginInFlight: loginStarted,
profile: 'prod',
user: null,
configPath: '/x',
});
}
if (url.endsWith('/api/integrations/vela/login') && init?.method === 'POST') {
loginStarted = true;
return jsonResponse({ pid: 123 }, 202);
}
if (url.endsWith('/api/integrations/vela/login/cancel') && init?.method === 'POST') {
loginStarted = false;
return jsonResponse({ canceled: true, pids: [123] });
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
const props = renderOnboarding();
const signIn = await screen.findByRole('button', { name: /Sign in to continue/i });
vi.useFakeTimers();
fireEvent.click(signIn);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(fetchMock).toHaveBeenCalledWith('/api/integrations/vela/login', { method: 'POST' });
expect(screen.getByText('Signing in…')).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(AMR_LOGIN_TIMEOUT_MS);
});
expect(fetchMock).toHaveBeenCalledWith('/api/integrations/vela/login/cancel', { method: 'POST' });
expect(screen.getByText('AMR sign-in failed.')).toBeTruthy();
expect(screen.queryByText('Signing in…')).toBeNull();
expect(screen.getByRole('button', { name: /Sign in to continue/i }).hasAttribute('disabled')).toBe(false);
expect(props.onCompleteOnboarding).not.toHaveBeenCalled();
});
it('continues after AMR device authorization completes during polling', async () => {
let statusCalls = 0;
const fetchMock = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
statusCalls += 1;
return jsonResponse(
statusCalls >= 3
? {
loggedIn: true,
profile: 'prod',
user: { id: 'u', email: 'user@example.com' },
configPath: '/x',
}
: { loggedIn: false, profile: 'prod', user: null, configPath: '/x' },
);
}
if (url.endsWith('/api/integrations/vela/login') && init?.method === 'POST') {
return jsonResponse({ pid: 123 }, 202);
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
renderOnboarding();
const signIn = await screen.findByRole('button', { name: /Sign in to continue/i });
vi.useFakeTimers();
fireEvent.click(signIn);
await act(async () => {});
expect(screen.getByText('Signing in…')).toBeTruthy();
await vi.advanceTimersByTimeAsync(2000);
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: 'About you' })).toBeTruthy();
});
});
it('recovers from a transient status failure during login polling and still continues after authorization completes', async () => {
let statusCalls = 0;
const fetchMock = vi.fn(async (input, init) => {
const url = String(input);
if (url.endsWith('/api/integrations/vela/status')) {
statusCalls += 1;
if (statusCalls === 2) throw new Error('temporary network failure');
return jsonResponse(
statusCalls >= 4
? {
loggedIn: true,
profile: 'prod',
user: { id: 'u', email: 'user@example.com' },
configPath: '/x',
}
: { loggedIn: false, profile: 'prod', user: null, configPath: '/x' },
);
}
if (url.endsWith('/api/integrations/vela/login') && init?.method === 'POST') {
return jsonResponse({ pid: 123 }, 202);
}
throw new Error(`unexpected fetch: ${url}`);
});
globalThis.fetch = fetchMock as typeof fetch;
renderOnboarding();
const signIn = await screen.findByRole('button', { name: /Sign in to continue/i });
vi.useFakeTimers();
fireEvent.click(signIn);
await act(async () => {});
expect(screen.getByText('Signing in…')).toBeTruthy();
await vi.advanceTimersByTimeAsync(2000);
expect(screen.getByText('Signing in…')).toBeTruthy();
await vi.advanceTimersByTimeAsync(4000);
await vi.waitFor(() => {
expect(screen.getByRole('heading', { name: 'About you' })).toBeTruthy();
});
});
it('continues normally when Open Design AMR is signed in', async () => {
globalThis.fetch = vi.fn(async () =>
jsonResponse({
loggedIn: true,
profile: 'prod',
configPath: '/x',
user: { id: 'u', email: 'user@example.com' },
}),
) as typeof fetch;
renderOnboarding();
expect(await screen.findByText('user@example.com')).toBeTruthy();
expect(screen.queryByText('Authorized')).toBeNull();
expect(screen.queryByRole('link', { name: /Authorize AMR/i })).toBeNull();
const continueButton = await screen.findByRole('button', { name: /^Continue$/i });
fireEvent.click(continueButton);
expect(screen.getByRole('heading', { name: 'About you' })).toBeTruthy();
});
it('lets Skip exit onboarding without starting AMR login', async () => {
const fetchMock = vi.fn(async (_input: RequestInfo | URL) =>
jsonResponse({ loggedIn: false, profile: 'prod', user: null, configPath: '/x' }),
);
globalThis.fetch = fetchMock as typeof fetch;
const props = renderOnboarding();
fireEvent.click(screen.getByRole('button', { name: /Skip/i }));
expect(props.onCompleteOnboarding).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls.some(([url]) => String(url).endsWith('/api/integrations/vela/login'))).toBe(false);
});
});

View file

@ -1049,6 +1049,71 @@ describe('HomeView prompt handoff', () => {
expect(screen.queryByRole('dialog', { name: /replace current prompt/i })).toBeNull();
});
it('lets selected chips seed the hero through preset cards', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN, SIMPLE_DECK_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply')) {
return new Response(JSON.stringify(SIMPLE_DECK_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
stubAnimationFrame();
render(
<HomeView
projects={[]}
designSystems={[REFLY_DESIGN_SYSTEM]}
defaultDesignSystemId="ds-refly"
onSubmit={() => undefined}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
await clearActiveTypeChip();
fireEvent.click(await screen.findByTestId('home-hero-rail-deck'));
await waitFor(() => {
expect(screen.getByTestId('home-hero-active-type-chip').textContent).toContain('Slide deck');
});
expect(screen.getByTestId('home-hero-plugin-presets')).toBeTruthy();
expect(screen.getByTestId('home-hero-plugin-presets').textContent).toContain('Simple Deck');
fireEvent.click(screen.getAllByTestId('home-hero-plugin-preset')[0]!);
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply')
))).toBe(false);
expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).toBe(
'Create a pitch deck for decision makers about the user brief with 10-15 pages. Speaker notes: include speaker notes. Use the active project design system.',
);
await clearActiveTypeChip();
fireEvent.click(await screen.findByTestId('home-hero-rail-prototype'));
await waitFor(() => {
expect(screen.getByTestId('home-hero-plugin-presets')).toBeTruthy();
});
fireEvent.click(screen.getAllByTestId('home-hero-plugin-preset')[0]!);
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
))).toBe(false);
expect((screen.getByTestId('home-hero-input') as HTMLTextAreaElement).value).toBe(
'Build a high-fidelity web prototype for product evaluators using the active project design system from the bundled web prototype seed.',
);
});
it('appends a plugin-use query handoff without replacing an existing prompt', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {

View file

@ -0,0 +1,605 @@
// @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 type { AgentInfo, AppConfig } from '../../src/types';
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],
) {
const onAgentModelChange = vi.fn();
const view = render(
<InlineModelSwitcher
config={{ ...baseConfig, ...config }}
agents={agents}
daemonLive={true}
onModeChange={vi.fn()}
onAgentChange={vi.fn()}
onAgentModelChange={onAgentModelChange}
onApiProtocolChange={vi.fn()}
onApiModelChange={vi.fn()}
onOpenSettings={vi.fn()}
/>,
);
return { ...view, onAgentModelChange };
}
describe('InlineModelSwitcher AMR row', () => {
afterEach(() => {
cleanup();
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-agent-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-agent-amr-reminder'),
).toBeNull();
view.unmount();
renderSwitcher({ agentId: 'codex' }, [amrAgent, codexAgent]);
expect(screen.queryByTestId('inline-model-switcher-amr-reminder')).toBeNull();
});
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('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('AMR');
expect(screen.getByTestId('inline-model-switcher-chip').textContent).not.toContain(
'Open Design AMR',
);
fireEvent.click(screen.getByTestId('inline-model-switcher-chip'));
const popover = screen.getByTestId('inline-model-switcher-popover');
const amrButton = await within(popover).findByRole('radio', {
name: /^AMR\s+Sign in$/i,
});
expect(within(amrButton).getByText(/Sign in/i)).toBeTruthy();
expect(amrButton.querySelector('.inline-switcher__agent-status-icon')).toBeNull();
expect(amrButton.querySelector('.inline-switcher__agent-action-label')).toBeTruthy();
expect(within(popover).queryByText(/AMR \(vela\)/i)).toBeNull();
expect(within(popover).queryByText(/vela/i)).toBeNull();
expect(within(popover).queryByText(/Not signed in/i)).toBeNull();
expect(within(popover).queryByRole('button', { name: 'Sign in' })).toBeNull();
const modelSelect = within(popover).getByTestId(
'inline-model-switcher-agent-model',
) as HTMLSelectElement;
expect(Array.from(modelSelect.options).map((option) => option.value)).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 modelSelect = within(popover).getByTestId(
'inline-model-switcher-agent-model',
) as HTMLSelectElement;
expect(modelSelect.value).toBe('default');
expect(Array.from(modelSelect.options).map((option) => option.value)).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: /^AMR\s+Signed in$/i,
});
expect(within(amrButton).getByText(/Signed in/i)).toBeTruthy();
expect(within(popover).queryByText(/manual-amr@example\.local/i)).toBeNull();
expect(within(popover).queryByRole('button', { name: 'Sign out' })).toBeNull();
});
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: /^AMR\s+Signed in$/i,
});
expect(within(amrButton).getByText(/Signed in/i)).toBeTruthy();
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: /^AMR\s+Signing in/i,
});
expect(within(amrButton).getByText(/Signing in/i)).toBeTruthy();
expect(within(amrButton).getByText('Cancel sign-in')).toBeTruthy();
});
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: /^AMR\s+Signed in$/i,
});
fireEvent.click(amrButton);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(fetchMock).toHaveBeenCalledWith('/api/integrations/vela/login', { method: 'POST' });
expect(
within(popover).getByRole('radio', { name: /^AMR\s+Signing in/i }),
).toBeTruthy();
});
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: /^AMR\s+Sign in$/i,
});
vi.useFakeTimers();
fireEvent.click(amrButton);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(fetchMock).toHaveBeenCalledWith('/api/integrations/vela/login', { method: 'POST' });
expect(
within(popover).getByRole('radio', { name: /^AMR\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: /^AMR\s+AMR sign-in failed\./i }),
).toBeTruthy();
expect(within(popover).getByText('Sign in')).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: /^AMR\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: /^AMR\s+Signing in/i,
});
expect(within(amrButton).getByText(/Signing in/i)).toBeTruthy();
expect(within(amrButton).getByText('Cancel sign-in')).toBeTruthy();
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: /^AMR\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: /^AMR\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: /^AMR\s+Sign in$/i });
expect(within(popover).queryByRole('radio', { name: /^AMR\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: /^AMR\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: /^AMR\s+Sign in$/i,
});
expect(loginCalls).toBe(0);
fireEvent.click(reopenedAmrButton);
await waitFor(() => {
expect(loginCalls).toBe(1);
expect(onAgentChange).toHaveBeenCalledWith('amr');
});
});
});

View file

@ -363,6 +363,67 @@ describe('ProjectView daemon reattach restore', () => {
});
});
it('renders AMR recharge guidance when a reattached run reports insufficient balance', async () => {
const startedAt = Date.now();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([
{
id: 'msg-amr-balance',
role: 'assistant',
content: '',
createdAt: startedAt,
startedAt,
runId: 'run-amr-balance',
runStatus: 'running',
preTurnFileNames: [],
} satisfies ChatMessage,
]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
fetchChatRunStatus.mockResolvedValue({
id: 'run-amr-balance',
status: 'running',
createdAt: startedAt,
updatedAt: startedAt,
exitCode: null,
signal: null,
});
listActiveChatRuns.mockResolvedValue([]);
reattachDaemonRun.mockImplementation(async (options: any) => {
const error = new Error(
'AMR Cloud reported insufficient balance for this model. Recharge your AMR wallet at https://open-design.ai/amr/wallet, then retry this run.',
) as Error & { code: string; details: unknown };
error.code = 'AMR_INSUFFICIENT_BALANCE';
error.details = {
kind: 'amr_account',
action: 'recharge',
actionUrl: 'https://open-design.ai/amr/wallet',
};
options.handlers.onError(error);
});
renderProjectView();
await waitFor(() => expect(reattachDaemonRun).toHaveBeenCalledTimes(1));
await waitFor(() => {
const finalSave = saveMessage.mock.calls
.map((call) => call[2] as ChatMessage)
.filter((m) => m?.id === 'msg-amr-balance' && m.runStatus === 'failed')
.at(-1);
expect(finalSave?.events?.some(
(event) => event.kind === 'status'
&& event.label === 'error'
&& (event as { code?: string }).code === 'AMR_INSUFFICIENT_BALANCE',
)).toBe(true);
});
});
it('preserves canceled runStatus when onRunStatus records cancellation before onDone fires', async () => {
const startedAt = Date.now();
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);

View file

@ -1075,6 +1075,75 @@ describe('ProjectView daemon cleanup', () => {
expect(phantomSave).toBeUndefined();
});
it('persists a daemon assistant row as failed after an AMR auth error returns post-run creation', async () => {
listConversations.mockResolvedValue([{ id: 'conv-1', title: 'Conversation' }]);
listMessages.mockResolvedValue([]);
fetchPreviewComments.mockResolvedValue([]);
loadTabs.mockResolvedValue({ tabs: [], activeTabId: null });
fetchProjectFiles.mockResolvedValue([]);
fetchLiveArtifacts.mockResolvedValue([]);
fetchSkill.mockResolvedValue(null);
fetchDesignSystem.mockResolvedValue(null);
getTemplate.mockResolvedValue(null);
listActiveChatRuns.mockResolvedValue([]);
streamViaDaemon.mockImplementation(async (options: {
onRunCreated?: (runId: string) => void;
handlers: { onError: (error: Error) => void };
}) => {
options.onRunCreated?.('run-auth-expired');
options.handlers.onError(
new Error('Your authentication token has expired. Please sign in again.'),
);
});
chatPaneSpy.mockClear();
render(
<ProjectView
project={{ id: 'project-auth-expired', name: 'Project', skillId: null, designSystemId: null } as never}
routeFileName={null}
config={{ mode: 'daemon', agentId: 'agent-1', notifications: undefined, agentModels: {} } as never}
agents={[{ id: 'agent-1', name: 'OpenCode', models: [] } as never]}
skills={[]}
designTemplates={[]}
designSystems={[]}
daemonLive
onModeChange={() => {}}
onAgentChange={() => {}}
onAgentModelChange={() => {}}
onRefreshAgents={() => {}}
onOpenSettings={() => {}}
onBack={() => {}}
onClearPendingPrompt={() => {}}
onTouchProject={() => {}}
onProjectChange={() => {}}
onProjectsRefresh={() => {}}
/>,
);
const sendProps = await waitForReadyChatPaneProps();
await sendProps!.onSend!('retry auth', [], []);
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
await waitFor(() => {
const failedAssistantSave = saveMessage.mock.calls.find(
(call) =>
call[0] === 'project-auth-expired' &&
call[1] === 'conv-1' &&
call[2]?.role === 'assistant' &&
call[2]?.runId === 'run-auth-expired' &&
call[2]?.runStatus === 'failed' &&
call[2]?.events?.some(
(event: { kind?: string; label?: string; detail?: string }) =>
event.kind === 'status' &&
event.label === 'error' &&
event.detail === 'Your authentication token has expired. Please sign in again.',
),
);
expect(failedAssistantSave).toBeTruthy();
});
});
it('relinks terminal replay to an existing artifact without writing a duplicate file', async () => {
const runCreatedAt = Date.now();
const existingArtifact = {

View file

@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProjectView } from '../../src/components/ProjectView';
import type {
AgentInfo,
AppConfig,
ChatMessage,
Conversation,
@ -156,6 +157,7 @@ vi.mock('../../src/components/ChatPane', () => ({
queuedItems,
previewComments,
attachedComments,
messages,
onAttachComment,
onSelectConversation,
onSend,
@ -170,6 +172,7 @@ vi.mock('../../src/components/ChatPane', () => ({
queuedItems?: Array<{ id: string; prompt: string }>;
previewComments?: PreviewComment[];
attachedComments?: PreviewComment[];
messages?: ChatMessage[];
error: string | null;
onAttachComment?: (comment: PreviewComment) => void;
onSelectConversation: (id: string) => void;
@ -183,6 +186,21 @@ vi.mock('../../src/components/ChatPane', () => ({
<output data-testid="active-conversation">{activeConversationId}</output>
<output data-testid="streaming-state">{streaming ? 'streaming' : 'idle'}</output>
<output data-testid="chat-error">{error}</output>
<output data-testid="assistant-events">
{(messages ?? [])
.filter((message) => message.role === 'assistant')
.flatMap((message) => message.events ?? [])
.map((event) => {
if (event.kind === 'text') return event.text;
if (event.kind === 'status') {
const code = (event as { code?: string }).code;
return `${code ? code + ' ' : ''}${event.detail ?? event.label}`;
}
return '';
})
.filter(Boolean)
.join('\n')}
</output>
<output data-testid="attached-comment-count">{attached.length}</output>
{queuedItems?.map((item, index) => (
<button
@ -446,6 +464,43 @@ describe('ProjectView conversation run isolation', () => {
);
});
it('submits the live AMR fallback model when the saved AMR model is stale', async () => {
conversationAMessages = [];
renderProjectView(
{
...config,
agentId: 'amr',
agentModels: {
amr: { model: 'gpt-5.4-mini', reasoning: 'medium' },
},
},
project,
[
{
id: 'amr',
name: 'AMR',
bin: 'amr',
available: true,
models: [{ id: 'glm-5', label: 'GLM 5' }],
},
],
);
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
await waitFor(() => expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false));
fireEvent.click(screen.getByTestId('send-message'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
expect(streamViaDaemon).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'amr',
model: 'glm-5',
reasoning: 'medium',
}),
);
});
it('does not create duplicate empty conversations while a fresh conversation is loading', async () => {
renderProjectView();
@ -747,15 +802,140 @@ describe('ProjectView conversation run isolation', () => {
await waitFor(() => expect(streamMessage).toHaveBeenCalledTimes(1));
await waitFor(() => expect(playSound).toHaveBeenCalledWith('success-sound'));
});
it('converges a daemon chat back to idle when the first AMR run fails authentication', async () => {
conversationAMessages = [];
fetchChatRunStatus.mockResolvedValue(null);
streamViaDaemon.mockImplementation(
async (options: {
onRunCreated?: (runId: string) => void;
handlers: { onError: (error: Error) => void };
}) => {
options.onRunCreated?.('run-auth-expired');
options.handlers.onError(
new Error('Your authentication token has expired. Please sign in again.'),
);
},
);
renderProjectView();
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
await waitFor(() => expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false));
fireEvent.click(screen.getByTestId('send-message'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(screen.getByTestId('chat-error').textContent).toBe(
'Your authentication token has expired. Please sign in again.',
),
);
await waitFor(() => expect(screen.getByTestId('streaming-state').textContent).toBe('idle'));
expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false);
fireEvent.click(screen.getByTestId('send-message'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(2));
});
it('renders recharge guidance for structured AMR insufficient-balance errors', async () => {
conversationAMessages = [];
fetchChatRunStatus.mockResolvedValue(null);
streamViaDaemon.mockImplementation(
async (options: {
onRunCreated?: (runId: string) => void;
handlers: { onError: (error: Error) => void };
}) => {
options.onRunCreated?.('run-amr-balance');
const error = new Error(
'AMR Cloud reported insufficient balance for this model. Recharge your AMR wallet at https://open-design.ai/amr/wallet, then retry this run.',
) as Error & { code: string; details: unknown };
error.code = 'AMR_INSUFFICIENT_BALANCE';
error.details = {
kind: 'amr_account',
action: 'recharge',
actionUrl: 'https://open-design.ai/amr/wallet',
};
options.handlers.onError(error);
},
);
renderProjectView();
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
await waitFor(() => expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false));
fireEvent.click(screen.getByTestId('send-message'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(screen.getByTestId('chat-error').textContent).toContain('insufficient balance'),
);
// The structured code rides on the error event; ChatPane keys the recharge
// affordance off it.
expect(screen.getByTestId('assistant-events').textContent).toContain(
'AMR_INSUFFICIENT_BALANCE',
);
expect(screen.getByTestId('streaming-state').textContent).toBe('idle');
});
it('renders re-login guidance for structured AMR auth-required errors', async () => {
conversationAMessages = [];
fetchChatRunStatus.mockResolvedValue(null);
streamViaDaemon.mockImplementation(
async (options: {
onRunCreated?: (runId: string) => void;
handlers: { onError: (error: Error) => void };
}) => {
options.onRunCreated?.('run-amr-auth');
const error = new Error(
'AMR sign-in is required. Sign in to AMR Cloud again, then retry this run.',
) as Error & { code: string; details: unknown };
error.code = 'AMR_AUTH_REQUIRED';
error.details = {
kind: 'amr_account',
action: 'relogin',
};
options.handlers.onError(error);
},
);
renderProjectView();
await waitFor(() => expect(screen.getByTestId('active-conversation').textContent).toBe('conv-a'));
await waitFor(() => expect(screen.getByTestId('send-message')).toHaveProperty('disabled', false));
fireEvent.click(screen.getByTestId('send-message'));
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(screen.getByTestId('chat-error').textContent).toContain(
'AMR sign-in is required',
),
);
// The structured code rides on the error event; ChatPane keys the
// authorize-and-retry affordance off it.
expect(screen.getByTestId('assistant-events').textContent).toContain(
'AMR_AUTH_REQUIRED',
);
expect(screen.getByTestId('streaming-state').textContent).toBe('idle');
});
});
function renderProjectView(renderConfig = config, renderProject: Project = project) {
function renderProjectView(
renderConfig = config,
renderProject: Project = project,
renderAgents: AgentInfo[] = [
{ id: 'agent-1', name: 'OpenCode', bin: 'opencode', available: true, models: [] },
],
) {
return render(
<ProjectView
project={renderProject}
routeFileName={null}
config={renderConfig}
agents={[{ id: 'agent-1', name: 'OpenCode', bin: 'opencode', available: true, models: [] }]}
agents={renderAgents}
skills={[]}
designTemplates={[]}
designSystems={[]}

View file

@ -107,6 +107,15 @@ const availableAgents: AgentInfo[] = [
},
];
const amrAgent: AgentInfo = {
id: 'amr',
name: 'AMR (vela)',
bin: 'amr',
available: true,
version: '1.0.0',
models: [{ id: 'default', label: 'Default' }],
};
type OnRefreshAgents = (
options?: AgentRefreshOptions,
) => void | AgentInfo[] | Promise<void | AgentInfo[]>;
@ -1075,9 +1084,14 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
expect(screen.getByText(en['settings.agentInstall.pathHint'])).toBeTruthy();
fireEvent.click(codexCard);
const modelHead = screen.getByText(/Model for:/);
const selectedCard = codexCard.closest('.agent-card') as HTMLElement;
expect(
modelHead.compareDocumentPosition(installGroupSummary) &
within(selectedCard).getByRole('combobox', {
name: en['settings.modelPicker'],
}),
).toBeTruthy();
expect(
selectedCard.compareDocumentPosition(installGroupSummary) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
await waitForPersist(
@ -1110,7 +1124,7 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
fireEvent.click(screen.getByRole('tab', { name: /Local CLI/i }));
expect(screen.getByText('Live from CLI')).toBeTruthy();
expect(
screen.getByText(/Models were refreshed from the installed CLI/i),
screen.getByText(/Model list comes from this CLI/i),
).toBeTruthy();
});
@ -1134,6 +1148,41 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
).toBeTruthy();
});
it('uses the existing Settings card picker for AMR without exposing custom stale models', () => {
renderSettingsDialog(
{
mode: 'daemon',
agentId: 'amr',
agentModels: { amr: { model: 'gpt-5.4-mini', reasoning: 'default' } },
},
{
agents: [
{
...amrAgent,
modelsSource: 'live',
models: [
{ id: 'glm-5', label: 'GLM 5' },
{ id: 'glm-5.1', label: 'GLM 5.1' },
],
},
],
},
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI/i }));
fireEvent.click(screen.getByRole('button', { name: /^Open Design AMR\b/ }));
const modelPickers = screen.getAllByRole('combobox', {
name: en['settings.modelPicker'],
}) as HTMLSelectElement[];
expect(modelPickers).toHaveLength(1);
expect(modelPickers[0]?.value).toBe('glm-5');
expect(
Array.from(modelPickers[0]?.options ?? []).map((option) => option.value),
).toEqual(['glm-5', 'glm-5.1']);
expect(screen.queryByLabelText(en['settings.modelCustomLabel'])).toBeNull();
});
it('shows an empty state when no local CLI agents are detected', () => {
renderSettingsDialog(
{ mode: 'daemon', agentId: null },
@ -1314,36 +1363,16 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
expect(screen.getByRole('tab', { name: /BYOK.*API provider/i }).getAttribute('aria-selected')).toBe('true');
});
it('runs the Local CLI connection test for the selected installed agent', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
it('renders a Local CLI connection test for selected installed agents', () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
// MemoryModelInline mounts inside the Local CLI section and reads
// the current extraction override from /api/memory on mount.
// Swallow it here so the assertion below only counts the
// test-connection POST the user actually triggered.
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
expect(url).toBe('/api/test/connection');
expect(JSON.parse(String(init?.body))).toMatchObject({
mode: 'agent',
agentId: 'codex',
agentCliEnv: {},
});
return new Response(
JSON.stringify({
ok: true,
kind: 'ok',
latencyMs: 31,
agentName: 'Codex CLI',
model: 'default',
sample: 'ready',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
@ -1353,18 +1382,537 @@ describe('SettingsDialog execution settings Local CLI interactions', () => {
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
fireEvent.click(screen.getByRole('button', { name: 'Test' }));
expect(screen.getByRole('button', { name: /Codex CLI/i })).toBeTruthy();
expect(screen.getByRole('button', { name: 'Test' })).toBeTruthy();
});
it('renders the AMR local agent without vela branding and with the Local CLI test action', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
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);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
expect(screen.getByRole('button', { name: /^Open Design AMR\b/ })).toBeTruthy();
expect(screen.queryByText('1.0.0')).toBeNull();
expect(screen.queryByText(/AMR \(vela\)/i)).toBeNull();
expect(screen.queryByText(/vela/i)).toBeNull();
expect(screen.queryByText(/Not signed in/i)).toBeNull();
expect(screen.getByText('Official')).toBeTruthy();
expect(screen.getByText('Lower cost')).toBeTruthy();
expect(screen.getByText('Many models')).toBeTruthy();
expect(screen.queryByText('Limited bonus: +100%')).toBeNull();
expect(await screen.findByRole('button', { name: 'Authorize' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Test' })).toBeNull();
});
it('only shows the AMR authorization action after selecting the AMR card', async () => {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({ loggedIn: false, profile: 'local', user: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
}) as typeof fetch;
const codexAgent = availableAgents.find((agent) => agent.id === 'codex');
expect(codexAgent).toBeTruthy();
if (!codexAgent) throw new Error('missing codex test agent');
renderSettingsDialog(
{ mode: 'daemon', agentId: codexAgent.id },
{ agents: [amrAgent, codexAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*2 installed/i }));
expect(screen.getByRole('button', { name: /^Open Design AMR\b/ })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Authorize' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /^Open Design AMR\b/ }));
expect(await screen.findByRole('button', { name: 'Authorize' })).toBeTruthy();
});
it('reveals AMR cancel only while hovering the active card during sign-in', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: false,
loginInFlight: true,
profile: 'local',
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);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
const amrCardButton = screen.getByRole('button', { name: /^Open Design AMR\b/ });
const amrCard = amrCardButton.closest('.agent-card') as HTMLElement;
expect(amrCard).toBeTruthy();
expect(await screen.findByText('Signing in…')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
fireEvent.mouseEnter(amrCard);
expect(await screen.findByRole('button', { name: 'Cancel' })).toBeTruthy();
fireEvent.mouseLeave(amrCard);
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
});
});
it('cancels an in-flight AMR sign-in and returns to Authorize after a brief canceled state', async () => {
let statusStage: 'pending' | 'signed-out' = 'pending';
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify(
statusStage === 'pending'
? {
loggedIn: false,
loginInFlight: true,
profile: 'local',
user: null,
configPath: '/Users/test/.amr/config.json',
}
: {
loggedIn: false,
loginInFlight: false,
profile: 'local',
user: null,
configPath: '/Users/test/.amr/config.json',
},
),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/login/cancel' && init?.method === 'POST') {
statusStage = 'signed-out';
return new Response(JSON.stringify({ canceled: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
const amrCard = screen.getByRole('button', { name: /^Open Design AMR\b/ }).closest('.agent-card') as HTMLElement;
expect(await screen.findByText('Signing in…')).toBeTruthy();
fireEvent.mouseEnter(amrCard);
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
expect(await screen.findByText('Canceled')).toBeTruthy();
expect(fetchMock).toHaveBeenCalledWith('/api/integrations/vela/login/cancel', { method: 'POST' });
await waitFor(() => {
expect(screen.getByText('Testing connection…')).toBeTruthy();
expect(screen.getByRole('button', { name: 'Authorize' })).toBeTruthy();
}, { timeout: 3000 });
expect(screen.queryByText('Canceled')).toBeNull();
});
await waitFor(() => {
expect(screen.getByText(/Codex CLI replied in 31 ms/)).toBeTruthy();
});
const testConnectionCalls = fetchMock.mock.calls.filter(
([input]) => input.toString() === '/api/test/connection',
// Regression for the race called out on #3158 by both codex-connector and
// looper: the daemon's `cancelVelaLogin()` only SIGTERMs the vela child
// and keeps it in `activeLoginProcs` until it actually exits, so a
// `/api/integrations/vela/status` read right after a successful cancel
// can legally still report `loginInFlight: true`. If the AmrLoginPill
// listener self-refreshed on the local cancel path it would bounce back
// into `Signing in…` polling and surface the timeout/error path even
// though the user already canceled.
it('does not bounce back to Signing in… when daemon /status still reports loginInFlight after a local cancel (#3158)', async () => {
let cancelReceived = false;
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
expect(testConnectionCalls).toHaveLength(1);
}
if (url === '/api/integrations/vela/status') {
// Keep reporting in-flight even *after* the cancel API succeeds —
// this is the SIGTERM-to-exit window where the daemon hasn't reaped
// the vela child yet.
return new Response(
JSON.stringify({
loggedIn: false,
loginInFlight: true,
profile: 'local',
user: null,
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/login/cancel' && init?.method === 'POST') {
cancelReceived = true;
return new Response(JSON.stringify({ canceled: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
const amrCard = screen.getByRole('button', { name: /^Open Design AMR\b/ }).closest('.agent-card') as HTMLElement;
expect(await screen.findByText('Signing in…')).toBeTruthy();
fireEvent.mouseEnter(amrCard);
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
expect(await screen.findByText('Canceled')).toBeTruthy();
expect(cancelReceived).toBe(true);
// Give the listener event handler — plus any rogue polling tick — a
// generous window to misfire. Under the buggy code path the pill would
// call /status again, see loginInFlight:true, setPending('login'), and
// restart polling, flipping the UI back to 'Signing in…'.
await new Promise((resolve) => setTimeout(resolve, 400));
expect(screen.queryByText('Signing in…')).toBeNull();
// Eventually the Canceled UI window times out and Authorize re-appears.
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Authorize' })).toBeTruthy();
}, { timeout: 3000 });
// And still no bounce back to Signing in…
expect(screen.queryByText('Signing in…')).toBeNull();
});
it('reconciles late AMR browser completion to Signed in after local cancel', async () => {
let statusStage: 'pending' | 'signed-out' | 'signed-in' = 'pending';
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
const body =
statusStage === 'pending'
? {
loggedIn: false,
loginInFlight: true,
profile: 'local',
user: null,
configPath: '/Users/test/.amr/config.json',
}
: statusStage === 'signed-in'
? {
loggedIn: true,
loginInFlight: false,
profile: 'local',
user: { id: 'user-1', email: 'late@example.com' },
configPath: '/Users/test/.amr/config.json',
}
: {
loggedIn: false,
loginInFlight: false,
profile: 'local',
user: null,
configPath: '/Users/test/.amr/config.json',
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/integrations/vela/login/cancel' && init?.method === 'POST') {
statusStage = 'signed-out';
return new Response(JSON.stringify({ canceled: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
const amrCard = screen.getByRole('button', { name: /^Open Design AMR\b/ }).closest('.agent-card') as HTMLElement;
expect(await screen.findByText('Signing in…')).toBeTruthy();
fireEvent.mouseEnter(amrCard);
fireEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
expect(await screen.findByText('Canceled')).toBeTruthy();
statusStage = 'signed-in';
window.dispatchEvent(
new CustomEvent('od:amr-login-status-change', {
detail: { reason: 'status-changed' },
}),
);
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Sign out' })).toBeTruthy();
});
expect(screen.getByText('late@example.com')).toBeTruthy();
});
it('renders the signed-in AMR account state inside Settings without leaking vela branding', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: true,
profile: 'local',
user: {
id: 'user-1',
email: 'signed-in@example.com',
name: 'Signed In User',
},
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
expect(await screen.findByRole('button', { name: 'Sign out' })).toBeTruthy();
expect(screen.getByRole('button', { name: /^Open Design AMR\b/ })).toBeTruthy();
expect(screen.getByText('signed-in@example.com')).toBeTruthy();
expect(screen.queryByText(/AMR \(vela\)/i)).toBeNull();
expect(screen.queryByText(/^vela$/i)).toBeNull();
});
it('renders env-backed AMR login inside Settings without fabricating account details', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
return new Response(
JSON.stringify({
loggedIn: true,
profile: 'local',
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);
renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
expect(await screen.findByRole('button', { name: 'Sign out' })).toBeTruthy();
expect(screen.getByRole('button', { name: /^Open Design AMR\b/ })).toBeTruthy();
expect(screen.queryByText(/@/i)).toBeNull();
expect(screen.queryByText(/AMR \(vela\)/i)).toBeNull();
});
it('does not keep a stale signed-in AMR state after a later Settings reopen reads loggedOut', async () => {
let statusCalls = 0;
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
statusCalls += 1;
return new Response(
JSON.stringify(
statusCalls === 1
? {
loggedIn: true,
profile: 'local',
user: { id: 'user-1', email: 'signed-in@example.com' },
configPath: '/Users/test/.amr/config.json',
}
: {
loggedIn: false,
profile: 'local',
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 first = renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
expect(await screen.findByRole('button', { name: 'Sign out' })).toBeTruthy();
first.unmount();
const second = renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
expect(await screen.findByRole('button', { name: 'Authorize' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Sign out' })).toBeNull();
second.unmount();
});
it('keeps AMR selected in Settings after local logout instead of silently switching agents', async () => {
let statusCalls = 0;
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(
JSON.stringify({ enabled: true, memories: [], extraction: null }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/status') {
statusCalls += 1;
return new Response(
JSON.stringify({
loggedIn: statusCalls === 1,
profile: 'local',
user: statusCalls === 1 ? { id: 'user-1', email: 'signed-in@example.com' } : null,
configPath: '/Users/test/.amr/config.json',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url === '/api/integrations/vela/logout' && init?.method === 'POST') {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const { onPersist } = renderSettingsDialog(
{ mode: 'daemon', agentId: 'amr' },
{ agents: [amrAgent] },
);
fireEvent.click(screen.getByRole('tab', { name: /Local CLI.*1 installed/i }));
expect(await screen.findByRole('button', { name: 'Sign out' })).toBeTruthy();
expect(screen.getByRole('button', { name: /^Open Design AMR\b/ })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Sign out' }));
expect(await screen.findByRole('button', { name: 'Authorize' })).toBeTruthy();
expect(screen.getByRole('button', { name: /^Open Design AMR\b/ })).toBeTruthy();
expect(
onPersist.mock.calls.some(
([nextConfig]) =>
typeof nextConfig === 'object' &&
nextConfig !== null &&
'agentId' in (nextConfig as Record<string, unknown>) &&
(nextConfig as Record<string, unknown>).agentId !== 'amr',
),
).toBe(false);
});
});

View file

@ -0,0 +1,589 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Routine } from '@open-design/contracts';
import { TasksView } from '../../src/components/TasksView';
import * as router from '../../src/router';
const originalFetch = globalThis.fetch;
const originalConfirm = window.confirm;
function mockTasksViewFetch({ routines = [] }: { routines?: Routine[] } = {}) {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-templates' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-proposals?status=pending-review' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ proposals: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-source-packets?limit=3' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ packets: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
}
describe('TasksView page shell', () => {
afterEach(() => {
cleanup();
globalThis.fetch = originalFetch;
window.confirm = originalConfirm;
vi.restoreAllMocks();
});
it('renders the automations page hero and summary metrics', async () => {
const routines: Routine[] = [
{
id: 'routine-active-1',
name: 'Daily digest',
prompt: 'Generate a digest.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now(),
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
},
{
id: 'routine-active-2',
name: 'Live artifact refresh',
prompt: 'Refresh the artifact.',
schedule: { kind: 'daily', time: '12:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now(),
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
},
{
id: 'routine-paused-1',
name: 'Weekly release notes',
prompt: 'Draft release notes.',
schedule: { kind: 'weekly', weekday: 1, time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: false,
nextRunAt: null,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
},
];
mockTasksViewFetch({ routines });
render(<TasksView />);
expect(await screen.findByRole('heading', { name: 'Automations' })).toBeTruthy();
expect(
screen.getByText(
'Plan recurring conversations for project work, Orbit digests, and live artifacts.',
),
).toBeTruthy();
expect(screen.getByTestId('automations-new')).toBeTruthy();
expect(screen.getByLabelText('Your automations')).toBeTruthy();
const summary = screen.getByLabelText('Automation summary');
await waitFor(() => {
expect(summary.textContent ?? '').toContain('2');
expect(summary.textContent ?? '').toContain('Active');
expect(summary.textContent ?? '').toContain('1');
expect(summary.textContent ?? '').toContain('Paused');
expect(summary.textContent ?? '').toContain('8');
expect(summary.textContent ?? '').toContain('Templates');
});
});
it('shows the empty state and opens the create modal from it', async () => {
mockTasksViewFetch();
render(<TasksView />);
const emptyState = await screen.findByRole('button', { name: /No automations yet/i });
expect(within(emptyState).getByText('Create one from a template or start with a blank schedule.')).toBeTruthy();
fireEvent.click(emptyState);
await waitFor(() => {
expect(screen.getByLabelText('Automation title')).toBeTruthy();
});
});
it('opens the create modal from the hero action', async () => {
mockTasksViewFetch();
render(<TasksView />);
fireEvent.click(await screen.findByTestId('automations-new'));
await waitFor(() => {
expect(screen.getByLabelText('Automation title')).toBeTruthy();
});
});
it('shows the template empty state when switching to an empty category', async () => {
mockTasksViewFetch();
render(<TasksView />);
const tabs = await screen.findByRole('tablist', { name: 'Template filters' });
fireEvent.click(within(tabs).getByRole('tab', { name: /Skills/i }));
await waitFor(() => {
expect(screen.getByRole('status').textContent ?? '').toContain('No templates in this category yet.');
});
fireEvent.click(within(tabs).getByRole('tab', { name: /^All/i }));
await waitFor(() => {
expect(screen.getByRole('button', { name: /Daily connector digest/i })).toBeTruthy();
});
});
it('runs an automation and opens its project conversation when the daemon returns one', async () => {
const routines: Routine[] = [
{
id: 'routine-run-1',
name: 'Daily digest',
prompt: 'Generate a digest.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now(),
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
},
];
const navigateSpy = vi.spyOn(router, 'navigate').mockImplementation(() => {});
const runCalls: string[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-templates' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-proposals?status=pending-review' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ proposals: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-source-packets?limit=3' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ packets: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-run-1/run' && init?.method === 'POST') {
runCalls.push(url);
return new Response(JSON.stringify({
projectId: 'proj-run',
conversationId: 'conv-run',
agentRunId: 'agent-run-1',
}), {
status: 202,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<TasksView />);
const row = (await screen.findByText('Daily digest')).closest('li')!;
fireEvent.click(within(row).getByRole('button', { name: 'Run' }));
await waitFor(() => {
expect(navigateSpy).toHaveBeenCalledWith({
kind: 'project',
projectId: 'proj-run',
conversationId: 'conv-run',
fileName: null,
});
});
expect(runCalls).toEqual(['/api/routines/routine-run-1/run']);
});
it('pauses and resumes an automation through PATCH updates', async () => {
let routines: Routine[] = [
{
id: 'routine-pause-1',
name: 'Daily digest',
prompt: 'Generate a digest.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now(),
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
},
];
const patchBodies: unknown[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-templates' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-proposals?status=pending-review' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ proposals: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-source-packets?limit=3' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ packets: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-pause-1' && init?.method === 'PATCH') {
const body = JSON.parse(String(init.body));
patchBodies.push(body);
routines = [{ ...routines[0]!, enabled: body.enabled, updatedAt: Date.now() }];
return new Response(JSON.stringify({ routine: routines[0] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<TasksView />);
const row = (await screen.findByText('Daily digest')).closest('li')!;
fireEvent.click(within(row).getByRole('button', { name: 'Pause' }));
await waitFor(() => {
expect(within(row).getByRole('button', { name: 'Resume' })).toBeTruthy();
});
fireEvent.click(within(row).getByRole('button', { name: 'Resume' }));
await waitFor(() => {
expect(within(row).getByRole('button', { name: 'Pause' })).toBeTruthy();
});
expect(patchBodies).toEqual([{ enabled: false }, { enabled: true }]);
});
it('deletes an automation after confirmation and returns to the empty state', async () => {
let routines: Routine[] = [
{
id: 'routine-delete-1',
name: 'Daily digest',
prompt: 'Generate a digest.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now(),
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
},
];
const deletedUrls: string[] = [];
window.confirm = vi.fn(() => true);
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-templates' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-proposals?status=pending-review' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ proposals: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-source-packets?limit=3' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ packets: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-delete-1' && init?.method === 'DELETE') {
deletedUrls.push(url);
routines = [];
return new Response(null, { status: 204 });
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<TasksView />);
const row = (await screen.findByText('Daily digest')).closest('li')!;
fireEvent.click(within(row).getByRole('button', { name: 'Delete automation' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: /No automations yet/i })).toBeTruthy();
});
expect(deletedUrls).toEqual(['/api/routines/routine-delete-1']);
});
it('opens the last run result from the saved automation row', async () => {
const navigateSpy = vi.spyOn(router, 'navigate').mockImplementation(() => {});
const startedAt = new Date('2026-05-25T09:29:00.000Z').getTime();
const routines: Routine[] = [
{
id: 'routine-result-1',
name: 'Orbit digest',
prompt: 'Build the digest.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now(),
lastRun: {
runId: 'run-1',
status: 'failed',
trigger: 'scheduled',
startedAt,
completedAt: startedAt + 5_000,
projectId: 'proj-result',
conversationId: 'conv-result',
agentRunId: 'agent-run-1',
},
createdAt: Date.now(),
updatedAt: Date.now(),
},
];
mockTasksViewFetch({ routines });
render(<TasksView />);
const row = (await screen.findByText('Orbit digest')).closest('li')!;
fireEvent.click(within(row).getByRole('button', { name: 'Open result' }));
expect(navigateSpy).toHaveBeenCalledWith({
kind: 'project',
projectId: 'proj-result',
conversationId: 'conv-result',
fileName: null,
});
});
it('expands and collapses automation history from the row action', async () => {
const startedAt = new Date('2026-05-25T09:29:00.000Z').getTime();
const routines: Routine[] = [
{
id: 'routine-history-1',
name: 'Orbit digest',
prompt: 'Build the digest.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now(),
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
},
];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-templates' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-proposals?status=pending-review' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ proposals: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/automation-source-packets?limit=3' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ packets: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-history-1/runs?limit=10') {
return new Response(JSON.stringify({
runs: [
{
id: 'run-1',
routineId: 'routine-history-1',
trigger: 'manual',
status: 'succeeded',
projectId: 'proj-result',
conversationId: 'conv-result',
agentRunId: 'agent-run-1',
startedAt,
completedAt: startedAt + 45_000,
summary: 'Updated orbit digest',
error: null,
errorCode: null,
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<TasksView />);
const row = (await screen.findByText('Orbit digest')).closest('li')!;
fireEvent.click(within(row).getByRole('button', { name: 'History' }));
expect(await screen.findByLabelText('Automation run history')).toBeTruthy();
expect(within(row).getByRole('button', { name: 'Hide history' })).toBeTruthy();
fireEvent.click(within(row).getByRole('button', { name: 'Hide history' }));
await waitFor(() => {
expect(screen.queryByLabelText('Automation run history')).toBeNull();
});
});
it('opens the edit modal with the routine title prefilled', async () => {
const routines: Routine[] = [
{
id: 'routine-edit-1',
name: 'Orbit digest',
prompt: 'Build the digest.',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'create_each_run' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now(),
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
},
];
mockTasksViewFetch({ routines });
render(<TasksView />);
const row = (await screen.findByText('Orbit digest')).closest('li')!;
fireEvent.click(within(row).getByRole('button', { name: 'Edit' }));
await waitFor(() => {
expect((screen.getByLabelText('Automation title') as HTMLInputElement).value).toBe(
'Orbit digest',
);
});
});
it('updates the active template filter tab state when switching categories', async () => {
mockTasksViewFetch();
render(<TasksView />);
const tabs = await screen.findByRole('tablist', { name: 'Template filters' });
const allTab = within(tabs).getByRole('tab', { name: /^All/i });
const orbitTab = within(tabs).getByRole('tab', { name: /Orbit/i });
expect(allTab.getAttribute('aria-selected')).toBe('true');
expect(orbitTab.getAttribute('aria-selected')).toBe('false');
fireEvent.click(orbitTab);
await waitFor(() => {
expect(allTab.getAttribute('aria-selected')).toBe('false');
expect(orbitTab.getAttribute('aria-selected')).toBe('true');
});
});
});

View file

@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import {
effectiveAgentModelChoice,
normalizeAgentModelChoice,
} from '../../src/components/agentModelSelection';
import type { AgentInfo } from '../../src/types';
const amrAgent: AgentInfo = {
id: 'amr',
name: 'AMR',
bin: 'amr',
available: true,
version: '1.0.0',
models: [
{ id: 'glm-5', label: 'GLM 5' },
{ id: 'glm-5.1', label: 'GLM 5.1' },
],
};
const codexAgent: AgentInfo = {
id: 'codex',
name: 'Codex',
bin: 'codex',
available: true,
version: '1.0.0',
models: [{ id: 'default', label: 'Default' }],
};
describe('agent model selection', () => {
it('normalizes stale saved AMR models to the first live model', () => {
expect(
normalizeAgentModelChoice(amrAgent, {
model: 'gpt-5.4-mini',
reasoning: 'medium',
}),
).toEqual({
model: 'glm-5',
reasoning: 'medium',
});
});
it('submits the same normalized AMR model that the switcher displays', () => {
expect(
effectiveAgentModelChoice(amrAgent, {
model: 'gpt-5.4-mini',
reasoning: 'medium',
}),
).toEqual({
model: 'glm-5',
reasoning: 'medium',
});
});
it('keeps non-AMR custom model choices unchanged', () => {
expect(
effectiveAgentModelChoice(codexAgent, {
model: 'custom-codex-model',
reasoning: 'high',
}),
).toEqual({
model: 'custom-codex-model',
reasoning: 'high',
});
});
});

Some files were not shown because too many files have changed in this diff Show more