mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-30 19:09:27 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/niko
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d8b90d8be | ||
|
|
18b338ba1a |
@@ -63,7 +63,6 @@ export const RUNTIME_PROFILE_PROTOCOL_FAMILIES = [
|
||||
"opencode",
|
||||
"openclaw",
|
||||
"hermes",
|
||||
"gemini",
|
||||
"pi",
|
||||
"cursor",
|
||||
"kimi",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
18
server/migrations/126_runtime_profile_drop_gemini.down.sql
Normal file
18
server/migrations/126_runtime_profile_drop_gemini.down.sql
Normal 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'
|
||||
));
|
||||
19
server/migrations/126_runtime_profile_drop_gemini.up.sql
Normal file
19
server/migrations/126_runtime_profile_drop_gemini.up.sql
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ¶ms)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user