mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 06:39:01 +02:00
* 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>
272 lines
8.6 KiB
Go
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")
|
|
}
|
|
}
|