mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 10:32:36 +02:00
Compare commits
3 Commits
agent/lamb
...
agent/j/9f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eeff0e6a73 | ||
|
|
b7d19bb1b0 | ||
|
|
adf8224e46 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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` flag(agy 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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user