diff --git a/cmd/entire/cli/agent/antigravity/antigravity.go b/cmd/entire/cli/agent/antigravity/antigravity.go new file mode 100644 index 0000000000..e557d2adb3 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/antigravity.go @@ -0,0 +1,60 @@ +// Package antigravity implements the Agent interface for Antigravity (Google's agentic coding CLI). +package antigravity + +import ( + "context" + "errors" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" +) + +//nolint:gochecknoinits // Agent self-registration is the intended pattern +func init() { + agent.Register(agent.AgentNameAntigravity, NewAntigravityAgent) +} + +// AntigravityAgent implements the Agent interface for Antigravity. +// +//nolint:revive // AntigravityAgent is clearer than Agent in this context +type AntigravityAgent struct { + CommandRunner agent.TextCommandRunner +} + +// NewAntigravityAgent creates a new AntigravityAgent instance. +func NewAntigravityAgent() agent.Agent { + return &AntigravityAgent{} +} + +// --- Identity --- + +func (a *AntigravityAgent) Name() types.AgentName { return agent.AgentNameAntigravity } +func (a *AntigravityAgent) Type() types.AgentType { return agent.AgentTypeAntigravity } +func (a *AntigravityAgent) Description() string { + return "Antigravity CLI - Google's agentic coding CLI (Gemini CLI successor)" +} +func (a *AntigravityAgent) IsPreview() bool { return true } + +// DetectPresence reports whether Entire's Antigravity hooks are configured for +// this workspace. Antigravity 2.0 stores runtime data user-scope in +// ~/.gemini/antigravity-cli/, so the only meaningful workspace-level signal is +// whether our entry exists in .agents/hooks.json. +func (a *AntigravityAgent) DetectPresence(ctx context.Context) (bool, error) { + return a.AreHooksInstalled(ctx), nil +} + +func (a *AntigravityAgent) ProtectedDirs() []string { return []string{".agents", ".gemini"} } + +// --- Legacy methods --- +// Antigravity supplies transcriptPath directly in every hook payload; no session discovery needed. + +func (a *AntigravityAgent) GetSessionID(input *agent.HookInput) string { return input.SessionID } +func (a *AntigravityAgent) GetSessionDir(_ string) (string, error) { return "", nil } +func (a *AntigravityAgent) ResolveSessionFile(_, _ string) string { return "" } +func (a *AntigravityAgent) ReadSession(_ *agent.HookInput) (*agent.AgentSession, error) { + return nil, errors.New("antigravity: legacy ReadSession not supported; use transcriptPath from hook stdin") +} +func (a *AntigravityAgent) WriteSession(_ context.Context, _ *agent.AgentSession) error { return nil } +func (a *AntigravityAgent) FormatResumeCommand(sessionID string) string { + return "agy --conversation " + sessionID +} diff --git a/cmd/entire/cli/agent/antigravity/antigravity_test.go b/cmd/entire/cli/agent/antigravity/antigravity_test.go new file mode 100644 index 0000000000..4dfa34459b --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/antigravity_test.go @@ -0,0 +1,66 @@ +package antigravity + +import ( + "context" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestAgent_ImplementsAgentAndHookSupport(t *testing.T) { + t.Parallel() + var _ agent.Agent = (*AntigravityAgent)(nil) + var _ agent.HookSupport = (*AntigravityAgent)(nil) +} + +func TestAgent_NameAndType(t *testing.T) { + t.Parallel() + a := &AntigravityAgent{} + if a.Name() != agent.AgentNameAntigravity { + t.Errorf("Name() = %q", a.Name()) + } + if a.Type() != agent.AgentTypeAntigravity { + t.Errorf("Type() = %q", a.Type()) + } +} + +func TestAgent_Registered(t *testing.T) { + t.Parallel() + _, err := agent.Get(agent.AgentNameAntigravity) + if err != nil { + t.Fatalf("agent not registered: %v", err) + } +} + +func TestDetectPresence(t *testing.T) { + t.Run("no hooks installed", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AntigravityAgent{} + present, err := ag.DetectPresence(context.Background()) + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if present { + t.Error("DetectPresence() = true, want false") + } + }) + + t.Run("hooks installed", func(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ag := &AntigravityAgent{} + if _, err := ag.InstallHooks(context.Background(), false, false); err != nil { + t.Fatalf("InstallHooks: %v", err) + } + present, err := ag.DetectPresence(context.Background()) + if err != nil { + t.Fatalf("DetectPresence() error = %v", err) + } + if !present { + t.Error("DetectPresence() = false, want true after InstallHooks") + } + }) +} diff --git a/cmd/entire/cli/agent/antigravity/discovery.go b/cmd/entire/cli/agent/antigravity/discovery.go new file mode 100644 index 0000000000..55fc42072a --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/discovery.go @@ -0,0 +1,14 @@ +package antigravity + +import ( + "context" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// DiscoverReviewSkills is a stub until the Antigravity on-disk extension layout +// is verified. Returns (nil, nil) so the picker relies on the per-agent +// install hint for Phase 1. +func (a *AntigravityAgent) DiscoverReviewSkills(_ context.Context) ([]agent.DiscoveredSkill, error) { + return nil, nil +} diff --git a/cmd/entire/cli/agent/antigravity/generate.go b/cmd/entire/cli/agent/antigravity/generate.go new file mode 100644 index 0000000000..17d6a8d401 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/generate.go @@ -0,0 +1,22 @@ +package antigravity + +import ( + "context" + "fmt" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// GenerateText submits a non-interactive prompt to the Antigravity CLI. The +// binary is `agy`; -p is the short alias for --print (single-prompt mode). +func (a *AntigravityAgent) GenerateText(ctx context.Context, prompt string, model string) (string, error) { + args := []string{"-p", " "} + if model != "" { + args = append(args, "--model", model) + } + result, err := agent.RunIsolatedTextGeneratorCLI(ctx, a.CommandRunner, "agy", "antigravity", args, prompt) + if err != nil { + return "", fmt.Errorf("antigravity text generation failed: %w", err) + } + return result, nil +} diff --git a/cmd/entire/cli/agent/antigravity/hooks.go b/cmd/entire/cli/agent/antigravity/hooks.go new file mode 100644 index 0000000000..967feebad8 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/hooks.go @@ -0,0 +1,213 @@ +package antigravity + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// Ensure AntigravityAgent implements HookSupport +var _ agent.HookSupport = (*AntigravityAgent)(nil) + +// AgentsHooksFileName is the hooks file used by Antigravity. +const AgentsHooksFileName = "hooks.json" + +// entireHookPrefixes are command prefixes that identify Entire hooks. +var entireHookPrefixes = []string{ + "entire hooks antigravity ", + "go run ", +} + +// InstallHooks installs Antigravity hooks in .agents/hooks.json. +// If localDev is true, hooks point to the local development build. +// If force is true, removes existing Entire hooks before installing. +// Returns the number of hooks installed. +func (a *AntigravityAgent) InstallHooks(ctx context.Context, localDev bool, force bool) (int, error) { + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + repoRoot, err = os.Getwd() //nolint:forbidigo // Intentional fallback when WorktreeRoot() fails (tests run outside git repos) + if err != nil { + return 0, fmt.Errorf("failed to get current directory: %w", err) + } + } + + hooksPath := filepath.Join(repoRoot, ".agents", AgentsHooksFileName) + + // Read and parse existing hooks file, preserving unknown keys + rawFile := make(map[string]json.RawMessage) + existingData, readErr := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path + if readErr == nil { + if err := json.Unmarshal(existingData, &rawFile); err != nil { + return 0, fmt.Errorf("failed to parse existing hooks.json: %w", err) + } + } + + // Build the candidate Entire hook config + var cmdPrefix string + if localDev { + cmdPrefix = `go run "$(git rev-parse --show-toplevel)"/cmd/entire/main.go hooks antigravity ` + } else { + cmdPrefix = "entire hooks antigravity " + } + + candidate := buildEntireHookConfig(cmdPrefix, localDev) + + // Idempotency check: compare candidate against existing "entire" entry by + // re-marshaling both to compact JSON for a stable comparison. + if !force { + if existing, ok := rawFile["entire"]; ok { + var existingCfg HookConfig + if err := json.Unmarshal(existing, &existingCfg); err == nil { + existingBytes, err1 := jsonutil.MarshalWithNoHTMLEscape(existingCfg) + candidateBytes, err2 := jsonutil.MarshalWithNoHTMLEscape(candidate) + if err1 == nil && err2 == nil && bytes.Equal(existingBytes, candidateBytes) { + return 0, nil + } + } + } + } + + // Marshal and insert the "entire" entry (replacing any prior value) + candidateBytes, err := jsonutil.MarshalWithNoHTMLEscape(candidate) + if err != nil { + return 0, fmt.Errorf("failed to marshal hook config: %w", err) + } + rawFile["entire"] = candidateBytes + + if err := writeHooksFile(rawFile, hooksPath); err != nil { + return 0, err + } + + // 5 hooks: pre-tool-use, post-tool-use, pre-invocation, post-invocation, stop + return 5, nil +} + +// UninstallHooks removes the Entire hook entry from .agents/hooks.json. +func (a *AntigravityAgent) UninstallHooks(ctx context.Context) error { + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + repoRoot = "." // Fallback to CWD if not in a git repo + } + + hooksPath := filepath.Join(repoRoot, ".agents", AgentsHooksFileName) + data, err := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return nil //nolint:nilerr // No hooks file means nothing to uninstall + } + + var rawFile map[string]json.RawMessage + if err := json.Unmarshal(data, &rawFile); err != nil { + return fmt.Errorf("failed to parse hooks.json: %w", err) + } + + delete(rawFile, "entire") + + return writeHooksFile(rawFile, hooksPath) +} + +// AreHooksInstalled checks if Entire hooks are installed. +func (a *AntigravityAgent) AreHooksInstalled(ctx context.Context) bool { + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + repoRoot = "." // Fallback to CWD if not in a git repo + } + + hooksPath := filepath.Join(repoRoot, ".agents", AgentsHooksFileName) + data, err := os.ReadFile(hooksPath) //nolint:gosec // path is constructed from repo root + fixed path + if err != nil { + return false + } + + var f HooksFile + if err := json.Unmarshal(data, &f); err != nil { + return false + } + + cfg, ok := f["entire"] + if !ok { + return false + } + + // Check at least one of our hook commands is present + return hasEntireHookInToolHandlers(cfg.PreToolUse) || + hasEntireHookInToolHandlers(cfg.PostToolUse) || + hasEntireHookInSimpleHandlers(cfg.PreInvocation) || + hasEntireHookInSimpleHandlers(cfg.PostInvocation) || + hasEntireHookInSimpleHandlers(cfg.Stop) +} + +// buildEntireHookConfig constructs the HookConfig for the "entire" entry. +func buildEntireHookConfig(cmdPrefix string, localDev bool) HookConfig { + makeCmd := func(verb string) string { + cmd := cmdPrefix + verb + if !localDev { + cmd = agent.WrapProductionSilentHookCommand(cmd) + } + return cmd + } + + return HookConfig{ + PreToolUse: []ToolHandler{ + { + Matcher: "*", + Hooks: []HookCommand{{Type: "command", Command: makeCmd("pre-tool-use")}}, + }, + }, + PostToolUse: []ToolHandler{ + { + Matcher: "*", + Hooks: []HookCommand{{Type: "command", Command: makeCmd("post-tool-use")}}, + }, + }, + PreInvocation: []SimpleHandler{{Type: "command", Command: makeCmd("pre-invocation")}}, + PostInvocation: []SimpleHandler{{Type: "command", Command: makeCmd("post-invocation")}}, + Stop: []SimpleHandler{{Type: "command", Command: makeCmd("stop")}}, + } +} + +// writeHooksFile marshals rawFile and writes it to hooksPath, creating +// parent directories as needed. +func writeHooksFile(rawFile map[string]json.RawMessage, hooksPath string) error { + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + return fmt.Errorf("failed to create .agents directory: %w", err) + } + + output, err := jsonutil.MarshalIndentWithNewline(rawFile, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal hooks.json: %w", err) + } + + if err := os.WriteFile(hooksPath, output, 0o600); err != nil { + return fmt.Errorf("failed to write hooks.json: %w", err) + } + return nil +} + +// hasEntireHookInToolHandlers checks if any ToolHandler entry is an Entire hook. +func hasEntireHookInToolHandlers(handlers []ToolHandler) bool { + for _, th := range handlers { + for _, hc := range th.Hooks { + if agent.IsManagedHookCommand(hc.Command, entireHookPrefixes) { + return true + } + } + } + return false +} + +// hasEntireHookInSimpleHandlers checks if any SimpleHandler entry is an Entire hook. +func hasEntireHookInSimpleHandlers(handlers []SimpleHandler) bool { + for _, sh := range handlers { + if agent.IsManagedHookCommand(sh.Command, entireHookPrefixes) { + return true + } + } + return false +} diff --git a/cmd/entire/cli/agent/antigravity/hooks_test.go b/cmd/entire/cli/agent/antigravity/hooks_test.go new file mode 100644 index 0000000000..f423d1caa7 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/hooks_test.go @@ -0,0 +1,228 @@ +package antigravity + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestInstallHooks_FreshRepo(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + a := &AntigravityAgent{} + n, err := a.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("InstallHooks: %v", err) + } + if n != 5 { + t.Errorf("installed %d hooks, want 5", n) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, ".agents", "hooks.json")) + if err != nil { + t.Fatal(err) + } + var f HooksFile + if err := json.Unmarshal(data, &f); err != nil { + t.Fatalf("parse hooks.json: %v", err) + } + cfg, ok := f["entire"] + if !ok { + t.Fatal("missing 'entire' hook entry") + } + if len(cfg.PreToolUse) != 1 || len(cfg.PostToolUse) != 1 || + len(cfg.PreInvocation) != 1 || len(cfg.PostInvocation) != 1 || len(cfg.Stop) != 1 { + t.Errorf("event coverage incomplete: %+v", cfg) + } + if cfg.PreToolUse[0].Matcher != "*" { + t.Errorf("PreToolUse matcher = %q, want %q", cfg.PreToolUse[0].Matcher, "*") + } + if cfg.PostToolUse[0].Matcher != "*" { + t.Errorf("PostToolUse matcher = %q, want %q", cfg.PostToolUse[0].Matcher, "*") + } +} + +func TestInstallHooks_Idempotent(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + a := &AntigravityAgent{} + + // First install + n, err := a.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("first InstallHooks: %v", err) + } + if n != 5 { + t.Errorf("first install: installed %d hooks, want 5", n) + } + + // Second install — idempotent, should return 0 + n, err = a.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("second InstallHooks: %v", err) + } + if n != 0 { + t.Errorf("second install: installed %d hooks, want 0 (idempotent)", n) + } +} + +func TestInstallHooks_PreservesForeignHooks(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + // Pre-seed .agents/hooks.json with a foreign entry + agentsDir := filepath.Join(tmpDir, ".agents") + if err := os.MkdirAll(agentsDir, 0o750); err != nil { + t.Fatal(err) + } + foreign := HooksFile{ + "safety-gate": { + PreToolUse: []ToolHandler{ + {Matcher: "*", Hooks: []HookCommand{{Type: "command", Command: "safety-gate check"}}}, + }, + }, + } + foreignBytes, err := json.Marshal(foreign) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentsDir, "hooks.json"), foreignBytes, 0o600); err != nil { + t.Fatal(err) + } + + a := &AntigravityAgent{} + n, err := a.InstallHooks(context.Background(), false, false) + if err != nil { + t.Fatalf("InstallHooks: %v", err) + } + if n != 5 { + t.Errorf("installed %d hooks, want 5", n) + } + + data, err := os.ReadFile(filepath.Join(agentsDir, "hooks.json")) + if err != nil { + t.Fatal(err) + } + var f HooksFile + if err := json.Unmarshal(data, &f); err != nil { + t.Fatalf("parse hooks.json: %v", err) + } + + // Foreign entry must survive + if _, ok := f["safety-gate"]; !ok { + t.Error("foreign 'safety-gate' hook entry was removed") + } + + // Entire entry must also exist + if _, ok := f["entire"]; !ok { + t.Error("missing 'entire' hook entry after install") + } +} + +func TestUninstallHooks_LeavesForeignHooks(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + a := &AntigravityAgent{} + + // Install entire hooks first + if _, err := a.InstallHooks(context.Background(), false, false); err != nil { + t.Fatalf("InstallHooks: %v", err) + } + + // Pre-seed a foreign entry alongside the entire one + agentsDir := filepath.Join(tmpDir, ".agents") + data, err := os.ReadFile(filepath.Join(agentsDir, "hooks.json")) + if err != nil { + t.Fatal(err) + } + var f HooksFile + if err := json.Unmarshal(data, &f); err != nil { + t.Fatal(err) + } + f["safety-gate"] = HookConfig{ + PreToolUse: []ToolHandler{ + {Matcher: "*", Hooks: []HookCommand{{Type: "command", Command: "safety-gate check"}}}, + }, + } + out, err := json.Marshal(f) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentsDir, "hooks.json"), out, 0o600); err != nil { + t.Fatal(err) + } + + // Uninstall + if err := a.UninstallHooks(context.Background()); err != nil { + t.Fatalf("UninstallHooks: %v", err) + } + + // Read back + data, err = os.ReadFile(filepath.Join(agentsDir, "hooks.json")) + if err != nil { + t.Fatal(err) + } + var after HooksFile + if err := json.Unmarshal(data, &after); err != nil { + t.Fatalf("parse hooks.json after uninstall: %v", err) + } + + // Foreign must survive + if _, ok := after["safety-gate"]; !ok { + t.Error("foreign 'safety-gate' entry was removed by UninstallHooks") + } + + // Entire entry must be gone + if _, ok := after["entire"]; ok { + t.Error("'entire' hook entry still present after UninstallHooks") + } +} + +func TestInstallHooks_LocalDevWritesQuotedSubshell(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + a := &AntigravityAgent{} + if _, err := a.InstallHooks(context.Background(), true, false); err != nil { + t.Fatalf("InstallHooks: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, ".agents", "hooks.json")) + if err != nil { + t.Fatal(err) + } + var f HooksFile + if err := json.Unmarshal(data, &f); err != nil { + t.Fatalf("parse hooks.json: %v", err) + } + cmd := f["entire"].PreToolUse[0].Hooks[0].Command + // The subshell must be quoted so paths with spaces don't break shell word-splitting. + if !strings.Contains(cmd, `"$(git rev-parse --show-toplevel)"`) { + t.Errorf("localDev command missing quoted subshell: %q", cmd) + } +} + +func TestAreHooksInstalled(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + + a := &AntigravityAgent{} + + if a.AreHooksInstalled(context.Background()) { + t.Error("AreHooksInstalled() = true before install, want false") + } + + if _, err := a.InstallHooks(context.Background(), false, false); err != nil { + t.Fatalf("InstallHooks: %v", err) + } + + if !a.AreHooksInstalled(context.Background()) { + t.Error("AreHooksInstalled() = false after install, want true") + } +} diff --git a/cmd/entire/cli/agent/antigravity/lifecycle.go b/cmd/entire/cli/agent/antigravity/lifecycle.go new file mode 100644 index 0000000000..80c9afd2d3 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/lifecycle.go @@ -0,0 +1,297 @@ +package antigravity + +import ( + "context" + "encoding/json" + "io" + "path/filepath" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// Antigravity hook name constants — these become subcommands under `entire hooks antigravity`. +const ( + HookNamePreToolUse = "pre-tool-use" + HookNamePostToolUse = "post-tool-use" + HookNamePreInvocation = "pre-invocation" + HookNamePostInvocation = "post-invocation" + HookNameStop = "stop" +) + +// HookNames returns the hook verbs Antigravity supports. +// These become subcommands: entire hooks antigravity +func (a *AntigravityAgent) HookNames() []string { + return []string{ + HookNamePreToolUse, + HookNamePostToolUse, + HookNamePreInvocation, + HookNamePostInvocation, + HookNameStop, + } +} + +// ParseHookEvent translates an Antigravity hook into a normalized lifecycle Event. +// Returns nil if the hook has no lifecycle significance (e.g., post-tool-use). +func (a *AntigravityAgent) ParseHookEvent(_ context.Context, hookName string, stdin io.Reader) (*agent.Event, error) { + switch hookName { + case HookNamePreInvocation: + return parsePreInvocation(stdin) + case HookNamePostInvocation: + return parsePostInvocation(stdin) + case HookNameStop: + return parseStop(stdin) + case HookNamePreToolUse: + return parsePreToolUse(stdin) + case HookNamePostToolUse: + // PostToolUse has no lifecycle significance in v1 + return nil, nil //nolint:nilnil // nil event = no lifecycle action + default: + return nil, nil //nolint:nilnil // Unknown hooks have no lifecycle action + } +} + +// parsePreInvocation handles the PreInvocation hook. +// +// Emits TurnStart ONLY on the first model invocation of a conversation +// (invocationNum == 0). Subsequent PreInvocations within the same +// conversation return nil. +// +// Background: agy's PreInvocation fires per *model invocation*, but Entire's +// TurnStart event is designed for per-*user-prompt*. The framework's TurnStart +// handler re-captures pre-prompt state (preUntrackedFiles, attribution +// baseline) on every call. If we emit TurnStart on every PreInvocation, the +// baseline gets clobbered each time — by the time TurnEnd fires at Stop, the +// pre-state reflects the post-tool-use snapshot, and DetectFileChanges sees +// no new files compared to itself ("no files modified during session, +// skipping checkpoint"). Confirmed by agy traces showing two PreInvocations +// per single-prompt conversation. +// +// agy wire-format quirk: invocationNum is **0-indexed** despite the docs +// describing it as "the sequence number of the current model invocation" +// (which most CLI tools interpret as 1-based). Real captured stdin from +// agy 1.0.0: +// +// PreInvocation #1: {"invocationNum":0,"initialNumSteps":1,...} ← turn start +// PreInvocation #2: {"invocationNum":1,"initialNumSteps":5,...} ← follow-up +// +// (initialNumSteps is not a usable "first?" signal — agy inserts the user +// prompt as a step before the first model call, so it's already 1.) +// +// Limitation: agy resumes (agy --continue / --conversation) start with +// invocationNum > 0, so they won't fire TurnStart. If the prior session state +// was already cleaned up (FullyCondensed), the resumed turn won't be tracked +// until the user starts a fresh conversation. Tracked in deferred work. +// +// Antigravity has no SessionStart hook surface, so there is no path to display +// a "tracked by entire" banner in the agy UI for v1. AntigravityAgent +// intentionally does not implement HookResponseWriter, matching the +// Cursor/OpenCode/Copilot/Pi pattern of silent session tracking. +func parsePreInvocation(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[InvocationPayload](stdin) + if err != nil { + return nil, err + } + if raw.InvocationNum != 0 { + return nil, nil //nolint:nilnil // follow-up model invocation, not a new turn + } + return &agent.Event{ + Type: agent.TurnStart, + SessionID: raw.ConversationID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil +} + +// parsePostInvocation handles the PostInvocation hook. +// +// Returning nil instead of a TurnEnd event is deliberate: Antigravity writes +// its transcript file (~/.gemini/antigravity-cli/brain//.system_generated/logs/transcript.jsonl) +// AFTER the Stop hook fires, not before PostInvocation. Emitting TurnEnd here +// would route the event through handleLifecycleTurnEnd, which requires the +// transcript file to exist (cli/lifecycle.go fileExists check) and would +// return exit 1 — terminating agy's agent turn. +// +// SessionEnd processing on Stop (with fullyIdle=true) already handles the +// session-finalization work safely, so PostInvocation as a no-op loses +// nothing material in v1. +func parsePostInvocation(stdin io.Reader) (*agent.Event, error) { + // Decode and discard — we still validate the payload shape, just don't + // surface a lifecycle event. + if _, err := agent.ReadAndParseHookInput[InvocationPayload](stdin); err != nil { + return nil, err + } + return nil, nil //nolint:nilnil // PostInvocation has no lifecycle action in v1 (see comment above) +} + +// parseStop handles the Stop hook. +// +// Returns TurnEnd when fullyIdle=true; returns nil when background tasks are +// still running (fullyIdle=false) so the session isn't finalized prematurely. +// +// We map fullyIdle=true to TurnEnd (not SessionEnd) because the framework's +// TurnEnd handler invokes SaveStep — which increments StepCount, writes a +// checkpoint to the shadow branch, and persists FilesTouched into the per- +// session metadata. Without that, the eventual `git commit` finds no shadow +// branch for the session and the cleanup pass at listAllSessionStates removes +// the state file before any checkpoint is condensed. Mapping to SessionEnd +// would mark the session ENDED but never run SaveStep, leaving files_touched +// in a state that never produces a checkpoint commit. +// +// Antigravity's lifecycle gives us exactly one definite "model loop finished" +// moment (Stop with fullyIdle=true), so it's the right anchor for TurnEnd. +// Multi-turn agy sessions get a single TurnEnd at exit, capturing the entire +// turn's work in one checkpoint — a deliberate trade-off vs the per-prompt +// granularity other agents (Gemini, Claude) achieve via separate BeforeAgent +// /AfterAgent or UserPromptSubmit/Stop hooks. See PrepareTranscript below for +// the asynchronous-transcript handling. +func parseStop(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[StopPayload](stdin) + if err != nil { + return nil, err + } + if !raw.FullyIdle { + return nil, nil //nolint:nilnil // Background tasks running — do not end session yet + } + return &agent.Event{ + Type: agent.TurnEnd, + SessionID: raw.ConversationID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + }, nil +} + +// parsePreToolUse handles the PreToolUse hook → ToolUse for mutating tools. +// Returns nil for non-mutating tools (no lifecycle action needed). +func parsePreToolUse(stdin io.Reader) (*agent.Event, error) { + raw, err := agent.ReadAndParseHookInput[PreToolUsePayload](stdin) + if err != nil { + return nil, err + } + modifiedFiles, newFiles := extractFilesFromToolCall(&raw.ToolCall) + if modifiedFiles == nil && newFiles == nil { + return nil, nil //nolint:nilnil // Non-mutating tool — no lifecycle action + } + return &agent.Event{ + Type: agent.ToolUse, + SessionID: raw.ConversationID, + SessionRef: raw.TranscriptPath, + ModifiedFiles: modifiedFiles, + NewFiles: newFiles, + Timestamp: time.Now(), + }, nil +} + +// resolveAgySymlinks resolves symlinks for an absolute path agy sends so it +// matches the symlink-resolved worktree root the framework uses (e.g. macOS +// /tmp → /private/tmp). Without this, FilterAndNormalizePaths produces a +// "../" relative path and drops the file as "outside repo" — silently +// breaking files_touched capture. +// +// We can't EvalSymlinks the path itself because it may not exist yet +// (write_to_file is creating it). We also can't rely on EvalSymlinks of the +// immediate parent because agy can create files in *new* nested directories +// — EvalSymlinks returns an error for any missing component. So we walk up +// until we find an existing ancestor, resolve symlinks there, and reattach +// the missing tail. Returns the input unchanged if the path isn't absolute +// or no ancestor resolves. +func resolveAgySymlinks(p string) string { + if !filepath.IsAbs(p) { + return p + } + suffix := filepath.Base(p) + dir := filepath.Dir(p) + for { + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + return filepath.Join(resolved, suffix) + } + parent := filepath.Dir(dir) + if parent == dir { + return p // reached root without finding a resolvable ancestor + } + suffix = filepath.Join(filepath.Base(dir), suffix) + dir = parent + } +} + +// extractFilesFromToolCall inspects the tool call and returns the files it +// will modify or create. Both slices are nil for non-mutating tools. +// +// agy 1.0.0 wire-format quirk: every tool arg value is double-encoded as a +// JSON string containing the actual value. So instead of: +// +// {"TargetFile": "/path/to/file", "Overwrite": true} +// +// the hook actually receives: +// +// {"TargetFile": "\"/path/to/file\"", "Overwrite": "true"} +// +// This is undocumented but consistent. To stay robust against both the +// docs-shape format and the actual agy 1.0.0 format, we parse args into raw +// values and unquote/coerce on the way out. +func extractFilesFromToolCall(tc *ToolCall) (modifiedFiles, newFiles []string) { + var raw map[string]json.RawMessage + if err := json.Unmarshal(tc.Args, &raw); err != nil { + return nil, nil + } + + switch tc.Name { + case "write_to_file": + targetFile := resolveAgySymlinks(decodeAgyString(raw["TargetFile"])) + if targetFile == "" { + return nil, nil + } + if decodeAgyBool(raw["Overwrite"]) { + return []string{targetFile}, nil + } + return nil, []string{targetFile} + + case "replace_file_content", "multi_replace_file_content": + targetFile := resolveAgySymlinks(decodeAgyString(raw["TargetFile"])) + if targetFile == "" { + return nil, nil + } + return []string{targetFile}, nil + + default: + return nil, nil + } +} + +// decodeAgyString handles agy's double-encoded string args. Tries the +// docs-shape format first (a plain JSON string), then falls back to the +// agy-actual format (a JSON string whose content is itself a JSON-encoded +// string). Returns "" when neither form decodes cleanly. +func decodeAgyString(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return "" + } + // agy double-encodes — unwrap once more if the inner content is itself JSON-quoted. + var inner string + if err := json.Unmarshal([]byte(s), &inner); err == nil { + return inner + } + return s +} + +// decodeAgyBool handles agy's double-encoded bool args. Tries the docs-shape +// format (real JSON boolean) first, then the agy-actual format (string "true" +// or "false"). Returns false for any unrecognized shape. +func decodeAgyBool(raw json.RawMessage) bool { + if len(raw) == 0 { + return false + } + var b bool + if err := json.Unmarshal(raw, &b); err == nil { + return b + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s == "true" + } + return false +} diff --git a/cmd/entire/cli/agent/antigravity/lifecycle_test.go b/cmd/entire/cli/agent/antigravity/lifecycle_test.go new file mode 100644 index 0000000000..00a137afec --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/lifecycle_test.go @@ -0,0 +1,467 @@ +package antigravity + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestHookNames(t *testing.T) { + t.Parallel() + a := &AntigravityAgent{} + names := a.HookNames() + want := []string{ + HookNamePreToolUse, + HookNamePostToolUse, + HookNamePreInvocation, + HookNamePostInvocation, + HookNameStop, + } + if len(names) != len(want) { + t.Fatalf("HookNames() returned %d names, want %d: %v", len(names), len(want), names) + } + for i, n := range want { + if names[i] != n { + t.Errorf("HookNames()[%d] = %q, want %q", i, names[i], n) + } + } +} + +func TestParseHookEvent_PreInvocation_FirstInvocationEmitsTurnStart(t *testing.T) { + t.Parallel() + // Payload values mirror real agy 1.0.0 wire format captured from the agy + // binary: the first PreInvocation of a fresh conversation has + // invocationNum=0 (yes, zero — agy is 0-indexed despite the docs reading + // like 1-based) and initialNumSteps=1 (agy inserts the user prompt as + // step 0 before the first model call fires). See the comment block on + // parsePreInvocation for the captured stdin samples. + payload := InvocationPayload{ + CommonPayload: CommonPayload{ + ConversationID: testConversationID, + TranscriptPath: testTranscriptPath, + WorkspacePaths: []string{testWorkspacePath}, + }, + InvocationNum: 0, + InitialNumSteps: 1, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNamePreInvocation, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev == nil { + t.Fatal("expected non-nil event for invocationNum=0 pre-invocation") + } + if ev.Type != agent.TurnStart { + t.Errorf("Type = %v, want TurnStart", ev.Type) + } + if ev.SessionID != testConversationID { + t.Errorf("SessionID = %q, want %q", ev.SessionID, testConversationID) + } + if ev.SessionRef != testTranscriptPath { + t.Errorf("SessionRef = %q, want %q", ev.SessionRef, testTranscriptPath) + } +} + +// TestParseHookEvent_PreInvocation_FollowUpReturnsNil verifies that agy's +// per-model-call PreInvocations (invocationNum > 0) do NOT re-fire TurnStart. +// This is the bug that produced "no files modified during session, skipping +// checkpoint" in real-agy testing: each PreInvocation that emits TurnStart +// causes the framework to re-capture pre-prompt state, clobbering the +// baseline used by TurnEnd's file-diff. +// +// agy 1.0.0 ships invocationNum **0-indexed** (the first call is 0, the +// second is 1, etc.) — captured from real stdin, not from the docs which +// describe invocationNum ambiguously. The fixture +// testdata/hook_stdin_pre_invocation.json carries invocationNum=1 for this +// reason — that's a follow-up under the real-agy numbering. +func TestParseHookEvent_PreInvocation_FollowUpReturnsNil(t *testing.T) { + t.Parallel() + data, err := os.ReadFile("testdata/hook_stdin_pre_invocation.json") + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNamePreInvocation, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev != nil { + t.Errorf("expected nil event for invocationNum>1 pre-invocation, got %+v", ev) + } +} + +func TestParseHookEvent_PostInvocationReturnsNil(t *testing.T) { + t.Parallel() + // Antigravity writes its transcript AFTER Stop fires, not before + // PostInvocation. Emitting TurnEnd here would trigger a transcript-read + // in handleLifecycleTurnEnd and fail with "transcript file not found", + // terminating agy's agent turn. parsePostInvocation must return nil so + // the framework treats it as a no-op. + data, err := os.ReadFile("testdata/hook_stdin_post_invocation.json") + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNamePostInvocation, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev != nil { + t.Errorf("expected nil event for post-invocation, got %+v", ev) + } +} + +func TestParseHookEvent_Stop_FullyIdleTrueEmitsTurnEnd(t *testing.T) { + t.Parallel() + // Stop with fullyIdle=true must emit TurnEnd (not SessionEnd) so the + // framework's TurnEnd handler invokes SaveStep — which increments + // step_count, writes a checkpoint to the shadow branch, and lets the + // eventual `git commit` produce a real checkpoint on entire/checkpoints/v1. + // Emitting SessionEnd here would skip SaveStep entirely, leaving the + // session without a shadow branch and getting it garbage-collected at + // commit time. + data, err := os.ReadFile("testdata/hook_stdin_stop.json") + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNameStop, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev == nil { + t.Fatal("expected non-nil event for stop with fullyIdle=true") + } + if ev.Type != agent.TurnEnd { + t.Errorf("Type = %v, want TurnEnd", ev.Type) + } + if ev.SessionID != testConversationID { + t.Errorf("SessionID = %q, want %q", ev.SessionID, testConversationID) + } +} + +func TestParseHookEvent_Stop_FullyIdleFalseReturnsNil(t *testing.T) { + t.Parallel() + // Synthesize a stop payload with fullyIdle=false + payload := StopPayload{ + CommonPayload: CommonPayload{ + ConversationID: testConversationID, + TranscriptPath: testTranscriptPath, + WorkspacePaths: []string{testWorkspacePath}, + }, + ExecutionNum: 1, + TerminationReason: "background_tasks", + FullyIdle: false, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNameStop, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev != nil { + t.Errorf("expected nil event for stop with fullyIdle=false, got %+v", ev) + } +} + +func TestParseHookEvent_PreToolUse_WriteToFileExtractsModifiedFiles(t *testing.T) { + t.Parallel() + // Synthesize a PreToolUse payload with write_to_file (Overwrite=true → ModifiedFiles) + type writeArgs struct { + TargetFile string `json:"TargetFile"` + Overwrite bool `json:"Overwrite"` + } + argsJSON, err := json.Marshal(writeArgs{TargetFile: "src/main.go", Overwrite: true}) + if err != nil { + t.Fatal(err) + } + payload := PreToolUsePayload{ + CommonPayload: CommonPayload{ + ConversationID: testConversationID, + TranscriptPath: testTranscriptPath, + WorkspacePaths: []string{testWorkspacePath}, + }, + ToolCall: ToolCall{ + Name: "write_to_file", + Args: json.RawMessage(argsJSON), + }, + StepIdx: 1, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNamePreToolUse, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev == nil { + t.Fatal("expected non-nil event for write_to_file tool") + } + if ev.Type != agent.ToolUse { + t.Errorf("Type = %v, want ToolUse", ev.Type) + } + if len(ev.ModifiedFiles) != 1 || ev.ModifiedFiles[0] != "src/main.go" { + t.Errorf("ModifiedFiles = %v, want [src/main.go]", ev.ModifiedFiles) + } + if len(ev.NewFiles) != 0 { + t.Errorf("NewFiles = %v, want empty (Overwrite=true → ModifiedFiles)", ev.NewFiles) + } +} + +func TestParseHookEvent_PreToolUse_WriteToFileNewFile(t *testing.T) { + t.Parallel() + // Overwrite=false → NewFiles + type writeArgs struct { + TargetFile string `json:"TargetFile"` + Overwrite bool `json:"Overwrite"` + } + argsJSON, err := json.Marshal(writeArgs{TargetFile: "src/new.go", Overwrite: false}) + if err != nil { + t.Fatal(err) + } + payload := PreToolUsePayload{ + CommonPayload: CommonPayload{ + ConversationID: testConversationID, + TranscriptPath: testTranscriptPath, + WorkspacePaths: []string{testWorkspacePath}, + }, + ToolCall: ToolCall{ + Name: "write_to_file", + Args: json.RawMessage(argsJSON), + }, + StepIdx: 2, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNamePreToolUse, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev == nil { + t.Fatal("expected non-nil event for write_to_file (new file)") + } + if ev.Type != agent.ToolUse { + t.Errorf("Type = %v, want ToolUse", ev.Type) + } + if len(ev.NewFiles) != 1 || ev.NewFiles[0] != "src/new.go" { + t.Errorf("NewFiles = %v, want [src/new.go]", ev.NewFiles) + } + if len(ev.ModifiedFiles) != 0 { + t.Errorf("ModifiedFiles = %v, want empty (Overwrite=false → NewFiles)", ev.ModifiedFiles) + } +} + +func TestParseHookEvent_PreToolUse_ReplaceFileContent(t *testing.T) { + t.Parallel() + type replaceArgs struct { + TargetFile string `json:"TargetFile"` + } + argsJSON, err := json.Marshal(replaceArgs{TargetFile: "src/foo.go"}) + if err != nil { + t.Fatal(err) + } + payload := PreToolUsePayload{ + CommonPayload: CommonPayload{ + ConversationID: testConversationID, + TranscriptPath: testTranscriptPath, + }, + ToolCall: ToolCall{Name: "replace_file_content", Args: json.RawMessage(argsJSON)}, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNamePreToolUse, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev == nil { + t.Fatal("expected non-nil event for replace_file_content") + } + if ev.Type != agent.ToolUse { + t.Errorf("Type = %v, want ToolUse", ev.Type) + } + if len(ev.ModifiedFiles) != 1 || ev.ModifiedFiles[0] != "src/foo.go" { + t.Errorf("ModifiedFiles = %v, want [src/foo.go]", ev.ModifiedFiles) + } +} + +func TestParseHookEvent_PreToolUse_NonMutatingToolReturnsNil(t *testing.T) { + t.Parallel() + // Use the testdata fixture which uses run_command (non-mutating) + data, err := os.ReadFile("testdata/hook_stdin_pre_tool_use.json") + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNamePreToolUse, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev != nil { + t.Errorf("expected nil event for non-mutating tool run_command, got %+v", ev) + } +} + +// TestParseHookEvent_PreToolUse_AgyDoubleEncodedArgs verifies the file +// extraction tolerates agy 1.0.0's quirky wire format where every tool arg +// value is itself a JSON-encoded string. Without this resilience, the +// json.Unmarshal of the args struct fails silently on the Overwrite type +// mismatch (string "true" vs Go bool), TargetFile stays empty, and no +// ToolUse event is emitted — meaning no files_touched gets recorded and +// no checkpoint is created when the user commits. This is the bug that +// agy smoke testing surfaced. +// TestResolveAgySymlinks_ParentSymlink verifies the symlink-resolution helper +// handles macOS-style /tmp → /private/tmp parent-dir symlinks. This is the +// exact bug that broke files_touched capture on macOS: agy sends paths under +// /tmp/foo/bar.md, but paths.WorktreeRoot returns /private/tmp/foo, and the +// framework's filepath.Rel against the unresolved path yields ../../tmp/... +// which gets filtered as "outside repo". +func TestResolveAgySymlinks_ParentSymlink(t *testing.T) { + t.Parallel() + // Set up a directory and a symlink that points to it. + realDir := t.TempDir() + linkDir := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(realDir, linkDir); err != nil { + t.Fatalf("create symlink: %v", err) + } + // File doesn't need to exist (write_to_file is creating it). + through := filepath.Join(linkDir, "new.txt") + resolved := resolveAgySymlinks(through) + // On macOS, t.TempDir() returns /var/folders/... which itself is a symlink + // to /private/var/folders/.... EvalSymlinks will resolve both layers, so + // the want value must also be resolved for a fair comparison. + wantParent, err := filepath.EvalSymlinks(realDir) + if err != nil { + t.Fatalf("EvalSymlinks(realDir): %v", err) + } + want := filepath.Join(wantParent, "new.txt") + if resolved != want { + t.Errorf("resolveAgySymlinks(%q) = %q, want %q", through, resolved, want) + } +} + +func TestResolveAgySymlinks_RelativePathUnchanged(t *testing.T) { + t.Parallel() + if got := resolveAgySymlinks("foo/bar.txt"); got != "foo/bar.txt" { + t.Errorf("relative path should pass through unchanged, got %q", got) + } +} + +// TestResolveAgySymlinks_NewNestedDirectory verifies the symlink resolver +// walks up to the deepest existing ancestor when agy's write_to_file is +// creating both a new directory AND a file inside it. The original +// implementation only EvalSymlinks'd the immediate parent and failed +// (lstat: no such file or directory), silently returning the unresolved +// path — which would then be filtered as "outside repo" on macOS due to +// the /tmp → /private/tmp symlink. +func TestResolveAgySymlinks_NewNestedDirectory(t *testing.T) { + t.Parallel() + realDir := t.TempDir() + linkDir := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(realDir, linkDir); err != nil { + t.Fatalf("create symlink: %v", err) + } + // Path: symlinked-dir// + // Both newdir and file.txt do not exist; resolver must walk up to + // linkDir, resolve the symlink, then reattach newdir/file.txt. + through := filepath.Join(linkDir, "newdir", "file.txt") + resolved := resolveAgySymlinks(through) + + wantParent, err := filepath.EvalSymlinks(realDir) + if err != nil { + t.Fatalf("EvalSymlinks(realDir): %v", err) + } + want := filepath.Join(wantParent, "newdir", "file.txt") + if resolved != want { + t.Errorf("resolveAgySymlinks(%q) = %q, want %q", through, resolved, want) + } +} + +// TestResolveAgySymlinks_NoExistingAncestor verifies the resolver returns +// the input unchanged when no ancestor of the path exists at all (root is +// reached without finding a resolvable directory). This is the only path +// where the function gives up; the test pins that behavior so we don't +// accidentally return "" or a partially-resolved bogus path. +func TestResolveAgySymlinks_NoExistingAncestor(t *testing.T) { + t.Parallel() + // /// — extremely unlikely to exist. + p := "/nonexistent-prefix-" + filepath.Base(t.TempDir()) + "/a/b/c.txt" + if got := resolveAgySymlinks(p); got != p { + t.Errorf("expected unchanged input %q, got %q", p, got) + } +} + +func TestParseHookEvent_PreToolUse_AgyDoubleEncodedArgs(t *testing.T) { + t.Parallel() + // Reproduce agy's actual wire format exactly: + // "TargetFile": "\"/tmp/hello.txt\"", ← string-containing-JSON-string + // "Overwrite": "true", ← string instead of bool + argsRaw := []byte(`{"TargetFile":"\"hello.txt\"","Overwrite":"true","CodeContent":"\"hi\""}`) + payload := PreToolUsePayload{ + CommonPayload: CommonPayload{ + ConversationID: testConversationID, + TranscriptPath: testTranscriptPath, + WorkspacePaths: []string{testWorkspacePath}, + }, + ToolCall: ToolCall{Name: "write_to_file", Args: json.RawMessage(argsRaw)}, + StepIdx: 1, + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNamePreToolUse, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev == nil { + t.Fatal("expected non-nil event for write_to_file with agy-double-encoded args; got nil — file extraction silently failed") + } + if ev.Type != agent.ToolUse { + t.Errorf("Type = %v, want ToolUse", ev.Type) + } + // Overwrite=true (as string) → file goes into ModifiedFiles, not NewFiles + if len(ev.ModifiedFiles) != 1 || ev.ModifiedFiles[0] != "hello.txt" { + t.Errorf("ModifiedFiles = %v, want [hello.txt]", ev.ModifiedFiles) + } + if len(ev.NewFiles) != 0 { + t.Errorf("NewFiles = %v, want empty (Overwrite=true)", ev.NewFiles) + } +} + +func TestParseHookEvent_PostToolUseReturnsNil(t *testing.T) { + t.Parallel() + data, err := os.ReadFile("testdata/hook_stdin_post_tool_use.json") + if err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + ev, err := a.ParseHookEvent(context.Background(), HookNamePostToolUse, bytes.NewReader(data)) + if err != nil { + t.Fatalf("ParseHookEvent: %v", err) + } + if ev != nil { + t.Errorf("expected nil event for post-tool-use, got %+v", ev) + } +} diff --git a/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_post_invocation.json b/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_post_invocation.json new file mode 100644 index 0000000000..e51576cc37 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_post_invocation.json @@ -0,0 +1,8 @@ +{ + "invocationNum": 4, + "initialNumSteps": 12, + "conversationId": "ec33ebf9-0cba-4100-8142-c61503f6c587", + "workspacePaths": ["/workspace/project"], + "transcriptPath": "/workspace/project/.gemini/jetski/transcript.jsonl", + "artifactDirectoryPath": "/workspace/project/.gemini/jetski/artifacts" +} diff --git a/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_post_tool_use.json b/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_post_tool_use.json new file mode 100644 index 0000000000..47b0577bed --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_post_tool_use.json @@ -0,0 +1,8 @@ +{ + "stepIdx": 5, + "error": "exit status 1", + "conversationId": "ec33ebf9-0cba-4100-8142-c61503f6c587", + "workspacePaths": ["/workspace/project"], + "transcriptPath": "/workspace/project/.gemini/jetski/transcript.jsonl", + "artifactDirectoryPath": "/workspace/project/.gemini/jetski/artifacts" +} diff --git a/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_pre_invocation.json b/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_pre_invocation.json new file mode 100644 index 0000000000..3d3230d4e9 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_pre_invocation.json @@ -0,0 +1,8 @@ +{ + "invocationNum": 1, + "initialNumSteps": 5, + "conversationId": "ec33ebf9-0cba-4100-8142-c61503f6c587", + "workspacePaths": ["/workspace/project"], + "transcriptPath": "/workspace/project/.gemini/jetski/transcript.jsonl", + "artifactDirectoryPath": "/workspace/project/.gemini/jetski/artifacts" +} diff --git a/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_pre_tool_use.json b/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_pre_tool_use.json new file mode 100644 index 0000000000..784c013522 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_pre_tool_use.json @@ -0,0 +1,15 @@ +{ + "toolCall": { + "name": "run_command", + "args": { + "CommandLine": "npm test", + "Cwd": "/workspace/project", + "WaitMsBeforeAsync": 5000 + } + }, + "stepIdx": 19, + "conversationId": "ec33ebf9-0cba-4100-8142-c61503f6c587", + "workspacePaths": ["/workspace/project"], + "transcriptPath": "/workspace/project/.gemini/jetski/transcript.jsonl", + "artifactDirectoryPath": "/workspace/project/.gemini/jetski/artifacts" +} diff --git a/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_stop.json b/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_stop.json new file mode 100644 index 0000000000..e645a2de2b --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/testdata/hook_stdin_stop.json @@ -0,0 +1,10 @@ +{ + "executionNum": 1, + "terminationReason": "model_stop", + "error": "", + "fullyIdle": true, + "conversationId": "ec33ebf9-0cba-4100-8142-c61503f6c587", + "workspacePaths": ["/workspace/project"], + "transcriptPath": "/workspace/project/.gemini/jetski/transcript.jsonl", + "artifactDirectoryPath": "/workspace/project/.gemini/jetski/artifacts" +} diff --git a/cmd/entire/cli/agent/antigravity/testdata/transcript_sample.jsonl b/cmd/entire/cli/agent/antigravity/testdata/transcript_sample.jsonl new file mode 100644 index 0000000000..6dadc4b85f --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/testdata/transcript_sample.jsonl @@ -0,0 +1,13 @@ +{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-05-20T23:46:29Z","content":"\nread a.txt and tell me what it says, then exit\n\n\nThe current local time is: 2026-05-20T19:46:29-04:00.\n\n\nThe user changed setting `Model Selection` from None to Gemini 3.5 Flash (High). No need to comment on this change if the user doesn't ask about it. If reporting what model you are, please use a human readable name instead of the exact string.\n"} +{"step_index":1,"source":"SYSTEM","type":"CONVERSATION_HISTORY","status":"DONE","created_at":"2026-05-20T23:46:29Z"} +{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-20T23:46:29Z","tool_calls":[{"name":"list_dir","args":{"DirectoryPath":"\"/Users/peytonmontei/.gemini/antigravity-cli/scratch\"","toolAction":"\"Listing the scratch directory\"","toolSummary":"\"Directory listing\""}}]} +{"step_index":3,"source":"MODEL","type":"LIST_DIRECTORY","status":"DONE","created_at":"2026-05-20T23:46:31Z","content":"Created At: 2026-05-20T23:46:31Z\nCompleted At: 2026-05-20T23:46:31Z\nEmpty directory"} +{"step_index":5,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-20T23:46:33Z","thinking":"**Investigating File Locations**\n\nThe `/Users/peytonmontei/.gemini/antigravity-cli/scratch` directory is empty. I'm expanding the search, now examining the home directory and checking for `a.txt` within `/Users/peytonmontei/.gemini/antigravity-cli`.\n\n\n","tool_calls":[{"name":"run_command","args":{"CommandLine":"\"find . -name \\\"a.txt\\\" -maxdepth 3\"","Cwd":"\"/Users/peytonmontei/.gemini/antigravity-cli/scratch\"","WaitMsBeforeAsync":"2000","toolAction":"\"Searching for a.txt\"","toolSummary":"\"File search\""}}]} +{"step_index":6,"source":"MODEL","type":"RUN_COMMAND","status":"DONE","created_at":"2026-05-20T23:46:34Z","content":"Created At: 2026-05-20T23:46:34Z\nCompleted At: 2026-05-20T23:46:34Z\n\n\t\t\t\tThe command completed successfully.\n\t\t\tStdout:\n\t\t\t\n\t\t\tStderr:\n\t\t\t\n"} +{"step_index":7,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-20T23:46:34Z","tool_calls":[{"name":"list_dir","args":{"DirectoryPath":"\"/Users/peytonmontei/.gemini/antigravity-cli\"","toolAction":"\"Listing the app data directory\"","toolSummary":"\"Directory listing\""}}]} +{"step_index":8,"source":"MODEL","type":"LIST_DIRECTORY","status":"ERROR","created_at":"2026-05-20T23:46:34Z","content":"Created At: 2026-05-20T23:46:34Z\nCompleted At: 2026-05-20T23:46:34Z\nEncountered error in step execution: Permission denied for read_file(/Users/peytonmontei/.gemini/antigravity-cli). Matches hardcoded system protection boundary rule."} +{"step_index":9,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-20T23:46:34Z","tool_calls":[{"name":"list_permissions","args":{"toolAction":"\"Listing current permissions\"","toolSummary":"\"Permission listing\""}}]} +{"step_index":10,"source":"MODEL","type":"GENERIC","status":"DONE","created_at":"2026-05-20T23:46:35Z","content":"Created At: 2026-05-20T23:46:35Z\nCompleted At: 2026-05-20T23:46:35Z\nYour current permission grants (listed in the order they are checked) are:\n- command(*): allowed\n- execute_url(*): allowed\n- read_url(*): allowed\n- mcp(*): allowed\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/scratch): allowed\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/scratch): allowed\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/browser_recordings): allowed\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/browser_recordings): allowed\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/html_artifacts): allowed\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/html_artifacts): allowed\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/knowledge): allowed\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/knowledge): allowed\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/worktrees): allowed\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/worktrees): allowed\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/mcp): allowed\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/mcp): denied\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/mcp_config.json): ask\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/mcp_config.json): ask\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/skills): allowed\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/skills): ask\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/conversations): ask\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/conversations): denied\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli/mcp_config.json): ask\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli/mcp_config.json): ask\n- read_file(/Users/peytonmontei/.gemini/config/skills): allowed\n- write_file(/Users/peytonmontei/.gemini/config/skills): ask\n- read_file(/Users/peytonmontei/.gemini/config/plugins): allowed\n- write_file(/Users/peytonmontei/.gemini/config/plugins): ask\n- read_file(/Users/peytonmontei/.gemini/config/agents): allowed\n- write_file(/Users/peytonmontei/.gemini/config/agents): ask\n- read_file(/Users/peytonmontei/.gemini/config/hooks.json): ask\n- write_file(/Users/peytonmontei/.gemini/config/hooks.json): ask\n- read_file(/Users/peytonmontei/.gemini/config/sidecars): allowed\n- write_file(/Users/peytonmontei/.gemini/config/sidecars): ask\n- read_file(/Users/peytonmontei/.gemini/config/projects): allowed\n- write_file(/Users/peytonmontei/.gemini/config/projects): denied\n- read_file(/Users/peytonmontei/.gemini/config/config.json): denied\n- write_file(/Users/peytonmontei/.gemini/config/config.json): denied\n- read_file(.env): ask\n- write_file(.env): ask\n- read_file(.env.local): ask\n- write_file(.env.local): ask\n- read_file(.env.production): ask\n- write_file(.env.production): ask\n- read_file(.env.development): ask\n- write_file(.env.development): ask\n- read_file(.env.staging): ask\n- write_file(.env.staging): ask\n- read_file(.npmrc): ask\n- write_file(.npmrc): ask\n- read_file(.pypirc): ask\n- write_file(.pypirc): ask\n- read_file(.netrc): ask\n- write_file(.netrc): ask\n- read_file(.git-credentials): ask\n- write_file(.git-credentials): ask\n- read_file(/Users/peytonmontei/.gemini/antigravity-cli): denied\n- write_file(/Users/peytonmontei/.gemini/antigravity-cli): denied\n- read_file(/Users/peytonmontei/.gemini/config): denied\n- write_file(/Users/peytonmontei/.gemini/config): denied\n- command(echo): allowed\n- command(date): allowed\n"} +{"step_index":11,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-20T23:46:35Z","thinking":"**Investigating File Permissions**\n\nOkay, so I've confirmed that I have read/write access to a specific file path, but the directory containing that file is showing as denied. That's a bit of a head-scratcher. Now, I'm trying to figure out where the `a.txt` file exists. Let's start with a search.\n\n\n","tool_calls":[{"name":"run_command","args":{"CommandLine":"\"find ~ -name \\\"a.txt\\\" -not -path '*/.*' 2>/dev/null\"","Cwd":"\"/Users/peytonmontei/.gemini/antigravity-cli/scratch\"","WaitMsBeforeAsync":"3000","toolAction":"\"Locating a.txt on the system\"","toolSummary":"\"System-wide search\""}}]} +{"step_index":12,"source":"MODEL","type":"RUN_COMMAND","status":"RUNNING","created_at":"2026-05-20T23:46:36Z","content":"Created At: 2026-05-20T23:46:36Z\nTool is running as a background task with task id: fd874856-8453-4f63-be51-35196f6d87f5/task-12\nTask Description: find ~ -name \"a.txt\" -not -path '*/.*' 2>/dev/null\nTask logs are available at: file:///Users/peytonmontei/.gemini/antigravity-cli/brain/fd874856-8453-4f63-be51-35196f6d87f5/.system_generated/tasks/task-12.log"} +{"step_index":13,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-05-20T23:46:39Z","content":"An asynchronous background search has been started to locate `a.txt` on your filesystem. I will wait for it to complete."} diff --git a/cmd/entire/cli/agent/antigravity/transcript.go b/cmd/entire/cli/agent/antigravity/transcript.go new file mode 100644 index 0000000000..71688994bd --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/transcript.go @@ -0,0 +1,82 @@ +package antigravity + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +// Antigravity 2.0 (agy) writes JSONL transcripts at +// ~/.gemini/antigravity-cli/brain//.system_generated/logs/transcript.jsonl +// The on-disk schema is a sequence of "step" objects: +// { +// "step_index": int, +// "source": "USER_EXPLICIT" | "SYSTEM" | "MODEL" | ..., +// "type": "USER_INPUT" | "CONVERSATION_HISTORY" | "PLANNER_RESPONSE" | ..., +// "status": "DONE" | ..., +// "created_at": RFC3339 timestamp, +// "content": string (optional — user request / model text), +// "tool_calls": [ { "name": string, "args": object } ] (optional) +// } +// v1 ships only the JSONL chunk/reassemble passthrough; field-aware decoding +// (token counting, file-change replay, prompt extraction) is deferred to a +// follow-up plan. See testdata/transcript_sample.jsonl for a captured fixture. + +func (a *AntigravityAgent) ReadTranscript(sessionRef string) ([]byte, error) { + data, err := os.ReadFile(sessionRef) //nolint:gosec // path supplied by agent hook stdin + if err != nil { + return nil, fmt.Errorf("antigravity: read transcript: %w", err) + } + return data, nil +} + +func (a *AntigravityAgent) ChunkTranscript(_ context.Context, content []byte, maxSize int) ([][]byte, error) { + chunks, err := agent.ChunkJSONL(content, maxSize) + if err != nil { + return nil, fmt.Errorf("antigravity: chunk transcript: %w", err) + } + return chunks, nil +} + +func (a *AntigravityAgent) ReassembleTranscript(chunks [][]byte) ([]byte, error) { + return agent.ReassembleJSONL(chunks), nil +} + +// PrepareTranscript implements the optional TranscriptPreparer interface. The +// framework calls this in handleLifecycleTurnEnd BEFORE its fileExists check +// — so we use it to handle agy's asynchronous transcript write. +// +// Background: agy writes its transcript file at +// +// ~/.gemini/antigravity-cli/brain//.system_generated/logs/transcript.jsonl +// +// AFTER the Stop hook fires (sometimes seconds later, depending on session +// shutdown timing). Our TurnEnd event maps to Stop, so we routinely race the +// transcript write. Without PrepareTranscript, the framework's fileExists +// check fails with "transcript file not found" and our hook returns exit 1, +// terminating agy's agent turn. +// +// We materialise an empty placeholder if the file is missing. files_touched +// is already captured via the PreToolUse hook (independent of transcript +// content), so condensation can still produce a meaningful checkpoint from +// an empty transcript. Full token-usage + prompt-extraction decoding is +// deferred (see file header) and would benefit from the real transcript once +// agy has finished writing it. +func (a *AntigravityAgent) PrepareTranscript(_ context.Context, transcriptRef string) error { + if transcriptRef == "" { + return nil + } + if _, err := os.Stat(transcriptRef); err == nil { + return nil // already present, nothing to do + } + if err := os.MkdirAll(filepath.Dir(transcriptRef), 0o750); err != nil { + return fmt.Errorf("antigravity: prepare transcript dir: %w", err) + } + if err := os.WriteFile(transcriptRef, []byte{}, 0o600); err != nil { + return fmt.Errorf("antigravity: create empty transcript placeholder: %w", err) + } + return nil +} diff --git a/cmd/entire/cli/agent/antigravity/transcript_test.go b/cmd/entire/cli/agent/antigravity/transcript_test.go new file mode 100644 index 0000000000..44744c87b3 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/transcript_test.go @@ -0,0 +1,84 @@ +package antigravity + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" +) + +func TestChunkAndReassemble_RoundTrip(t *testing.T) { + t.Parallel() + a := &AntigravityAgent{} + original := []byte(`{"role":"user","content":"hi"}` + "\n" + `{"role":"assistant","content":"hello"}` + "\n") + chunks, err := a.ChunkTranscript(context.Background(), original, 1024) + if err != nil { + t.Fatal(err) + } + out, err := a.ReassembleTranscript(chunks) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(out, original) { + t.Errorf("round-trip mismatch:\n in: %q\n out: %q", original, out) + } +} + +// TestPrepareTranscript_AbsentFileCreatesPlaceholder verifies the +// TranscriptPreparer creates an empty file when agy hasn't flushed its +// transcript yet (the common case at Stop hook time). Without this, the +// framework's fileExists check in handleLifecycleTurnEnd would fail and our +// hook would exit non-zero, aborting agy's turn. +func TestPrepareTranscript_AbsentFileCreatesPlaceholder(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Path with non-existent parent dirs to also exercise MkdirAll + path := filepath.Join(dir, ".gemini", "antigravity-cli", "brain", "conv", "logs", "t.jsonl") + a := &AntigravityAgent{} + if err := a.PrepareTranscript(context.Background(), path); err != nil { + t.Fatalf("PrepareTranscript: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("placeholder not created: %v", err) + } + if info.Size() != 0 { + t.Errorf("placeholder size = %d, want 0 (empty)", info.Size()) + } +} + +// TestPrepareTranscript_PresentFilePreserved verifies PrepareTranscript leaves +// an already-written transcript untouched. This is the case when agy's writer +// races ahead of the Stop hook. +func TestPrepareTranscript_PresentFilePreserved(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "t.jsonl") + original := []byte(`{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE"}` + "\n") + if err := os.WriteFile(path, original, 0o600); err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + if err := a.PrepareTranscript(context.Background(), path); err != nil { + t.Fatalf("PrepareTranscript: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Errorf("PrepareTranscript should not overwrite an existing transcript\n before: %q\n after: %q", original, got) + } +} + +// TestPrepareTranscript_EmptyRefIsNoOp verifies an empty transcript path is +// a graceful no-op (defensive — the framework probably never passes empty, +// but agents have been bitten by empty refs in the past). +func TestPrepareTranscript_EmptyRefIsNoOp(t *testing.T) { + t.Parallel() + a := &AntigravityAgent{} + if err := a.PrepareTranscript(context.Background(), ""); err != nil { + t.Errorf("PrepareTranscript(\"\") should not error, got %v", err) + } +} diff --git a/cmd/entire/cli/agent/antigravity/types.go b/cmd/entire/cli/agent/antigravity/types.go new file mode 100644 index 0000000000..1d26ef1ea8 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/types.go @@ -0,0 +1,115 @@ +package antigravity + +import "encoding/json" + +// HooksFile maps hook names to their event configurations. +// The top-level key is the hook name (user-defined, e.g. "my-linter-hook"). +type HooksFile = map[string]HookConfig + +// HookConfig defines the event handlers for a named hook entry. +type HookConfig struct { + Enabled *bool `json:"enabled,omitempty"` + PreToolUse []ToolHandler `json:"PreToolUse,omitempty"` + PostToolUse []ToolHandler `json:"PostToolUse,omitempty"` + PreInvocation []SimpleHandler `json:"PreInvocation,omitempty"` + PostInvocation []SimpleHandler `json:"PostInvocation,omitempty"` + Stop []SimpleHandler `json:"Stop,omitempty"` +} + +// ToolHandler is a matcher + handlers entry used for PreToolUse / PostToolUse. +type ToolHandler struct { + Matcher string `json:"matcher,omitempty"` + Hooks []HookCommand `json:"hooks,omitempty"` +} + +// SimpleHandler is a direct handler entry used for PreInvocation, PostInvocation, and Stop. +type SimpleHandler struct { + Type string `json:"type,omitempty"` + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// HookCommand is a single executable hook command. +type HookCommand struct { + Type string `json:"type,omitempty"` + Command string `json:"command"` + Timeout int `json:"timeout,omitempty"` +} + +// CommonPayload contains system metadata fields present in all hook payloads. +type CommonPayload struct { + ConversationID string `json:"conversationId"` + WorkspacePaths []string `json:"workspacePaths"` + TranscriptPath string `json:"transcriptPath"` + ArtifactDirectoryPath string `json:"artifactDirectoryPath"` +} + +// ToolCall represents a proposed or completed tool invocation. +type ToolCall struct { + Name string `json:"name"` + Args json.RawMessage `json:"args"` +} + +// PreToolUsePayload is the stdin payload for the PreToolUse hook. +type PreToolUsePayload struct { + CommonPayload + + ToolCall ToolCall `json:"toolCall"` + StepIdx int `json:"stepIdx"` +} + +// PreToolUseOutput is the stdout response for the PreToolUse hook. +type PreToolUseOutput struct { + Decision string `json:"decision"` // Required: "allow", "deny", "ask", "force_ask" + Reason string `json:"reason,omitempty"` + PermissionOverrides []string `json:"permissionOverrides,omitempty"` +} + +// PostToolUsePayload is the stdin payload for the PostToolUse hook. +type PostToolUsePayload struct { + CommonPayload + + StepIdx int `json:"stepIdx"` + Error string `json:"error,omitempty"` +} + +// InvocationPayload is the stdin payload for PreInvocation and PostInvocation hooks. +type InvocationPayload struct { + CommonPayload + + InvocationNum int `json:"invocationNum"` + InitialNumSteps int `json:"initialNumSteps"` +} + +// PostInvocationPayload is an alias for InvocationPayload; both events share the same input shape. +type PostInvocationPayload = InvocationPayload + +// InvocationOutput is the stdout response for PreInvocation and PostInvocation hooks. +type InvocationOutput struct { + InjectSteps []InjectStep `json:"injectSteps,omitempty"` + TerminationBehavior string `json:"terminationBehavior,omitempty"` // PostInvocation only +} + +// InjectStep is a step injected into the conversation trajectory. +// Exactly one of ToolCall, UserMessage, or EphemeralMessage should be set. +type InjectStep struct { + ToolCall *ToolCall `json:"toolCall,omitempty"` + UserMessage string `json:"userMessage,omitempty"` + EphemeralMessage string `json:"ephemeralMessage,omitempty"` +} + +// StopPayload is the stdin payload for the Stop hook. +type StopPayload struct { + CommonPayload + + ExecutionNum int `json:"executionNum"` + TerminationReason string `json:"terminationReason"` + Error string `json:"error,omitempty"` + FullyIdle bool `json:"fullyIdle"` // Required +} + +// StopOutput is the stdout response for the Stop hook. +type StopOutput struct { + Decision string `json:"decision"` // Required: "continue" to re-enter the loop; any other value allows stop + Reason string `json:"reason,omitempty"` +} diff --git a/cmd/entire/cli/agent/antigravity/types_test.go b/cmd/entire/cli/agent/antigravity/types_test.go new file mode 100644 index 0000000000..a8de1117d3 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/types_test.go @@ -0,0 +1,143 @@ +package antigravity + +import ( + "encoding/json" + "os" + "testing" +) + +const ( + testConversationID = "ec33ebf9-0cba-4100-8142-c61503f6c587" + testTranscriptPath = "/workspace/project/.gemini/jetski/transcript.jsonl" + testWorkspacePath = "/workspace/project" +) + +func TestParsePreToolUsePayload(t *testing.T) { + t.Parallel() + data, err := os.ReadFile("testdata/hook_stdin_pre_tool_use.json") + if err != nil { + t.Fatal(err) + } + var p PreToolUsePayload + if err := json.Unmarshal(data, &p); err != nil { + t.Fatalf("unmarshal PreToolUsePayload: %v", err) + } + if p.ConversationID != testConversationID { + t.Errorf("ConversationID = %q", p.ConversationID) + } + if p.ToolCall.Name != "run_command" { + t.Errorf("ToolCall.Name = %q", p.ToolCall.Name) + } + if p.StepIdx != 19 { + t.Errorf("StepIdx = %d", p.StepIdx) + } + if p.TranscriptPath != testTranscriptPath { + t.Errorf("TranscriptPath = %q", p.TranscriptPath) + } + if len(p.WorkspacePaths) != 1 || p.WorkspacePaths[0] != testWorkspacePath { + t.Errorf("WorkspacePaths = %v", p.WorkspacePaths) + } +} + +func TestParsePostToolUsePayload(t *testing.T) { + t.Parallel() + data, err := os.ReadFile("testdata/hook_stdin_post_tool_use.json") + if err != nil { + t.Fatal(err) + } + var p PostToolUsePayload + if err := json.Unmarshal(data, &p); err != nil { + t.Fatalf("unmarshal PostToolUsePayload: %v", err) + } + if p.ConversationID != testConversationID { + t.Errorf("ConversationID = %q", p.ConversationID) + } + if p.StepIdx != 5 { + t.Errorf("StepIdx = %d", p.StepIdx) + } + if p.Error != "exit status 1" { + t.Errorf("Error = %q", p.Error) + } + if p.TranscriptPath != testTranscriptPath { + t.Errorf("TranscriptPath = %q", p.TranscriptPath) + } +} + +func TestParsePreInvocationPayload(t *testing.T) { + t.Parallel() + data, err := os.ReadFile("testdata/hook_stdin_pre_invocation.json") + if err != nil { + t.Fatal(err) + } + var p InvocationPayload + if err := json.Unmarshal(data, &p); err != nil { + t.Fatalf("unmarshal InvocationPayload (PreInvocation): %v", err) + } + if p.ConversationID != testConversationID { + t.Errorf("ConversationID = %q", p.ConversationID) + } + // Fixture mirrors a real agy 1.0.0 follow-up PreInvocation: invocationNum=1 + // (0-indexed in agy's wire format despite docs reading like 1-based) and + // initialNumSteps=5 (the user prompt as step 0 plus four steps added by + // the first model invocation). See parsePreInvocation comment block. + if p.InvocationNum != 1 { + t.Errorf("InvocationNum = %d", p.InvocationNum) + } + if p.InitialNumSteps != 5 { + t.Errorf("InitialNumSteps = %d", p.InitialNumSteps) + } + if p.TranscriptPath != testTranscriptPath { + t.Errorf("TranscriptPath = %q", p.TranscriptPath) + } +} + +func TestParsePostInvocationPayload(t *testing.T) { + t.Parallel() + data, err := os.ReadFile("testdata/hook_stdin_post_invocation.json") + if err != nil { + t.Fatal(err) + } + var p PostInvocationPayload + if err := json.Unmarshal(data, &p); err != nil { + t.Fatalf("unmarshal PostInvocationPayload: %v", err) + } + if p.ConversationID != testConversationID { + t.Errorf("ConversationID = %q", p.ConversationID) + } + if p.InvocationNum != 4 { + t.Errorf("InvocationNum = %d", p.InvocationNum) + } + if p.InitialNumSteps != 12 { + t.Errorf("InitialNumSteps = %d", p.InitialNumSteps) + } +} + +func TestParseStopPayload(t *testing.T) { + t.Parallel() + data, err := os.ReadFile("testdata/hook_stdin_stop.json") + if err != nil { + t.Fatal(err) + } + var p StopPayload + if err := json.Unmarshal(data, &p); err != nil { + t.Fatalf("unmarshal StopPayload: %v", err) + } + if p.ConversationID != testConversationID { + t.Errorf("ConversationID = %q", p.ConversationID) + } + if p.ExecutionNum != 1 { + t.Errorf("ExecutionNum = %d", p.ExecutionNum) + } + if p.TerminationReason != "model_stop" { + t.Errorf("TerminationReason = %q", p.TerminationReason) + } + if !p.FullyIdle { + t.Error("FullyIdle should be true") + } + if p.Error != "" { + t.Errorf("Error = %q, want empty", p.Error) + } + if p.TranscriptPath != testTranscriptPath { + t.Errorf("TranscriptPath = %q", p.TranscriptPath) + } +} diff --git a/cmd/entire/cli/agent/generate_external_test.go b/cmd/entire/cli/agent/generate_external_test.go index 9c42b1d9ca..3cfd33dcbd 100644 --- a/cmd/entire/cli/agent/generate_external_test.go +++ b/cmd/entire/cli/agent/generate_external_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/antigravity" _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" "github.com/entireio/cli/cmd/entire/cli/agent/codex" "github.com/entireio/cli/cmd/entire/cli/agent/copilotcli" @@ -67,6 +68,18 @@ func TestGenerateText_PromptViaStdin(t *testing.T) { } }, }, + { + name: "antigravity", + agent: &antigravity.AntigravityAgent{}, + requiredFlags: []string{"-p"}, + extraCheck: func(t *testing.T, args []string) { + t.Helper() + pIdx := slices.Index(args, "-p") + if pIdx < 0 || pIdx+1 >= len(args) || args[pIdx+1] != " " { + t.Fatalf("expected -p followed by space placeholder, got %v", args) + } + }, + }, } for _, tt := range tests { @@ -112,6 +125,8 @@ func setRunner(tg agent.TextGenerator, runner agent.TextCommandRunner) { a.CommandRunner = runner case *geminicli.GeminiCLIAgent: a.CommandRunner = runner + case *antigravity.AntigravityAgent: + a.CommandRunner = runner } } diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index ae417665d7..10f346b751 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -165,6 +165,7 @@ func pathHasDirPrefix(path, dir string) bool { // Agent name constants (registry keys) const ( + AgentNameAntigravity types.AgentName = "antigravity" AgentNameClaudeCode types.AgentName = "claude-code" AgentNameCodex types.AgentName = "codex" AgentNameCopilotCLI types.AgentName = "copilot-cli" @@ -177,6 +178,7 @@ const ( // Agent type constants (type identifiers stored in metadata/trailers) const ( + AgentTypeAntigravity types.AgentType = "Antigravity" AgentTypeClaudeCode types.AgentType = "Claude Code" AgentTypeCodex types.AgentType = "Codex" AgentTypeCopilotCLI types.AgentType = "Copilot CLI" diff --git a/cmd/entire/cli/agent/text_generator_cli.go b/cmd/entire/cli/agent/text_generator_cli.go index 6a18eb3a20..29e995e23b 100644 --- a/cmd/entire/cli/agent/text_generator_cli.go +++ b/cmd/entire/cli/agent/text_generator_cli.go @@ -71,11 +71,12 @@ func RunIsolatedTextGeneratorCLI(ctx context.Context, runner TextCommandRunner, // check PATH instead of repo-level DetectPresence, because a repo can use // one agent for development while a different agent generates summaries. var summaryProviderBinaries = map[types.AgentName]string{ - AgentNameClaudeCode: "claude", - AgentNameCodex: "codex", - AgentNameCopilotCLI: "copilot", - AgentNameCursor: "agent", - AgentNameGemini: "gemini", + AgentNameAntigravity: "agy", + AgentNameClaudeCode: "claude", + AgentNameCodex: "codex", + AgentNameCopilotCLI: "copilot", + AgentNameCursor: "agent", + AgentNameGemini: "gemini", } // IsSummaryCLIAvailable reports whether the CLI binary for a summary-capable diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index 7f58d67f56..d819cd6c6f 100644 --- a/cmd/entire/cli/hooks_cmd.go +++ b/cmd/entire/cli/hooks_cmd.go @@ -9,6 +9,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent/types" // Import agents to ensure they are registered before we iterate + _ "github.com/entireio/cli/cmd/entire/cli/agent/antigravity" _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" _ "github.com/entireio/cli/cmd/entire/cli/agent/codex" _ "github.com/entireio/cli/cmd/entire/cli/agent/copilotcli" diff --git a/cmd/entire/cli/integration_test/antigravity_test.go b/cmd/entire/cli/integration_test/antigravity_test.go new file mode 100644 index 0000000000..09e914f599 --- /dev/null +++ b/cmd/entire/cli/integration_test/antigravity_test.go @@ -0,0 +1,145 @@ +//go:build integration + +package integration + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/execx" + "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAntigravity_FullEventFlow drives the five Antigravity hooks +// (pre-invocation, pre-tool-use, post-tool-use, post-invocation, stop) against +// a real git repo via `entire hooks antigravity ` subprocesses and +// verifies the lifecycle wiring: session state lazy-initializes on the first +// PreInvocation, file changes from a write_to_file PreToolUse land in +// state.FilesTouched, and a Stop with fullyIdle=true records SessionEnd. +func TestAntigravity_FullEventFlow(t *testing.T) { + t.Parallel() + env := NewFeatureBranchEnv(t) + + conversationID := "antigravity-it-conv-id" + transcriptPath := filepath.Join(env.RepoDir, ".gemini", "antigravity-cli", + "brain", conversationID, ".system_generated", "logs", "transcript.jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(transcriptPath), 0o750)) + require.NoError(t, os.WriteFile(transcriptPath, + []byte(`{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"create foo.txt"}`+"\n"), + 0o600)) + + common := map[string]any{ + "conversationId": conversationID, + "workspacePaths": []string{env.RepoDir}, + "transcriptPath": transcriptPath, + "artifactDirectoryPath": filepath.Join(env.RepoDir, ".gemini", "antigravity-cli", "artifacts"), + } + + // agy 1.0.0 wire format: invocationNum is 0-indexed. The first model + // invocation of a conversation carries invocationNum=0; only that one + // is mapped to TurnStart by parsePreInvocation. initialNumSteps=1 reflects + // the user prompt being inserted as step 0 before the first model call — + // values mirror real captured agy stdin. + preInv := mergeMaps(common, map[string]any{ + "invocationNum": 0, + "initialNumSteps": 1, + }) + require.NoError(t, runAntigravityHook(t, env.RepoDir, "pre-invocation", preInv), + "pre-invocation hook should succeed and lazy-init session state") + + statePath := filepath.Join(env.RepoDir, ".git", "entire-sessions", conversationID+".json") + require.FileExists(t, statePath, "session state file should exist after pre-invocation") + + preTU := mergeMaps(common, map[string]any{ + "toolCall": map[string]any{ + "name": "write_to_file", + "args": map[string]any{ + "TargetFile": "foo.txt", + "Overwrite": false, + }, + }, + "stepIdx": 1, + }) + require.NoError(t, runAntigravityHook(t, env.RepoDir, "pre-tool-use", preTU), + "pre-tool-use hook should record the new file in state.FilesTouched") + + postTU := mergeMaps(common, map[string]any{"stepIdx": 1}) + require.NoError(t, runAntigravityHook(t, env.RepoDir, "post-tool-use", postTU), + "post-tool-use hook should be a no-op") + + postInv := mergeMaps(common, map[string]any{ + "invocationNum": 1, + "initialNumSteps": 2, + }) + require.NoError(t, runAntigravityHook(t, env.RepoDir, "post-invocation", postInv), + "post-invocation hook should be a no-op (Antigravity writes its transcript after Stop, so emitting TurnEnd here would fail with transcript-not-found)") + + stopBackgroundActive := mergeMaps(common, map[string]any{ + "executionNum": 1, + "terminationReason": "model_stop", + "error": "", + "fullyIdle": false, + }) + require.NoError(t, runAntigravityHook(t, env.RepoDir, "stop", stopBackgroundActive), + "stop hook with fullyIdle=false must not finalize the session") + + stopIdle := mergeMaps(common, map[string]any{ + "executionNum": 1, + "terminationReason": "model_stop", + "error": "", + "fullyIdle": true, + }) + require.NoError(t, runAntigravityHook(t, env.RepoDir, "stop", stopIdle), + "stop hook with fullyIdle=true should emit SessionEnd") + + stateBytes, err := os.ReadFile(statePath) + require.NoError(t, err, "session state file should still be readable after the full flow") + var state map[string]any + require.NoError(t, json.Unmarshal(stateBytes, &state)) + + if sid, ok := state["session_id"].(string); !ok || sid != conversationID { + t.Errorf("session_id = %v, want %q", state["session_id"], conversationID) + } + + rawFiles, _ := state["files_touched"].([]any) + got := make([]string, 0, len(rawFiles)) + for _, v := range rawFiles { + if s, ok := v.(string); ok { + got = append(got, s) + } + } + assert.True(t, slices.Contains(got, "foo.txt"), + "PreToolUse(write_to_file foo.txt) should have populated files_touched; got %v", got) +} + +func runAntigravityHook(t *testing.T, repoDir, hookName string, input map[string]any) error { + t.Helper() + inputJSON, err := json.Marshal(input) + require.NoError(t, err) + + cmd := execx.NonInteractive(context.Background(), getTestBinary(), "hooks", "antigravity", hookName) + cmd.Dir = repoDir + cmd.Stdin = bytes.NewReader(inputJSON) + cmd.Env = testutil.GitIsolatedEnv() + output, runErr := cmd.CombinedOutput() + t.Logf("antigravity hook %s output: %s", hookName, output) + return runErr +} + +func mergeMaps(base, overrides map[string]any) map[string]any { + out := make(map[string]any, len(base)+len(overrides)) + for k, v := range base { + out[k] = v + } + for k, v := range overrides { + out[k] = v + } + return out +} diff --git a/e2e/agents/antigravity.go b/e2e/agents/antigravity.go new file mode 100644 index 0000000000..06630ff5b0 --- /dev/null +++ b/e2e/agents/antigravity.go @@ -0,0 +1,176 @@ +package agents + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +func init() { + if env := os.Getenv("E2E_AGENT"); env != "" && env != "antigravity" { + return + } + if _, err := exec.LookPath(antigravityBinary); err != nil { + return + } + Register(&Antigravity{}) + RegisterGate("antigravity", 2) +} + +const ( + antigravityBinary = "agy" + antigravityDefaultModel = "gemini-2.5-flash" +) + +// Antigravity implements the Agent interface for the agy CLI (Antigravity 2.0, +// successor to Gemini CLI). +type Antigravity struct{} + +func (a *Antigravity) Name() string { return "antigravity" } +func (a *Antigravity) Binary() string { return antigravityBinary } +func (a *Antigravity) EntireAgent() string { return "antigravity" } + +// PromptPattern is a placeholder until the real interactive prompt pattern is +// observed against a working `agy` session. Tracked in the Deferred table. +func (a *Antigravity) PromptPattern() string { return `>` } +func (a *Antigravity) TimeoutMultiplier() float64 { return 2.5 } + +func (a *Antigravity) Bootstrap() error { return nil } + +func (a *Antigravity) IsTransientError(out Output, err error) bool { + if errors.Is(err, context.DeadlineExceeded) { + return true + } + combined := out.Stdout + out.Stderr + transientPatterns := []string{ + "timed out waiting for response", + "429", + "RESOURCE_EXHAUSTED", + "UNAVAILABLE", + "DEADLINE_EXCEEDED", + "INTERNAL", + "503", + "ECONNRESET", + "ETIMEDOUT", + } + for _, p := range transientPatterns { + if strings.Contains(combined, p) { + return true + } + } + return false +} + +func (a *Antigravity) RunPrompt(ctx context.Context, dir string, prompt string, opts ...Option) (Output, error) { + cfg := &runConfig{Model: antigravityDefaultModel} + for _, o := range opts { + o(cfg) + } + + timeout := 60 * time.Second + if cfg.PromptTimeout > 0 { + timeout = cfg.PromptTimeout + } + promptCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + // agy (as of 1.0.2) has no --model flag — model selection happens via + // settings.json (selectedModel) or the in-product picker. Passing a + // `--model ` arg made agy exit 2 with the flag-not-defined error. + // We accept the agy default here; tests that need a specific model + // should seed settings.json instead. + _ = cfg.Model + // agy -p ignores cwd: without --add-dir it runs in + // ~/.gemini/antigravity-cli/scratch/ instead of the test repo + // (observed in PR #1287 validation — agy initialized a brand-new git + // repo in scratch and committed the requested file there). --add-dir + // pins agy to the workspace we actually want it to modify. + args := []string{"-p", prompt, "--dangerously-skip-permissions", "--add-dir", dir} + displayArgs := []string{"-p", fmt.Sprintf("%q", prompt), "--dangerously-skip-permissions", "--add-dir", dir} + + cmd := exec.CommandContext(promptCtx, a.Binary(), args...) + cmd.Dir = dir + cmd.Stdin = nil + cmd.Env = antigravityPromptEnv() + setupProcessGroup(cmd) + cmd.WaitDelay = 5 * time.Second + + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + exitCode := 0 + if err != nil { + exitErr := &exec.ExitError{} + if errors.As(err, &exitErr) { + exitCode = exitErr.ExitCode() + } else { + exitCode = -1 + } + if promptCtx.Err() == context.DeadlineExceeded { + err = fmt.Errorf("%w: %w", err, context.DeadlineExceeded) + } + } + + return Output{ + Command: a.Binary() + " " + strings.Join(displayArgs, " "), + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + }, err +} + +func (a *Antigravity) StartSession(_ context.Context, dir string) (Session, error) { + name := fmt.Sprintf("antigravity-test-%d", time.Now().UnixNano()) + + envArgs := []string{"ACCESSIBLE=1"} + for _, key := range []string{"TERM"} { + if v := os.Getenv(key); v != "" { + envArgs = append(envArgs, key+"="+v) + } + } + + args := append([]string{"env"}, envArgs...) + args = append(args, a.Binary(), "--dangerously-skip-permissions") + s, err := NewTmuxSession(name, dir, []string{"CI", "GITHUB_ACTIONS", "ENTIRE_TEST_TTY"}, args[0], args[1:]...) + if err != nil { + return nil, err + } + + for range 10 { + content, err := s.WaitFor(`(>|trust|Enter to select|Enter to confirm|Acknowledge)`, 30*time.Second) + if err != nil { + _ = s.Close() + return nil, fmt.Errorf("waiting for startup prompt: %w", err) + } + if strings.Contains(content, ">") { + break + } + _ = s.SendKeys("Enter") + time.Sleep(500 * time.Millisecond) + } + s.stableAtSend = "" + + return s, nil +} + +// antigravityPromptEnv returns the env for spawning agy in print mode. +// +// Local-validation NOTE: HOME isolation was removed because agy stores its +// OAuth, installation id, and onboarding state under HOME/.gemini/, and +// pointing HOME at a fresh dir (even with selective symlinks) made agy +// re-trigger the browser auth flow. Sharing the user's real HOME lets agy +// authenticate; the test repo (cmd.Dir) still provides workspace isolation +// for files agy creates. Side effect: test conversations and brain state +// land in the user's real ~/.gemini/antigravity-cli/ and need manual +// cleanup. The harness should grow a proper HOME-isolation mechanism (e.g. +// importing the auth/state surface from the real home) before this is run +// in CI — tracked as PR review feedback on #1287. +func antigravityPromptEnv() []string { + return append(filterEnv(os.Environ(), "ENTIRE_TEST_TTY"), "ACCESSIBLE=1") +} diff --git a/e2e/agents/antigravity_test.go b/e2e/agents/antigravity_test.go new file mode 100644 index 0000000000..0032b73117 --- /dev/null +++ b/e2e/agents/antigravity_test.go @@ -0,0 +1,20 @@ +package agents + +import "testing" + +func TestAntigravityIdentity(t *testing.T) { + t.Parallel() + a := &Antigravity{} + if got, want := a.Name(), "antigravity"; got != want { + t.Errorf("Name() = %q, want %q", got, want) + } + if got, want := a.Binary(), "agy"; got != want { + t.Errorf("Binary() = %q, want %q", got, want) + } + if got, want := a.EntireAgent(), "antigravity"; got != want { + t.Errorf("EntireAgent() = %q, want %q", got, want) + } + if a.TimeoutMultiplier() <= 0 { + t.Errorf("TimeoutMultiplier() = %v, want > 0", a.TimeoutMultiplier()) + } +}