Compare commits

...

2 Commits

Author SHA1 Message Date
J
3d8b90d8be fix: skip unsupported custom runtime profiles
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 15:01:21 +08:00
J
18b338ba1a fix: remove gemini cli runtime
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 14:44:05 +08:00
24 changed files with 133 additions and 631 deletions

View File

@@ -63,7 +63,6 @@ export const RUNTIME_PROFILE_PROTOCOL_FAMILIES = [
"opencode",
"openclaw",
"hermes",
"gemini",
"pi",
"cursor",
"kimi",

View File

@@ -193,18 +193,6 @@ function QoderLogo({ className }: { className: string }) {
);
}
// Gemini (Google) — official "Google Gemini" mark from Simple Icons
// (simpleicons.org/icons/googlegemini.svg, CC0 1.0). Rendered in the
// Simple Icons brand color (#8E75B2), matching the pattern used by the
// other provider marks in this file.
function GeminiLogo({ className }: { className: string }) {
return (
<svg viewBox="0 0 24 24" fill="#8E75B2" className={className}>
<path d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81" />
</svg>
);
}
// Antigravity (Google) — official mark, shipped as a PNG asset next to
// this file. Different bundlers type the PNG import differently — Next.js
// gives a StaticImageData object (.src), electron-vite + plain vite give
@@ -291,8 +279,6 @@ export function ProviderLogo({
return <KiroLogo className={className} />;
case "qoder":
return <QoderLogo className={className} />;
case "gemini":
return <GeminiLogo className={className} />;
case "antigravity":
return <AntigravityLogo className={className} />;
default:

View File

@@ -80,7 +80,7 @@ type Config struct {
CLIVersion string // multica CLI version (e.g. "0.1.13")
LaunchedBy string // "desktop" when spawned by the Electron app, empty for standalone
Profile string // profile name (empty = default)
Agents map[string]AgentEntry // keyed by provider: claude, codebuddy, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi, kiro, antigravity, qoder
Agents map[string]AgentEntry // keyed by provider: claude, codebuddy, codex, copilot, opencode, openclaw, hermes, pi, cursor, kimi, kiro, antigravity, qoder
WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces)
KeepEnvAfterTask bool // preserve env after task for debugging
HealthPort int // local HTTP port for health checks (default: 19514)
@@ -272,9 +272,6 @@ func LoadConfig(overrides Overrides) (Config, error) {
if e, ok := probe("MULTICA_HERMES_PATH", "hermes", "MULTICA_HERMES_MODEL"); ok {
agents["hermes"] = e
}
if e, ok := probe("MULTICA_GEMINI_PATH", "gemini", "MULTICA_GEMINI_MODEL"); ok {
agents["gemini"] = e
}
if e, ok := probe("MULTICA_PI_PATH", "pi", "MULTICA_PI_MODEL"); ok {
agents["pi"] = e
}
@@ -308,7 +305,7 @@ func LoadConfig(overrides Overrides) (Config, error) {
}
}
if len(agents) == 0 {
return Config{}, fmt.Errorf("no agent CLI found: install claude, codebuddy, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor-agent, kimi, kiro-cli, agy, or qodercli and ensure it is on PATH")
return Config{}, fmt.Errorf("no agent CLI found: install claude, codebuddy, codex, copilot, opencode, openclaw, hermes, pi, cursor-agent, kimi, kiro-cli, agy, or qodercli and ensure it is on PATH")
}
claudeArgs, err := shellArgsFromEnv("MULTICA_CLAUDE_ARGS")
@@ -657,7 +654,7 @@ func shellArgsFromEnv(name string) ([]string, error) {
// invocation, instead of paying the cost-per-miss.
var defaultAgentCommandNames = []string{
"claude", "codex", "opencode", "openclaw", "hermes",
"gemini", "pi", "cursor-agent", "copilot", "kimi", "kiro-cli", "codebuddy", "agy",
"pi", "cursor-agent", "copilot", "kimi", "kiro-cli", "codebuddy", "agy",
}
var codexDesktopAppBundlePaths = func() []string {

View File

@@ -587,7 +587,6 @@ func pinNonCodexAgentsToMissingPaths(t *testing.T) {
"MULTICA_OPENCODE_PATH",
"MULTICA_OPENCLAW_PATH",
"MULTICA_HERMES_PATH",
"MULTICA_GEMINI_PATH",
"MULTICA_PI_PATH",
"MULTICA_CURSOR_PATH",
"MULTICA_COPILOT_PATH",

View File

@@ -1020,6 +1020,18 @@ func (d *Daemon) appendProfileRuntimes(ctx context.Context, workspaceID string,
"workspace_id", workspaceID, "profile_id", profile.ID, "display_name", profile.DisplayName)
continue
}
if !agent.IsSupportedType(profile.ProtocolFamily) {
reason := "unsupported protocol_family: " + profile.ProtocolFamily
d.logger.Warn("skip custom runtime profile: unsupported protocol_family",
"workspace_id", workspaceID, "profile_id", profile.ID,
"display_name", profile.DisplayName, "protocol_family", profile.ProtocolFamily)
*failedProfiles = append(*failedProfiles, map[string]string{
"profile_id": profile.ID,
"command_name": profile.CommandName,
"reason": reason,
})
continue
}
// Resolve the executable to launch for this profile. A per-machine
// path override (MUL-3284, `multica runtime profile set-path`) wins
// over the PATH lookup when it is set AND points at a real
@@ -3464,7 +3476,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
// In local_directory mode the workdir is the user's own repo, reuse is
// already disabled above (see localAssignment == nil), and the brief
// would otherwise live on inside the user's repository — a subsequent
// manual `claude` / `codex` / `gemini` run in that directory would pick
// manual `claude` / `codex` run in that directory would pick
// up stale Multica instructions (issue id, trigger comment id, reply
// rules) and start acting on the previous task's context. Excise the
// marker block on the way out instead.
@@ -3478,7 +3490,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
// into the user's repo. Without this pass the user's tree
// accumulates one directory layer per task — see MUL-2784.
// CleanupRuntimeConfig handles the runtime brief inside
// CLAUDE.md / AGENTS.md / GEMINI.md; CleanupSidecars handles
// CLAUDE.md / AGENTS.md; CleanupSidecars handles
// every other file Prepare placed under WorkDir. Together
// they round-trip the workdir to its exact pre-task bytes.
if cerr := execenv.CleanupSidecars(env.RootDir); cerr != nil {

View File

@@ -1823,7 +1823,7 @@ func TestDefaultArgsForProvider(t *testing.T) {
if got := defaultArgsForProvider(cfg, "codex"); strings.Join(got, " ") != "--sandbox workspace-write" {
t.Fatalf("unexpected codex args: %#v", got)
}
if got := defaultArgsForProvider(cfg, "gemini"); got != nil {
if got := defaultArgsForProvider(cfg, "unsupported"); got != nil {
t.Fatalf("expected nil for unsupported provider, got %#v", got)
}
}

View File

@@ -48,7 +48,7 @@ func writeContextFiles(workDir, provider string, ctx TaskContextForEnv, manifest
// themselves or it survived from a crashed prior run we can't
// safely distinguish from intentional content. Refusing the
// write is the correct call: the runtime brief (CLAUDE.md /
// AGENTS.md / GEMINI.md) already carries every fact this file
// AGENTS.md) already carries every fact this file
// would, so the agent runs fine without the sidecar copy.
// Anything else is a real failure.
if !errors.Is(err, errPathPreExists) {

View File

@@ -817,7 +817,6 @@ func TestInjectRuntimeConfigBackgroundTaskSafetyProviderAgnostic(t *testing.T) {
{"claude", "CLAUDE.md"},
{"codex", "AGENTS.md"},
{"opencode", "AGENTS.md"},
{"gemini", "GEMINI.md"},
{"hermes", "AGENTS.md"},
}
@@ -915,44 +914,6 @@ func TestInjectRuntimeConfigAvailableCommandsCoreOnly(t *testing.T) {
}
}
func TestInjectRuntimeConfigGemini(t *testing.T) {
t.Parallel()
dir := t.TempDir()
ctx := TaskContextForEnv{
IssueID: "test-issue-id",
AgentSkills: []SkillContextForEnv{{Name: "Writing", Content: "Write clearly."}},
}
if _, err := InjectRuntimeConfig(dir, "gemini", ctx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(dir, "GEMINI.md"))
if err != nil {
t.Fatalf("failed to read GEMINI.md: %v", err)
}
s := string(content)
for _, want := range []string{
"Multica Agent Runtime",
"multica issue get",
"Writing",
} {
if !strings.Contains(s, want) {
t.Errorf("GEMINI.md missing %q", want)
}
}
// Should not write CLAUDE.md or AGENTS.md for gemini provider.
if _, err := os.Stat(filepath.Join(dir, "CLAUDE.md")); !os.IsNotExist(err) {
t.Error("gemini provider should not create CLAUDE.md")
}
if _, err := os.Stat(filepath.Join(dir, "AGENTS.md")); !os.IsNotExist(err) {
t.Error("gemini provider should not create AGENTS.md")
}
}
func TestInjectRuntimeConfigCodex(t *testing.T) {
t.Parallel()
dir := t.TempDir()
@@ -1593,7 +1554,7 @@ func TestInjectRuntimeConfigCommentGuardrailIsProviderAgnostic(t *testing.T) {
t.Cleanup(func() { runtimeGOOS = saved })
for _, host := range []string{"linux", "darwin", "windows"} {
for _, provider := range []string{"claude", "opencode", "openclaw", "hermes", "kimi", "kiro", "cursor", "gemini"} {
for _, provider := range []string{"claude", "opencode", "openclaw", "hermes", "kimi", "kiro", "cursor"} {
t.Run(provider+"/"+host, func(t *testing.T) {
runtimeGOOS = host
dir := t.TempDir()
@@ -1605,9 +1566,6 @@ func TestInjectRuntimeConfigCommentGuardrailIsProviderAgnostic(t *testing.T) {
if provider != "claude" {
configFile = "AGENTS.md"
}
if provider == "gemini" {
configFile = "GEMINI.md"
}
data, err := os.ReadFile(filepath.Join(dir, configFile))
if err != nil {
t.Fatalf("read %s: %v", configFile, err)

View File

@@ -78,7 +78,7 @@ func TestBuildCommentReplyInstructionsNonCodexLinux(t *testing.T) {
triggerID := "22222222-2222-2222-2222-222222222222"
for _, host := range []string{"linux", "darwin"} {
for _, provider := range []string{"claude", "opencode", "openclaw", "hermes", "kimi", "kiro", "cursor", "gemini"} {
for _, provider := range []string{"claude", "opencode", "openclaw", "hermes", "kimi", "kiro", "cursor"} {
name := provider + "/" + host
t.Run(name, func(t *testing.T) {
runtimeGOOS = host
@@ -133,7 +133,7 @@ func TestBuildCommentReplyInstructionsWindowsUsesContentFile(t *testing.T) {
issueID := "11111111-1111-1111-1111-111111111111"
triggerID := "22222222-2222-2222-2222-222222222222"
for _, provider := range []string{"codex", "claude", "opencode", "openclaw", "hermes", "kimi", "kiro", "cursor", "gemini"} {
for _, provider := range []string{"codex", "claude", "opencode", "openclaw", "hermes", "kimi", "kiro", "cursor"} {
t.Run(provider+"/windows", func(t *testing.T) {
got := BuildCommentReplyInstructions(provider, issueID, triggerID)
for _, want := range []string{

View File

@@ -12,7 +12,7 @@ import (
)
// runtimeMarkerBegin and runtimeMarkerEnd delimit the Multica-managed brief
// inside the runtime config file (CLAUDE.md / AGENTS.md / GEMINI.md). The
// inside the runtime config file (CLAUDE.md / AGENTS.md). The
// markers exist so writeRuntimeConfigFile can:
//
// - preserve user-authored content in the same file (the user's repo may
@@ -154,7 +154,6 @@ func formatProjectResource(r ProjectResourceForEnv) string {
// For OpenCode: writes {workDir}/AGENTS.md (skills discovered natively from .opencode/skills/)
// For OpenClaw: writes {workDir}/AGENTS.md (skills discovered natively from {workDir}/skills/ via per-task openclaw-config.json that pins agents.defaults.workspace)
// For Hermes: writes {workDir}/AGENTS.md (skills fall back to .agent_context/skills/; AGENTS.md points there)
// For Gemini: writes {workDir}/GEMINI.md (discovered natively by the Gemini CLI)
// For Pi: writes {workDir}/AGENTS.md (skills discovered natively from .pi/skills/)
// For Cursor: writes {workDir}/AGENTS.md (skills discovered natively from .cursor/skills/)
// For Kimi: writes {workDir}/AGENTS.md (Kimi Code CLI reads AGENTS.md natively; skills auto-discovered from project skills dirs)
@@ -182,8 +181,6 @@ func runtimeConfigPath(workDir, provider string) string {
return filepath.Join(workDir, "CLAUDE.md")
case "codex", "copilot", "opencode", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity", "qoder":
return filepath.Join(workDir, "AGENTS.md")
case "gemini":
return filepath.Join(workDir, "GEMINI.md")
default:
return ""
}
@@ -208,7 +205,7 @@ func runtimeConfigPath(workDir, provider string) string {
// separator established by the first inject) is preserved verbatim.
//
// The previous implementation called os.WriteFile unconditionally, which
// silently truncated a repository's CLAUDE.md / AGENTS.md / GEMINI.md the
// silently truncated a repository's CLAUDE.md / AGENTS.md the
// first time the agent was pointed at the user's own directory via the
// local_directory project resource flow. See MUL-2753.
func writeRuntimeConfigFile(path, brief string) error {
@@ -304,7 +301,7 @@ func locateMarkerBlock(content string) (start, end int, found bool) {
// PR #3438 review feedback.
//
// Required for the local_directory flow (WorkDir is the user's own repo):
// without this pass, a manual `claude` / `codex` / `gemini` run started by
// without this pass, a manual `claude` / `codex` run started by
// the user inside the same directory after a Multica task would pick up
// the stale brief and act on the previous task's issue id, trigger
// comment id, and reply rules. Cloud workspace runs never trigger this
@@ -751,10 +748,9 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
// Antigravity inherits Gemini CLI's workspace skill layout —
// {workDir}/.agents/skills/ — see resolveSkillsDir.
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
case "gemini", "hermes":
// Gemini reads GEMINI.md directly. Hermes has no native skill
// discovery path wired up in resolveSkillsDir; both fall back to
// referencing the files explicitly under .agent_context/skills/.
case "hermes":
// Hermes has no native skill discovery path wired up in resolveSkillsDir;
// fall back to referencing the files explicitly under .agent_context/skills/.
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")
default:
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")

View File

@@ -378,7 +378,7 @@ func writeSkills(b *strings.Builder, provider string, ctx TaskContextForEnv) {
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
case "codex", "copilot", "opencode", "openclaw", "pi", "cursor", "kimi", "kiro", "qoder", "antigravity":
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
case "gemini", "hermes":
case "hermes":
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")
default:
b.WriteString("Detailed skill instructions are in `.agent_context/skills/`. Each subdirectory contains a `SKILL.md`.\n\n")

View File

@@ -536,7 +536,7 @@ func TestSubIssueCreationSectionSkippedForNonIssueModes(t *testing.T) {
}
// writeRuntimeConfigFile is the safe replacement for the previous
// unconditional os.WriteFile of CLAUDE.md / AGENTS.md / GEMINI.md. The three
// unconditional os.WriteFile of CLAUDE.md / AGENTS.md. The two
// states it must handle correctly are: file missing, file present without
// markers (user-authored content already there — the regression case from
// MUL-2753), and file present with markers (idempotent second-run replace).
@@ -699,7 +699,6 @@ func TestInjectRuntimeConfigPreservesUserContent(t *testing.T) {
{"kimi", "AGENTS.md"},
{"kiro", "AGENTS.md"},
{"antigravity", "AGENTS.md"},
{"gemini", "GEMINI.md"},
}
for _, tc := range cases {
tc := tc
@@ -742,7 +741,7 @@ func TestInjectRuntimeConfigUnknownProviderSkipsWrite(t *testing.T) {
dir := t.TempDir()
// Seed all three candidate filenames so we can verify none of them get
// written when the provider is unknown.
for _, name := range []string{"CLAUDE.md", "AGENTS.md", "GEMINI.md"} {
for _, name := range []string{"CLAUDE.md", "AGENTS.md"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("untouched\n"), 0o644); err != nil {
t.Fatalf("seed %s: %v", name, err)
}
@@ -753,7 +752,7 @@ func TestInjectRuntimeConfigUnknownProviderSkipsWrite(t *testing.T) {
}); err != nil {
t.Fatalf("InjectRuntimeConfig: %v", err)
}
for _, name := range []string{"CLAUDE.md", "AGENTS.md", "GEMINI.md"} {
for _, name := range []string{"CLAUDE.md", "AGENTS.md"} {
got, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
@@ -910,7 +909,7 @@ func TestCleanupRuntimeConfigPreservesUserContent(t *testing.T) {
// Cleanup removes the file entirely when the marker block was the only
// content — i.e. we created the file from scratch in a directory that had
// no pre-existing CLAUDE.md / AGENTS.md / GEMINI.md.
// no pre-existing CLAUDE.md / AGENTS.md.
func TestCleanupRuntimeConfigRemovesFileWhenOnlyBlockRemained(t *testing.T) {
t.Parallel()
dir := t.TempDir()
@@ -975,7 +974,7 @@ func TestCleanupRuntimeConfigNoOpCases(t *testing.T) {
t.Parallel()
dir := t.TempDir()
// Seed every candidate filename to verify none of them get touched.
for _, name := range []string{"CLAUDE.md", "AGENTS.md", "GEMINI.md"} {
for _, name := range []string{"CLAUDE.md", "AGENTS.md"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("untouched\n"), 0o644); err != nil {
t.Fatalf("seed %s: %v", name, err)
}
@@ -983,7 +982,7 @@ func TestCleanupRuntimeConfigNoOpCases(t *testing.T) {
if err := CleanupRuntimeConfig(dir, "totally-unknown-provider"); err != nil {
t.Errorf("unknown provider must be no-op, got: %v", err)
}
for _, name := range []string{"CLAUDE.md", "AGENTS.md", "GEMINI.md"} {
for _, name := range []string{"CLAUDE.md", "AGENTS.md"} {
got, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
@@ -1048,7 +1047,6 @@ func TestCleanupRuntimeConfigByProvider(t *testing.T) {
{"kimi", "AGENTS.md"},
{"kiro", "AGENTS.md"},
{"antigravity", "AGENTS.md"},
{"gemini", "GEMINI.md"},
}
for _, tc := range cases {
tc := tc

View File

@@ -251,7 +251,7 @@ func writeSidecarManifest(envRoot string, m *sidecarManifest) error {
//
// Pair this with CleanupRuntimeConfig on the local_directory cleanup
// path: that function handles the runtime brief inside CLAUDE.md /
// AGENTS.md / GEMINI.md, this one handles the sidecar tree
// AGENTS.md, this one handles the sidecar tree
// (.agent_context/, .multica/, .claude/skills/, .github/skills/,
// .opencode/skills/, skills/, .pi/skills/, .cursor/skills/,
// .kimi/skills/, .kiro/skills/, .agents/skills/, fallback

View File

@@ -154,7 +154,6 @@ var allFileBasedProviders = []string{
"kimi",
"kiro",
"antigravity",
"gemini",
}
// TestPrepareThenCleanupSidecarsRoundTripEmptyWorkdir is the headline
@@ -212,7 +211,7 @@ func TestPrepareThenCleanupSidecarsRoundTripEmptyWorkdir(t *testing.T) {
func TestPrepareThenCleanupSidecarsPreservesUserSkillSibling(t *testing.T) {
t.Parallel()
// One representative case per provider that writes into a
// provider-native skill directory. Gemini and Hermes don't have a
// provider-native skill directory. Hermes doesn't have a
// native discovery path; they fall back to .agent_context/skills/,
// which is also covered (a user-created sibling under there should
// also survive). Codex is intentionally excluded — its workspace
@@ -233,7 +232,6 @@ func TestPrepareThenCleanupSidecarsPreservesUserSkillSibling(t *testing.T) {
{"kiro", filepath.Join(".kiro", "skills", "my-own"), "SKILL.md"},
{"antigravity", filepath.Join(".agents", "skills", "my-own"), "SKILL.md"},
{"hermes", filepath.Join(".agent_context", "skills", "my-own"), "SKILL.md"},
{"gemini", filepath.Join(".agent_context", "skills", "my-own"), "SKILL.md"},
}
for _, tc := range cases {
tc := tc
@@ -608,7 +606,7 @@ func TestSidecarManifestRoundTripJSON(t *testing.T) {
// is fully cleaned up.
//
// Codex skills live under codex-home (not workdir), so the per-skill
// collision branch doesn't apply to it. Gemini falls back to
// collision branch doesn't apply to it. Hermes falls back to
// .agent_context/skills/ same as the default; Hermes goes there too.
var sameSlugSkillProviderCases = []struct {
provider string
@@ -624,7 +622,6 @@ var sameSlugSkillProviderCases = []struct {
{"kiro", filepath.Join(".kiro", "skills", "issue-review")},
{"antigravity", filepath.Join(".agents", "skills", "issue-review")},
{"hermes", filepath.Join(".agent_context", "skills", "issue-review")},
{"gemini", filepath.Join(".agent_context", "skills", "issue-review")},
}
// TestPrepareThenCleanupSidecarsSameSlugCollisionPerProvider is the

View File

@@ -244,6 +244,54 @@ func TestRegisterRuntimes_SkipsProfileNotOnPath(t *testing.T) {
}
}
// TestRegisterRuntimes_SkipsUnsupportedProfileFamily verifies historical
// profiles whose protocol_family is no longer supported are not registered as
// online runtimes even when their command still resolves locally.
func TestRegisterRuntimes_SkipsUnsupportedProfileFamily(t *testing.T) {
t.Cleanup(stubAgentVersion(t))
stubLookPath(t, map[string]string{"gemini": "/usr/bin/gemini"})
profiles := []RuntimeProfile{{
ID: "prof-gemini",
WorkspaceID: "ws-1",
DisplayName: "Old Gemini",
ProtocolFamily: "gemini",
CommandName: "gemini",
Enabled: true,
}}
fx := newProfileRegisterFixture(t, profiles, http.StatusOK)
d := fx.daemon
d.cfg.Agents = map[string]AgentEntry{}
_, sig, err := d.registerRuntimesForWorkspace(context.Background(), "ws-1")
if err != nil {
t.Fatalf("registerRuntimesForWorkspace: %v", err)
}
if sig == "" {
t.Errorf("profileSig must still be returned for unsupported historical profiles")
}
if _, ok := d.profileLaunchSpecs["prof-gemini"]; ok {
t.Errorf("profileLaunchSpecs should not record an unsupported profile")
}
if len(fx.sentRuntimes) != 0 {
t.Fatalf("sent runtimes = %+v, want none", fx.sentRuntimes)
}
if len(fx.sentFailures) != 1 {
t.Fatalf("sent failures = %+v, want one unsupported profile failure", fx.sentFailures)
}
failure := fx.sentFailures[0]
if failure["profile_id"] != "prof-gemini" {
t.Errorf("failure profile_id = %v, want prof-gemini", failure["profile_id"])
}
if failure["command_name"] != "gemini" {
t.Errorf("failure command_name = %v, want gemini", failure["command_name"])
}
reason, _ := failure["reason"].(string)
if !strings.Contains(reason, "unsupported protocol_family: gemini") {
t.Errorf("failure reason = %q, want unsupported protocol_family: gemini", reason)
}
}
// TestRegisterRuntimes_ProfilesFetchErrorIsBestEffort verifies a 404 from the
// profiles endpoint does not fail registration when a built-in agent exists.
func TestRegisterRuntimes_ProfilesFetchErrorIsBestEffort(t *testing.T) {

View File

@@ -0,0 +1,18 @@
ALTER TABLE runtime_profile DROP CONSTRAINT IF EXISTS runtime_profile_protocol_family_check;
ALTER TABLE runtime_profile ADD CONSTRAINT runtime_profile_protocol_family_check
CHECK (protocol_family IN (
'claude',
'codebuddy',
'codex',
'copilot',
'opencode',
'openclaw',
'hermes',
'gemini',
'pi',
'cursor',
'kimi',
'kiro',
'antigravity'
));

View File

@@ -0,0 +1,19 @@
ALTER TABLE runtime_profile DROP CONSTRAINT IF EXISTS runtime_profile_protocol_family_check;
-- Enforce the new whitelist for future writes without blocking upgrades for
-- workspaces that already have historical Gemini custom runtime profiles.
ALTER TABLE runtime_profile ADD CONSTRAINT runtime_profile_protocol_family_check
CHECK (protocol_family IN (
'claude',
'codebuddy',
'codex',
'copilot',
'opencode',
'openclaw',
'hermes',
'pi',
'cursor',
'kimi',
'kiro',
'antigravity'
)) NOT VALID;

View File

@@ -1,6 +1,6 @@
// Package agent provides a unified interface for executing prompts via
// coding agents (Claude Code, CodeBuddy, Codex, Copilot, OpenCode, OpenClaw,
// Hermes, Gemini, Pi, Cursor, Kimi, Kiro, Antigravity, Qoder). It mirrors the
// Hermes, Pi, Cursor, Kimi, Kiro, Antigravity, Qoder). It mirrors the
// happy-cli AgentBackend pattern, translated to idiomatic Go.
package agent
@@ -127,13 +127,13 @@ type Result struct {
// Config configures a Backend instance.
type Config struct {
ExecutablePath string // path to CLI binary (claude, codebuddy, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi, kiro-cli, agy, qodercli)
ExecutablePath string // path to CLI binary (claude, codebuddy, codex, copilot, opencode, openclaw, hermes, pi, cursor, kimi, kiro-cli, agy, qodercli)
Env map[string]string // extra environment variables
Logger *slog.Logger
}
// New creates a Backend for the given agent type.
// Supported types: "claude", "codebuddy", "codex", "copilot", "opencode", "openclaw", "hermes", "gemini", "pi", "cursor", "kimi", "kiro", "antigravity", "qoder".
// Supported types: "claude", "codebuddy", "codex", "copilot", "opencode", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro", "antigravity", "qoder".
//
// SupportedTypes is the canonical whitelist of agent types eligible to back a
// custom runtime profile. It MUST stay in lockstep with the
@@ -149,7 +149,6 @@ var SupportedTypes = []string{
"opencode",
"openclaw",
"hermes",
"gemini",
"pi",
"cursor",
"kimi",
@@ -189,8 +188,6 @@ func New(agentType string, cfg Config) (Backend, error) {
return &openclawBackend{cfg: cfg}, nil
case "hermes":
return &hermesBackend{cfg: cfg}, nil
case "gemini":
return &geminiBackend{cfg: cfg}, nil
case "pi":
return &piBackend{cfg: cfg}, nil
case "cursor":
@@ -204,7 +201,7 @@ func New(agentType string, cfg Config) (Backend, error) {
case "qoder":
return &qoderBackend{cfg: cfg}, nil
default:
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codebuddy, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi, kiro, antigravity, qoder)", agentType)
return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codebuddy, codex, copilot, opencode, openclaw, hermes, pi, cursor, kimi, kiro, antigravity, qoder)", agentType)
}
}
@@ -226,7 +223,6 @@ var launchHeaders = map[string]string{
"codex": "codex app-server",
"copilot": "copilot (json)",
"cursor": "cursor-agent (stream-json)",
"gemini": "gemini (stream-json)",
"hermes": "hermes acp",
"kimi": "kimi acp",
"kiro": "kiro-cli acp",

View File

@@ -9,7 +9,7 @@ import (
// in SupportedTypes must be constructable by New, and New must reject anything
// not in SupportedTypes. This is the single source of truth the custom runtime
// profile protocol_family validation (handler) and the runtime_profile
// protocol_family CHECK (migration 120) are aligned to. If a backend is added
// protocol_family CHECK (migration 120 plus later tightening migrations) are aligned to. If a backend is added
// to New, it must be added here too — and to the migration CHECK.
func TestSupportedTypesLockstepWithNew(t *testing.T) {
cfg := Config{Logger: slog.Default()}
@@ -34,11 +34,11 @@ func TestSupportedTypesLockstepWithNew(t *testing.T) {
}
// TestSupportedTypesMatchesMigrationWhitelist pins the exact set so a drift
// from the runtime_profile.protocol_family CHECK in migration 120 fails loudly.
// from the runtime_profile.protocol_family CHECK fails loudly.
func TestSupportedTypesMatchesMigrationWhitelist(t *testing.T) {
want := map[string]bool{
"claude": true, "codebuddy": true, "codex": true, "copilot": true,
"opencode": true, "openclaw": true, "hermes": true, "gemini": true,
"opencode": true, "openclaw": true, "hermes": true,
"pi": true, "cursor": true, "kimi": true, "kiro": true, "antigravity": true,
}
if len(SupportedTypes) != len(want) {

View File

@@ -105,7 +105,7 @@ func TestLaunchHeaderCoversAllSupportedBackends(t *testing.T) {
// runtime the daemon actually spawns. If a new backend is added, add an
// entry to launchHeaders in agent.go and extend this list.
supported := []string{
"antigravity", "claude", "codebuddy", "codex", "copilot", "cursor", "gemini",
"antigravity", "claude", "codebuddy", "codex", "copilot", "cursor",
"hermes", "kimi", "kiro", "openclaw", "opencode", "pi", "qoder",
}
for _, t_ := range supported {

View File

@@ -1,290 +0,0 @@
package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"log/slog"
"os/exec"
"strings"
"time"
)
// geminiBackend implements Backend by spawning the Google Gemini CLI
// with `--output-format stream-json` and parsing its NDJSON event stream.
type geminiBackend struct {
cfg Config
}
func (b *geminiBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
execPath := b.cfg.ExecutablePath
if execPath == "" {
execPath = "gemini"
}
if _, err := exec.LookPath(execPath); err != nil {
return nil, fmt.Errorf("gemini executable not found at %q: %w", execPath, err)
}
timeout := opts.Timeout
runCtx, cancel := runContext(ctx, timeout)
args := buildGeminiArgs(prompt, opts, b.cfg.Logger)
cmd := exec.CommandContext(runCtx, execPath, args...)
hideAgentWindow(cmd)
b.cfg.Logger.Info("agent command", "exec", execPath, "args", args)
cmd.WaitDelay = 10 * time.Second
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
cmd.Env = buildGeminiEnv(b.cfg.Env)
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("gemini stdout pipe: %w", err)
}
cmd.Stderr = newLogWriter(b.cfg.Logger, "[gemini:stderr] ")
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("start gemini: %w", err)
}
b.cfg.Logger.Info("gemini started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
// Close stdout when the context is cancelled so scanner.Scan() unblocks.
go func() {
<-runCtx.Done()
_ = stdout.Close()
}()
go func() {
defer cancel()
defer close(msgCh)
defer close(resCh)
startTime := time.Now()
var output strings.Builder
var sessionID string
finalStatus := "completed"
var finalError string
usage := make(map[string]TokenUsage)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var evt geminiStreamEvent
if err := json.Unmarshal([]byte(line), &evt); err != nil {
continue
}
switch evt.Type {
case "init":
sessionID = evt.SessionID
trySend(msgCh, Message{Type: MessageStatus, Status: "running"})
case "message":
if evt.Role == "assistant" && evt.Content != "" {
output.WriteString(evt.Content)
trySend(msgCh, Message{Type: MessageText, Content: evt.Content})
}
case "tool_use":
var params map[string]any
if evt.Parameters != nil {
_ = json.Unmarshal(evt.Parameters, &params)
}
trySend(msgCh, Message{
Type: MessageToolUse,
Tool: evt.ToolName,
CallID: evt.ToolID,
Input: params,
})
case "tool_result":
trySend(msgCh, Message{
Type: MessageToolResult,
CallID: evt.ToolID,
Output: evt.Output,
})
case "error":
trySend(msgCh, Message{
Type: MessageError,
Content: evt.Message,
})
case "result":
if evt.Status == "error" && evt.Error != nil {
finalStatus = "failed"
finalError = evt.Error.Message
}
if evt.Stats != nil {
b.accumulateUsage(usage, evt.Stats)
}
}
}
waitErr := cmd.Wait()
duration := time.Since(startTime)
if runCtx.Err() == context.DeadlineExceeded {
finalStatus = "timeout"
finalError = fmt.Sprintf("gemini timed out after %s", timeout)
} else if runCtx.Err() == context.Canceled {
finalStatus = "aborted"
finalError = "execution cancelled"
} else if waitErr != nil && finalStatus == "completed" {
finalStatus = "failed"
finalError = fmt.Sprintf("gemini exited with error: %v", waitErr)
}
b.cfg.Logger.Info("gemini finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
resCh <- Result{
Status: finalStatus,
Output: output.String(),
Error: finalError,
DurationMs: duration.Milliseconds(),
SessionID: sessionID,
Usage: usage,
}
}()
return &Session{Messages: msgCh, Result: resCh}, nil
}
// accumulateUsage extracts per-model token usage from Gemini's result stats.
func (b *geminiBackend) accumulateUsage(usage map[string]TokenUsage, stats *geminiStreamStats) {
for model, m := range stats.Models {
u := usage[model]
u.InputTokens += int64(m.InputTokens)
u.OutputTokens += int64(m.OutputTokens)
u.CacheReadTokens += int64(m.Cached)
usage[model] = u
}
}
// ── Gemini stream-json event types ──
type geminiStreamEvent struct {
Type string `json:"type"`
Timestamp string `json:"timestamp,omitempty"`
SessionID string `json:"session_id,omitempty"`
Model string `json:"model,omitempty"`
// message fields
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
Delta bool `json:"delta,omitempty"`
// tool_use fields
ToolName string `json:"tool_name,omitempty"`
ToolID string `json:"tool_id,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
// tool_result fields
Status string `json:"status,omitempty"`
Output string `json:"output,omitempty"`
// error fields
Severity string `json:"severity,omitempty"`
Message string `json:"message,omitempty"`
// result fields
Error *geminiStreamError `json:"error,omitempty"`
Stats *geminiStreamStats `json:"stats,omitempty"`
}
type geminiStreamError struct {
Type string `json:"type"`
Message string `json:"message"`
}
type geminiStreamStats struct {
TotalTokens int `json:"total_tokens"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
DurationMs int `json:"duration_ms"`
ToolCalls int `json:"tool_calls"`
Models map[string]geminiModelStats `json:"models,omitempty"`
}
type geminiModelStats struct {
TotalTokens int `json:"total_tokens"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
Cached int `json:"cached"`
}
// ── Arg builder ──
// buildGeminiArgs assembles the argv for a one-shot gemini invocation.
//
// Flags:
//
// -p / --prompt non-interactive prompt (the user's task)
// --yolo auto-approve all tool executions
// -o stream-json streaming NDJSON output for live events
// -m <model> optional model override
// -r <session> resume a previous session (if provided)
// geminiBlockedArgs are flags hardcoded by the daemon that must not be
// overridden by user-configured custom_args.
var geminiBlockedArgs = map[string]blockedArgMode{
"-p": blockedWithValue, // non-interactive prompt
"--yolo": blockedStandalone, // auto-approve tool use
"-o": blockedWithValue, // stream-json output format
}
func buildGeminiArgs(prompt string, opts ExecOptions, logger *slog.Logger) []string {
args := []string{
"-p", prompt,
"--yolo",
"-o", "stream-json",
}
if opts.Model != "" {
args = append(args, "-m", opts.Model)
}
if opts.ResumeSessionID != "" {
args = append(args, "-r", opts.ResumeSessionID)
}
args = append(args, filterCustomArgs(opts.CustomArgs, geminiBlockedArgs, logger)...)
return args
}
// buildGeminiEnv wraps buildEnv and defaults GEMINI_CLI_TRUST_WORKSPACE=true so
// gemini's folder-trust gate doesn't fail every headless daemon invocation with
// exit code 55 (FatalUntrustedWorkspaceError). When a user has enabled
// `security.folderTrust.enabled` in `~/.gemini/settings.json` and the daemon
// spawns gemini in a worktree that isn't pre-listed in `trustedFolders.json`,
// the CLI throws during startup warnings with no interactive prompt available,
// so the run fails after ~10s with no useful output (see #2516).
//
// The env-var bypass is gemini's own documented escape hatch (mirrors the
// `--skip-trust` CLI flag) and has been in place for the entire folder-trust
// feature lifetime, so this works on every gemini version that can produce the
// crash. If the caller explicitly sets the same key in cfg.Env it wins,
// preserving the ability to opt back into the check.
func buildGeminiEnv(extra map[string]string) []string {
const trustKey = "GEMINI_CLI_TRUST_WORKSPACE"
if _, ok := extra[trustKey]; ok {
return buildEnv(extra)
}
merged := make(map[string]string, len(extra)+1)
for k, v := range extra {
merged[k] = v
}
merged[trustKey] = "true"
return buildEnv(merged)
}

View File

@@ -1,169 +0,0 @@
package agent
import (
"log/slog"
"strings"
"testing"
)
func TestBuildGeminiArgsBaseline(t *testing.T) {
t.Parallel()
args := buildGeminiArgs("write a haiku", ExecOptions{}, slog.Default())
expected := []string{
"-p", "write a haiku",
"--yolo",
"-o", "stream-json",
}
if len(args) != len(expected) {
t.Fatalf("expected %d args, got %d: %v", len(expected), len(args), args)
}
for i, want := range expected {
if args[i] != want {
t.Fatalf("expected args[%d] = %q, got %q", i, want, args[i])
}
}
}
func TestBuildGeminiArgsWithModel(t *testing.T) {
t.Parallel()
args := buildGeminiArgs("hi", ExecOptions{Model: "gemini-2.5-pro"}, slog.Default())
var foundModel bool
for i, a := range args {
if a == "-m" {
if i+1 >= len(args) || args[i+1] != "gemini-2.5-pro" {
t.Fatalf("expected -m followed by gemini-2.5-pro, got %v", args)
}
foundModel = true
break
}
}
if !foundModel {
t.Fatalf("expected -m flag when Model is set, got args=%v", args)
}
}
func TestBuildGeminiArgsWithResume(t *testing.T) {
t.Parallel()
args := buildGeminiArgs("hi", ExecOptions{ResumeSessionID: "3"}, slog.Default())
var foundResume bool
for i, a := range args {
if a == "-r" {
if i+1 >= len(args) || args[i+1] != "3" {
t.Fatalf("expected -r followed by session id, got %v", args)
}
foundResume = true
break
}
}
if !foundResume {
t.Fatalf("expected -r flag when ResumeSessionID is set, got args=%v", args)
}
}
func TestBuildGeminiArgsOmitsModelWhenEmpty(t *testing.T) {
t.Parallel()
args := buildGeminiArgs("hi", ExecOptions{}, slog.Default())
for _, a := range args {
if a == "-m" {
t.Fatalf("expected no -m flag when Model is empty, got args=%v", args)
}
if a == "-r" {
t.Fatalf("expected no -r flag when ResumeSessionID is empty, got args=%v", args)
}
}
}
func TestBuildGeminiArgsPassesThroughCustomArgs(t *testing.T) {
t.Parallel()
args := buildGeminiArgs("hi", ExecOptions{
CustomArgs: []string{"--sandbox"},
}, slog.Default())
if args[len(args)-1] != "--sandbox" {
t.Fatalf("expected --sandbox at end of args, got %v", args)
}
}
// envLookup returns the value of key in an env slice, or ("", false) if absent.
// When the key appears multiple times the last occurrence wins, mirroring how
// libc's getenv resolves duplicates on the daemon's supported platforms — the
// caller-supplied override therefore takes precedence over our default.
func envLookup(env []string, key string) (string, bool) {
prefix := key + "="
var value string
var found bool
for _, entry := range env {
if strings.HasPrefix(entry, prefix) {
value = strings.TrimPrefix(entry, prefix)
found = true
}
}
return value, found
}
func TestBuildGeminiEnvSetsTrustWorkspaceDefault(t *testing.T) {
t.Parallel()
env := buildGeminiEnv(nil)
got, ok := envLookup(env, "GEMINI_CLI_TRUST_WORKSPACE")
if !ok {
t.Fatalf("expected GEMINI_CLI_TRUST_WORKSPACE to be set, got env=%v", env)
}
if got != "true" {
t.Fatalf("expected GEMINI_CLI_TRUST_WORKSPACE=true, got %q", got)
}
}
func TestBuildGeminiEnvRespectsExplicitOverride(t *testing.T) {
t.Parallel()
// Users who deliberately set the value (e.g. to "false" to opt back into
// gemini's folder-trust gate, or to a future-proofed value) must win over
// our daemon default.
env := buildGeminiEnv(map[string]string{"GEMINI_CLI_TRUST_WORKSPACE": "false"})
got, ok := envLookup(env, "GEMINI_CLI_TRUST_WORKSPACE")
if !ok {
t.Fatalf("expected GEMINI_CLI_TRUST_WORKSPACE to be set, got env=%v", env)
}
if got != "false" {
t.Fatalf("expected caller's GEMINI_CLI_TRUST_WORKSPACE=false to win, got %q", got)
}
}
func TestBuildGeminiEnvPreservesOtherExtras(t *testing.T) {
t.Parallel()
env := buildGeminiEnv(map[string]string{"GEMINI_API_KEY": "secret"})
if got, ok := envLookup(env, "GEMINI_API_KEY"); !ok || got != "secret" {
t.Fatalf("expected GEMINI_API_KEY=secret to pass through, got %q (ok=%v)", got, ok)
}
if got, ok := envLookup(env, "GEMINI_CLI_TRUST_WORKSPACE"); !ok || got != "true" {
t.Fatalf("expected default GEMINI_CLI_TRUST_WORKSPACE=true, got %q (ok=%v)", got, ok)
}
}
func TestBuildGeminiArgsFiltersBlockedCustomArgs(t *testing.T) {
t.Parallel()
args := buildGeminiArgs("hi", ExecOptions{
CustomArgs: []string{"-o", "text", "--sandbox"},
}, slog.Default())
// -o text should be filtered, --sandbox should pass through
for i, a := range args {
if a == "-o" && i+1 < len(args) && args[i+1] == "text" {
t.Fatalf("blocked -o text should have been filtered: %v", args)
}
}
if args[len(args)-1] != "--sandbox" {
t.Fatalf("expected --sandbox to pass through, got %v", args)
}
}

View File

@@ -101,8 +101,6 @@ func ListModels(ctx context.Context, providerType, executablePath string) ([]Mod
models := codexStaticModels()
annotateCodexThinking(ctx, models, executablePath)
return models, nil
case "gemini":
return geminiStaticModels(), nil
case "antigravity":
// agy 1.0.6 added a `--model` flag plus an `agy models` catalog
// command (MUL-3125). Enumerate it on demand like the other
@@ -204,8 +202,6 @@ func acceptedModelIDsForProvider(providerType string) (map[string]bool, bool) {
return modelIDSet(claudeStaticModels()), true
case providerType == "codex":
return modelIDSet(codexStaticModels()), true
case providerType == "gemini":
return modelIDSet(geminiStaticModels()), true
default:
return nil, false
}
@@ -225,8 +221,7 @@ func isRuntimeSpecificModelID(model string) bool {
}
return modelHasKnownPrefix(model) ||
modelIDSet(claudeStaticModels())[model] ||
modelIDSet(codexStaticModels())[model] ||
modelIDSet(geminiStaticModels())[model]
modelIDSet(codexStaticModels())[model]
}
func modelHasKnownPrefix(model string) bool {
@@ -308,31 +303,6 @@ func codexStaticModels() []Model {
}
}
// geminiStaticModels lists the values we pass via `gemini -m`. Gemini
// CLI has no `models list` subcommand, so dynamic discovery isn't
// possible; the next best thing is to expose the CLI's own aliases
// (auto / pro / flash / flash-lite and the `auto-gemini-*` family)
// alongside a few explicit version pins. Aliases track whatever the
// installed CLI considers current (see `resolveModel` in the CLI's
// packages/core/src/config/models.ts), so new Gemini releases light
// up without a Multica redeploy. Default is `auto` to match Google's
// recommendation — the CLI picks Pro vs Flash per task and falls back
// when quota is exhausted.
func geminiStaticModels() []Model {
return []Model{
{ID: "auto", Label: "Auto (Gemini 3)", Provider: "google", Default: true},
{ID: "auto-gemini-2.5", Label: "Auto (Gemini 2.5)", Provider: "google"},
{ID: "pro", Label: "Pro", Provider: "google"},
{ID: "flash", Label: "Flash", Provider: "google"},
{ID: "flash-lite", Label: "Flash Lite", Provider: "google"},
{ID: "gemini-3-pro-preview", Label: "Gemini 3 Pro (preview)", Provider: "google"},
{ID: "gemini-3-flash-preview", Label: "Gemini 3 Flash (preview)", Provider: "google"},
{ID: "gemini-2.5-pro", Label: "Gemini 2.5 Pro", Provider: "google"},
{ID: "gemini-2.5-flash", Label: "Gemini 2.5 Flash", Provider: "google"},
{ID: "gemini-2.5-flash-lite", Label: "Gemini 2.5 Flash Lite", Provider: "google"},
}
}
// cursorStaticModels is a minimal fallback used when
// `cursor-agent --list-models` isn't available (binary missing,
// offline, etc). The real catalog is fetched dynamically because

View File

@@ -11,7 +11,7 @@ import (
func TestListModelsStaticProviders(t *testing.T) {
ctx := context.Background()
for _, provider := range []string{"claude", "codex", "gemini", "cursor"} {
for _, provider := range []string{"claude", "codex", "cursor"} {
got, err := ListModels(ctx, provider, "")
if err != nil {
t.Fatalf("ListModels(%q) error: %v", provider, err)
@@ -80,37 +80,6 @@ func TestClaudeStaticModelsExposesFable5(t *testing.T) {
}
}
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 —
@@ -361,7 +330,6 @@ func TestStaticCatalogsHaveAtMostOneDefault(t *testing.T) {
catalogs := map[string][]Model{
"claude": claudeStaticModels(),
"codex": codexStaticModels(),
"gemini": geminiStaticModels(),
"cursor": cursorStaticModels(),
"copilot": copilotStaticModels(),
}