Compare commits

...

3 Commits

Author SHA1 Message Date
J
eeff0e6a73 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>
2026-06-08 15:27:13 +08:00
J
b7d19bb1b0 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>
2026-06-08 15:08:42 +08:00
J
adf8224e46 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>
2026-06-08 15:08:42 +08:00
11 changed files with 296 additions and 54 deletions

View File

@@ -13,7 +13,7 @@ Multica は **12 個の AI コーディングツール**を標準でサポート
| ツール | ベンダー | セッション再開 | MCP | スキル注入パス | モデル選択 |
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Antigravity CLI 自体の内部で管理 |
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | 動的探索(`agy models` |
| **Claude Code** | Anthropic | ✅ | **✅(実際に使用する唯一のツール)** | `.claude/skills/` | 静的 + flag |
| **Codex** | OpenAI | ⚠️ コードは存在するが到達不可 | ❌ | `$CODEX_HOME/skills/` | 静的 |
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 静的(アカウントの権限で決定) |
@@ -30,7 +30,7 @@ Multica は **12 個の AI コーディングツール**を標準でサポート
### Antigravity
Google が提供します。CLI バイナリ名は `agy` です。Google の Antigravity サービスと連携し、Gemini ベースのデフォルトモデルが付属しています。**セッション再開が動作します** — `--conversation <id>` を通じて行われ、stdout が構造化されたイベントストリームではなくプレーンテキストであるため、デーモンが CLI のログファイルから conversation UUID をキャプチャします。`--model` flag はありません — モデル選択は Antigravity CLI の設定内にあるため、Multica はこのプロバイダーに対してエージェントごとのモデルピッカーを無効にします。スキルは `.agents/skills/` に配置されますCLI が Gemini CLI のワークスペーススキルレイアウトをそのまま継承します — [Antigravity 移行ドキュメント](https://antigravity.google/docs/gcli-migration)を参照)。
Google が提供します。CLI バイナリ名は `agy` です。Google の Antigravity サービスと連携し、Gemini ベースのデフォルトモデルが付属しています。**セッション再開が動作します** — `--conversation <id>` を通じて行われ、stdout が構造化されたイベントストリームではなくプレーンテキストであるため、デーモンが CLI のログファイルから conversation UUID をキャプチャします。**モデル選択が動作します** — `--model` flagagy 1.0.6 で追加)を通じて行われ、デーモンが `agy models` でカタログを列挙し、選択された値をそのまま渡します。これらは `provider/model` slug ではなく `Claude Opus 4.6 (Thinking)` のような人間が読める表示名である点に注意してください。また agy は認識できない値を渡すと黙って空実行するため、手入力ではなく検出されたリストから選ぶことをおすすめします。スキルは `.agents/skills/` に配置されますCLI が Gemini CLI のワークスペーススキルレイアウトをそのまま継承します — [Antigravity 移行ドキュメント](https://antigravity.google/docs/gcli-migration)を参照)。
### Claude Code

View File

@@ -13,7 +13,7 @@ Multica는 **12개의 AI 코딩 도구**를 기본 지원합니다. 이들은
| 도구 | 공급사 | 세션 재개 | MCP | 스킬 주입 경로 | 모델 선택 |
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Antigravity CLI 자체 내부에서 관리 |
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | 동적 탐색(`agy models`) |
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 정적 + flag |
| **Codex** | OpenAI | ⚠️ 코드는 존재하지만 도달 불가 | ✅ | `$CODEX_HOME/skills/` | 정적 |
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 정적 (계정 권한으로 결정) |
@@ -30,7 +30,7 @@ Multica는 **12개의 AI 코딩 도구**를 기본 지원합니다. 이들은
### Antigravity
Google에서 제공합니다. CLI 바이너리 이름은 `agy`입니다. Google의 Antigravity 서비스와 연동되며 Gemini 기반의 기본 모델을 함께 제공합니다. **세션 재개가 동작합니다** — `--conversation <id>`를 통해서이며, stdout이 구조화된 이벤트 스트림이 아니라 일반 텍스트이기 때문에 데몬이 CLI의 로그 파일에서 conversation UUID를 캡처합니다. `--model` flag는 없습니다 — 모델 선택은 Antigravity CLI 설정 안에 있으므로, Multica는 이 제공자에 대해 에이전트별 모델 선택기를 비활성화합니다. 스킬은 `.agents/skills/`에 들어갑니다(CLI가 Gemini CLI의 워크스페이스 스킬 레이아웃을 그대로 따릅니다 — [Antigravity 마이그레이션 문서](https://antigravity.google/docs/gcli-migration) 참고).
Google에서 제공합니다. CLI 바이너리 이름은 `agy`입니다. Google의 Antigravity 서비스와 연동되며 Gemini 기반의 기본 모델을 함께 제공합니다. **세션 재개가 동작합니다** — `--conversation <id>`를 통해서이며, stdout이 구조화된 이벤트 스트림이 아니라 일반 텍스트이기 때문에 데몬이 CLI의 로그 파일에서 conversation UUID를 캡처합니다. **모델 선택이 동작합니다** — `--model` flag(agy 1.0.6에서 추가)를 통해서이며, 데몬이 `agy models`로 카탈로그를 열거하고 선택된 값을 그대로 전달합니다. 이 값들은 `provider/model` slug가 아니라 `Claude Opus 4.6 (Thinking)` 같은 사람이 읽는 표시 이름이라는 점에 유의하세요. 또한 agy는 인식할 수 없는 값을 받으면 조용히 빈 실행을 하므로, 직접 입력하기보다 발견된 목록에서 선택하는 것을 권장합니다. 스킬은 `.agents/skills/`에 들어갑니다(CLI가 Gemini CLI의 워크스페이스 스킬 레이아웃을 그대로 따릅니다 — [Antigravity 마이그레이션 문서](https://antigravity.google/docs/gcli-migration) 참고).
### Claude Code

View File

@@ -13,7 +13,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
| Tool | Vendor | Session resumption | MCP | Skill injection path | Model selection |
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Managed inside the Antigravity CLI itself |
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Dynamic discovery (`agy models`) |
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | Static + flag |
| **Codex** | OpenAI | ⚠️ Code exists but unreachable | ✅ | `$CODEX_HOME/skills/` | Static |
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | Static (determined by account entitlement) |
@@ -30,7 +30,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
### Antigravity
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file because stdout is plain text rather than a structured event stream. There is no `--model` flag — model selection lives inside the Antigravity CLI settings, so Multica disables the per-agent model picker for this provider. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file because stdout is plain text rather than a structured event stream. **Model selection works** via the `--model` flag (added in agy 1.0.6): the daemon enumerates the catalog with `agy models` and ships the chosen value verbatim. Note these are human display strings such as `Claude Opus 4.6 (Thinking)`, not `provider/model` slugs — and agy silently no-ops on a value it doesn't recognise, so prefer picking from the discovered list over typing a custom one. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
### Claude Code

View File

@@ -13,7 +13,7 @@ Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接
| 工具 | 厂商 | 会话恢复 | MCP | Skill 注入路径 | 模型选择 |
|---|---|---|---|---|---|
| **Antigravity** | Google | ✅(`--conversation <id>`| ❌ | `.agents/skills/` | 由 Antigravity CLI 自己管理 |
| **Antigravity** | Google | ✅(`--conversation <id>`| ❌ | `.agents/skills/` | 动态发现(`agy models`|
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 静态 + flag |
| **Codex** | OpenAI | ⚠️ 代码存在但不可达 | ✅ | `$CODEX_HOME/skills/` | 静态 |
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 静态(账号权益决定)|
@@ -30,7 +30,7 @@ Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接
### Antigravity
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。**会话恢复真用**——通过 `--conversation <id>`;因为 stdout 是纯文本而非结构化事件流,守护进程从 CLI 的日志文件里抓取 conversation UUID。CLI 没有 `--model` flag——模型选择保存在 Antigravity 自己的设置里,因此 Multica 禁用了这款工具的模型选择控件。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。**会话恢复真用**——通过 `--conversation <id>`;因为 stdout 是纯文本而非结构化事件流,守护进程从 CLI 的日志文件里抓取 conversation UUID。**模型选择真用**——通过 `--model` flagagy 1.0.6 新增):守护进程用 `agy models` 枚举可选项,并把选中的值原样传入。注意这些是 `Claude Opus 4.6 (Thinking)` 这样的人类可读显示名,而非 `provider/model` slug而且 agy 遇到无法识别的值会静默空跑,所以优先从发现列表里挑选,不要手填。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
### Claude Code

View File

@@ -19,10 +19,11 @@ import { useT } from "../../../i18n";
* it fits a single PropRow. Drops the "select a runtime first" state because
* the inspector only renders this picker after a runtime is bound.
*
* Unsupported providers (e.g. antigravity, whose `agy` CLI has no
* `--model` flag and reads model selection from its own settings) render
* an inert italic "Managed by runtime" label instead of a clickable
* picker — the back-end ignores agent.model for those runtimes anyway.
* Providers whose runtime ignores per-agent model selection report
* `supported=false` and render an inert italic "Managed by runtime" label
* instead of a clickable picker. No built-in provider sets this today
* (Antigravity gained `--model` in agy 1.0.6), but the branch stays for any
* future model-less runtime.
*/
export function ModelPicker({
runtimeId,

View File

@@ -17,11 +17,11 @@ import { useT } from "../../i18n";
// ModelDropdown renders a searchable, creatable model picker for an agent.
// It fetches the supported-model catalog from the selected runtime — the
// daemon enumerates models on demand via heartbeat piggyback. Providers
// that don't honour per-agent model selection at runtime (currently
// antigravity — `agy` has no `--model` flag and reads selection from
// its own settings) return supported=false, and the dropdown renders
// disabled with an explanation instead of silently accepting a value
// the backend would ignore.
// whose runtime ignores per-agent model selection return supported=false,
// and the dropdown renders disabled with an explanation instead of silently
// accepting a value the backend would ignore. No built-in provider does so
// today — Antigravity gained `--model` in agy 1.0.6 — but the path stays for
// any future model-less runtime.
export function ModelDropdown({
runtimeId,
runtimeOnline,

View File

@@ -232,11 +232,11 @@ func LoadConfig(overrides Overrides) (Config, error) {
if e, ok := probe("MULTICA_KIRO_PATH", "kiro-cli", "MULTICA_KIRO_MODEL"); ok {
agents["kiro"] = e
}
// Antigravity has no `--model` flag and ModelSelectionSupported returns
// false for it (see server/pkg/agent/models.go). Pass an empty modelEnv
// so we don't seed AgentEntry.Model from an environment variable that
// the backend would silently ignore, and don't lead users to set it.
if e, ok := probe("MULTICA_ANTIGRAVITY_PATH", "agy", ""); ok {
// agy 1.0.6 added a `--model` flag (MUL-3125), so Antigravity now takes a
// model env like every other backend. MULTICA_ANTIGRAVITY_MODEL seeds the
// daemon-wide default; its value is the exact `agy models` display string
// (e.g. "Claude Opus 4.6 (Thinking)"), not a provider/model slug.
if e, ok := probe("MULTICA_ANTIGRAVITY_PATH", "agy", "MULTICA_ANTIGRAVITY_MODEL"); ok {
agents["antigravity"] = e
}
if len(agents) == 0 {

View File

@@ -38,6 +38,23 @@ func (b *antigravityBackend) Execute(ctx context.Context, prompt string, opts Ex
return nil, fmt.Errorf("agy executable not found at %q: %w", execPath, err)
}
// Guard against agy's silent no-op on an unrecognised --model: it exits 0
// with empty output, which would otherwise surface as a "completed" but
// empty task. opts.Model is the single funnel for both agent.model and the
// daemon-wide MULTICA_ANTIGRAVITY_MODEL default (resolved in daemon.go), so
// validating it here covers every source — UI free-text, API, a persisted
// value, and the env default alike. Reject a non-empty model the installed
// CLI definitively does not advertise, with an actionable error. Validation
// is fail-OPEN: if the `agy models` catalog can't be discovered we let agy
// resolve the value itself rather than blocking the run on a discovery
// hiccup (see antigravityModelError).
if opts.Model != "" {
catalog, _ := ListModels(ctx, "antigravity", execPath)
if err := antigravityModelError(opts.Model, catalog); err != nil {
return nil, err
}
}
timeout := opts.Timeout
runCtx, cancel := runContext(ctx, timeout)
@@ -196,6 +213,7 @@ var antigravityBlockedArgs = map[string]blockedArgMode{
"-c": blockedStandalone, // resume via --conversation, not --continue
"--continue": blockedStandalone,
"--conversation": blockedWithValue, // managed via ExecOptions.ResumeSessionID
"--model": blockedWithValue, // managed via ExecOptions.Model / agent.model
"--print-timeout": blockedWithValue,
"--dangerously-skip-permissions": blockedStandalone, // always-on in daemon mode
"--log-file": blockedWithValue, // daemon needs it for session capture
@@ -203,17 +221,30 @@ var antigravityBlockedArgs = map[string]blockedArgMode{
// buildAntigravityArgs assembles the argv for a one-shot agy invocation.
//
// agy -p <prompt> --dangerously-skip-permissions --print-timeout <duration>
// --log-file <tmp> [--conversation <id>] [--add-dir <cwd>]
// agy -p <prompt> --dangerously-skip-permissions [--model <display name>]
// --print-timeout <duration> --log-file <tmp>
// [--conversation <id>] [--add-dir <cwd>]
//
// The Antigravity CLI exposes neither --model nor --system-prompt today;
// model selection lives in the user's Antigravity settings, and runtime
// instructions are delivered via AGENTS.md in the task workdir.
// agy 1.0.6 added a `--model` flag (MUL-3125), so opts.Model is now wired
// through when set. The value is the exact human display string `agy models`
// prints (e.g. "Claude Opus 4.6 (Thinking)"), NOT a provider/model slug —
// it's passed verbatim as a single exec arg, so spaces and parens need no
// shell quoting. agy still exposes no --system-prompt; runtime instructions
// are delivered via AGENTS.md in the task workdir.
//
// agy silently no-ops on a model string it doesn't recognise (empty output,
// exit 0), so Execute validates opts.Model against the `agy models` catalog
// and rejects an unrecognised value up front (see antigravityModelError) —
// by the time we build argv the value is either empty or known-good. When
// opts.Model is empty we omit the flag and agy resolves its own default.
func buildAntigravityArgs(prompt, logPath string, timeout time.Duration, opts ExecOptions, logger *slog.Logger) []string {
args := []string{
"-p", prompt,
"--dangerously-skip-permissions",
}
if opts.Model != "" {
args = append(args, "--model", opts.Model)
}
// Only pass --print-timeout when a positive wall-clock cap is configured.
// timeout <= 0 means "no cap" (MUL-3064): agy then runs without its own
// print-timeout guillotine, matching every other backend's runContext
@@ -234,6 +265,31 @@ func buildAntigravityArgs(prompt, logPath string, timeout time.Duration, opts Ex
return args
}
// antigravityModelError returns an actionable error when `model` is non-empty
// and definitively absent from `available` (the `agy models` catalog); it
// returns nil otherwise. An empty `available` means discovery couldn't produce
// a catalog (agy missing, transient failure) — we fail OPEN there and let agy
// resolve the value, so a discovery hiccup never blocks a run. The match is
// exact because agy's --model wants the precise display string; a near-miss
// (extra space, dropped suffix) is correctly rejected since agy would silently
// no-op on it anyway.
func antigravityModelError(model string, available []Model) error {
if model == "" || len(available) == 0 {
return nil
}
ids := make([]string, 0, len(available))
for _, m := range available {
if m.ID == model {
return nil
}
ids = append(ids, m.ID)
}
return fmt.Errorf(
"antigravity model %q is not available from `agy models`; pick one of: %s",
model, strings.Join(ids, ", "),
)
}
// antigravityFormatTimeout renders a Go duration in the `<n>m<n>s` shape the
// agy CLI accepts (e.g. 20m0s). Sub-second timeouts round up to 1s so the CLI
// doesn't reject the flag.

View File

@@ -38,6 +38,39 @@ func TestBuildAntigravityArgsBasic(t *testing.T) {
}
}
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()
@@ -99,6 +132,7 @@ func TestBuildAntigravityArgsFiltersBlockedCustomArgs(t *testing.T) {
"--continue",
"-c",
"--conversation", "bad-id",
"--model", "sneaky-model", // managed via ExecOptions.Model
"--dangerously-skip-permissions",
"--print-timeout", "1h",
"--log-file", "/elsewhere.log",
@@ -126,6 +160,9 @@ func TestBuildAntigravityArgsFiltersBlockedCustomArgs(t *testing.T) {
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)
}
@@ -177,3 +214,58 @@ func TestReadAntigravityConversationIDMissingFile(t *testing.T) {
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")
}
}

View File

@@ -103,12 +103,12 @@ func ListModels(ctx context.Context, providerType, executablePath string) ([]Mod
case "gemini":
return geminiStaticModels(), nil
case "antigravity":
// Antigravity CLI (`agy`) does not expose a `--model` flag today;
// model selection lives in the user's Antigravity settings and is
// communicated to the backend internally by the CLI itself. Return
// an empty catalog so the daemon's model_list endpoint succeeds
// without populating a misleading dropdown.
return []Model{}, nil
// agy 1.0.6 added a `--model` flag plus an `agy models` catalog
// command (MUL-3125). Enumerate it on demand like the other
// dynamic-discovery backends.
return cachedDiscovery(providerType, func() ([]Model, error) {
return discoverAntigravityModels(ctx, executablePath)
})
case "cursor":
return cachedDiscovery(providerType, func() ([]Model, error) {
return discoverCursorModels(ctx, executablePath)
@@ -147,20 +147,18 @@ func ListModels(ctx context.Context, providerType, executablePath string) ([]Mod
}
// ModelSelectionSupported reports whether setting `agent.model` has
// any effect for the given provider. Most providers honour `opts.Model`
// end-to-end — Hermes routes it through the ACP `session/set_model` RPC
// before each prompt, Claude / Codex / Cursor / Gemini / Copilot / Kimi /
// Kiro / OpenCode / OpenClaw / Pi pass it via flag or session config.
// any effect for the given provider. Every built-in provider now honours
// `opts.Model` end-to-end — Hermes routes it through the ACP
// `session/set_model` RPC before each prompt; Claude / Codex / Cursor /
// Gemini / Copilot / Kimi / Kiro / OpenCode / OpenClaw / Pi / Antigravity
// pass it via flag or session config (Antigravity gained `--model` in agy
// 1.0.6 — MUL-3125).
//
// Antigravity is the lone exception: `agy` has no `--model` flag today,
// and the backend in antigravity.go deliberately drops opts.Model on the
// floor. Returning false here makes the UI render a disabled
// "Managed by runtime" picker instead of an empty dropdown plus a
// silently-ignored manual-entry field.
// The hook is retained — rather than inlining `true` at the call sites — so
// a future model-less runtime can opt out in one place, which makes the UI
// render a disabled "Managed by runtime" picker instead of an empty
// dropdown plus a silently-ignored manual-entry field.
func ModelSelectionSupported(providerType string) bool {
if providerType == "antigravity" {
return false
}
return true
}
@@ -981,6 +979,62 @@ func acpModelLabel(name, modelID string) string {
return label
}
// discoverAntigravityModels runs `agy models` and returns the catalog the
// installed Antigravity CLI advertises (one display name per line).
//
// Unlike cursor / pi / opencode there is deliberately NO static fallback.
// agy's `--model` takes the exact human display string (e.g.
// "Claude Opus 4.6 (Thinking)") and silently no-ops on any value it doesn't
// recognise — empty output, exit 0 — so a guessed static list would risk
// offering a model the installed CLI can't honour, turning a typo into a
// "successful" empty run. On any discovery failure we return an empty
// catalog instead; agent.model stays unset and agy resolves its own
// default. cachedDiscovery never caches empty results, so this retries on
// the next request once the cause clears.
func discoverAntigravityModels(ctx context.Context, executablePath string) ([]Model, error) {
if executablePath == "" {
executablePath = "agy"
}
if _, err := exec.LookPath(executablePath); err != nil {
return nil, nil
}
// `agy models` is a local enumeration (no network round-trip), so a
// short cap is plenty; keep it generous enough to absorb cold starts.
runCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
cmd := exec.CommandContext(runCtx, executablePath, "models")
hideAgentWindow(cmd)
out, err := cmd.Output()
if err != nil && len(out) == 0 {
return nil, nil
}
return parseAntigravityModels(string(out)), nil
}
// parseAntigravityModels turns `agy models` output — one model display name
// per line — into Model entries. The display string IS the value `--model`
// expects, so ID and Label are identical and the daemon ships opts.Model
// verbatim. Blank and duplicate lines are skipped.
func parseAntigravityModels(output string) []Model {
scanner := bufio.NewScanner(strings.NewReader(output))
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var models []Model
seen := map[string]bool{}
for scanner.Scan() {
name := strings.TrimSpace(scanner.Text())
if name == "" || seen[name] {
continue
}
seen[name] = true
models = append(models, Model{
ID: name,
Label: name,
Provider: "antigravity",
})
}
return models
}
// discoverCursorModels runs `cursor-agent --list-models` and parses
// the `id - Label` rows. Cursor's catalog changes often and ships
// many variants of the same base model (thinking / fast / max

View File

@@ -836,15 +836,54 @@ func TestHermesModelSelectionSupported(t *testing.T) {
}
}
// TestAntigravityModelSelectionUnsupported pins that the antigravity
// provider reports model selection as unsupported: `agy` has no
// `--model` flag and antigravityBackend deliberately drops opts.Model on
// the floor, so the UI must render a disabled "Managed by runtime"
// picker rather than an empty dropdown that accepts a silently-ignored
// custom value.
func TestAntigravityModelSelectionUnsupported(t *testing.T) {
if ModelSelectionSupported("antigravity") {
t.Error("antigravity should not be model-selection-supported: agy has no --model flag")
// 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)
}
}