Files
multica/server/pkg/agent/antigravity_test.go
Bohan Jiang 1ddf89a8f2 feat(daemon): enable Antigravity (agy) per-agent model selection (MUL-3125) (#3894)
* feat(daemon): wire agy --model and model discovery for Antigravity

agy 1.0.6 added a --model flag and an `agy models` catalog command, which
were the #1 blocker in the earlier agy-backend review (MUL-3125). The
antigravity backend already shipped but deliberately dropped opts.Model
because agy 1.0.1 had no way to select a model.

- buildAntigravityArgs now passes --model <display name> when opts.Model is
  set; the value is the exact `agy models` display string (spaces + parens),
  passed as a single exec arg so no shell quoting is needed.
- Block --model in custom_args so it can't override the managed value.
- ListModels("antigravity") enumerates via `agy models` (no static fallback:
  agy silently no-ops on unrecognised models, so a stale guess would turn a
  typo into a successful empty run).
- ModelSelectionSupported now returns true for every built-in provider; the
  hook stays for any future model-less runtime.
- Daemon probe reads MULTICA_ANTIGRAVITY_MODEL for the daemon-wide default.

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

* docs(providers): mark Antigravity model selection as supported

Antigravity gained --model in agy 1.0.6 (MUL-3125). Update the provider
matrix + prose (en/zh/ja/ko) from "managed internally / no --model" to
dynamic discovery via `agy models`, and refresh the now-stale picker
comments. Flag the display-string (not slug) shape and agy's silent no-op
on unrecognised values.

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

* fix(daemon): reject unknown Antigravity model at spawn (MUL-3125)

agy exits 0 with empty output on an unrecognised --model, so a stale/typo'd
value would surface as a 'completed' but empty task. Validate opts.Model
against the `agy models` catalog in Execute before spawning: a non-empty
model the CLI does not advertise fails fast with an actionable error listing
the real choices. opts.Model is the single funnel for agent.model and the
MULTICA_ANTIGRAVITY_MODEL default, so this one check covers every source
(UI free-text, API, persisted value, env) — addressing Elon's review that a
UI-only guard is bypassable.

Validation is fail-OPEN: if the catalog can't be discovered we pass the
value through and let agy resolve it, so a discovery hiccup never blocks a
run. Pure antigravityModelError() is unit-tested (valid / unknown / near-miss
/ empty-model / empty-catalog); verified live against real agy 1.0.6.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-08 15:32:53 +08:00

272 lines
8.6 KiB
Go

package agent
import (
"io"
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
)
func quietAntigravityLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func TestBuildAntigravityArgsBasic(t *testing.T) {
t.Parallel()
args := buildAntigravityArgs(
"hello",
"/tmp/agy.log",
20*time.Minute,
ExecOptions{Cwd: "/work"},
quietAntigravityLogger(),
)
want := []string{
"-p", "hello",
"--dangerously-skip-permissions",
"--print-timeout", "20m0s",
"--log-file", "/tmp/agy.log",
"--add-dir", "/work",
}
if !slices.Equal(args, want) {
t.Fatalf("buildAntigravityArgs basic mismatch\n got: %v\nwant: %v", args, want)
}
}
func TestBuildAntigravityArgsModel(t *testing.T) {
t.Parallel()
// agy 1.0.6's --model takes the exact human display string (spaces +
// parens), not a slug. It must ride as a single argv element so no shell
// quoting is required, and it must sit before the user's custom args.
args := buildAntigravityArgs(
"hello",
"/tmp/agy.log",
20*time.Minute,
ExecOptions{Cwd: "/work", Model: "Claude Opus 4.6 (Thinking)"},
quietAntigravityLogger(),
)
want := []string{
"-p", "hello",
"--dangerously-skip-permissions",
"--model", "Claude Opus 4.6 (Thinking)",
"--print-timeout", "20m0s",
"--log-file", "/tmp/agy.log",
"--add-dir", "/work",
}
if !slices.Equal(args, want) {
t.Fatalf("buildAntigravityArgs with model mismatch\n got: %v\nwant: %v", args, want)
}
// Empty model must omit the flag entirely so agy resolves its own default.
bare := buildAntigravityArgs("hi", "/tmp/agy.log", 0, ExecOptions{}, quietAntigravityLogger())
if slices.Contains(bare, "--model") {
t.Fatalf("--model must be omitted when opts.Model is empty; got %v", bare)
}
}
func TestBuildAntigravityArgsNoTimeoutOmitsPrintTimeout(t *testing.T) {
t.Parallel()
// timeout <= 0 means "no wall-clock cap" (MUL-3064): agy must be launched
// WITHOUT --print-timeout, otherwise antigravityFormatTimeout(0) clamps to
// 1s and the run is killed almost immediately — the opposite of "no cap".
args := buildAntigravityArgs(
"hello",
"/tmp/agy.log",
0,
ExecOptions{Cwd: "/work"},
quietAntigravityLogger(),
)
want := []string{
"-p", "hello",
"--dangerously-skip-permissions",
"--log-file", "/tmp/agy.log",
"--add-dir", "/work",
}
if !slices.Equal(args, want) {
t.Fatalf("buildAntigravityArgs(timeout=0) mismatch\n got: %v\nwant: %v", args, want)
}
if slices.Contains(args, "--print-timeout") {
t.Fatalf("--print-timeout must be omitted when timeout <= 0; got %v", args)
}
}
func TestBuildAntigravityArgsResume(t *testing.T) {
t.Parallel()
args := buildAntigravityArgs(
"continue",
"/tmp/agy.log",
20*time.Minute,
ExecOptions{ResumeSessionID: "b8b263a4-4b2f-4339-acc9-78b248e2b606"},
quietAntigravityLogger(),
)
joined := strings.Join(args, " ")
if !strings.Contains(joined, "--conversation b8b263a4-4b2f-4339-acc9-78b248e2b606") {
t.Fatalf("expected --conversation flag with id; got %v", args)
}
}
func TestBuildAntigravityArgsFiltersBlockedCustomArgs(t *testing.T) {
t.Parallel()
args := buildAntigravityArgs(
"go",
"/tmp/agy.log",
time.Minute,
ExecOptions{
// Each blocked flag below must be stripped silently — the daemon
// owns these because they're required for non-interactive,
// resume-aware operation.
CustomArgs: []string{
"-p", "hijacked-prompt",
"--continue",
"-c",
"--conversation", "bad-id",
"--model", "sneaky-model", // managed via ExecOptions.Model
"--dangerously-skip-permissions",
"--print-timeout", "1h",
"--log-file", "/elsewhere.log",
"--add-dir", "/extra", // user-added workspace dir should survive
},
},
quietAntigravityLogger(),
)
joined := strings.Join(args, " ")
// Prompt argument should appear exactly once — the daemon's, not the
// user's hijacked copy.
pCount := 0
for _, a := range args {
if a == "-p" {
pCount++
}
}
if pCount != 1 {
t.Errorf("expected exactly one -p flag, got args=%v", args)
}
if strings.Contains(joined, "hijacked-prompt") {
t.Errorf("custom -p value leaked through filter: %v", args)
}
if strings.Contains(joined, "bad-id") {
t.Errorf("custom --conversation value leaked through filter: %v", args)
}
if strings.Contains(joined, "sneaky-model") {
t.Errorf("custom --model value leaked through filter: %v", args)
}
if strings.Contains(joined, "/elsewhere.log") {
t.Errorf("custom --log-file value leaked through filter: %v", args)
}
if !strings.Contains(joined, "--add-dir /extra") {
t.Errorf("non-blocked --add-dir flag should pass through: %v", args)
}
}
func TestAntigravityFormatTimeoutClampsSubSecond(t *testing.T) {
t.Parallel()
if got := antigravityFormatTimeout(0); got != "1s" {
t.Errorf("antigravityFormatTimeout(0) = %q, want 1s", got)
}
if got := antigravityFormatTimeout(20 * time.Minute); got != "20m0s" {
t.Errorf("antigravityFormatTimeout(20m) = %q, want 20m0s", got)
}
}
func TestReadAntigravityConversationID(t *testing.T) {
t.Parallel()
dir := t.TempDir()
logPath := filepath.Join(dir, "agy.log")
// Sample log content modelled on real agy glog output: the
// conversation= line is what printmode.go writes once per dispatch.
logBody := strings.Join([]string{
`I0528 13:36:19.959748 73304 printmode.go:71] Print mode: starting (promptLength=18, model="", conversationID="")`,
`I0528 13:36:23.318877 73304 printmode.go:130] Print mode: conversation=b8b263a4-4b2f-4339-acc9-78b248e2b606, sending message`,
`I0528 13:36:23.318892 73304 server.go:1083] Sending user message to conversation b8b263a4-4b2f-4339-acc9-78b248e2b606 (items=1, media=0)`,
}, "\n")
if err := os.WriteFile(logPath, []byte(logBody), 0o644); err != nil {
t.Fatal(err)
}
got := readAntigravityConversationID(logPath)
want := "b8b263a4-4b2f-4339-acc9-78b248e2b606"
if got != want {
t.Fatalf("readAntigravityConversationID = %q, want %q", got, want)
}
}
func TestReadAntigravityConversationIDMissingFile(t *testing.T) {
t.Parallel()
if got := readAntigravityConversationID("/nonexistent/path"); got != "" {
t.Errorf("expected empty string for missing file, got %q", got)
}
if got := readAntigravityConversationID(""); got != "" {
t.Errorf("expected empty string for empty path, got %q", got)
}
}
// TestAntigravityModelError is the regression guard for the silent-no-op fix:
// agy exits 0 with empty output on an unrecognised --model, so Execute must
// reject a non-empty model that isn't in the `agy models` catalog instead of
// letting it run to a fake "completed + empty" success. This covers the same
// validation regardless of whether opts.Model originated from agent.model, a
// persisted/API value, or the daemon-wide MULTICA_ANTIGRAVITY_MODEL default —
// they all collapse to opts.Model before Execute runs this check.
func TestAntigravityModelError(t *testing.T) {
t.Parallel()
catalog := []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"},
}
// Exact catalog hit → accepted.
if err := antigravityModelError("Claude Opus 4.6 (Thinking)", catalog); err != nil {
t.Errorf("valid model rejected: %v", err)
}
// Empty model → accepted (flag omitted, agy resolves its own default).
if err := antigravityModelError("", catalog); err != nil {
t.Errorf("empty model should not error: %v", err)
}
// Empty / nil catalog → fail open (discovery couldn't produce a list, so we
// can't prove the value is bad — let agy decide rather than block the run).
if err := antigravityModelError("anything at all", nil); err != nil {
t.Errorf("empty catalog should fail open, got: %v", err)
}
// Unknown model with a known catalog → actionable error that names the
// rejected value and points at `agy models`. THIS is the case that stops
// the silent empty-success.
err := antigravityModelError("Totally Made Up Model", catalog)
if err == nil {
t.Fatal("unknown model should be rejected, not silently accepted")
}
if !strings.Contains(err.Error(), "Totally Made Up Model") {
t.Errorf("error should name the rejected model: %v", err)
}
if !strings.Contains(err.Error(), "agy models") {
t.Errorf("error should point the user at `agy models`: %v", err)
}
// Near-miss (trailing space / dropped suffix) → still rejected, because agy
// needs the exact display string and would no-op on anything else.
if err := antigravityModelError("Claude Opus 4.6 (Thinking) ", catalog); err == nil {
t.Error("near-miss model (trailing space) should be rejected")
}
if err := antigravityModelError("Claude Opus 4.6", catalog); err == nil {
t.Error("near-miss model (dropped suffix) should be rejected")
}
}