mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
* feat(agent): add Antigravity runtime backend Adds Google's Antigravity CLI (`agy`) as the 12th supported coding-tool runtime, alongside Claude / Codex / Cursor / Copilot / Gemini / Hermes / Kimi / Kiro / OpenCode / OpenClaw / Pi. The CLI emits plain assistant text on stdout (no structured event stream), so the backend streams stdout line-by-line as `MessageText` events and accumulates the same text as the final `Result.Output`. Session resumption uses `--conversation <id>`; because the conversation UUID is not echoed on stdout, the daemon routes `--log-file` to a temp file and recovers the id from the glog-formatted log lines. MUL-2767 Co-authored-by: multica-agent <github@multica.ai> * fix(agent): correct Antigravity capability contract from Elon review - ModelSelectionSupported now returns false for antigravity. `agy` has no --model flag and antigravityBackend deliberately drops opts.Model, so the UI must render a disabled "Managed by runtime" picker instead of an empty dropdown plus a silently-ignored manual-entry field. Also stop seeding AgentEntry.Model from MULTICA_ANTIGRAVITY_MODEL — the backend would silently ignore it. - Antigravity skills now write to {workDir}/.agents/skills/, the CLI's native workspace path (inherits Gemini CLI's layout per https://antigravity.google/docs/gcli-migration). Previously they went to the .agent_context/skills/ fallback that the CLI doesn't scan. Runtime brief moves antigravity into the native-discovery branch and local_skills.go points the user-level skill root at ~/.gemini/antigravity-cli/skills for Runtime → local skill import. - Doc + UI comment sync: providers matrix / install-agent-runtime / cloud-quickstart / agents-create / tasks (session-resume support) / skills / README all now list Antigravity in the right buckets, and the model-picker / model-dropdown comments cite antigravity (not the stale hermes reference) as the supported=false example. New tests: TestAntigravityModelSelectionUnsupported, TestInjectRuntimeConfigAntigravity (native discovery wording), TestWriteContextFilesAntigravityNativeSkills (.agents/skills/ landing, .agent_context/skills/ NOT written). Co-authored-by: multica-agent <github@multica.ai> * feat(provider-logo): swap inline placeholder for real Antigravity PNG Replaces the hand-drawn planet+arc placeholder with the official asset shipped from Downloads. Stored next to the component; bundlers (Next.js / electron-vite) resolve the PNG import to a URL string at build time. Added a small assets.d.ts so packages/views' tsc accepts PNG / SVG module imports — there was no prior asset usage in this package to register the declaration. --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
152 lines
4.2 KiB
Go
152 lines
4.2 KiB
Go
package agent
|
|
|
|
import (
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func quietAntigravityLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
func TestBuildAntigravityArgsBasic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
args := buildAntigravityArgs(
|
|
"hello",
|
|
"/tmp/agy.log",
|
|
20*time.Minute,
|
|
ExecOptions{Cwd: "/work"},
|
|
quietAntigravityLogger(),
|
|
)
|
|
|
|
want := []string{
|
|
"-p", "hello",
|
|
"--dangerously-skip-permissions",
|
|
"--print-timeout", "20m0s",
|
|
"--log-file", "/tmp/agy.log",
|
|
"--add-dir", "/work",
|
|
}
|
|
if !slices.Equal(args, want) {
|
|
t.Fatalf("buildAntigravityArgs basic mismatch\n got: %v\nwant: %v", args, want)
|
|
}
|
|
}
|
|
|
|
func TestBuildAntigravityArgsResume(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
args := buildAntigravityArgs(
|
|
"continue",
|
|
"/tmp/agy.log",
|
|
20*time.Minute,
|
|
ExecOptions{ResumeSessionID: "b8b263a4-4b2f-4339-acc9-78b248e2b606"},
|
|
quietAntigravityLogger(),
|
|
)
|
|
|
|
joined := strings.Join(args, " ")
|
|
if !strings.Contains(joined, "--conversation b8b263a4-4b2f-4339-acc9-78b248e2b606") {
|
|
t.Fatalf("expected --conversation flag with id; got %v", args)
|
|
}
|
|
}
|
|
|
|
func TestBuildAntigravityArgsFiltersBlockedCustomArgs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
args := buildAntigravityArgs(
|
|
"go",
|
|
"/tmp/agy.log",
|
|
time.Minute,
|
|
ExecOptions{
|
|
// Each blocked flag below must be stripped silently — the daemon
|
|
// owns these because they're required for non-interactive,
|
|
// resume-aware operation.
|
|
CustomArgs: []string{
|
|
"-p", "hijacked-prompt",
|
|
"--continue",
|
|
"-c",
|
|
"--conversation", "bad-id",
|
|
"--dangerously-skip-permissions",
|
|
"--print-timeout", "1h",
|
|
"--log-file", "/elsewhere.log",
|
|
"--add-dir", "/extra", // user-added workspace dir should survive
|
|
},
|
|
},
|
|
quietAntigravityLogger(),
|
|
)
|
|
|
|
joined := strings.Join(args, " ")
|
|
// Prompt argument should appear exactly once — the daemon's, not the
|
|
// user's hijacked copy.
|
|
pCount := 0
|
|
for _, a := range args {
|
|
if a == "-p" {
|
|
pCount++
|
|
}
|
|
}
|
|
if pCount != 1 {
|
|
t.Errorf("expected exactly one -p flag, got args=%v", args)
|
|
}
|
|
if strings.Contains(joined, "hijacked-prompt") {
|
|
t.Errorf("custom -p value leaked through filter: %v", args)
|
|
}
|
|
if strings.Contains(joined, "bad-id") {
|
|
t.Errorf("custom --conversation value leaked through filter: %v", args)
|
|
}
|
|
if strings.Contains(joined, "/elsewhere.log") {
|
|
t.Errorf("custom --log-file value leaked through filter: %v", args)
|
|
}
|
|
if !strings.Contains(joined, "--add-dir /extra") {
|
|
t.Errorf("non-blocked --add-dir flag should pass through: %v", args)
|
|
}
|
|
}
|
|
|
|
func TestAntigravityFormatTimeoutClampsSubSecond(t *testing.T) {
|
|
t.Parallel()
|
|
if got := antigravityFormatTimeout(0); got != "1s" {
|
|
t.Errorf("antigravityFormatTimeout(0) = %q, want 1s", got)
|
|
}
|
|
if got := antigravityFormatTimeout(20 * time.Minute); got != "20m0s" {
|
|
t.Errorf("antigravityFormatTimeout(20m) = %q, want 20m0s", got)
|
|
}
|
|
}
|
|
|
|
func TestReadAntigravityConversationID(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
logPath := filepath.Join(dir, "agy.log")
|
|
|
|
// Sample log content modelled on real agy glog output: the
|
|
// conversation= line is what printmode.go writes once per dispatch.
|
|
logBody := strings.Join([]string{
|
|
`I0528 13:36:19.959748 73304 printmode.go:71] Print mode: starting (promptLength=18, model="", conversationID="")`,
|
|
`I0528 13:36:23.318877 73304 printmode.go:130] Print mode: conversation=b8b263a4-4b2f-4339-acc9-78b248e2b606, sending message`,
|
|
`I0528 13:36:23.318892 73304 server.go:1083] Sending user message to conversation b8b263a4-4b2f-4339-acc9-78b248e2b606 (items=1, media=0)`,
|
|
}, "\n")
|
|
if err := os.WriteFile(logPath, []byte(logBody), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := readAntigravityConversationID(logPath)
|
|
want := "b8b263a4-4b2f-4339-acc9-78b248e2b606"
|
|
if got != want {
|
|
t.Fatalf("readAntigravityConversationID = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestReadAntigravityConversationIDMissingFile(t *testing.T) {
|
|
t.Parallel()
|
|
if got := readAntigravityConversationID("/nonexistent/path"); got != "" {
|
|
t.Errorf("expected empty string for missing file, got %q", got)
|
|
}
|
|
if got := readAntigravityConversationID(""); got != "" {
|
|
t.Errorf("expected empty string for empty path, got %q", got)
|
|
}
|
|
}
|