diff --git a/src/components/settings/acp-agent-settings.provider-rebind.test.tsx b/src/components/settings/acp-agent-settings.provider-rebind.test.tsx new file mode 100644 index 0000000000..fddf07b8c1 --- /dev/null +++ b/src/components/settings/acp-agent-settings.provider-rebind.test.tsx @@ -0,0 +1,206 @@ +/** + * Leaving "model_provider" auth mode drops the draft's provider binding (a save + * in another mode must not persist one), so coming back falls into the + * auto-select. That auto-select used to take the head of the provider list — + * the OLDEST provider, since the list is ordered by row id — and the rebind is + * provider-authoritative: it rewrites the model fields, the env text and the + * config text from whichever provider it lands on. These drive the real panel + * through that round trip and pin where it lands: the user's own last pick, + * then the binding saved on the agent, and only then the head. + */ +import { render, screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { NextIntlClientProvider } from "next-intl" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import enMessages from "@/i18n/messages/en.json" +import { + acpListAgents, + acpUpdateAgentConfig, + acpUpdateAgentEnv, + listModelProviders, +} from "@/lib/api" +import type { AcpAgentInfo, ModelProviderInfo } from "@/lib/types" + +import { AcpAgentSettings } from "./acp-agent-settings" + +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal>() + // Every function is stubbed so nothing here can reach the real transport; + // the calls the panel makes on mount that these tests don't need (preflight, + // catalogs) simply never settle. + return Object.fromEntries( + Object.entries(actual).map(([name, value]) => [ + name, + typeof value === "function" ? vi.fn(() => new Promise(() => {})) : value, + ]) + ) +}) +vi.mock("next/navigation", () => { + const params = new URLSearchParams() + return { useSearchParams: () => params } +}) + +function claudeAgent(overrides: Partial = {}): AcpAgentInfo { + return { + agent_type: "claude_code", + skills_capable: true, + registry_id: "claude-code", + registry_version: "1.0.0", + supports_custom_version: false, + name: "Claude Code", + description: "", + available: true, + distribution_type: "npx", + is_acp_adapter: true, + custom_source: null, + enabled: true, + sort_order: 0, + installed_version: null, + host_tools_agent_mode: false, + env: {}, + config_json: null, + config_file_path: null, + opencode_auth_json: null, + codex_auth_json: null, + cline_secrets_json: null, + codex_config_toml: null, + codex_model_catalog: null, + codex_sandbox_settings: null, + grok_config_toml: null, + grok_settings: null, + hermes_config_yaml: null, + cursor_cli_config_json: null, + cursor_settings: null, + model_provider_id: null, + icon_url: null, + ...overrides, + } +} + +function provider(id: number, letter: string): ModelProviderInfo { + return { + id, + name: `Provider ${letter}`, + api_url: `https://gateway-${letter.toLowerCase()}.test`, + api_key: `key-${letter.toLowerCase()}`, + api_key_masked: "", + agent_type: "claude_code", + model: JSON.stringify({ main: `model-${letter.toLowerCase()}` }), + created_at: "", + updated_at: "", + } +} + +// Row-id order, as `list_all` returns them: A is the oldest, so the head. +const PROVIDERS = [provider(1, "A"), provider(2, "B"), provider(3, "C")] + +function renderPanel() { + return render( + + + + ) +} + +/** The select that sits under a visible `