diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index e9ceab7fd..60d29c7f0 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -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 diff --git a/CONTEXT.md b/CONTEXT.md index 2a861ec10..4fd5425a5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 diff --git a/apps/daemon/package.json b/apps/daemon/package.json index e4c760c50..fc33d934a 100644 --- a/apps/daemon/package.json +++ b/apps/daemon/package.json @@ -1,6 +1,6 @@ { "name": "@open-design/daemon", - "version": "0.8.0", + "version": "0.8.1", "private": true, "type": "module", "main": "./dist/cli.js", diff --git a/apps/daemon/scripts/verify-amr-real-vela.mjs b/apps/daemon/scripts/verify-amr-real-vela.mjs new file mode 100755 index 000000000..97ef92446 --- /dev/null +++ b/apps/daemon/scripts/verify-amr-real-vela.mjs @@ -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= \ + * VELA_LINK_URL=https://openrouter.ai/api/v1 \ + * PATH=:$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'); diff --git a/apps/daemon/src/acp.ts b/apps/daemon/src/acp.ts index b8597ef08..ca2ebf8ca 100644 --- a/apps/daemon/src/acp.ts +++ b/apps/daemon/src/acp.ts @@ -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}`)); diff --git a/apps/daemon/src/app-config.ts b/apps/daemon/src/app-config.ts index ee70d9fb4..c53522347 100644 --- a/apps/daemon/src/app-config.ts +++ b/apps/daemon/src/app-config.ts @@ -142,6 +142,14 @@ function validateTelemetry(raw: unknown): TelemetryPrefs | undefined { } const AGENT_CLI_ENV_KEYS: ReadonlyMap> = 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'])], diff --git a/apps/daemon/src/connectionTest.ts b/apps/daemon/src/connectionTest.ts index 41865cc7b..6d579c0a0 100644 --- a/apps/daemon/src/connectionTest.ts +++ b/apps/daemon/src/connectionTest.ts @@ -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, }); diff --git a/apps/daemon/src/integrations/vela-errors.ts b/apps/daemon/src/integrations/vela-errors.ts new file mode 100644 index 000000000..348332a35 --- /dev/null +++ b/apps/daemon/src/integrations/vela-errors.ts @@ -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 } : {}), + }; +} diff --git a/apps/daemon/src/integrations/vela-profile.ts b/apps/daemon/src/integrations/vela-profile.ts new file mode 100644 index 000000000..5650aa347 --- /dev/null +++ b/apps/daemon/src/integrations/vela-profile.ts @@ -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; + +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) }; +} diff --git a/apps/daemon/src/integrations/vela.ts b/apps/daemon/src/integrations/vela.ts new file mode 100644 index 000000000..d6e9dd303 --- /dev/null +++ b/apps/daemon/src/integrations/vela.ts @@ -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; +} + +export function mergeVelaEnv( + env: NodeJS.ProcessEnv = process.env, + configuredEnv: Record = {}, +): 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 = {}, +): 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(); +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; + baseEnv?: NodeJS.ProcessEnv; +} + +async function waitForImmediateLoginFailure(child: ChildProcess): Promise { + 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 { + 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), + }; +} diff --git a/apps/daemon/src/prompts/discovery.ts b/apps/daemon/src/prompts/discovery.ts index 9b5406a92..40d2292f5 100644 --- a/apps/daemon/src/prompts/discovery.ts +++ b/apps/daemon/src/prompts/discovery.ts @@ -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 \`\` in this turn. If you tailor \`\` 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. diff --git a/apps/daemon/src/runtimes/auth.ts b/apps/daemon/src/runtimes/auth.ts index 9b4eb129a..83b64336d 100644 --- a/apps/daemon/src/runtimes/auth.ts +++ b/apps/daemon/src/runtimes/auth.ts @@ -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; diff --git a/apps/daemon/src/runtimes/defs/amr.ts b/apps/daemon/src/runtimes/defs/amr.ts new file mode 100644 index 000000000..5ae9b0b83 --- /dev/null +++ b/apps/daemon/src/runtimes/defs/amr.ts @@ -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 = 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(); + 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; diff --git a/apps/daemon/src/runtimes/env.ts b/apps/daemon/src/runtimes/env.ts index dd9545f7e..5f4f74e43 100644 --- a/apps/daemon/src/runtimes/env.ts +++ b/apps/daemon/src/runtimes/env.ts @@ -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; @@ -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; diff --git a/apps/daemon/src/runtimes/executables.ts b/apps/daemon/src/runtimes/executables.ts index aa56424b6..68e046dd4 100644 --- a/apps/daemon/src/runtimes/executables.ts +++ b/apps/daemon/src/runtimes/executables.ts @@ -7,6 +7,7 @@ import { expandHomePath } from './paths.js'; import type { RuntimeAgentDef } from './types.js'; const AGENT_BIN_ENV_KEYS = new Map([ + ['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 | 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 | null { + const envKey = AGENT_BIN_ENV_KEYS.get(def?.id); + if (!envKey) return null; + return executableFilePath(configuredEnv?.[envKey]); +} + +export function resolveAmrOpenCodeExecutable( + env: Record = 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 +// `/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 | 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 = {}, @@ -164,9 +252,10 @@ export function inspectAgentExecutableResolution( break; } } + const builtInPath = packagedBuiltInExecutable(def, configuredEnv); return { configuredOverridePath, pathResolvedPath, - selectedPath: configuredOverridePath || pathResolvedPath, + selectedPath: configuredOverridePath || builtInPath || pathResolvedPath, }; } diff --git a/apps/daemon/src/runtimes/metadata.ts b/apps/daemon/src/runtimes/metadata.ts index 4bcd8f0d7..bc6d16fe9 100644 --- a/apps/daemon/src/runtimes/metadata.ts +++ b/apps/daemon/src/runtimes/metadata.ts @@ -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', diff --git a/apps/daemon/src/runtimes/models.ts b/apps/daemon/src/runtimes/models.ts index 31c6fcaab..ef0cb6970 100644 --- a/apps/daemon/src/runtimes/models.ts +++ b/apps/daemon/src/runtimes/models.ts @@ -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>(); +const liveModelOrder = new Map(); 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 = 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 diff --git a/apps/daemon/src/runtimes/registry.ts b/apps/daemon/src/runtimes/registry.ts index 194d4339a..505b773f1 100644 --- a/apps/daemon/src/runtimes/registry.ts +++ b/apps/daemon/src/runtimes/registry.ts @@ -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, diff --git a/apps/daemon/src/runtimes/types.ts b/apps/daemon/src/runtimes/types.ts index 888d997ab..b2cf94cda 100644 --- a/apps/daemon/src/runtimes/types.ts +++ b/apps/daemon/src/runtimes/types.ts @@ -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< diff --git a/apps/daemon/src/server.ts b/apps/daemon/src/server.ts index 72ae3d253..a522b54d8 100644 --- a/apps/daemon/src/server.ts +++ b/apps/daemon/src/server.ts @@ -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, - [ - agentStreamError, - typeof ev.raw === 'string' ? ev.raw : '', - agentStdoutTail, - agentStderrTail, - ].join('\n'), - ); + const failureText = [ + agentStreamError, + typeof ev.raw === 'string' ? ev.raw : '', + agentStdoutTail, + agentStderrTail, + ].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 diff --git a/apps/daemon/tests/amr-acp-integration.test.ts b/apps/daemon/tests/amr-acp-integration.test.ts new file mode 100644 index 000000000..2c79b48c0 --- /dev/null +++ b/apps/daemon/tests/amr-acp-integration.test.ts @@ -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 { + if (child.exitCode !== null) return; + await new Promise((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'); + }); +}); diff --git a/apps/daemon/tests/app-config.test.ts b/apps/daemon/tests/app-config.test.ts index 7a74adc4d..6de4af84b 100644 --- a/apps/daemon/tests/app-config.test.ts +++ b/apps/daemon/tests/app-config.test.ts @@ -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' }, }); }); diff --git a/apps/daemon/tests/diagnostics-export.test.ts b/apps/daemon/tests/diagnostics-export.test.ts index e2b117325..1e42ee165 100644 --- a/apps/daemon/tests/diagnostics-export.test.ts +++ b/apps/daemon/tests/diagnostics-export.test.ts @@ -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([]); }); }); diff --git a/apps/daemon/tests/fixtures/fake-vela.mjs b/apps/daemon/tests/fixtures/fake-vela.mjs new file mode 100755 index 000000000..02defb42d --- /dev/null +++ b/apps/daemon/tests/fixtures/fake-vela.mjs @@ -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); +} diff --git a/apps/daemon/tests/integrations/vela-errors.test.ts b/apps/daemon/tests/integrations/vela-errors.test.ts new file mode 100644 index 000000000..0500d5268 --- /dev/null +++ b/apps/daemon/tests/integrations/vela-errors.test.ts @@ -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(); + }); +}); diff --git a/apps/daemon/tests/integrations/vela.routes.test.ts b/apps/daemon/tests/integrations/vela.routes.test.ts new file mode 100644 index 000000000..ded918bda --- /dev/null +++ b/apps/daemon/tests/integrations/vela.routes.test.ts @@ -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(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(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 = {}): 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) ?? {}), + }, + ...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) ?? {}), + VELA_BIN: FAKE_VELA, + }, + }, + }); + const started = (await startServer({ port: 0, returnServer: true })) as StartedServer; + baseUrl = started.url; + server = started.server; +}); + +afterAll(() => new Promise((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) ?? {}), + 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); + } + }); + + 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) ?? {}), + 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); + } + }); + + 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) ?? {}), + 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); + } + }); + + 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) ?? {}), + 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); + } + }); + + 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) ?? {}), + 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); + } + }); + + 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) ?? {}), + 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); + } + }); + + 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; + }); +}); diff --git a/apps/daemon/tests/integrations/vela.test.ts b/apps/daemon/tests/integrations/vela.test.ts new file mode 100644 index 000000000..8ecfadd10 --- /dev/null +++ b/apps/daemon/tests/integrations/vela.test.ts @@ -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(); + }); +}); diff --git a/apps/daemon/tests/prompts/discovery-form.test.ts b/apps/daemon/tests/prompts/discovery-form.test.ts index e6cf66a50..46ccb1556 100644 --- a/apps/daemon/tests/prompts/discovery-form.test.ts +++ b/apps/daemon/tests/prompts/discovery-form.test.ts @@ -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 `` 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 diff --git a/apps/daemon/tests/runtimes/env-and-detection.test.ts b/apps/daemon/tests/runtimes/env-and-detection.test.ts index 124fe7b27..4f21cc58a 100644 --- a/apps/daemon/tests/runtimes/env-and-detection.test.ts +++ b/apps/daemon/tests/runtimes/env-and-detection.test.ts @@ -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'; diff --git a/apps/daemon/tests/runtimes/executables.test.ts b/apps/daemon/tests/runtimes/executables.test.ts index ddf5e4b4e..0d646fea3 100644 --- a/apps/daemon/tests/runtimes/executables.test.ts +++ b/apps/daemon/tests/runtimes/executables.test.ts @@ -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 `/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', () => { diff --git a/apps/daemon/tests/runtimes/launch.test.ts b/apps/daemon/tests/runtimes/launch.test.ts index 6132e344f..02e7d154d 100644 --- a/apps/daemon/tests/runtimes/launch.test.ts +++ b/apps/daemon/tests/runtimes/launch.test.ts @@ -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 { diff --git a/apps/daemon/tests/runtimes/resolve-model.test.ts b/apps/daemon/tests/runtimes/resolve-model.test.ts new file mode 100644 index 000000000..d614c3966 --- /dev/null +++ b/apps/daemon/tests/runtimes/resolve-model.test.ts @@ -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'); + }); +}); diff --git a/apps/daemon/tests/runtimes/service-failure-classification.test.ts b/apps/daemon/tests/runtimes/service-failure-classification.test.ts new file mode 100644 index 000000000..22c09bdfe --- /dev/null +++ b/apps/daemon/tests/runtimes/service-failure-classification.test.ts @@ -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(); + } + }); +}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8ae32aa44..4ae60fbc6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/landing-page/package.json b/apps/landing-page/package.json index e686768cf..396b1fb0e 100644 --- a/apps/landing-page/package.json +++ b/apps/landing-page/package.json @@ -1,6 +1,6 @@ { "name": "@open-design/landing-page", - "version": "0.8.0", + "version": "0.8.1", "private": true, "type": "module", "scripts": { diff --git a/apps/packaged/package.json b/apps/packaged/package.json index 959d63675..f7b20b4ad 100644 --- a/apps/packaged/package.json +++ b/apps/packaged/package.json @@ -1,6 +1,6 @@ { "name": "@open-design/packaged", - "version": "0.8.0", + "version": "0.8.1", "private": true, "type": "module", "main": "./dist/index.mjs", diff --git a/apps/packaged/src/config.ts b/apps/packaged/src/config.ts index 69dfea628..86f46241d 100644 --- a/apps/packaged/src/config.ts +++ b/apps/packaged/src/config.ts @@ -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 { const webSidecarEntry = await resolvePackagedRelativeEntry(raw.webSidecarEntryRelative); return { + amrProfile: resolvePackagedAmrProfile(raw.amrProfile), appVersion: cleanOptionalString(raw.appVersion), daemonCliEntry, daemonSidecarEntry, diff --git a/apps/packaged/src/headless.ts b/apps/packaged/src/headless.ts index 2ed42ff84..9ef0065c9 100644 --- a/apps/packaged/src/headless.ts +++ b/apps/packaged/src/headless.ts @@ -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 { const sidecars = await startPackagedSidecars(runtime, paths, { appVersion: config.appVersion, + amrProfile: config.amrProfile, daemonCliEntry: config.daemonCliEntry, daemonSidecarEntry: config.daemonSidecarEntry, nodeCommand: config.nodeCommand, diff --git a/apps/packaged/src/index.ts b/apps/packaged/src/index.ts index 198b1f347..465a0241f 100644 --- a/apps/packaged/src/index.ts +++ b/apps/packaged/src/index.ts @@ -102,6 +102,7 @@ async function main(): Promise { const sidecars = await startPackagedSidecars(runtime, paths, { appVersion: config.appVersion, + amrProfile: config.amrProfile, daemonCliEntry: config.daemonCliEntry, daemonSidecarEntry: config.daemonSidecarEntry, nodeCommand: config.nodeCommand, diff --git a/apps/packaged/src/sidecars.ts b/apps/packaged/src/sidecars.ts index 909131f68..9bc6cc489 100644 --- a/apps/packaged/src/sidecars.ts +++ b/apps/packaged/src/sidecars.ts @@ -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 { + try { + await access(path); + return true; + } catch { + return false; + } +} + +export async function resolvePackagedElectronNodeCommand( + execPath = process.execPath, + platform = process.platform, +): Promise { + 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 { 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, diff --git a/apps/packaged/tests/paths.test.ts b/apps/packaged/tests/paths.test.ts index 45fe9ec4a..115bdc9b9 100644 --- a/apps/packaged/tests/paths.test.ts +++ b/apps/packaged/tests/paths.test.ts @@ -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, diff --git a/apps/packaged/tests/sidecars.test.ts b/apps/packaged/tests/sidecars.test.ts index 42ae84b46..aa1f3becc 100644 --- a/apps/packaged/tests/sidecars.test.ts +++ b/apps/packaged/tests/sidecars.test.ts @@ -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, diff --git a/apps/web/package.json b/apps/web/package.json index b64106e1a..095f95336 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@open-design/web", - "version": "0.8.0", + "version": "0.8.1", "private": true, "type": "module", "exports": { diff --git a/apps/web/public/agent-icons/amr.svg b/apps/web/public/agent-icons/amr.svg new file mode 100644 index 000000000..ac59261b2 --- /dev/null +++ b/apps/web/public/agent-icons/amr.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/web/public/official_badge.svg b/apps/web/public/official_badge.svg new file mode 100644 index 000000000..4ed1fb645 --- /dev/null +++ b/apps/web/public/official_badge.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 官方 + + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 42b1eda7d..817e5c87a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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('execution'); + const [settingsHighlight, setSettingsHighlight] = useState(null); const [integrationInitialTab, setIntegrationInitialTab] = useState('mcp'); const [daemonLive, setDaemonLive] = useState(false); const [agents, setAgents] = useState([]); @@ -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() { = { + amr: 'svg', claude: 'svg', codex: 'svg', gemini: 'svg', diff --git a/apps/web/src/components/AmrGuidance.tsx b/apps/web/src/components/AmrGuidance.tsx new file mode 100644 index 000000000..81ee5689b --- /dev/null +++ b/apps/web/src/components/AmrGuidance.tsx @@ -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 ( +
+
+ + {t('chat.amrCard.switchTitle')} +
+

{t('chat.amrCard.switchBody')}

+ +
+ +
+
+ ); +} diff --git a/apps/web/src/components/AmrLoginPill.tsx b/apps/web/src/components/AmrLoginPill.tsx new file mode 100644 index 000000000..f456d2eea --- /dev/null +++ b/apps/web/src/components/AmrLoginPill.tsx @@ -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) => void; + onSignOut?: (event: MouseEvent) => void; + onCancelSignIn?: (event: MouseEvent) => 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 { + 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 ( +
+ {statusText ? ( + {statusText} + ) : null} + {isSignedIn && onSignOut ? ( + + ) : null} + {isSigningIn && showCancelSignInAction && onCancelSignIn ? ( + + ) : null} + {canSignIn ? ( + + ) : null} + {badgeLabel ? ( + {badgeLabel} + ) : null} + {hasError ? ( + + {t('settings.amrLoginErrorCompact')} + + ) : null} +
+ ); +} + +// 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(initialStatus); + const [pending, setPending] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [canceledVisible, setCanceledVisible] = useState(false); + const pollRef = useRef(null); + const loginStartedAtRef = useRef(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) => { + 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) => { + 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) => { + 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 ( +
event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + +
+ ); +} diff --git a/apps/web/src/components/AssistantMessage.tsx b/apps/web/src/components/AssistantMessage.tsx index 708db93b5..443dd73b5 100644 --- a/apps/web/src/components/AssistantMessage.tsx +++ b/apps/web/src/components/AssistantMessage.tsx @@ -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( - suppressAskUserQuestionFallbackText(buildBlocks(events)), + 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 ; + } return null; })} {!streaming && displayedProduced.length > 0 && projectId ? ( @@ -1821,11 +1835,52 @@ function StatusPill({ return (
{label} - {detail ? {detail} : null} + {detail ? {renderStatusDetail(detail)} : null}
); } +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( + + {href} + , + ); + 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; result?: Extract; @@ -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(); + 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 diff --git a/apps/web/src/components/AvatarMenu.tsx b/apps/web/src/components/AvatarMenu.tsx index 81ae81c23..547b7090b 100644 --- a/apps/web/src/components/AvatarMenu.tsx +++ b/apps/web/src/components/AvatarMenu.tsx @@ -22,6 +22,10 @@ interface Props { onBack?: () => void; } +function displayAgentName(agent: Pick): 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')} @@ -191,12 +203,12 @@ export function AvatarMenu({ }} > - {a.name} + {displayAgentName(a)} {selected ? ( {t('avatar.metaSelected')} - ) : a.version ? ( + ) : a.id !== 'amr' && a.version ? ( {a.version} ) : null} {selected ? ( diff --git a/apps/web/src/components/ChatPane.tsx b/apps/web/src/components/ChatPane.tsx index 21e0c6e7e..31e8017d9 100644 --- a/apps/web/src/components/ChatPane.tsx +++ b/apps/web/src/components/ChatPane.tsx @@ -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,20 +1168,61 @@ export function ChatPane({ ); })} - {error ? ( + {displayError ? (
- {error} - {retryAssistant && onRetry ? ( - + {displayError} + {retryAssistant && onRetry && runFailureUi ? ( +
+ {runFailureUi.primaryAction === 'authorize' ? ( + + ) : runFailureUi.primaryAction === 'recharge' ? ( + + ) : null} + {runFailureUi.primaryAction === 'retry' || runFailureUi.secondaryRetry ? ( + + ) : null} +
) : null}
) : null} + {amrSwitchPayload ? ( + { + if (retryAssistant && onSwitchToAmrAndRetry) { + onSwitchToAmrAndRetry(retryAssistant); + } else { + onOpenAmrSettings?.(); + } + }} + /> + ) : null} {/* Always mounted so the CSS transition can play in both directions; the `chat-jump-btn-active` class flips the diff --git a/apps/web/src/components/EntryShell.tsx b/apps/web/src/components/EntryShell.tsx index d2e339372..b6520d74a 100644 --- a/apps/web/src/components/EntryShell.tsx +++ b/apps/web/src/components/EntryShell.tsx @@ -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(null); + const [amrLoginPending, setAmrLoginPending] = useState(false); + const [amrLoginError, setAmrLoginError] = useState(false); const [visibleAgentIds, setVisibleAgentIds] = useState([]); const [providerTestState, setProviderTestState] = useState< | { status: 'idle' } @@ -809,6 +830,8 @@ function OnboardingView({ }, [profile]); const agentRevealTimersRef = useRef>>([]); 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(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 { + 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,9 +1550,13 @@ function OnboardingView({ } } - const primaryActionLabel = isLastStep - ? t('settings.onboardingFinish') - : t('settings.onboardingContinue'); + const primaryActionLabel = step === 0 && amrSelectedAndSignedOut + ? t('settings.amrSignInToContinue') + : step === 1 + ? t('settings.onboardingContinue') + : isLastStep + ? t('settings.onboardingFinish') + : t('settings.onboardingContinue'); return (
@@ -1465,6 +1586,54 @@ function OnboardingView({ body={t('settings.onboardingConnectBody')} />
+ {showAmrCloudOption ? ( +
+ + ) : null + } + featured + selected={runtime === 'amr'} + onClick={() => { + setRuntime('amr'); + onModeChange('daemon'); + onAgentChange('amr'); + }} + /> +
+ ) : null}
{runtimeItems.map((item) => ( {primaryActionLabel} -
)} @@ -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) { + if (event.target !== event.currentTarget) return; + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onClick(); + } + return ( - +
); } diff --git a/apps/web/src/components/InlineModelSwitcher.tsx b/apps/web/src/components/InlineModelSwitcher.tsx index a8ab1bb0d..2fb6afd9c 100644 --- a/apps/web/src/components/InlineModelSwitcher.tsx +++ b/apps/web/src/components/InlineModelSwitcher.tsx @@ -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): string { + return agent.id === 'amr' ? 'Open Design AMR' : agent.name; +} + +function displayAgentChipName(agent: Pick): 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(null); + const [amrStatus, setAmrStatus] = useState(null); + const [amrLoginPending, setAmrLoginPending] = useState(false); + const [amrLoginError, setAmrLoginError] = useState(false); + const [amrReminderSeen, setAmrReminderSeen] = useState(readAmrReminderSeen); + const [showAmrReminderInPopover, setShowAmrReminderInPopover] = + useState(false); + const amrPollRef = useRef(null); + const amrLoginStartedAtRef = useRef(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 (
+ +
); })} @@ -287,7 +629,8 @@ export function InlineModelSwitcher({ } > {renderModelOptions(currentAgent.models)} - {currentModelId && + {currentAgent.id !== 'amr' && + currentModelId && !currentAgent.models.some((m) => m.id === currentModelId) ? (