mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-12 12:18:55 +02:00
* fix(agent): expand Copilot CLI model catalog with correct dotted IDs The Copilot CLI provider only exposed two models in the runtime dropdown, and one of them used the dashed legacy form `claude-sonnet-4-6` which `copilot --model` rejects with "Model ... is not available". The CLI accepts dotted IDs (e.g. `claude-sonnet-4.6`, `gpt-5.4`). Sync `copilotStaticModels()` with the official supported-models catalog so the dropdown surfaces the full set the user's account can route to (8 OpenAI + 4 Anthropic), and add a regression test that pins the expected IDs and bans the dashed form. Closes MUL-1948. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: multica-agent <github@multica.ai> * feat(agent): dynamic Copilot model discovery via ACP session/new The previous static catalog could only ever lag behind the user's real entitlements and what GitHub ships. Copilot CLI exposes the live catalog through its ACP server (`copilot --acp`): the `session/new` response includes `models.availableModels` plus `currentModelId`, scoped to the authenticated account. Wire copilot through the existing discoverACPModels helper — already used by hermes/kimi/kiro — so the dropdown reflects the account's real catalog, including the `auto` entry and per-tier model availability (Pro / Pro+ / Enterprise / evaluation models). The Copilot CLI puts itself into ACP server mode via the `--acp` flag instead of an `acp` subcommand, so acpDiscoveryProvider now takes an optional acpArgs override. Copilot's ACP payload omits the vendor name, so a small prefix-based inferCopilotProvider keeps the UI's openai / anthropic / google grouping working. When the binary is missing or auth fails, fall back to copilotStaticModels() so self-hosted runtimes without a copilot install still see a populated dropdown. Verified against `copilot 1.0.44`: live discovery returns 13 models with gpt-5.5 marked Default. Closes MUL-1948. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: multica-agent <github@multica.ai> * fix(agent): drop no-op COPILOT_ALLOW_ALL env and generalize OpenAI o-series prefix check - discoverCopilotModels: remove COPILOT_ALLOW_ALL=1 (not a real Copilot CLI env var; copy-pasta from HERMES_YOLO_MODE=1). Discovery only drives initialize + session/new which never trigger tool-permission prompts, so no extra env is needed. - inferCopilotProvider: replace the o1/o3/o4 prefix chain with a generic o<digit>+ check via isOpenAIReasoningSeriesID, so future o5/o6/… reasoning models are tagged as openai automatically. Guards against false positives like 'opus-…' or bare 'o'. - Extend TestInferCopilotProvider with o5/o6 forward-compat cases and negative cases (opus-fake, omni, o). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: multica-agent <github@multica.ai>
540 lines
17 KiB
Go
540 lines
17 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestListModelsStaticProviders(t *testing.T) {
|
|
ctx := context.Background()
|
|
for _, provider := range []string{"claude", "codex", "gemini", "cursor"} {
|
|
got, err := ListModels(ctx, provider, "")
|
|
if err != nil {
|
|
t.Fatalf("ListModels(%q) error: %v", provider, err)
|
|
}
|
|
if len(got) == 0 {
|
|
t.Errorf("ListModels(%q) returned no models", provider)
|
|
}
|
|
for i, m := range got {
|
|
if m.ID == "" {
|
|
t.Errorf("ListModels(%q)[%d] has empty ID", provider, i)
|
|
}
|
|
if m.Label == "" {
|
|
t.Errorf("ListModels(%q)[%d] has empty Label", provider, i)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListModelsCopilotFallsBackToStatic(t *testing.T) {
|
|
// Copilot uses dynamic ACP discovery, but with no `copilot`
|
|
// binary on PATH (the discovery LookPath fails) it must fall
|
|
// back to copilotStaticModels() so the UI dropdown stays
|
|
// populated. This is the "binary missing on the daemon host"
|
|
// path we care about for self-hosted runtimes.
|
|
ctx := context.Background()
|
|
modelCacheMu.Lock()
|
|
delete(modelCache, "copilot")
|
|
modelCacheMu.Unlock()
|
|
|
|
got, err := ListModels(ctx, "copilot", "/nonexistent/copilot-cli")
|
|
if err != nil {
|
|
t.Fatalf("ListModels(copilot) error: %v", err)
|
|
}
|
|
if len(got) == 0 {
|
|
t.Fatal("expected static fallback models, got empty list")
|
|
}
|
|
ids := map[string]bool{}
|
|
for _, m := range got {
|
|
ids[m.ID] = true
|
|
}
|
|
if !ids["gpt-5.4"] || !ids["claude-sonnet-4.6"] {
|
|
t.Errorf("static fallback missing expected models: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestGeminiStaticModelsExposesAliasesAndGemini3(t *testing.T) {
|
|
// Gemini CLI has no `models list` subcommand, so we expose the
|
|
// CLI's own aliases (auto / pro / flash / flash-lite) plus
|
|
// explicit version pins including Gemini 3. Regression guard
|
|
// for multica-ai/multica#1503 — Gemini 3 must be selectable.
|
|
models := geminiStaticModels()
|
|
ids := map[string]Model{}
|
|
for _, m := range models {
|
|
ids[m.ID] = m
|
|
}
|
|
for _, want := range []string{
|
|
"auto", "auto-gemini-2.5",
|
|
"pro", "flash", "flash-lite",
|
|
"gemini-3-pro-preview", "gemini-3-flash-preview",
|
|
"gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite",
|
|
} {
|
|
if _, ok := ids[want]; !ok {
|
|
t.Errorf("missing expected Gemini model %q in: %+v", want, models)
|
|
}
|
|
}
|
|
auto, ok := ids["auto"]
|
|
if !ok || !auto.Default {
|
|
t.Errorf("expected `auto` to be the default Gemini entry, got %+v", auto)
|
|
}
|
|
for _, m := range models {
|
|
if m.Provider != "google" {
|
|
t.Errorf("all Gemini entries must carry Provider=google, got %+v", m)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCodexStaticModelsExposesGPT55(t *testing.T) {
|
|
// Codex CLI has no `models list` subcommand so the catalog is
|
|
// hand-maintained. Regression guard for multica-ai/multica#2009 —
|
|
// GPT-5.5 must be selectable, and the badge default must point at
|
|
// the latest release rather than lagging a version behind.
|
|
models := codexStaticModels()
|
|
ids := map[string]Model{}
|
|
for _, m := range models {
|
|
ids[m.ID] = m
|
|
}
|
|
for _, want := range []string{
|
|
"gpt-5.5", "gpt-5.5-mini",
|
|
"gpt-5.4", "gpt-5.4-mini",
|
|
"gpt-5.3-codex", "gpt-5",
|
|
"o3", "o3-mini",
|
|
} {
|
|
if _, ok := ids[want]; !ok {
|
|
t.Errorf("missing expected Codex model %q in: %+v", want, models)
|
|
}
|
|
}
|
|
latest, ok := ids["gpt-5.5"]
|
|
if !ok || !latest.Default {
|
|
t.Errorf("expected `gpt-5.5` to be the default Codex entry, got %+v", latest)
|
|
}
|
|
defaults := 0
|
|
for _, m := range models {
|
|
if m.Default {
|
|
defaults++
|
|
}
|
|
if m.Provider != "openai" {
|
|
t.Errorf("all Codex entries must carry Provider=openai, got %+v", m)
|
|
}
|
|
}
|
|
if defaults != 1 {
|
|
t.Errorf("expected exactly one default Codex entry, got %d", defaults)
|
|
}
|
|
}
|
|
|
|
func TestInferCopilotProvider(t *testing.T) {
|
|
cases := map[string]string{
|
|
"gpt-5.5": "openai",
|
|
"gpt-5.4-mini": "openai",
|
|
"gpt-5.3-codex": "openai",
|
|
"gpt-4.1": "openai",
|
|
"o1": "openai",
|
|
"o3": "openai",
|
|
"o3-mini": "openai",
|
|
"o4-mini": "openai",
|
|
"o5": "openai", // future-proof: any o<digit>+
|
|
"o6-mini-high": "openai",
|
|
"claude-opus-4.7": "anthropic",
|
|
"claude-sonnet-4.6": "anthropic",
|
|
"claude-haiku-4.5": "anthropic",
|
|
"gemini-3-pro": "google",
|
|
"grok-code-fast-1": "xai",
|
|
"auto": "",
|
|
"raptor-mini": "",
|
|
// negative cases: must not be misidentified as OpenAI
|
|
// reasoning series even though they start with `o`.
|
|
"opus-fake": "",
|
|
"omni": "",
|
|
"o": "",
|
|
}
|
|
for id, want := range cases {
|
|
if got := inferCopilotProvider(id); got != want {
|
|
t.Errorf("inferCopilotProvider(%q) = %q, want %q", id, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCopilotStaticModelsExposesFullCatalog(t *testing.T) {
|
|
// GitHub Copilot CLI has no `models list` subcommand, so the
|
|
// catalog is hand-maintained from the official supported-models
|
|
// docs. Regression guard for multica-ai/multica#1948 — the
|
|
// dropdown previously shipped only 2 models and used dashed IDs
|
|
// (`claude-sonnet-4-6`) which the CLI rejects. IDs must use the
|
|
// dotted form (`claude-sonnet-4.6`) that `copilot --model <id>`
|
|
// actually accepts, and cover both OpenAI and Anthropic families.
|
|
models := copilotStaticModels()
|
|
ids := map[string]Model{}
|
|
for _, m := range models {
|
|
ids[m.ID] = m
|
|
}
|
|
for _, want := range []string{
|
|
"gpt-5.5", "gpt-5.4", "gpt-5.4-mini",
|
|
"gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.2",
|
|
"gpt-5-mini", "gpt-4.1",
|
|
"claude-opus-4.7", "claude-sonnet-4.6",
|
|
"claude-sonnet-4.5", "claude-haiku-4.5",
|
|
} {
|
|
if _, ok := ids[want]; !ok {
|
|
t.Errorf("missing expected Copilot model %q in: %+v", want, models)
|
|
}
|
|
}
|
|
// Dashed legacy IDs must not reappear — `copilot --model
|
|
// claude-sonnet-4-6` errors with "Model ... is not available".
|
|
for _, banned := range []string{"claude-sonnet-4-6", "claude-sonnet-4-5"} {
|
|
if _, ok := ids[banned]; ok {
|
|
t.Errorf("Copilot catalog must not use dashed model id %q; use dotted form", banned)
|
|
}
|
|
}
|
|
for _, m := range models {
|
|
switch m.Provider {
|
|
case "openai", "anthropic":
|
|
default:
|
|
t.Errorf("Copilot entry %q has unexpected Provider %q", m.ID, m.Provider)
|
|
}
|
|
if m.Default {
|
|
t.Errorf("Copilot entries should not set Default; account routing decides. got %+v", m)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListModelsHermesWithoutBinary(t *testing.T) {
|
|
// With no `hermes` binary on PATH the discovery fast-paths to
|
|
// an empty list (the UI then falls back to creatable manual
|
|
// entry). This test only verifies the fast-path; an actual
|
|
// ACP session is exercised in integration.
|
|
ctx := context.Background()
|
|
// Prime the cache miss so we hit the live discovery function.
|
|
modelCacheMu.Lock()
|
|
delete(modelCache, "hermes")
|
|
modelCacheMu.Unlock()
|
|
|
|
got, err := ListModels(ctx, "hermes", "/nonexistent/hermes")
|
|
if err != nil {
|
|
t.Fatalf("ListModels(hermes) error: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Error("expected non-nil slice even when binary is missing")
|
|
}
|
|
}
|
|
|
|
func TestListModelsKiroWithoutBinary(t *testing.T) {
|
|
ctx := context.Background()
|
|
modelCacheMu.Lock()
|
|
delete(modelCache, "kiro")
|
|
modelCacheMu.Unlock()
|
|
|
|
got, err := ListModels(ctx, "kiro", "/nonexistent/kiro-cli")
|
|
if err != nil {
|
|
t.Fatalf("ListModels(kiro) error: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Error("expected non-nil slice even when binary is missing")
|
|
}
|
|
}
|
|
|
|
func TestListModelsUnknownProvider(t *testing.T) {
|
|
ctx := context.Background()
|
|
_, err := ListModels(ctx, "nonexistent", "")
|
|
if err == nil {
|
|
t.Fatal("ListModels(unknown) expected error")
|
|
}
|
|
}
|
|
|
|
func TestStaticCatalogsHaveAtMostOneDefault(t *testing.T) {
|
|
// Each catalog should tag at most one entry as the display
|
|
// default so the UI badge is unambiguous. More than one
|
|
// usually means a copy/paste slip when adding new models.
|
|
catalogs := map[string][]Model{
|
|
"claude": claudeStaticModels(),
|
|
"codex": codexStaticModels(),
|
|
"gemini": geminiStaticModels(),
|
|
"cursor": cursorStaticModels(),
|
|
"copilot": copilotStaticModels(),
|
|
}
|
|
for provider, models := range catalogs {
|
|
count := 0
|
|
for _, m := range models {
|
|
if m.Default {
|
|
count++
|
|
}
|
|
}
|
|
if count > 1 {
|
|
t.Errorf("%s: %d models marked Default, want 0 or 1", provider, count)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseOpenCodeModels(t *testing.T) {
|
|
input := `PROVIDER/MODEL CONTEXT MAX_OUT
|
|
openai/gpt-4o 128000 16384
|
|
anthropic/claude-sonnet-4-6 200000 8192
|
|
openai/gpt-4o 128000 16384
|
|
nonprefixed-line
|
|
`
|
|
models := parseOpenCodeModels(input)
|
|
if len(models) != 2 {
|
|
t.Fatalf("expected 2 models (header skipped, duplicate deduped, non-slash skipped), got %d: %+v", len(models), models)
|
|
}
|
|
if models[0].ID != "openai/gpt-4o" || models[0].Provider != "openai" {
|
|
t.Errorf("unexpected first model: %+v", models[0])
|
|
}
|
|
if models[1].ID != "anthropic/claude-sonnet-4-6" || models[1].Provider != "anthropic" {
|
|
t.Errorf("unexpected second model: %+v", models[1])
|
|
}
|
|
}
|
|
|
|
func TestParsePiModels(t *testing.T) {
|
|
input := `openai:gpt-4o
|
|
anthropic:claude-opus-4-7
|
|
openai:gpt-4o
|
|
bareword
|
|
`
|
|
models := parsePiModels(input)
|
|
if len(models) != 2 {
|
|
t.Fatalf("expected 2 models, got %d: %+v", len(models), models)
|
|
}
|
|
if models[0].ID != "openai/gpt-4o" {
|
|
t.Errorf("expected colon normalized to slash: %+v", models[0])
|
|
}
|
|
}
|
|
|
|
func TestParsePiModelsTableFormat(t *testing.T) {
|
|
input := `provider model context max-out thinking images
|
|
bailian-coding-plan glm-4.7 202.8K 16.4K no no
|
|
bailian-coding-plan qwen3.6-plus 1M 65.5K no yes
|
|
opencode claude-sonnet-4-6 1M 64K yes yes
|
|
opencode claude-sonnet-4-6:exp 1M 64K yes yes
|
|
opencode claude-sonnet-4-6 1M 64K yes yes
|
|
bareword-only-line
|
|
`
|
|
models := parsePiModels(input)
|
|
if len(models) != 4 {
|
|
t.Fatalf("expected 4 models (header skipped, duplicate deduped, bareword skipped), got %d: %+v", len(models), models)
|
|
}
|
|
if models[0].ID != "bailian-coding-plan/glm-4.7" || models[0].Provider != "bailian-coding-plan" {
|
|
t.Errorf("unexpected first model: %+v", models[0])
|
|
}
|
|
if models[1].ID != "bailian-coding-plan/qwen3.6-plus" || models[1].Provider != "bailian-coding-plan" {
|
|
t.Errorf("unexpected second model: %+v", models[1])
|
|
}
|
|
if models[2].ID != "opencode/claude-sonnet-4-6" || models[2].Provider != "opencode" {
|
|
t.Errorf("unexpected third model: %+v", models[2])
|
|
}
|
|
// Colon inside a model name in column 1 must be preserved — only
|
|
// the legacy `provider:model` form gets colon→slash normalization.
|
|
if models[3].ID != "opencode/claude-sonnet-4-6:exp" || models[3].Provider != "opencode" {
|
|
t.Errorf("expected ':' inside table-format model name to be preserved: %+v", models[3])
|
|
}
|
|
}
|
|
|
|
func TestParseOpenclawAgents(t *testing.T) {
|
|
input := `deepseek-v4 deepseek-v4
|
|
claude-sonnet claude-sonnet-4-6
|
|
deepseek-v4 deepseek-v4
|
|
`
|
|
models := parseOpenclawAgents(input)
|
|
// duplicate deduped; label includes model name.
|
|
if len(models) != 2 {
|
|
t.Fatalf("expected 2 agents, got %d: %+v", len(models), models)
|
|
}
|
|
if models[0].ID != "deepseek-v4" {
|
|
t.Errorf("unexpected first agent: %+v", models[0])
|
|
}
|
|
if models[0].Label != "deepseek-v4 (deepseek-v4)" {
|
|
t.Errorf("unexpected label: %+v", models[0])
|
|
}
|
|
if models[0].Provider != "openclaw" {
|
|
t.Errorf("expected provider openclaw, got %q", models[0].Provider)
|
|
}
|
|
}
|
|
|
|
func TestParseOpenclawAgentsRejectsDecoratedTUI(t *testing.T) {
|
|
// Reproduces the shape of real `openclaw agents list` output
|
|
// that leaked header tokens like "Identity:" / "Workspace:"
|
|
// and single-character box-drawing icons into the dropdown.
|
|
input := `╭───────────────────────────────╮
|
|
│ │
|
|
│ ◇ Agents: │
|
|
│ │ │
|
|
│ │ Identity: │
|
|
│ │ Workspace: │
|
|
│ │ Agent │
|
|
│ │ │
|
|
╰───────────────────────────────╯
|
|
deepseek-v4 deepseek-v4
|
|
claude-sonnet claude-sonnet-4-6
|
|
`
|
|
models := parseOpenclawAgents(input)
|
|
if len(models) != 2 {
|
|
t.Fatalf("expected 2 agents (decoration skipped), got %d: %+v", len(models), models)
|
|
}
|
|
for _, m := range models {
|
|
if strings.HasSuffix(m.ID, ":") {
|
|
t.Errorf("section header leaked into result: %+v", m)
|
|
}
|
|
}
|
|
if models[0].ID != "deepseek-v4" || models[1].ID != "claude-sonnet" {
|
|
t.Errorf("unexpected agents: %+v", models)
|
|
}
|
|
}
|
|
|
|
func TestParseOpenclawAgentsJSONArray(t *testing.T) {
|
|
input := []byte(`[
|
|
{"name": "deepseek-v4", "model": "deepseek-v4"},
|
|
{"name": "claude-sonnet", "model": "claude-sonnet-4-6"}
|
|
]`)
|
|
models, ok := parseOpenclawAgentsJSON(input)
|
|
if !ok {
|
|
t.Fatal("expected parseOpenclawAgentsJSON to accept an array")
|
|
}
|
|
if len(models) != 2 {
|
|
t.Fatalf("got %d, want 2: %+v", len(models), models)
|
|
}
|
|
if models[0].ID != "deepseek-v4" || models[0].Label != "deepseek-v4 (deepseek-v4)" {
|
|
t.Errorf("unexpected first entry: %+v", models[0])
|
|
}
|
|
}
|
|
|
|
func TestParseOpenclawAgentsJSONWrapped(t *testing.T) {
|
|
input := []byte(`{"agents": [{"name": "foo", "model": "bar"}]}`)
|
|
models, ok := parseOpenclawAgentsJSON(input)
|
|
if !ok {
|
|
t.Fatal("expected parseOpenclawAgentsJSON to accept wrapped object")
|
|
}
|
|
if len(models) != 1 || models[0].ID != "foo" {
|
|
t.Errorf("unexpected: %+v", models)
|
|
}
|
|
}
|
|
|
|
func TestParseOpenclawAgentsJSONRejectsGarbage(t *testing.T) {
|
|
if _, ok := parseOpenclawAgentsJSON([]byte("not json")); ok {
|
|
t.Error("expected ok=false for non-JSON")
|
|
}
|
|
}
|
|
|
|
func TestParseCursorModels(t *testing.T) {
|
|
input := `Available models
|
|
|
|
auto - Auto
|
|
composer-2-fast - Composer 2 Fast (current, default)
|
|
composer-2 - Composer 2
|
|
claude-4.6-sonnet-medium - Sonnet 4.6 1M
|
|
claude-opus-4-7-high - Opus 4.7 1M
|
|
gemini-3.1-pro - Gemini 3.1 Pro
|
|
`
|
|
models := parseCursorModels(input)
|
|
if len(models) != 6 {
|
|
t.Fatalf("expected 6 models, got %d: %+v", len(models), models)
|
|
}
|
|
ids := map[string]Model{}
|
|
for _, m := range models {
|
|
ids[m.ID] = m
|
|
}
|
|
for _, want := range []string{"auto", "composer-2-fast", "composer-2", "claude-4.6-sonnet-medium", "claude-opus-4-7-high", "gemini-3.1-pro"} {
|
|
if _, ok := ids[want]; !ok {
|
|
t.Errorf("missing expected model %q in: %+v", want, models)
|
|
}
|
|
}
|
|
if def := ids["composer-2-fast"]; !def.Default {
|
|
t.Errorf("composer-2-fast should be marked default, got %+v", def)
|
|
}
|
|
if def := ids["composer-2-fast"]; def.Label != "Composer 2 Fast" {
|
|
t.Errorf("default label should be stripped of parenthetical, got %q", def.Label)
|
|
}
|
|
// Non-default entry should not carry Default=true.
|
|
if auto := ids["auto"]; auto.Default {
|
|
t.Errorf("non-default entry should not be flagged default: %+v", auto)
|
|
}
|
|
}
|
|
|
|
func TestParseCursorModelsSkipsHeaderAndBlankLines(t *testing.T) {
|
|
input := `Available models
|
|
|
|
composer-2 - Composer 2
|
|
`
|
|
models := parseCursorModels(input)
|
|
if len(models) != 1 || models[0].ID != "composer-2" {
|
|
t.Fatalf("unexpected: %+v", models)
|
|
}
|
|
}
|
|
|
|
func TestParseHermesSessionNewModels(t *testing.T) {
|
|
// Mirrors the real shape emitted by hermes'
|
|
// acp_adapter/server.py _build_model_state -> SessionModelState.
|
|
raw := []byte(`{
|
|
"sessionId": "ses_123",
|
|
"models": {
|
|
"availableModels": [
|
|
{"modelId": "nous:moonshotai/kimi-k2.5", "name": "moonshotai/kimi-k2.5", "description": "Provider: Nous"},
|
|
{"modelId": "nous:anthropic/claude-opus-4.7", "name": "anthropic/claude-opus-4.7", "description": "Provider: Nous • current"},
|
|
{"modelId": "nous:moonshotai/kimi-k2.5", "name": "duplicate", "description": "dup"}
|
|
],
|
|
"currentModelId": "nous:anthropic/claude-opus-4.7"
|
|
}
|
|
}`)
|
|
models := parseACPSessionNewModels(raw)
|
|
if len(models) != 2 {
|
|
t.Fatalf("expected 2 models (duplicate deduped), got %d: %+v", len(models), models)
|
|
}
|
|
if models[0].ID != "nous:moonshotai/kimi-k2.5" || models[0].Provider != "nous" {
|
|
t.Errorf("unexpected first model: %+v", models[0])
|
|
}
|
|
if models[0].Default {
|
|
t.Errorf("non-current entry must not be marked default: %+v", models[0])
|
|
}
|
|
if !models[1].Default {
|
|
t.Errorf("current entry must be marked default: %+v", models[1])
|
|
}
|
|
if models[1].ID != "nous:anthropic/claude-opus-4.7" {
|
|
t.Errorf("expected current model second: %+v", models[1])
|
|
}
|
|
}
|
|
|
|
func TestParseHermesSessionNewModelsMissingField(t *testing.T) {
|
|
// session/new without the models field — older hermes or
|
|
// failed _build_model_state — should yield nil so the caller
|
|
// can distinguish "no catalog" from "empty catalog".
|
|
raw := []byte(`{"sessionId": "ses_123"}`)
|
|
if got := parseACPSessionNewModels(raw); got != nil && len(got) != 0 {
|
|
t.Errorf("expected nil/empty, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestParseHermesSessionNewModelsGarbage(t *testing.T) {
|
|
if got := parseACPSessionNewModels([]byte("not json")); got != nil {
|
|
t.Errorf("expected nil for non-JSON, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestHermesModelSelectionSupported(t *testing.T) {
|
|
// Regression guard: hermes now supports model selection via
|
|
// the ACP session/set_model RPC, so the UI dropdown should
|
|
// not be disabled for it.
|
|
if !ModelSelectionSupported("hermes") {
|
|
t.Error("hermes should be model-selection-supported now that set_session_model is wired")
|
|
}
|
|
}
|
|
|
|
func TestCachedDiscovery(t *testing.T) {
|
|
calls := 0
|
|
fn := func() ([]Model, error) {
|
|
calls++
|
|
return []Model{{ID: "x", Label: "x"}}, nil
|
|
}
|
|
// First call populates the cache; reset for isolation.
|
|
modelCacheMu.Lock()
|
|
delete(modelCache, "testkey")
|
|
modelCacheMu.Unlock()
|
|
|
|
if _, err := cachedDiscovery("testkey", fn); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := cachedDiscovery("testkey", fn); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if calls != 1 {
|
|
t.Errorf("expected 1 underlying call due to cache, got %d", calls)
|
|
}
|
|
}
|