Files
multica/server/pkg/agent/models_test.go
DylanLi 78342a39ce MUL-3305: feat(agent): add qoder CLI as a choice of agent provider. (#2461)
* feat(agent): Qoder ACP runtime, chat reconnect recovery, and task linkage

- Add Qoder CLI backend (ACP transport, model discovery, blocked-args policy)
- Wire daemon/runtime config, docs, and UI provider assets
- Retry terminal task reports; add backoff unit tests
- Chat: SQL attach user message to task; handler + optimistic cache reconcile
- Invalidate chat/task-messages caches on WS reconnect; extract helper + tests

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: drop non-Qoder changes (chat reconnect, task link, terminal report retries)

Keep only Qoder runtime, docs, daemon config/execenv, and UI provider assets.

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(agent): harden Qoder ACP drain and wire project skills path

- Stop streaming to msgCh after reader wait so grace timeout cannot race close
- Resolve injected skills to .qoder/skills per Qoder CLI discovery
- Update AGENTS.md skill copy and add execenv tests

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(qoder): add provider logo and wire MCP config into ACP sessions

- Add inline SVG QoderLogo component to provider-logo.tsx, replacing
  the generic Monitor icon placeholder
- Add convertMcpConfigForACP helper to convert Claude-style MCP server
  config (object map) into ACP array format for session/new and
  session/resume
- Add unit tests for convertMcpConfigForACP covering stdio, SSE,
  empty/nil, and multi-server cases

Co-authored-by: Orca <help@stably.ai>

* fix(test): capture both return values from InjectRuntimeConfig in Qoder test

Co-authored-by: Orca <help@stably.ai>

* fix(qoder): preserve remote MCP headers and promote provider errors

Addresses review feedback on #2461 (Bohan-J): two runtime-correctness
issues in the Qoder ACP backend.

1. Remote MCP headers were dropped. The bespoke convertMcpConfigForACP
   only forwarded url/type, so an authenticated remote MCP server looked
   configured in Multica but failed inside the Qoder session. Replace it
   with the shared buildACPMcpServers helper (same path Hermes/Kimi/Kiro
   use), which preserves headers as [{name, value}], sorts for
   deterministic output, and handles remote transport aliases. Fail
   closed on malformed mcp_config instead of silently dropping servers.

2. Provider failures could report as completed tasks. stderr was wired
   via io.MultiWriter and the result was only promoted to failed when
   output was empty, so a terminal upstream error (HTTP 429 / expired
   token) racing a stopReason=end_turn with text still became
   "completed". Switch to StderrPipe + an explicit copier, drain it
   (bounded by the existing grace window, since qodercli can leave a
   child holding the inherited fds) before the decision, and run the
   shared promoteACPResultOnProviderError.

Tests: replace the convertMcpConfigForACP unit tests with two
end-to-end Qoder tests — one asserts the Authorization header reaches
the session/new payload as {name, value}, the other asserts a terminal
stderr error with non-empty output reports failed.

Co-authored-by: Orca <help@stably.ai>

* fix(qoder): align ACP session handling

Co-authored-by: Orca <help@stably.ai>

* fix(agent): guard qoder late output after drain

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-22 18:55:45 +08:00

1046 lines
34 KiB
Go

package agent
import (
"context"
"path/filepath"
"runtime"
"strconv"
"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 TestClaudeStaticModelsExposesFable5(t *testing.T) {
models := claudeStaticModels()
ids := map[string]Model{}
defaults := 0
for _, m := range models {
ids[m.ID] = m
if m.Default {
defaults++
}
}
fable, ok := ids["claude-fable-5"]
if !ok {
t.Fatalf("missing Claude Fable 5 in: %+v", models)
}
if fable.Label != "Claude Fable 5" || fable.Provider != "anthropic" || fable.Default {
t.Errorf("unexpected Fable entry: %+v", fable)
}
if defaults != 1 || !ids["claude-sonnet-4-6"].Default {
t.Errorf("expected Sonnet 4.6 to remain the sole default, got defaults=%d models=%+v", defaults, models)
}
}
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 TestModelKnownIncompatibleWithProvider(t *testing.T) {
cases := []struct {
name string
provider string
model string
want bool
}{
{
name: "claude model is incompatible with codex",
provider: "codex",
model: "claude-sonnet-4-6",
want: true,
},
{
name: "codex model is compatible with codex",
provider: "codex",
model: "gpt-5.5",
want: false,
},
{
name: "codex model is incompatible with claude",
provider: "claude",
model: "o3",
want: true,
},
{
name: "exact claude model is compatible with claude",
provider: "claude",
model: "claude-opus-4-7",
want: false,
},
{
name: "provider-prefixed openai model is incompatible with codex",
provider: "codex",
model: "openai/gpt-4o",
want: true,
},
{
name: "provider-prefixed anthropic model is incompatible with claude",
provider: "claude",
model: "anthropic/claude-opus-4.7",
want: true,
},
{
name: "known openai-looking model outside codex catalog is incompatible",
provider: "codex",
model: "gpt-99",
want: true,
},
{
name: "unknown custom model is not classified",
provider: "codex",
model: "private-lab-model",
want: false,
},
{
name: "unknown target provider does not clear",
provider: "opencode",
model: "claude-sonnet-4-6",
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := ModelKnownIncompatibleWithProvider(tc.provider, tc.model); got != tc.want {
t.Fatalf("ModelKnownIncompatibleWithProvider(%q, %q) = %v, want %v", tc.provider, tc.model, got, tc.want)
}
})
}
}
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 TestListModelsQoderWithoutBinary(t *testing.T) {
ctx := context.Background()
modelCacheMu.Lock()
delete(modelCache, "qoder")
modelCacheMu.Unlock()
got, err := ListModels(ctx, "qoder", "/nonexistent/qodercli")
if err != nil {
t.Fatalf("ListModels(qoder) 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 TestParseOpenCodeModelsVerboseVariants(t *testing.T) {
input := `openai/gpt-5
{
"id": "gpt-5",
"name": "GPT-5",
"reasoning": true,
"variants": {
"high": { "reasoningEffort": "high" },
"low": { "reasoningEffort": "low" },
"xhigh": { "reasoningEffort": "xhigh" },
"fast-mode": { "reasoningEffort": "low" },
"disabled": { "disabled": true }
}
}
anthropic/claude-sonnet-4-6
{
"id": "claude-sonnet-4-6",
"reasoning": true,
"variants": {
"max": { "thinking": { "type": "enabled", "budgetTokens": 32000 } },
"high": { "thinking": { "type": "enabled", "budgetTokens": 16000 } }
}
}
`
models := parseOpenCodeModels(input)
if len(models) != 2 {
t.Fatalf("expected 2 models, got %d: %+v", len(models), models)
}
if models[0].Thinking == nil {
t.Fatalf("expected first model to expose thinking variants")
}
got := make([]string, 0, len(models[0].Thinking.SupportedLevels))
for _, lvl := range models[0].Thinking.SupportedLevels {
got = append(got, lvl.Value)
if lvl.Value == "xhigh" && lvl.Label != "Extra high" {
t.Errorf("xhigh label: got %q, want Extra high", lvl.Label)
}
if lvl.Value == "fast-mode" && lvl.Label != "Fast Mode" {
t.Errorf("custom variant label: got %q, want Fast Mode", lvl.Label)
}
}
want := []string{"low", "high", "xhigh", "fast-mode"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("variant order/values: got %v, want %v", got, want)
}
if models[1].Thinking == nil || len(models[1].Thinking.SupportedLevels) != 2 {
t.Fatalf("expected second model variants, got %+v", models[1].Thinking)
}
}
func TestParseOpenCodeModelsMalformedVerboseBlockKeepsFollowingModels(t *testing.T) {
input := `openai/gpt-5
{
"id": "gpt-5",
"reasoning": true,
"variants": {
"high": {}
}
anthropic/claude-sonnet-4-6
{
"id": "claude-sonnet-4-6",
"reasoning": true,
"variants": {
"high": {},
"max": {}
}
}
`
models := parseOpenCodeModels(input)
if len(models) != 2 {
t.Fatalf("expected both model rows to survive malformed JSON, got %d: %+v", len(models), models)
}
if models[0].ID != "openai/gpt-5" {
t.Fatalf("unexpected first model: %+v", models[0])
}
if models[0].Thinking != nil {
t.Fatalf("malformed first JSON block should not annotate thinking: %+v", models[0].Thinking)
}
if models[1].ID != "anthropic/claude-sonnet-4-6" {
t.Fatalf("unexpected second model: %+v", models[1])
}
if models[1].Thinking == nil || len(models[1].Thinking.SupportedLevels) != 2 {
t.Fatalf("valid following JSON block should still annotate thinking: %+v", models[1].Thinking)
}
}
func TestDiscoverOpenCodeModelsFallsBackWhenVerboseFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script fake binary requires a POSIX shell")
}
dir := t.TempDir()
fake := filepath.Join(dir, "opencode")
script := `#!/bin/sh
if [ "$1" = "models" ] && [ "$2" = "--verbose" ]; then
exit 2
fi
if [ "$1" = "models" ]; then
cat <<'EOF'
PROVIDER/MODEL CONTEXT MAX_OUT
openai/gpt-4o 128000 16384
EOF
exit 0
fi
exit 1
`
writeTestExecutable(t, fake, []byte(script))
models, err := discoverOpenCodeModels(context.Background(), fake)
if err != nil {
t.Fatalf("discoverOpenCodeModels: %v", err)
}
if len(models) != 1 {
t.Fatalf("expected fallback non-verbose model, got %d: %+v", len(models), models)
}
if models[0].ID != "openai/gpt-4o" || models[0].Thinking != nil {
t.Fatalf("unexpected fallback model: %+v", models[0])
}
}
// TestCachedDiscoveryDoesNotCacheEmpty verifies that an empty discovery result
// is not cached, so a transient failure (e.g. a `pi --list-models` timeout)
// doesn't keep the model picker blank for the full TTL. A non-empty result is
// still cached. See #3729.
func TestCachedDiscoveryDoesNotCacheEmpty(t *testing.T) {
const emptyKey, nonEmptyKey = "test-cache-empty", "test-cache-nonempty"
// modelCache is a package-level global; clear our keys up front and on
// cleanup so the test stays hermetic under `go test -count=N` (a leftover
// non-empty entry from a prior run would otherwise skip the callback).
resetCache := func() {
modelCacheMu.Lock()
delete(modelCache, emptyKey)
delete(modelCache, nonEmptyKey)
modelCacheMu.Unlock()
}
resetCache()
t.Cleanup(resetCache)
emptyCalls := 0
empty := func() ([]Model, error) {
emptyCalls++
return []Model{}, nil
}
for i := 0; i < 2; i++ {
got, err := cachedDiscovery(emptyKey, empty)
if err != nil {
t.Fatalf("cachedDiscovery: %v", err)
}
if len(got) != 0 {
t.Fatalf("expected empty result, got %+v", got)
}
}
if emptyCalls != 2 {
t.Fatalf("empty result must not be cached: expected fn called 2x, got %d", emptyCalls)
}
nonEmptyCalls := 0
nonEmpty := func() ([]Model, error) {
nonEmptyCalls++
return []Model{{ID: "provider/model"}}, nil
}
for i := 0; i < 2; i++ {
if _, err := cachedDiscovery(nonEmptyKey, nonEmpty); err != nil {
t.Fatalf("cachedDiscovery: %v", err)
}
}
if nonEmptyCalls != 1 {
t.Fatalf("non-empty result must be cached: expected fn called 1x, got %d", nonEmptyCalls)
}
}
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])
}
}
// TestDiscoverPiModelsNonZeroExit verifies that discoverPiModels still returns
// the resolvable catalog when `pi --list-models` exits non-zero. Pi exits
// non-zero (and warns) when an agent config references stale provider/model
// patterns that no longer match the local catalog. Before the fix the daemon
// discarded the populated output on any non-zero exit and returned an empty
// list, so the UI model picker was blank even though the runtime was online and
// agents ran fine. See GitHub #3729.
func TestDiscoverPiModelsNonZeroExit(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake pi binary is a /bin/sh script")
}
const table = "provider model context max-out thinking images\n" +
"glm-coding-plan glm-4.7 202.8K 16.4K no no"
// The unmatched-pattern warning, with and without the `Warning:` prefix —
// the prefix is not guaranteed across pi versions, and the bare form is
// what slips past a naive guard into a bogus `No/models` model.
const prefixed = `Warning: No models match pattern "opencode-go/mimo-v2-omni"`
const bare = `No models match pattern "opencode-go/mimo-v2-pro"`
cases := []struct {
name string
script string
}{
{
// Newer pi prints the catalog to stdout; the stale-pattern
// warning goes to stderr and the process exits non-zero.
name: "catalog on stdout",
script: "#!/bin/sh\n" +
"cat <<'EOF'\n" + table + "\nEOF\n" +
"echo " + strconv.Quote(prefixed) + " >&2\n" +
"exit 1\n",
},
{
// Older pi prints the catalog (and the warning) to stderr; same
// non-zero exit. The stderr fallback must still parse the catalog.
name: "catalog and prefixed warning on stderr",
script: "#!/bin/sh\n" +
"cat >&2 <<'EOF'\n" + table + "\n" + prefixed + "\nEOF\n" +
"exit 1\n",
},
{
// Same, but the warning has no `Warning:` prefix — must not leak in
// as a `No/models` row.
name: "catalog and bare warning on stderr",
script: "#!/bin/sh\n" +
"cat >&2 <<'EOF'\n" + table + "\n" + bare + "\nEOF\n" +
"exit 1\n",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fakePath := filepath.Join(t.TempDir(), "pi")
writeTestExecutable(t, fakePath, []byte(tc.script))
models, err := discoverPiModels(context.Background(), fakePath)
if err != nil {
t.Fatalf("discoverPiModels: %v", err)
}
// Exactly the resolvable model — no warning line coined into a
// bogus entry, no header row.
if len(models) != 1 || models[0].ID != "glm-coding-plan/glm-4.7" {
t.Fatalf("expected exactly [glm-coding-plan/glm-4.7] despite non-zero exit, got %+v", models)
}
})
}
}
// TestDiscoverOpenCodeModelsFallsBackOnVerboseNoise verifies that a non-zero
// `opencode models --verbose` whose stdout is unparseable noise still falls
// back to the plain `opencode models` command instead of returning empty. The
// earlier fix skipped the fallback whenever verbose printed any bytes, which
// regressed this case. Mirrors the pi hardening in #3729.
func TestDiscoverOpenCodeModelsFallsBackOnVerboseNoise(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake opencode binary is a /bin/sh script")
}
// `opencode models --verbose` => $2 == "--verbose": emit noise + exit 1.
// `opencode models` => no $2: print the plain catalog.
script := "#!/bin/sh\n" +
"if [ \"$2\" = \"--verbose\" ]; then\n" +
" echo 'panic: catalog sync failed'\n" +
" exit 1\n" +
"fi\n" +
"echo 'openai/gpt-4o'\n"
fakePath := filepath.Join(t.TempDir(), "opencode")
writeTestExecutable(t, fakePath, []byte(script))
models, err := discoverOpenCodeModels(context.Background(), fakePath)
if err != nil {
t.Fatalf("discoverOpenCodeModels: %v", err)
}
if len(models) != 1 || models[0].ID != "openai/gpt-4o" {
t.Fatalf("expected fallback to plain `opencode models` to yield [openai/gpt-4o], got %+v", models)
}
}
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 TestOpenclawEntriesToModelsUsesIDOverName(t *testing.T) {
// When both id and name are present, Model.ID should use the id field
// because openclaw resolves --agent by id. Names with spaces (e.g.
// "Sub2API OPS") would be mangled by openclaw's normalizeAgentId.
input := []byte(`[{"id": "sub2api", "name": "Sub2API OPS", "model": "gpt-4o"}]`)
models, ok := parseOpenclawAgentsJSON(input)
if !ok {
t.Fatal("expected parseOpenclawAgentsJSON to accept array")
}
if len(models) != 1 {
t.Fatalf("got %d models, want 1", len(models))
}
if models[0].ID != "sub2api" {
t.Errorf("Model.ID = %q, want %q (should use id, not name)", models[0].ID, "sub2api")
}
if models[0].Label != "Sub2API OPS (gpt-4o)" {
t.Errorf("Model.Label = %q, want %q (should use name for display)", models[0].Label, "Sub2API OPS (gpt-4o)")
}
}
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 TestParseHermesSessionNewModelsPreservesCustomModelIDsWithColons(t *testing.T) {
raw := []byte(`{
"sessionId": "ses_123",
"models": {
"availableModels": [
{"modelId": "custom:lfm2.5:8b", "name": "lfm2.5:8b", "description": "Provider: Custom"}
],
"currentModelId": "custom:lfm2.5:8b"
}
}`)
models := parseACPSessionNewModels(raw)
if len(models) != 1 {
t.Fatalf("expected 1 model, got %d: %+v", len(models), models)
}
if models[0].ID != "custom:lfm2.5:8b" {
t.Errorf("model id must be preserved verbatim, got %+v", models[0])
}
if models[0].Provider != "custom" {
t.Errorf("provider should be derived from the first colon only, got %+v", models[0])
}
if !models[0].Default {
t.Errorf("current custom model should be marked default: %+v", models[0])
}
}
func TestParseHermesSessionNewModelsSnakeCaseAndUnknownNames(t *testing.T) {
raw := []byte(`{
"session_id": "ses_123",
"models": {
"available_models": [
{"model_id": "nous:moonshotai/kimi-k2.6", "name": "Unknown", "description": "Provider: Nous"},
{"model_id": "nous:anthropic/claude-sonnet-4.6", "name": "unknown", "description": "Provider: Nous"}
],
"current_model_id": "nous:moonshotai/kimi-k2.6"
}
}`)
models := parseACPSessionNewModels(raw)
if len(models) != 2 {
t.Fatalf("expected 2 models, got %d: %+v", len(models), models)
}
if models[0].Label != "nous:moonshotai/kimi-k2.6" {
t.Errorf("Unknown label should fall back to model id, got %+v", models[0])
}
if !models[0].Default {
t.Errorf("snake_case current_model_id should mark default: %+v", models[0])
}
if models[1].Label != "nous:anthropic/claude-sonnet-4.6" {
t.Errorf("lowercase unknown label should fall back to model id, got %+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")
}
}
// TestAntigravityModelSelectionSupported pins that the antigravity provider
// now reports model selection as supported: agy 1.0.6 added a `--model` flag
// (MUL-3125) and buildAntigravityArgs wires opts.Model through, so the UI
// must render the live picker rather than a disabled "Managed by runtime"
// label.
func TestAntigravityModelSelectionSupported(t *testing.T) {
if !ModelSelectionSupported("antigravity") {
t.Error("antigravity should be model-selection-supported now that agy 1.0.6 has --model")
}
}
// TestParseAntigravityModels covers the `agy models` line-per-name format:
// each non-blank line becomes a Model whose ID and Label are the verbatim
// display string `--model` expects, duplicates collapse, and blanks drop.
func TestParseAntigravityModels(t *testing.T) {
t.Parallel()
out := strings.Join([]string{
"Gemini 3.5 Flash (Medium)",
"Claude Opus 4.6 (Thinking)",
"", // blank line — skipped
"GPT-OSS 120B (Medium)",
"Claude Opus 4.6 (Thinking)", // duplicate — collapsed
}, "\n")
got := parseAntigravityModels(out)
want := []Model{
{ID: "Gemini 3.5 Flash (Medium)", Label: "Gemini 3.5 Flash (Medium)", Provider: "antigravity"},
{ID: "Claude Opus 4.6 (Thinking)", Label: "Claude Opus 4.6 (Thinking)", Provider: "antigravity"},
{ID: "GPT-OSS 120B (Medium)", Label: "GPT-OSS 120B (Medium)", Provider: "antigravity"},
}
if len(got) != len(want) {
t.Fatalf("parseAntigravityModels len = %d, want %d (%+v)", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("model[%d] = %+v, want %+v", i, got[i], want[i])
}
}
}
// TestParseAntigravityModelsEmpty pins that empty / whitespace-only output
// yields no models (so cachedDiscovery treats it as a transient miss and
// retries rather than caching a blank catalog).
func TestParseAntigravityModelsEmpty(t *testing.T) {
t.Parallel()
if got := parseAntigravityModels(" \n\t\n"); len(got) != 0 {
t.Errorf("expected no models for blank output, got %+v", got)
}
}
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)
}
}